feat: 优化批量邮件页并支持线上安全密码与手动收件人
重构收件人录入与邮箱配置弹窗,修复账号接口解析;支持脱敏密码复用后直接测试与发送。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
67cf30b6e8
commit
43209625d0
@ -1,9 +1,16 @@
|
||||
import { axiosInstance } from '@/api/rbac'
|
||||
import { assertBusinessOk, omsUrl } from '@/api/oms/common'
|
||||
import { assertBusinessOk, omsUrl, unwrapData } from '@/api/oms/common'
|
||||
|
||||
/** 与桌面版 emailAuto.py 一致:阿里企业邮等 */
|
||||
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 {
|
||||
name: string
|
||||
subject: string
|
||||
@ -106,10 +113,11 @@ function unwrapList(resBody: unknown): unknown[] {
|
||||
return unwrapPageResult(resBody).items
|
||||
}
|
||||
|
||||
function unwrapObject(resBody: unknown): unknown {
|
||||
assertBusinessOk(resBody)
|
||||
const root = (resBody as Record<string, unknown>) ?? {}
|
||||
return root.data ?? root.Data ?? null
|
||||
/** GET /system/batch-email/account */
|
||||
export async function getBatchEmailAccount(): Promise<BatchEmailAccount | null> {
|
||||
const res = await axiosInstance.get(omsUrl('/system/batch-email/account'))
|
||||
const raw = unwrapData<Record<string, unknown> | null>(res.data)
|
||||
return raw ? normalizeAccount(raw) : null
|
||||
}
|
||||
|
||||
/** GET /system/batch-email/templates/list */
|
||||
@ -136,13 +144,6 @@ export async function deleteBatchEmailTemplate(body: { name: string }) {
|
||||
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 */
|
||||
export async function saveBatchEmailAccount(body: BatchEmailAccount) {
|
||||
const res = await axiosInstance.post(omsUrl('/system/batch-email/account/save'), body)
|
||||
|
||||
@ -786,11 +786,13 @@ export default {
|
||||
'order.placeOrder.single.stepBasic': 'Basic info',
|
||||
'order.placeOrder.single.stepSender': 'Sender',
|
||||
'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.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.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.customTemplates': 'Custom templates',
|
||||
@ -1716,6 +1718,10 @@ export default {
|
||||
'system.batchEmail.emailConfig': 'Mailbox',
|
||||
'system.batchEmail.account': 'Account',
|
||||
'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.server.qiye': 'Aliyun enterprise 25',
|
||||
'system.batchEmail.server.qiyeSsl': 'Aliyun enterprise SSL 465',
|
||||
@ -1765,6 +1771,21 @@ export default {
|
||||
'system.batchEmail.msgTestOk': 'SMTP test succeeded',
|
||||
'system.batchEmail.errUnknown': 'Request 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.warnNoTemplate': 'Select or enter a template',
|
||||
'system.batchEmail.errNoAccount': 'Configure mailbox account and password',
|
||||
@ -1778,8 +1799,12 @@ export default {
|
||||
'system.batchEmail.msgSendDone': 'Done: {successCount} succeeded, {failCount} failed',
|
||||
|
||||
// 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.addNotice': 'Add notice',
|
||||
'system.announcement.actions.addMessage': 'Add message',
|
||||
'system.announcement.actions.refresh': 'Refresh',
|
||||
'system.announcement.actions.batchDelete': 'Delete selected',
|
||||
'system.announcement.actions.edit': 'Edit',
|
||||
'system.announcement.actions.view': 'View',
|
||||
@ -1794,6 +1819,10 @@ export default {
|
||||
'system.announcement.popup.no': 'No',
|
||||
'system.announcement.filter.noticeType': 'Notice 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.delay': 'Delay 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.receiverIdsPlaceholderCustomer': 'Search and select customers',
|
||||
'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.select': 'Please select',
|
||||
'system.announcement.modal.addTitle': 'Add Announcement',
|
||||
@ -2743,7 +2779,7 @@ export default {
|
||||
'channel.autoTrigger.form.promptRequired': 'Please enter a prompt message',
|
||||
'channel.autoTrigger.form.ruleNamePlaceholder': 'e.g. UK oversize intercept',
|
||||
'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.legacyTitle': 'Legacy plain-text condition detected — rebuild with the builder below',
|
||||
'channel.autoTrigger.form.testFormula': 'Test formula',
|
||||
|
||||
@ -671,11 +671,13 @@ export default {
|
||||
'order.placeOrder.single.stepBasic': '基本信息',
|
||||
'order.placeOrder.single.stepSender': '发件人',
|
||||
'order.placeOrder.single.stepRecipient': '收件人',
|
||||
'order.placeOrder.single.stepDeclaration': '申报并提交',
|
||||
'order.placeOrder.single.stepDeclaration': '申报信息',
|
||||
'order.placeOrder.single.tip1': '带 * 号为必填项,请按区块依次填写订单信息。',
|
||||
'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。',
|
||||
'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交',
|
||||
'order.placeOrder.single.submitReady': '信息已完善,可提交下单',
|
||||
'order.placeOrder.single.viewCustomerGuide': '查看对客说明',
|
||||
'order.placeOrder.single.declaredCurrencyHint': '默认是美元,请注意填写申报价格',
|
||||
|
||||
'order.placeOrder.standardTemplates': '标准模板',
|
||||
'order.placeOrder.customTemplates': '自定义模板',
|
||||
@ -1734,6 +1736,10 @@ export default {
|
||||
'system.batchEmail.emailConfig': '邮箱配置',
|
||||
'system.batchEmail.account': '账号',
|
||||
'system.batchEmail.password': '安全密码',
|
||||
'system.batchEmail.passwordPlaceholder': '请填写邮箱客户端授权码/安全密码',
|
||||
'system.batchEmail.passwordPlaceholderEdit': '留空则沿用已保存的安全密码',
|
||||
'system.batchEmail.passwordSavedHint': '已保存在服务器,无需重复填写;仅修改账号或 SMTP 类型时可直接保存',
|
||||
'system.batchEmail.warnPassword': '请填写安全密码(阿里邮箱请使用客户端授权码)',
|
||||
'system.batchEmail.serverType': 'SMTP',
|
||||
'system.batchEmail.server.qiye': '企业邮 25',
|
||||
'system.batchEmail.server.qiyeSsl': '企业邮 SSL 465',
|
||||
@ -1783,6 +1789,21 @@ export default {
|
||||
'system.batchEmail.msgTestOk': '邮箱连接测试成功',
|
||||
'system.batchEmail.errUnknown': '请求失败',
|
||||
'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.warnNoTemplate': '请先选择或填写发送模板',
|
||||
'system.batchEmail.errNoAccount': '请配置邮箱账号和密码',
|
||||
@ -1796,8 +1817,12 @@ export default {
|
||||
'system.batchEmail.msgSendDone': '发送完成:成功 {successCount},失败 {failCount}',
|
||||
|
||||
// 系统 - 公告通知
|
||||
'system.announcement.search.placeholder': '请输入关键字',
|
||||
'system.announcement.search.placeholder': '搜索标题或正文',
|
||||
'system.announcement.search.reset': '重置',
|
||||
'system.announcement.actions.add': '新增公告通知',
|
||||
'system.announcement.actions.addNotice': '新增公告',
|
||||
'system.announcement.actions.addMessage': '新增站内信',
|
||||
'system.announcement.actions.refresh': '刷新',
|
||||
'system.announcement.actions.batchDelete': '删除所选',
|
||||
'system.announcement.actions.edit': '编辑',
|
||||
'system.announcement.actions.view': '查看',
|
||||
@ -1812,6 +1837,10 @@ export default {
|
||||
'system.announcement.popup.no': '否',
|
||||
'system.announcement.filter.noticeType': '通知类型',
|
||||
'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.delay': '延迟发送',
|
||||
'system.announcement.sendWay.schedule': '定时发送',
|
||||
@ -1842,6 +1871,13 @@ export default {
|
||||
'system.announcement.form.receiverIdsPlaceholderUser': '请搜索并选择内部用户',
|
||||
'system.announcement.form.receiverIdsPlaceholderCustomer': '请搜索并选择客户',
|
||||
'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.select': '请选择',
|
||||
'system.announcement.modal.addTitle': '新增公告通知',
|
||||
@ -2779,7 +2815,7 @@ export default {
|
||||
'channel.autoTrigger.form.promptRequired': '请填写提示语',
|
||||
'channel.autoTrigger.form.ruleNamePlaceholder': '例如:英国阿里超尺寸拦截',
|
||||
'channel.autoTrigger.form.promptPlaceholder': '例如:超尺寸,禁止下单',
|
||||
'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,用户 ID,逗号分隔',
|
||||
'channel.autoTrigger.form.notifierPlaceholder': '警告/问题件可选,请选择通知人',
|
||||
'channel.autoTrigger.form.loadExample': '载入超尺寸示例',
|
||||
'channel.autoTrigger.form.legacyTitle': '检测到旧版纯文本条件,请用下方构建器重新配置',
|
||||
'channel.autoTrigger.form.testFormula': '测试公式',
|
||||
|
||||
@ -6,34 +6,82 @@
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<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>
|
||||
<a-select
|
||||
v-model="selectedTemplateName"
|
||||
:style="{ width: '240px' }"
|
||||
:style="{ width: '220px' }"
|
||||
:placeholder="t('system.batchEmail.selectTemplate')"
|
||||
allow-clear
|
||||
@change="onTemplateChange"
|
||||
>
|
||||
<a-option v-for="name in templateNames" :key="name" :value="name">{{ name }}</a-option>
|
||||
</a-select>
|
||||
<a-tag v-if="recipientCount > 0" color="arcoblue" class="recipient-tag">
|
||||
{{ t('system.batchEmail.recipientCount', { n: recipientCount }) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-button class="toolbar-btn" :loading="accountLoading" @click="openEmailConfigModal">
|
||||
{{ t('system.batchEmail.emailConfig') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="toolbar-btn"
|
||||
:loading="sending"
|
||||
:disabled="sending"
|
||||
:disabled="sending || recipientCount === 0"
|
||||
@click="startBatchSend"
|
||||
>
|
||||
{{ t('system.batchEmail.send') }}
|
||||
<template v-if="recipientCount > 0"> ({{ recipientCount }})</template>
|
||||
</a-button>
|
||||
</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-title">{{ t('system.batchEmail.preview') }}</div>
|
||||
<div class="preview-toolbar">
|
||||
@ -56,30 +104,6 @@
|
||||
/>
|
||||
</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
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
@ -104,22 +128,24 @@
|
||||
@page-size-change="handleRecordPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="recordColumns"
|
||||
:data="recordList"
|
||||
:loading="recordLoading"
|
||||
:pagination="false"
|
||||
:scroll="{ y: recordTableHeight }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
class="record-table"
|
||||
>
|
||||
<template #content="{ record }">
|
||||
<a-tooltip :content="record.content">
|
||||
<span class="record-content-cell">{{ record.content }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="list-wrap">
|
||||
<a-table
|
||||
:columns="recordColumns"
|
||||
:data="recordList"
|
||||
:loading="recordLoading"
|
||||
:pagination="false"
|
||||
:scroll="{ y: '100%' }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
class="record-table"
|
||||
>
|
||||
<template #content="{ record }">
|
||||
<a-tooltip :content="record.content">
|
||||
<span class="record-content-cell">{{ record.content }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -132,6 +158,54 @@
|
||||
>
|
||||
<a-input v-model="newTemplateName" :placeholder="t('system.batchEmail.saveTplNamePlaceholder')" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -146,11 +220,13 @@ import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||
import {
|
||||
deleteBatchEmailTemplate,
|
||||
getBatchEmailAccount,
|
||||
isSmtpSecretMask,
|
||||
listBatchEmailRecords,
|
||||
listBatchEmailTemplates,
|
||||
saveBatchEmailAccount,
|
||||
saveBatchEmailTemplate,
|
||||
sendBatchEmailOne,
|
||||
SMTP_SECRET_MASK,
|
||||
testBatchEmailConnection,
|
||||
type BatchEmailSendRecord,
|
||||
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 orderData = ref<OrderRow[]>([])
|
||||
const recipientMode = ref<'manual' | 'excel'>('manual')
|
||||
const manualEmailsText = ref('')
|
||||
const templates = ref<Record<string, TemplateData>>({})
|
||||
const selectedTemplateName = ref<string>('')
|
||||
const contentDraft = ref('')
|
||||
const subjectDraft = ref('')
|
||||
const accountUsername = ref('')
|
||||
const accountPassword = ref('')
|
||||
const accountConfigured = ref(false)
|
||||
const serverType = ref<BatchEmailServerType>('qiye')
|
||||
const sending = ref(false)
|
||||
const testing = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const accountLoading = ref(false)
|
||||
const emailConfigModalVisible = ref(false)
|
||||
|
||||
const saveTplModalVisible = ref(false)
|
||||
const newTemplateName = ref('')
|
||||
@ -223,10 +308,38 @@ const recordTotal = ref(0)
|
||||
const recordPage = ref(1)
|
||||
const recordPageSize = ref(30)
|
||||
const recordLoading = ref(false)
|
||||
const recordTableHeight = '100%'
|
||||
const recipientPreviewLimit = 8
|
||||
|
||||
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(() => [
|
||||
{ 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 },
|
||||
@ -243,6 +356,48 @@ function templatesToMap(list: Array<{ name: string; subject: string; content: st
|
||||
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() {
|
||||
try {
|
||||
const list = await listBatchEmailTemplates()
|
||||
@ -258,17 +413,47 @@ async function loadTemplatesFromApi() {
|
||||
}
|
||||
|
||||
async function loadAccountFromApi() {
|
||||
accountLoading.value = true
|
||||
try {
|
||||
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
|
||||
accountPassword.value = account.smtpPassword
|
||||
accountPassword.value = account.smtpPassword || SMTP_SECRET_MASK
|
||||
if (account.serverType) serverType.value = account.serverType
|
||||
} catch {
|
||||
/* ignore */
|
||||
} catch (e: unknown) {
|
||||
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) {
|
||||
if (!value) return '-'
|
||||
const parsed = dayjs(value)
|
||||
@ -300,7 +485,7 @@ function handleRecordPageSizeChange(size: number) {
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
function applyTemplateByName(name: string | undefined, silent?: boolean) {
|
||||
function applyTemplateByName(name: string | undefined) {
|
||||
if (!name || !templates.value[name]) return
|
||||
const tpl = templates.value[name]
|
||||
contentDraft.value = tpl.content
|
||||
@ -308,7 +493,7 @@ function applyTemplateByName(name: string | undefined, silent?: boolean) {
|
||||
}
|
||||
|
||||
function onTemplateChange(name: string | undefined) {
|
||||
applyTemplateByName(name, false)
|
||||
applyTemplateByName(name)
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
@ -390,6 +575,7 @@ function onExcelSelected(ev: Event) {
|
||||
})
|
||||
|
||||
orderData.value = list
|
||||
recipientMode.value = 'excel'
|
||||
Message.success(t('system.batchEmail.msgImportOk', { n: list.length }))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@ -486,7 +672,7 @@ function deleteCurrentTemplate() {
|
||||
await loadTemplatesFromApi()
|
||||
const keys = Object.keys(templates.value)
|
||||
selectedTemplateName.value = keys[0] ?? ''
|
||||
applyTemplateByName(keys[0], true)
|
||||
applyTemplateByName(keys[0])
|
||||
Message.success(t('system.batchEmail.msgDeleteTplOk', { name }))
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as Error)?.message || t('system.batchEmail.errUnknown')
|
||||
@ -498,36 +684,54 @@ function deleteCurrentTemplate() {
|
||||
|
||||
async function saveAccountConfig() {
|
||||
const u = accountUsername.value.trim()
|
||||
const p = accountPassword.value
|
||||
if (!u || !p) {
|
||||
const p = resolvePasswordForRequest()
|
||||
if (!u) {
|
||||
Message.warning(t('system.batchEmail.warnAccount'))
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!accountConfigured.value && (!p || isSmtpSecretMask(p))) {
|
||||
Message.warning(t('system.batchEmail.warnPassword'))
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await saveBatchEmailAccount({
|
||||
smtpUsername: u,
|
||||
smtpPassword: p,
|
||||
smtpPassword: p || SMTP_SECRET_MASK,
|
||||
serverType: serverType.value,
|
||||
})
|
||||
accountConfigured.value = true
|
||||
if (!accountPassword.value.trim() || isSmtpSecretMask(accountPassword.value)) {
|
||||
accountPassword.value = SMTP_SECRET_MASK
|
||||
}
|
||||
Message.success(t('system.batchEmail.msgSaveConfigOk'))
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as Error)?.message || t('system.batchEmail.errUnknown')
|
||||
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() {
|
||||
const u = accountUsername.value.trim()
|
||||
const p = accountPassword.value
|
||||
if (!u || !p) {
|
||||
if (!hasUsableSmtpAuth()) {
|
||||
Message.warning(t('system.batchEmail.warnAccount'))
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
await testBatchEmailConnection({
|
||||
smtpUsername: u,
|
||||
smtpPassword: p,
|
||||
smtpUsername: accountUsername.value.trim(),
|
||||
smtpPassword: resolvePasswordForRequest(),
|
||||
serverType: serverType.value,
|
||||
})
|
||||
Message.success(t('system.batchEmail.msgTestOk'))
|
||||
@ -543,30 +747,35 @@ async function testConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startBatchSend() {
|
||||
if (!orderData.value.length) {
|
||||
Message.warning(t('system.batchEmail.warnNoOrders'))
|
||||
return
|
||||
function ensureRecipientsReady(): boolean {
|
||||
if (!getRecipientsForSend().length) {
|
||||
Message.warning(t('system.batchEmail.warnNoRecipients'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function startBatchSend() {
|
||||
if (!ensureRecipientsReady()) return
|
||||
const recipients = getRecipientsForSend()
|
||||
const bodyContent = contentDraft.value.trim()
|
||||
if (!bodyContent) {
|
||||
Message.warning(t('system.batchEmail.warnNoTemplate'))
|
||||
return
|
||||
}
|
||||
const u = accountUsername.value.trim()
|
||||
const p = accountPassword.value
|
||||
if (!u || !p) {
|
||||
if (!hasUsableSmtpAuth()) {
|
||||
Message.error(t('system.batchEmail.errNoAccount'))
|
||||
return
|
||||
}
|
||||
const p = resolvePasswordForRequest()
|
||||
|
||||
sending.value = true
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
const total = orderData.value.length
|
||||
|
||||
for (let i = 0; i < orderData.value.length; i += 1) {
|
||||
const order = orderData.value[i]
|
||||
for (let i = 0; i < recipients.length; i += 1) {
|
||||
const order = recipients[i]
|
||||
const email = order.寄件人邮箱?.trim() ?? ''
|
||||
try {
|
||||
if (!email || !email.includes('@')) {
|
||||
@ -605,7 +814,7 @@ onMounted(async () => {
|
||||
const names = templateNames.value
|
||||
if (names.length) {
|
||||
selectedTemplateName.value = names[0]
|
||||
applyTemplateByName(names[0], true)
|
||||
applyTemplateByName(names[0])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -614,7 +823,7 @@ onMounted(async () => {
|
||||
.batch-email-container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
padding: 16px 16px 24px;
|
||||
background-color: #f0f3f4;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@ -632,7 +841,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.batch-email-left {
|
||||
flex: 1 1 52%;
|
||||
flex: 1 1 54%;
|
||||
min-width: 480px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@ -640,8 +849,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.batch-email-right {
|
||||
flex: 1 1 48%;
|
||||
min-width: 420px;
|
||||
flex: 1 1 46%;
|
||||
min-width: 400px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -653,6 +862,7 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.batch-email-card {
|
||||
@ -664,10 +874,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.record-card {
|
||||
padding: 16px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.recipient-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@ -676,22 +890,24 @@ onMounted(async () => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.section-config {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px 16px 0;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@ -736,6 +952,10 @@ onMounted(async () => {
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.recipient-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
@ -763,6 +983,56 @@ onMounted(async () => {
|
||||
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 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@ -772,24 +1042,41 @@ onMounted(async () => {
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.warn-text {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
margin-top: 4px;
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
min-height: 140px;
|
||||
|
||||
:deep(textarea) {
|
||||
height: 100% !important;
|
||||
min-height: 160px;
|
||||
min-height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.config-row {
|
||||
margin-top: 8px;
|
||||
.password-hint {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
.email-config-spin {
|
||||
display: block;
|
||||
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 {
|
||||
@ -801,7 +1088,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.subject-label {
|
||||
flex: 0 0 96px;
|
||||
flex: 0 0 72px;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
@ -810,4 +1097,18 @@ onMounted(async () => {
|
||||
flex: 1;
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user