feat: 优化批量邮件页并支持线上安全密码与手动收件人

重构收件人录入与邮箱配置弹窗,修复账号接口解析;支持脱敏密码复用后直接测试与发送。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-11 11:59:01 +08:00
parent 67cf30b6e8
commit 43209625d0
4 changed files with 485 additions and 111 deletions

View File

@ -1,9 +1,16 @@
import { axiosInstance } from '@/api/rbac' import { axiosInstance } from '@/api/rbac'
import { assertBusinessOk, omsUrl } from '@/api/oms/common' import { assertBusinessOk, omsUrl, unwrapData } from '@/api/oms/common'
/** 与桌面版 emailAuto.py 一致:阿里企业邮等 */ /** 与桌面版 emailAuto.py 一致:阿里企业邮等 */
export type BatchEmailServerType = 'qiye' | 'qiye_ssl' | 'aliyun' | 'aliyun_ssl' | 'mxhichina' export type BatchEmailServerType = 'qiye' | 'qiye_ssl' | 'aliyun' | 'aliyun_ssl' | 'mxhichina'
/** 与后端 BatchEmailSmtpHelper.SecretMask 一致,用于线上已保存密码占位 */
export const SMTP_SECRET_MASK = '******'
export function isSmtpSecretMask(value?: string | null): boolean {
return String(value ?? '').trim() === SMTP_SECRET_MASK
}
export interface BatchEmailTemplate { export interface BatchEmailTemplate {
name: string name: string
subject: string subject: string
@ -106,10 +113,11 @@ function unwrapList(resBody: unknown): unknown[] {
return unwrapPageResult(resBody).items return unwrapPageResult(resBody).items
} }
function unwrapObject(resBody: unknown): unknown { /** GET /system/batch-email/account */
assertBusinessOk(resBody) export async function getBatchEmailAccount(): Promise<BatchEmailAccount | null> {
const root = (resBody as Record<string, unknown>) ?? {} const res = await axiosInstance.get(omsUrl('/system/batch-email/account'))
return root.data ?? root.Data ?? null const raw = unwrapData<Record<string, unknown> | null>(res.data)
return raw ? normalizeAccount(raw) : null
} }
/** GET /system/batch-email/templates/list */ /** GET /system/batch-email/templates/list */
@ -136,13 +144,6 @@ export async function deleteBatchEmailTemplate(body: { name: string }) {
assertBusinessOk(res.data) assertBusinessOk(res.data)
} }
/** GET /system/batch-email/account */
export async function getBatchEmailAccount(): Promise<BatchEmailAccount | null> {
const res = await axiosInstance.get(omsUrl('/system/batch-email/account'))
const raw = unwrapObject(res.data)
return raw ? normalizeAccount(raw) : null
}
/** POST /system/batch-email/account/save */ /** POST /system/batch-email/account/save */
export async function saveBatchEmailAccount(body: BatchEmailAccount) { export async function saveBatchEmailAccount(body: BatchEmailAccount) {
const res = await axiosInstance.post(omsUrl('/system/batch-email/account/save'), body) const res = await axiosInstance.post(omsUrl('/system/batch-email/account/save'), body)

View File

@ -786,11 +786,13 @@ export default {
'order.placeOrder.single.stepBasic': 'Basic info', 'order.placeOrder.single.stepBasic': 'Basic info',
'order.placeOrder.single.stepSender': 'Sender', 'order.placeOrder.single.stepSender': 'Sender',
'order.placeOrder.single.stepRecipient': 'Recipient', 'order.placeOrder.single.stepRecipient': 'Recipient',
'order.placeOrder.single.stepDeclaration': 'Declaration & submit', 'order.placeOrder.single.stepDeclaration': 'Declaration info',
'order.placeOrder.single.tip1': 'Fields marked * are required. Fill in each section in order.', 'order.placeOrder.single.tip1': 'Fields marked * are required. Fill in each section in order.',
'order.placeOrder.single.tip2': 'Use “Generate sample” to try the full flow quickly.', 'order.placeOrder.single.tip2': 'Use “Generate sample” to try the full flow quickly.',
'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit', 'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit',
'order.placeOrder.single.submitReady': 'Ready to submit', 'order.placeOrder.single.submitReady': 'Ready to submit',
'order.placeOrder.single.viewCustomerGuide': 'View customer guide',
'order.placeOrder.single.declaredCurrencyHint': 'Default is USD; please enter declared prices carefully',
'order.placeOrder.standardTemplates': 'Standard templates', 'order.placeOrder.standardTemplates': 'Standard templates',
'order.placeOrder.customTemplates': 'Custom templates', 'order.placeOrder.customTemplates': 'Custom templates',
@ -1716,6 +1718,10 @@ export default {
'system.batchEmail.emailConfig': 'Mailbox', 'system.batchEmail.emailConfig': 'Mailbox',
'system.batchEmail.account': 'Account', 'system.batchEmail.account': 'Account',
'system.batchEmail.password': 'App password', 'system.batchEmail.password': 'App password',
'system.batchEmail.passwordPlaceholder': 'Enter SMTP app password / authorization code',
'system.batchEmail.passwordPlaceholderEdit': 'Leave blank to keep the saved password',
'system.batchEmail.passwordSavedHint': 'Password is stored on the server; leave blank unless you want to change it',
'system.batchEmail.warnPassword': 'Enter app password (e.g. Aliyun authorization code)',
'system.batchEmail.serverType': 'SMTP', 'system.batchEmail.serverType': 'SMTP',
'system.batchEmail.server.qiye': 'Aliyun enterprise 25', 'system.batchEmail.server.qiye': 'Aliyun enterprise 25',
'system.batchEmail.server.qiyeSsl': 'Aliyun enterprise SSL 465', 'system.batchEmail.server.qiyeSsl': 'Aliyun enterprise SSL 465',
@ -1765,6 +1771,21 @@ export default {
'system.batchEmail.msgTestOk': 'SMTP test succeeded', 'system.batchEmail.msgTestOk': 'SMTP test succeeded',
'system.batchEmail.errUnknown': 'Request failed', 'system.batchEmail.errUnknown': 'Request failed',
'system.batchEmail.logTestFail': 'Connection test failed', 'system.batchEmail.logTestFail': 'Connection test failed',
'system.batchEmail.recipients': 'Recipients',
'system.batchEmail.recipientMode.manual': 'Manual entry',
'system.batchEmail.recipientMode.excel': 'Excel import',
'system.batchEmail.manualEmailsPlaceholder': 'One email per line, or separated by comma, semicolon, or space',
'system.batchEmail.parseEmails': 'Parse emails',
'system.batchEmail.clearRecipients': 'Clear',
'system.batchEmail.recipientCount': '{n} recipient(s)',
'system.batchEmail.invalidEmailCount': 'Skipped {n} invalid email(s)',
'system.batchEmail.recipientPreview': 'Recipient preview',
'system.batchEmail.recipientPreviewMore': '{n} more not shown',
'system.batchEmail.previewOrderNo': 'Order no.',
'system.batchEmail.previewTrack': 'Latest tracking',
'system.batchEmail.excelImportHint': 'Supports .xlsx / .xls with email and order id columns',
'system.batchEmail.msgManualParseOk': 'Parsed {n} valid email(s)',
'system.batchEmail.warnNoRecipients': 'Add recipient emails first',
'system.batchEmail.warnNoOrders': 'Import an Excel file first', 'system.batchEmail.warnNoOrders': 'Import an Excel file first',
'system.batchEmail.warnNoTemplate': 'Select or enter a template', 'system.batchEmail.warnNoTemplate': 'Select or enter a template',
'system.batchEmail.errNoAccount': 'Configure mailbox account and password', 'system.batchEmail.errNoAccount': 'Configure mailbox account and password',
@ -1778,8 +1799,12 @@ export default {
'system.batchEmail.msgSendDone': 'Done: {successCount} succeeded, {failCount} failed', 'system.batchEmail.msgSendDone': 'Done: {successCount} succeeded, {failCount} failed',
// System - Announcement // System - Announcement
'system.announcement.search.placeholder': 'Enter keyword', 'system.announcement.search.placeholder': 'Search title or content',
'system.announcement.search.reset': 'Reset',
'system.announcement.actions.add': 'Add announcement', 'system.announcement.actions.add': 'Add announcement',
'system.announcement.actions.addNotice': 'Add notice',
'system.announcement.actions.addMessage': 'Add message',
'system.announcement.actions.refresh': 'Refresh',
'system.announcement.actions.batchDelete': 'Delete selected', 'system.announcement.actions.batchDelete': 'Delete selected',
'system.announcement.actions.edit': 'Edit', 'system.announcement.actions.edit': 'Edit',
'system.announcement.actions.view': 'View', 'system.announcement.actions.view': 'View',
@ -1794,6 +1819,10 @@ export default {
'system.announcement.popup.no': 'No', 'system.announcement.popup.no': 'No',
'system.announcement.filter.noticeType': 'Notice Type', 'system.announcement.filter.noticeType': 'Notice Type',
'system.announcement.filter.receiverType': 'Receiver Type', 'system.announcement.filter.receiverType': 'Receiver Type',
'system.announcement.tab.notice': 'Notice',
'system.announcement.tab.message': 'Message',
'system.announcement.desc.notice': 'System notices for all or selected recipients, with optional popup',
'system.announcement.desc.message': 'Direct messages without a required title',
'system.announcement.sendWay.now': 'Send now', 'system.announcement.sendWay.now': 'Send now',
'system.announcement.sendWay.delay': 'Delay send', 'system.announcement.sendWay.delay': 'Delay send',
'system.announcement.sendWay.schedule': 'Schedule send', 'system.announcement.sendWay.schedule': 'Schedule send',
@ -1824,6 +1853,13 @@ export default {
'system.announcement.form.receiverIdsPlaceholderUser': 'Search and select internal users', 'system.announcement.form.receiverIdsPlaceholderUser': 'Search and select internal users',
'system.announcement.form.receiverIdsPlaceholderCustomer': 'Search and select customers', 'system.announcement.form.receiverIdsPlaceholderCustomer': 'Search and select customers',
'system.announcement.form.receiverIdsFallback': 'Selected #{id}', 'system.announcement.form.receiverIdsFallback': 'Selected #{id}',
'system.announcement.form.section.basic': 'Basic info',
'system.announcement.form.section.receiver': 'Recipients',
'system.announcement.form.messageTitleHint': 'Messages do not require a title; content is shown directly',
'system.announcement.form.popupHint': 'When enabled, recipients see a popup on the workplace',
'system.announcement.drawer.close': 'Close',
'system.announcement.modal.addNoticeTitle': 'Add notice',
'system.announcement.modal.addMessageTitle': 'Add message',
'system.announcement.placeholder.input': 'Please enter', 'system.announcement.placeholder.input': 'Please enter',
'system.announcement.placeholder.select': 'Please select', 'system.announcement.placeholder.select': 'Please select',
'system.announcement.modal.addTitle': 'Add Announcement', 'system.announcement.modal.addTitle': 'Add Announcement',
@ -2743,7 +2779,7 @@ export default {
'channel.autoTrigger.form.promptRequired': 'Please enter a prompt message', 'channel.autoTrigger.form.promptRequired': 'Please enter a prompt message',
'channel.autoTrigger.form.ruleNamePlaceholder': 'e.g. UK oversize intercept', 'channel.autoTrigger.form.ruleNamePlaceholder': 'e.g. UK oversize intercept',
'channel.autoTrigger.form.promptPlaceholder': 'e.g. Oversize — order blocked', 'channel.autoTrigger.form.promptPlaceholder': 'e.g. Oversize — order blocked',
'channel.autoTrigger.form.notifierPlaceholder': 'Optional user IDs for warn/problem, comma-separated', 'channel.autoTrigger.form.notifierPlaceholder': 'Optional notifier users for warn/problem',
'channel.autoTrigger.form.loadExample': 'Load oversize example', 'channel.autoTrigger.form.loadExample': 'Load oversize example',
'channel.autoTrigger.form.legacyTitle': 'Legacy plain-text condition detected — rebuild with the builder below', 'channel.autoTrigger.form.legacyTitle': 'Legacy plain-text condition detected — rebuild with the builder below',
'channel.autoTrigger.form.testFormula': 'Test formula', 'channel.autoTrigger.form.testFormula': 'Test formula',

View File

@ -671,11 +671,13 @@ export default {
'order.placeOrder.single.stepBasic': '基本信息', 'order.placeOrder.single.stepBasic': '基本信息',
'order.placeOrder.single.stepSender': '发件人', 'order.placeOrder.single.stepSender': '发件人',
'order.placeOrder.single.stepRecipient': '收件人', 'order.placeOrder.single.stepRecipient': '收件人',
'order.placeOrder.single.stepDeclaration': '申报并提交', 'order.placeOrder.single.stepDeclaration': '申报信息',
'order.placeOrder.single.tip1': '带 * 号为必填项,请按区块依次填写订单信息。', 'order.placeOrder.single.tip1': '带 * 号为必填项,请按区块依次填写订单信息。',
'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。', 'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。',
'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交', 'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交',
'order.placeOrder.single.submitReady': '信息已完善,可提交下单', 'order.placeOrder.single.submitReady': '信息已完善,可提交下单',
'order.placeOrder.single.viewCustomerGuide': '查看对客说明',
'order.placeOrder.single.declaredCurrencyHint': '默认是美元,请注意填写申报价格',
'order.placeOrder.standardTemplates': '标准模板', 'order.placeOrder.standardTemplates': '标准模板',
'order.placeOrder.customTemplates': '自定义模板', 'order.placeOrder.customTemplates': '自定义模板',
@ -1734,6 +1736,10 @@ export default {
'system.batchEmail.emailConfig': '邮箱配置', 'system.batchEmail.emailConfig': '邮箱配置',
'system.batchEmail.account': '账号', 'system.batchEmail.account': '账号',
'system.batchEmail.password': '安全密码', 'system.batchEmail.password': '安全密码',
'system.batchEmail.passwordPlaceholder': '请填写邮箱客户端授权码/安全密码',
'system.batchEmail.passwordPlaceholderEdit': '留空则沿用已保存的安全密码',
'system.batchEmail.passwordSavedHint': '已保存在服务器,无需重复填写;仅修改账号或 SMTP 类型时可直接保存',
'system.batchEmail.warnPassword': '请填写安全密码(阿里邮箱请使用客户端授权码)',
'system.batchEmail.serverType': 'SMTP', 'system.batchEmail.serverType': 'SMTP',
'system.batchEmail.server.qiye': '企业邮 25', 'system.batchEmail.server.qiye': '企业邮 25',
'system.batchEmail.server.qiyeSsl': '企业邮 SSL 465', 'system.batchEmail.server.qiyeSsl': '企业邮 SSL 465',
@ -1783,6 +1789,21 @@ export default {
'system.batchEmail.msgTestOk': '邮箱连接测试成功', 'system.batchEmail.msgTestOk': '邮箱连接测试成功',
'system.batchEmail.errUnknown': '请求失败', 'system.batchEmail.errUnknown': '请求失败',
'system.batchEmail.logTestFail': '连接测试失败', 'system.batchEmail.logTestFail': '连接测试失败',
'system.batchEmail.recipients': '收件人',
'system.batchEmail.recipientMode.manual': '手动填入',
'system.batchEmail.recipientMode.excel': 'Excel 导入',
'system.batchEmail.manualEmailsPlaceholder': '每行一个邮箱,或用逗号、分号、空格分隔',
'system.batchEmail.parseEmails': '解析邮箱',
'system.batchEmail.clearRecipients': '清空',
'system.batchEmail.recipientCount': '已识别 {n} 个邮箱',
'system.batchEmail.invalidEmailCount': '已跳过 {n} 个无效邮箱',
'system.batchEmail.recipientPreview': '收件人预览',
'system.batchEmail.recipientPreviewMore': '另有 {n} 个收件人未展示',
'system.batchEmail.previewOrderNo': '客户单号',
'system.batchEmail.previewTrack': '最新轨迹',
'system.batchEmail.excelImportHint': '支持 .xlsx / .xls,需包含邮箱列与订单号列',
'system.batchEmail.msgManualParseOk': '已解析 {n} 个有效邮箱',
'system.batchEmail.warnNoRecipients': '请先添加收件人邮箱',
'system.batchEmail.warnNoOrders': '请先导入订单文件', 'system.batchEmail.warnNoOrders': '请先导入订单文件',
'system.batchEmail.warnNoTemplate': '请先选择或填写发送模板', 'system.batchEmail.warnNoTemplate': '请先选择或填写发送模板',
'system.batchEmail.errNoAccount': '请配置邮箱账号和密码', 'system.batchEmail.errNoAccount': '请配置邮箱账号和密码',
@ -1796,8 +1817,12 @@ export default {
'system.batchEmail.msgSendDone': '发送完成:成功 {successCount},失败 {failCount}', 'system.batchEmail.msgSendDone': '发送完成:成功 {successCount},失败 {failCount}',
// 系统 - 公告通知 // 系统 - 公告通知
'system.announcement.search.placeholder': '请输入关键字', 'system.announcement.search.placeholder': '搜索标题或正文',
'system.announcement.search.reset': '重置',
'system.announcement.actions.add': '新增公告通知', 'system.announcement.actions.add': '新增公告通知',
'system.announcement.actions.addNotice': '新增公告',
'system.announcement.actions.addMessage': '新增站内信',
'system.announcement.actions.refresh': '刷新',
'system.announcement.actions.batchDelete': '删除所选', 'system.announcement.actions.batchDelete': '删除所选',
'system.announcement.actions.edit': '编辑', 'system.announcement.actions.edit': '编辑',
'system.announcement.actions.view': '查看', 'system.announcement.actions.view': '查看',
@ -1812,6 +1837,10 @@ export default {
'system.announcement.popup.no': '否', 'system.announcement.popup.no': '否',
'system.announcement.filter.noticeType': '通知类型', 'system.announcement.filter.noticeType': '通知类型',
'system.announcement.filter.receiverType': '接收对象', 'system.announcement.filter.receiverType': '接收对象',
'system.announcement.tab.notice': '公告',
'system.announcement.tab.message': '站内信',
'system.announcement.desc.notice': '面向全体或指定对象的系统公告,支持弹窗提醒',
'system.announcement.desc.message': '点对点站内消息,无需填写标题',
'system.announcement.sendWay.now': '立即发送', 'system.announcement.sendWay.now': '立即发送',
'system.announcement.sendWay.delay': '延迟发送', 'system.announcement.sendWay.delay': '延迟发送',
'system.announcement.sendWay.schedule': '定时发送', 'system.announcement.sendWay.schedule': '定时发送',
@ -1842,6 +1871,13 @@ export default {
'system.announcement.form.receiverIdsPlaceholderUser': '请搜索并选择内部用户', 'system.announcement.form.receiverIdsPlaceholderUser': '请搜索并选择内部用户',
'system.announcement.form.receiverIdsPlaceholderCustomer': '请搜索并选择客户', 'system.announcement.form.receiverIdsPlaceholderCustomer': '请搜索并选择客户',
'system.announcement.form.receiverIdsFallback': '已选 #{id}', 'system.announcement.form.receiverIdsFallback': '已选 #{id}',
'system.announcement.form.section.basic': '基本信息',
'system.announcement.form.section.receiver': '接收设置',
'system.announcement.form.messageTitleHint': '站内信无需填写标题,将直接展示正文内容',
'system.announcement.form.popupHint': '开启后,接收人登录工作台时将弹窗展示',
'system.announcement.drawer.close': '关闭',
'system.announcement.modal.addNoticeTitle': '新增公告',
'system.announcement.modal.addMessageTitle': '新增站内信',
'system.announcement.placeholder.input': '请输入', 'system.announcement.placeholder.input': '请输入',
'system.announcement.placeholder.select': '请选择', 'system.announcement.placeholder.select': '请选择',
'system.announcement.modal.addTitle': '新增公告通知', 'system.announcement.modal.addTitle': '新增公告通知',
@ -2779,7 +2815,7 @@ export default {
'channel.autoTrigger.form.promptRequired': '请填写提示语', 'channel.autoTrigger.form.promptRequired': '请填写提示语',
'channel.autoTrigger.form.ruleNamePlaceholder': '例如:英国阿里超尺寸拦截', 'channel.autoTrigger.form.ruleNamePlaceholder': '例如:英国阿里超尺寸拦截',
'channel.autoTrigger.form.promptPlaceholder': '例如:超尺寸,禁止下单', 'channel.autoTrigger.form.promptPlaceholder': '例如:超尺寸,禁止下单',
'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,用户 ID,逗号分隔', 'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,请选择通知人',
'channel.autoTrigger.form.loadExample': '载入超尺寸示例', 'channel.autoTrigger.form.loadExample': '载入超尺寸示例',
'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置', 'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置',
'channel.autoTrigger.form.testFormula': '测试公式', 'channel.autoTrigger.form.testFormula': '测试公式',

View File

@ -6,34 +6,82 @@
<div class="table-toolbar"> <div class="table-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<a-space :size="8" wrap> <a-space :size="8" wrap>
<a-button type="primary" class="toolbar-btn" @click="triggerImport">
{{ t('system.batchEmail.import') }}
</a-button>
<span class="toolbar-label">{{ t('system.batchEmail.template') }}</span> <span class="toolbar-label">{{ t('system.batchEmail.template') }}</span>
<a-select <a-select
v-model="selectedTemplateName" v-model="selectedTemplateName"
:style="{ width: '240px' }" :style="{ width: '220px' }"
:placeholder="t('system.batchEmail.selectTemplate')" :placeholder="t('system.batchEmail.selectTemplate')"
allow-clear allow-clear
@change="onTemplateChange" @change="onTemplateChange"
> >
<a-option v-for="name in templateNames" :key="name" :value="name">{{ name }}</a-option> <a-option v-for="name in templateNames" :key="name" :value="name">{{ name }}</a-option>
</a-select> </a-select>
<a-tag v-if="recipientCount > 0" color="arcoblue" class="recipient-tag">
{{ t('system.batchEmail.recipientCount', { n: recipientCount }) }}
</a-tag>
</a-space> </a-space>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<a-button class="toolbar-btn" :loading="accountLoading" @click="openEmailConfigModal">
{{ t('system.batchEmail.emailConfig') }}
</a-button>
<a-button <a-button
type="primary" type="primary"
class="toolbar-btn" class="toolbar-btn"
:loading="sending" :loading="sending"
:disabled="sending" :disabled="sending || recipientCount === 0"
@click="startBatchSend" @click="startBatchSend"
> >
{{ t('system.batchEmail.send') }} {{ t('system.batchEmail.send') }}
<template v-if="recipientCount > 0"> ({{ recipientCount }})</template>
</a-button> </a-button>
</div> </div>
</div> </div>
<div class="section-block recipient-section">
<div class="section-title">{{ t('system.batchEmail.recipients') }}</div>
<a-tabs v-model:active-key="recipientMode" type="rounded" size="small" class="recipient-tabs">
<a-tab-pane key="manual" :title="t('system.batchEmail.recipientMode.manual')">
<a-textarea
v-model="manualEmailsText"
:placeholder="t('system.batchEmail.manualEmailsPlaceholder')"
:auto-size="{ minRows: 6, maxRows: 12 }"
class="manual-email-textarea"
/>
</a-tab-pane>
<a-tab-pane key="excel" :title="t('system.batchEmail.recipientMode.excel')">
<div class="excel-import-panel">
<a-space :size="8" wrap>
<a-button type="primary" class="toolbar-btn" @click="triggerImport">
{{ t('system.batchEmail.import') }}
</a-button>
<span class="hint-text">{{ t('system.batchEmail.excelImportHint') }}</span>
</a-space>
</div>
</a-tab-pane>
</a-tabs>
<div v-if="recipientMode === 'excel' && recipientCount > 0" class="recipient-preview">
<div class="preview-header">
<span class="preview-title">{{ t('system.batchEmail.recipientPreview') }}</span>
<a-button type="text" size="small" @click="clearRecipients">
{{ t('system.batchEmail.clearRecipients') }}
</a-button>
</div>
<a-table
:columns="recipientColumns"
:data="recipientPreviewRows"
:pagination="false"
size="mini"
row-key="key"
class="recipient-preview-table"
/>
<div v-if="recipientCount > recipientPreviewLimit" class="preview-more">
{{ t('system.batchEmail.recipientPreviewMore', { n: recipientCount - recipientPreviewLimit }) }}
</div>
</div>
</div>
<div class="section-block preview-section"> <div class="section-block preview-section">
<div class="section-title">{{ t('system.batchEmail.preview') }}</div> <div class="section-title">{{ t('system.batchEmail.preview') }}</div>
<div class="preview-toolbar"> <div class="preview-toolbar">
@ -56,30 +104,6 @@
/> />
</div> </div>
<div class="section-block section-config">
<div class="section-title">{{ t('system.batchEmail.emailConfig') }}</div>
<div class="config-row">
<a-space :size="8" wrap align="center">
<span class="config-label">{{ t('system.batchEmail.account') }}</span>
<a-input v-model="accountUsername" :style="{ width: '200px' }" allow-clear />
<span class="config-label">{{ t('system.batchEmail.password') }}</span>
<a-input-password v-model="accountPassword" :style="{ width: '200px' }" allow-clear />
<span class="config-label">{{ t('system.batchEmail.serverType') }}</span>
<a-select v-model="serverType" :style="{ width: '180px' }">
<a-option value="qiye">{{ t('system.batchEmail.server.qiye') }}</a-option>
<a-option value="qiye_ssl">{{ t('system.batchEmail.server.qiyeSsl') }}</a-option>
<a-option value="aliyun">{{ t('system.batchEmail.server.aliyun') }}</a-option>
<a-option value="aliyun_ssl">{{ t('system.batchEmail.server.aliyunSsl') }}</a-option>
<a-option value="mxhichina">{{ t('system.batchEmail.server.mxhichina') }}</a-option>
</a-select>
<a-button class="toolbar-btn" @click="saveAccountConfig">{{ t('system.batchEmail.saveConfig') }}</a-button>
<a-button class="toolbar-btn" :loading="testing" @click="testConnection">
{{ t('system.batchEmail.testConnection') }}
</a-button>
</a-space>
</div>
</div>
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
@ -104,22 +128,24 @@
@page-size-change="handleRecordPageSizeChange" @page-size-change="handleRecordPageSizeChange"
/> />
</div> </div>
<a-table <div class="list-wrap">
:columns="recordColumns" <a-table
:data="recordList" :columns="recordColumns"
:loading="recordLoading" :data="recordList"
:pagination="false" :loading="recordLoading"
:scroll="{ y: recordTableHeight }" :pagination="false"
row-key="id" :scroll="{ y: '100%' }"
size="small" row-key="id"
class="record-table" size="small"
> class="record-table"
<template #content="{ record }"> >
<a-tooltip :content="record.content"> <template #content="{ record }">
<span class="record-content-cell">{{ record.content }}</span> <a-tooltip :content="record.content">
</a-tooltip> <span class="record-content-cell">{{ record.content }}</span>
</template> </a-tooltip>
</a-table> </template>
</a-table>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -132,6 +158,54 @@
> >
<a-input v-model="newTemplateName" :placeholder="t('system.batchEmail.saveTplNamePlaceholder')" /> <a-input v-model="newTemplateName" :placeholder="t('system.batchEmail.saveTplNamePlaceholder')" />
</a-modal> </a-modal>
<a-modal
v-model:visible="emailConfigModalVisible"
:title="t('system.batchEmail.emailConfig')"
:width="520"
:footer="false"
unmount-on-close
>
<a-spin :loading="accountLoading" class="email-config-spin">
<a-form layout="vertical" class="email-config-form">
<a-form-item :label="t('system.batchEmail.account')" required>
<a-input v-model="accountUsername" allow-clear />
</a-form-item>
<a-form-item :label="t('system.batchEmail.password')" required>
<a-input-password
v-model="accountPassword"
:placeholder="accountPasswordPlaceholder"
allow-clear
/>
<div v-if="accountConfigured" class="hint-text password-hint">
{{ t('system.batchEmail.passwordSavedHint') }}
</div>
</a-form-item>
<a-form-item :label="t('system.batchEmail.serverType')">
<a-select v-model="serverType">
<a-option value="qiye">{{ t('system.batchEmail.server.qiye') }}</a-option>
<a-option value="qiye_ssl">{{ t('system.batchEmail.server.qiyeSsl') }}</a-option>
<a-option value="aliyun">{{ t('system.batchEmail.server.aliyun') }}</a-option>
<a-option value="aliyun_ssl">{{ t('system.batchEmail.server.aliyunSsl') }}</a-option>
<a-option value="mxhichina">{{ t('system.batchEmail.server.mxhichina') }}</a-option>
</a-select>
</a-form-item>
</a-form>
<div class="email-config-footer">
<a-space :size="8">
<a-button class="toolbar-btn" @click="emailConfigModalVisible = false">
{{ t('system.batchEmail.confirmCancel') }}
</a-button>
<a-button class="toolbar-btn" :loading="testing" @click="testConnection">
{{ t('system.batchEmail.testConnection') }}
</a-button>
<a-button type="primary" class="toolbar-btn" :loading="savingConfig" @click="saveAccountConfigFromModal">
{{ t('system.batchEmail.saveConfig') }}
</a-button>
</a-space>
</div>
</a-spin>
</a-modal>
</div> </div>
</template> </template>
@ -146,11 +220,13 @@ import PaginationPro from '@/components/PaginationPro/index.vue'
import { import {
deleteBatchEmailTemplate, deleteBatchEmailTemplate,
getBatchEmailAccount, getBatchEmailAccount,
isSmtpSecretMask,
listBatchEmailRecords, listBatchEmailRecords,
listBatchEmailTemplates, listBatchEmailTemplates,
saveBatchEmailAccount, saveBatchEmailAccount,
saveBatchEmailTemplate, saveBatchEmailTemplate,
sendBatchEmailOne, sendBatchEmailOne,
SMTP_SECRET_MASK,
testBatchEmailConnection, testBatchEmailConnection,
type BatchEmailSendRecord, type BatchEmailSendRecord,
type BatchEmailServerType, type BatchEmailServerType,
@ -202,17 +278,26 @@ Alibaba.com Logistics
一代国际深圳仓客户服务中心`, 一代国际深圳仓客户服务中心`,
} }
const EMAIL_SPLIT_RE = /[\n,;,;\s]+/
const EMAIL_VALID_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const orderData = ref<OrderRow[]>([]) const orderData = ref<OrderRow[]>([])
const recipientMode = ref<'manual' | 'excel'>('manual')
const manualEmailsText = ref('')
const templates = ref<Record<string, TemplateData>>({}) const templates = ref<Record<string, TemplateData>>({})
const selectedTemplateName = ref<string>('') const selectedTemplateName = ref<string>('')
const contentDraft = ref('') const contentDraft = ref('')
const subjectDraft = ref('') const subjectDraft = ref('')
const accountUsername = ref('') const accountUsername = ref('')
const accountPassword = ref('') const accountPassword = ref('')
const accountConfigured = ref(false)
const serverType = ref<BatchEmailServerType>('qiye') const serverType = ref<BatchEmailServerType>('qiye')
const sending = ref(false) const sending = ref(false)
const testing = ref(false) const testing = ref(false)
const savingConfig = ref(false)
const accountLoading = ref(false)
const emailConfigModalVisible = ref(false)
const saveTplModalVisible = ref(false) const saveTplModalVisible = ref(false)
const newTemplateName = ref('') const newTemplateName = ref('')
@ -223,10 +308,38 @@ const recordTotal = ref(0)
const recordPage = ref(1) const recordPage = ref(1)
const recordPageSize = ref(30) const recordPageSize = ref(30)
const recordLoading = ref(false) const recordLoading = ref(false)
const recordTableHeight = '100%' const recipientPreviewLimit = 8
const templateNames = computed(() => Object.keys(templates.value)) const templateNames = computed(() => Object.keys(templates.value))
const accountPasswordPlaceholder = computed(() =>
accountConfigured.value
? t('system.batchEmail.passwordPlaceholderEdit')
: t('system.batchEmail.passwordPlaceholder'),
)
const recipientCount = computed(() => {
if (recipientMode.value === 'manual') {
return parseEmailTokens(manualEmailsText.value).valid.length
}
return orderData.value.length
})
const recipientColumns = computed(() => [
{ title: t('system.batchEmail.record.toEmail'), dataIndex: 'email', ellipsis: true, tooltip: true },
{ title: t('system.batchEmail.previewOrderNo'), dataIndex: 'orderNo', width: 140, ellipsis: true, tooltip: true },
{ title: t('system.batchEmail.previewTrack'), dataIndex: 'track', ellipsis: true, tooltip: true },
])
const recipientPreviewRows = computed(() =>
orderData.value.slice(0, recipientPreviewLimit).map((row, index) => ({
key: `${row.寄件人邮箱}-${index}`,
email: row.寄件人邮箱,
orderNo: row.客户单号 || '-',
track: row.最新轨迹 || '-',
})),
)
const recordColumns = computed(() => [ const recordColumns = computed(() => [
{ title: t('system.batchEmail.record.toEmail'), dataIndex: 'toEmail', width: 160, ellipsis: true, tooltip: true }, { title: t('system.batchEmail.record.toEmail'), dataIndex: 'toEmail', width: 160, ellipsis: true, tooltip: true },
{ title: t('system.batchEmail.record.subject'), dataIndex: 'subject', width: 140, ellipsis: true, tooltip: true }, { title: t('system.batchEmail.record.subject'), dataIndex: 'subject', width: 140, ellipsis: true, tooltip: true },
@ -243,6 +356,48 @@ function templatesToMap(list: Array<{ name: string; subject: string; content: st
return out return out
} }
function parseEmailTokens(text: string) {
const tokens = text
.split(EMAIL_SPLIT_RE)
.map((item) => item.trim())
.filter(Boolean)
const valid: string[] = []
const seen = new Set<string>()
let invalid = 0
tokens.forEach((token) => {
if (!EMAIL_VALID_RE.test(token)) {
invalid += 1
return
}
const key = token.toLowerCase()
if (seen.has(key)) return
seen.add(key)
valid.push(token)
})
return { valid, invalid }
}
function buildOrderRowsFromEmails(emails: string[]): OrderRow[] {
return emails.map((email) => ({
客户单号: '',
最新轨迹: '',
换单号: '',
寄件人邮箱: email,
}))
}
function getRecipientsForSend(): OrderRow[] {
if (recipientMode.value === 'manual') {
return buildOrderRowsFromEmails(parseEmailTokens(manualEmailsText.value).valid)
}
return orderData.value
}
function clearRecipients() {
orderData.value = []
manualEmailsText.value = ''
}
async function loadTemplatesFromApi() { async function loadTemplatesFromApi() {
try { try {
const list = await listBatchEmailTemplates() const list = await listBatchEmailTemplates()
@ -258,17 +413,47 @@ async function loadTemplatesFromApi() {
} }
async function loadAccountFromApi() { async function loadAccountFromApi() {
accountLoading.value = true
try { try {
const account = await getBatchEmailAccount() const account = await getBatchEmailAccount()
if (!account) return if (!account) {
accountConfigured.value = false
accountUsername.value = ''
accountPassword.value = ''
serverType.value = 'qiye'
return
}
accountConfigured.value = true
accountUsername.value = account.smtpUsername accountUsername.value = account.smtpUsername
accountPassword.value = account.smtpPassword accountPassword.value = account.smtpPassword || SMTP_SECRET_MASK
if (account.serverType) serverType.value = account.serverType if (account.serverType) serverType.value = account.serverType
} catch { } catch (e: unknown) {
/* ignore */ const msg = (e as Error)?.message || t('system.batchEmail.errUnknown')
Message.error(msg)
} finally {
accountLoading.value = false
} }
} }
function resolvePasswordForRequest(): string {
const trimmed = accountPassword.value.trim()
if (trimmed && !isSmtpSecretMask(trimmed)) return trimmed
if (accountConfigured.value) return SMTP_SECRET_MASK
return trimmed
}
function hasUsableSmtpAuth(): boolean {
if (!accountUsername.value.trim()) return false
const trimmed = accountPassword.value.trim()
if (trimmed && !isSmtpSecretMask(trimmed)) return true
return accountConfigured.value
}
async function openEmailConfigModal() {
emailConfigModalVisible.value = true
await loadAccountFromApi()
}
function formatSendTime(value?: string) { function formatSendTime(value?: string) {
if (!value) return '-' if (!value) return '-'
const parsed = dayjs(value) const parsed = dayjs(value)
@ -300,7 +485,7 @@ function handleRecordPageSizeChange(size: number) {
loadRecords() loadRecords()
} }
function applyTemplateByName(name: string | undefined, silent?: boolean) { function applyTemplateByName(name: string | undefined) {
if (!name || !templates.value[name]) return if (!name || !templates.value[name]) return
const tpl = templates.value[name] const tpl = templates.value[name]
contentDraft.value = tpl.content contentDraft.value = tpl.content
@ -308,7 +493,7 @@ function applyTemplateByName(name: string | undefined, silent?: boolean) {
} }
function onTemplateChange(name: string | undefined) { function onTemplateChange(name: string | undefined) {
applyTemplateByName(name, false) applyTemplateByName(name)
} }
function triggerImport() { function triggerImport() {
@ -390,6 +575,7 @@ function onExcelSelected(ev: Event) {
}) })
orderData.value = list orderData.value = list
recipientMode.value = 'excel'
Message.success(t('system.batchEmail.msgImportOk', { n: list.length })) Message.success(t('system.batchEmail.msgImportOk', { n: list.length }))
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
@ -486,7 +672,7 @@ function deleteCurrentTemplate() {
await loadTemplatesFromApi() await loadTemplatesFromApi()
const keys = Object.keys(templates.value) const keys = Object.keys(templates.value)
selectedTemplateName.value = keys[0] ?? '' selectedTemplateName.value = keys[0] ?? ''
applyTemplateByName(keys[0], true) applyTemplateByName(keys[0])
Message.success(t('system.batchEmail.msgDeleteTplOk', { name })) Message.success(t('system.batchEmail.msgDeleteTplOk', { name }))
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e as Error)?.message || t('system.batchEmail.errUnknown') const msg = (e as Error)?.message || t('system.batchEmail.errUnknown')
@ -498,36 +684,54 @@ function deleteCurrentTemplate() {
async function saveAccountConfig() { async function saveAccountConfig() {
const u = accountUsername.value.trim() const u = accountUsername.value.trim()
const p = accountPassword.value const p = resolvePasswordForRequest()
if (!u || !p) { if (!u) {
Message.warning(t('system.batchEmail.warnAccount')) Message.warning(t('system.batchEmail.warnAccount'))
return return false
}
if (!accountConfigured.value && (!p || isSmtpSecretMask(p))) {
Message.warning(t('system.batchEmail.warnPassword'))
return false
} }
try { try {
await saveBatchEmailAccount({ await saveBatchEmailAccount({
smtpUsername: u, smtpUsername: u,
smtpPassword: p, smtpPassword: p || SMTP_SECRET_MASK,
serverType: serverType.value, serverType: serverType.value,
}) })
accountConfigured.value = true
if (!accountPassword.value.trim() || isSmtpSecretMask(accountPassword.value)) {
accountPassword.value = SMTP_SECRET_MASK
}
Message.success(t('system.batchEmail.msgSaveConfigOk')) Message.success(t('system.batchEmail.msgSaveConfigOk'))
return true
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e as Error)?.message || t('system.batchEmail.errUnknown') const msg = (e as Error)?.message || t('system.batchEmail.errUnknown')
Message.error(msg) Message.error(msg)
return false
}
}
async function saveAccountConfigFromModal() {
savingConfig.value = true
try {
const ok = await saveAccountConfig()
if (ok) emailConfigModalVisible.value = false
} finally {
savingConfig.value = false
} }
} }
async function testConnection() { async function testConnection() {
const u = accountUsername.value.trim() if (!hasUsableSmtpAuth()) {
const p = accountPassword.value
if (!u || !p) {
Message.warning(t('system.batchEmail.warnAccount')) Message.warning(t('system.batchEmail.warnAccount'))
return return
} }
testing.value = true testing.value = true
try { try {
await testBatchEmailConnection({ await testBatchEmailConnection({
smtpUsername: u, smtpUsername: accountUsername.value.trim(),
smtpPassword: p, smtpPassword: resolvePasswordForRequest(),
serverType: serverType.value, serverType: serverType.value,
}) })
Message.success(t('system.batchEmail.msgTestOk')) Message.success(t('system.batchEmail.msgTestOk'))
@ -543,30 +747,35 @@ async function testConnection() {
} }
} }
async function startBatchSend() { function ensureRecipientsReady(): boolean {
if (!orderData.value.length) { if (!getRecipientsForSend().length) {
Message.warning(t('system.batchEmail.warnNoOrders')) Message.warning(t('system.batchEmail.warnNoRecipients'))
return return false
} }
return true
}
async function startBatchSend() {
if (!ensureRecipientsReady()) return
const recipients = getRecipientsForSend()
const bodyContent = contentDraft.value.trim() const bodyContent = contentDraft.value.trim()
if (!bodyContent) { if (!bodyContent) {
Message.warning(t('system.batchEmail.warnNoTemplate')) Message.warning(t('system.batchEmail.warnNoTemplate'))
return return
} }
const u = accountUsername.value.trim() const u = accountUsername.value.trim()
const p = accountPassword.value if (!hasUsableSmtpAuth()) {
if (!u || !p) {
Message.error(t('system.batchEmail.errNoAccount')) Message.error(t('system.batchEmail.errNoAccount'))
return return
} }
const p = resolvePasswordForRequest()
sending.value = true sending.value = true
let successCount = 0 let successCount = 0
let failCount = 0 let failCount = 0
const total = orderData.value.length
for (let i = 0; i < orderData.value.length; i += 1) { for (let i = 0; i < recipients.length; i += 1) {
const order = orderData.value[i] const order = recipients[i]
const email = order.寄件人邮箱?.trim() ?? '' const email = order.寄件人邮箱?.trim() ?? ''
try { try {
if (!email || !email.includes('@')) { if (!email || !email.includes('@')) {
@ -605,7 +814,7 @@ onMounted(async () => {
const names = templateNames.value const names = templateNames.value
if (names.length) { if (names.length) {
selectedTemplateName.value = names[0] selectedTemplateName.value = names[0]
applyTemplateByName(names[0], true) applyTemplateByName(names[0])
} }
}) })
</script> </script>
@ -614,7 +823,7 @@ onMounted(async () => {
.batch-email-container { .batch-email-container {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 16px; padding: 16px 16px 24px;
background-color: #f0f3f4; background-color: #f0f3f4;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -632,7 +841,7 @@ onMounted(async () => {
} }
.batch-email-left { .batch-email-left {
flex: 1 1 52%; flex: 1 1 54%;
min-width: 480px; min-width: 480px;
min-height: 0; min-height: 0;
display: flex; display: flex;
@ -640,8 +849,8 @@ onMounted(async () => {
} }
.batch-email-right { .batch-email-right {
flex: 1 1 48%; flex: 1 1 46%;
min-width: 420px; min-width: 400px;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -653,6 +862,7 @@ onMounted(async () => {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.batch-email-card { .batch-email-card {
@ -664,10 +874,14 @@ onMounted(async () => {
} }
.record-card { .record-card {
padding: 16px; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.recipient-section {
flex-shrink: 0;
}
.preview-section { .preview-section {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -676,22 +890,24 @@ onMounted(async () => {
overflow: auto; overflow: auto;
} }
.section-config {
flex-shrink: 0;
margin-top: auto;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
}
.record-header { .record-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px; padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0; flex-shrink: 0;
} }
.list-wrap {
flex: 1;
min-height: 0;
padding: 0 16px 16px;
display: flex;
flex-direction: column;
}
.record-table { .record-table {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -736,6 +952,10 @@ onMounted(async () => {
color: #262626; color: #262626;
} }
.recipient-tag {
margin-left: 4px;
}
.toolbar-btn { .toolbar-btn {
height: 32px; height: 32px;
border-radius: 4px; border-radius: 4px;
@ -763,6 +983,56 @@ onMounted(async () => {
margin-bottom: 8px; margin-bottom: 8px;
} }
.recipient-tabs {
:deep(.arco-tabs-content) {
padding-top: 12px;
}
}
.manual-email-textarea {
:deep(textarea) {
font-family: inherit;
}
}
.excel-import-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.recipient-preview {
margin-top: 12px;
padding: 12px;
background: #f7f8fa;
border-radius: 4px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.preview-title {
font-size: 13px;
color: #4e5969;
font-weight: 500;
}
.preview-more {
margin-top: 8px;
font-size: 12px;
color: #86909c;
}
.recipient-preview-table {
:deep(.arco-table-th) {
background: #f2f3f5;
}
}
.preview-toolbar { .preview-toolbar {
margin-bottom: 8px; margin-bottom: 8px;
} }
@ -772,24 +1042,41 @@ onMounted(async () => {
color: #86909c; color: #86909c;
} }
.warn-text {
color: rgb(var(--orange-6));
}
.content-textarea { .content-textarea {
margin-top: 4px; margin-top: 4px;
flex: 1; flex: 1;
min-height: 160px; min-height: 140px;
:deep(textarea) { :deep(textarea) {
height: 100% !important; height: 100% !important;
min-height: 160px; min-height: 140px;
} }
} }
.config-row { .password-hint {
margin-top: 8px; margin-top: 4px;
} }
.config-label { .email-config-spin {
font-size: 14px; display: block;
color: #262626; width: 100%;
}
.email-config-form {
:deep(.arco-form-item) {
margin-bottom: 16px;
}
}
.email-config-footer {
display: flex;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid var(--color-border-2);
} }
.subject-row { .subject-row {
@ -801,7 +1088,7 @@ onMounted(async () => {
} }
.subject-label { .subject-label {
flex: 0 0 96px; flex: 0 0 72px;
font-size: 14px; font-size: 14px;
color: #262626; color: #262626;
} }
@ -810,4 +1097,18 @@ onMounted(async () => {
flex: 1; flex: 1;
min-width: 240px; min-width: 240px;
} }
@media (max-width: 1200px) {
.batch-email-layout {
flex-direction: column;
overflow-y: auto;
}
.batch-email-left,
.batch-email-right {
min-width: 0;
flex: none;
min-height: 480px;
}
}
</style> </style>