feat: 批量邮件记录 Handsontable 查询与近30天软拦防重
All checks were successful
Build and Deploy Front / deploy (push) Successful in 9m36s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-11-04 15:00:08 +08:00
parent 0910538e6b
commit 338374397c
8 changed files with 394 additions and 109 deletions

View File

@ -167,8 +167,37 @@ export async function sendBatchEmailOne(body: BatchEmailSendOneBody) {
assertBusinessOk(res.data)
}
export interface BatchEmailDuplicatePair {
to: string
subject: string
}
export interface BatchEmailDuplicateHit {
toEmail: string
subject: string
sendTime: string
sender: string
}
function normalizeDuplicateHit(raw: unknown): BatchEmailDuplicateHit | null {
if (raw == null || typeof raw !== 'object') return null
const row = raw as Record<string, unknown>
const toEmail = pickStr(row, 'toEmail', 'ToEmail')
if (!toEmail) return null
return {
toEmail,
subject: pickStr(row, 'subject', 'Subject'),
sendTime: pickStr(row, 'sendTime', 'SendTime'),
sender: pickStr(row, 'sender', 'Sender'),
}
}
/** GET /system/batch-email/records/list */
export async function listBatchEmailRecords(params?: { page?: number; pageSize?: number }) {
export async function listBatchEmailRecords(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
const res = await axiosInstance.get(omsUrl('/system/batch-email/records/list'), { params })
const page = unwrapPageResult(res.data)
return {
@ -178,3 +207,15 @@ export async function listBatchEmailRecords(params?: { page?: number; pageSize?:
total: page.total,
}
}
/** POST /system/batch-email/check-duplicates — 近 N 天同邮箱同主题预检 */
export async function checkBatchEmailDuplicates(body: {
pairs: BatchEmailDuplicatePair[]
withinDays?: number
}): Promise<BatchEmailDuplicateHit[]> {
const res = await axiosInstance.post(omsUrl('/system/batch-email/check-duplicates'), body)
const list = unwrapList(res.data)
return list
.map(normalizeDuplicateHit)
.filter((item): item is BatchEmailDuplicateHit => item != null)
}

View File

@ -4399,11 +4399,24 @@ export default {
'system.batchEmail.errLoadTemplates': 'Failed to load templates',
'system.batchEmail.errLoadRecords': 'Failed to load email records',
'system.batchEmail.recordList': 'Email records',
'system.batchEmail.recordSearch': 'Search',
'system.batchEmail.recordSearchPlaceholder': 'Email / subject / body / sender',
'system.batchEmail.record.toEmail': 'Email',
'system.batchEmail.record.subject': 'Subject',
'system.batchEmail.record.content': 'Content',
'system.batchEmail.record.sendTime': 'Sent at',
'system.batchEmail.record.sender': 'Sender',
'system.batchEmail.errCheckDup': 'Duplicate check failed. Please try again.',
'system.batchEmail.dupConfirmTitle': 'Duplicates in the last 30 days',
'system.batchEmail.dupConfirmBody': '{count} item(s) share the same email + subject within {days} days (including in-batch duplicates):',
'system.batchEmail.dupConfirmMore': '{n} more not shown',
'system.batchEmail.dupConfirmBatchOnly': '{n} more are duplicates within this batch',
'system.batchEmail.dupBatchOnlyTip': '{n} duplicate group(s) in this batch — only the first of each email+subject will be sent',
'system.batchEmail.dupConfirmHint': 'Duplicates are skipped by default. Click “Send anyway” to include them.',
'system.batchEmail.dupConfirmForce': 'Send anyway',
'system.batchEmail.dupConfirmSkip': 'Skip duplicates',
'system.batchEmail.failDupRecent': 'Already sent with the same subject within {days} days',
'system.batchEmail.failDupInBatch': 'Duplicate in this batch, skipped',
'system.batchEmail.warnEmptyContent': 'Template body is required',
'system.batchEmail.warnEmptyName': 'Template name is required',
'system.batchEmail.confirmOverwriteTitle': 'Overwrite?',

View File

@ -4423,11 +4423,24 @@ export default {
'system.batchEmail.errLoadTemplates': '加载模板失败',
'system.batchEmail.errLoadRecords': '加载邮件记录失败',
'system.batchEmail.recordList': '邮件记录',
'system.batchEmail.recordSearch': '查询',
'system.batchEmail.recordSearchPlaceholder': '邮箱 / 主题 / 内容 / 发送人',
'system.batchEmail.record.toEmail': '邮箱',
'system.batchEmail.record.subject': '主题',
'system.batchEmail.record.content': '内容',
'system.batchEmail.record.sendTime': '发送时间',
'system.batchEmail.record.sender': '发送人',
'system.batchEmail.errCheckDup': '查重失败,请稍后重试',
'system.batchEmail.dupConfirmTitle': '发现近 30 天重复发送',
'system.batchEmail.dupConfirmBody': '有 {count} 条「同邮箱 + 同主题」在近 {days} 天内已发送过(含本批重复):',
'system.batchEmail.dupConfirmMore': '另有 {n} 条未展示',
'system.batchEmail.dupConfirmBatchOnly': '另有 {n} 条为本批内重复收件',
'system.batchEmail.dupBatchOnlyTip': '本批有 {n} 组重复收件,同邮箱同主题仅发送首条',
'system.batchEmail.dupConfirmHint': '默认跳过重复项;若仍要发送,请点「仍要发送」。',
'system.batchEmail.dupConfirmForce': '仍要发送',
'system.batchEmail.dupConfirmSkip': '仅发送未重复',
'system.batchEmail.failDupRecent': '近 {days} 天已发送过同主题',
'system.batchEmail.failDupInBatch': '本批内重复,已跳过',
'system.batchEmail.warnEmptyContent': '模板内容不能为空',
'system.batchEmail.warnEmptyName': '模板名称不能为空',
'system.batchEmail.confirmOverwriteTitle': '确认覆盖',

View File

@ -325,6 +325,8 @@ void main() {
canvas.style.height = '100%';
canvas.style.display = 'block';
canvas.style.pointerEvents = 'none';
canvas.style.opacity = '0';
canvas.style.transition = 'opacity 0.55s ease';
container.appendChild(canvas);
const geometry = new Triangle(gl);
@ -457,6 +459,11 @@ void main() {
tryStart();
syncUniforms();
// 先画一帧再淡入,盖在静态海天底上,避免灰/白闪
requestAnimationFrame(() => {
paint(performance.now());
canvas.style.opacity = '1';
});
teardown = () => {
tryStop();
@ -494,5 +501,13 @@ void main() {
display: block;
width: 100% !important;
height: 100% !important;
will-change: opacity;
}
@media (prefers-reduced-motion: reduce) {
.gradient-waves :deep(canvas) {
transition: none !important;
opacity: 1 !important;
}
}
</style>

View File

@ -40,7 +40,8 @@
as="span"
class-name="ai-welcome-title__type"
:text="shortTitle ? copy.titleRestShort : copy.titleRest"
:typing-speed="36"
:typing-speed="48"
:initial-delay="280"
:pause-duration="900"
:show-cursor="false"
:loop="false"

View File

@ -133,7 +133,7 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import SiteHeader from './SiteHeader.vue';
import SiteAdvisorShell from './SiteAdvisorShell.vue';
@ -141,6 +141,7 @@
import SiteServicesPanel from './SiteServicesPanel.vue';
import SiteTopicPanel from './SiteTopicPanel.vue';
import SiteWecomModal from './SiteWecomModal.vue';
import GradientWaves from './GradientWaves.vue';
import {
goOfficialLoginFromHref,
officialLoginUrl,
@ -154,7 +155,6 @@
import './styles/main.css';
import './styles/ai-advisor.css';
const GradientWaves = defineAsyncComponent(() => import('./GradientWaves.vue'));
const glass = provideSiteLiquidGlass();
const glassSceneEl = ref<HTMLElement | null>(null);
const glassContentEl = ref<HTMLElement | null>(null);

View File

@ -2526,24 +2526,56 @@ body.command-home .ai-stage-map--eu {
justify-content: flex-start;
min-height: 0;
padding: 0 0 16px;
animation: ai-h5-rise-in 0.42s var(--ai-ease) both;
}
.ai-welcome.is-hidden {
display: none;
}
@keyframes ai-h5-rise-in {
/* 首页入场:轻上移 + 淡入,分拍错开,避免整块突然砸下来 */
@keyframes ai-welcome-rise {
from {
opacity: 0;
transform: translateY(10px);
transform: translate3d(0, 14px, 0);
}
to {
opacity: 1;
transform: translateY(0);
transform: translate3d(0, 0, 0);
}
}
.ai-welcome__intro,
.ai-welcome__meta,
.ai-welcome-compose-panel,
.ai-intent-group,
.ai-trust-bar {
animation: ai-welcome-rise 0.78s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.ai-welcome__intro {
animation-delay: 0.12s;
}
.ai-welcome__meta {
animation-delay: 0.26s;
}
.ai-welcome-compose-panel {
animation-delay: 0.38s;
}
.ai-intent-group:nth-child(1) {
animation-delay: 0.52s;
}
.ai-intent-group:nth-child(2) {
animation-delay: 0.66s;
}
.ai-trust-bar {
animation-delay: 0.8s;
}
.ai-welcome__hero {
position: relative;
display: flex;
@ -2813,10 +2845,6 @@ body.command-home #navbar .site-nav-avatar:hover {
height: auto;
padding: 10px 12px;
gap: 5px;
background: color-mix(in srgb, #fff 70%, #e8eef2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-welcome__command .ai-intent-grid--growth .ai-intent-door__head {
@ -2856,20 +2884,19 @@ body.command-home #navbar .site-nav-avatar:hover {
line-clamp: 2;
}
/* 履约 / 权益门卡(liquid-glass 表面) */
/* 履约 / 权益门卡(liquid-glass 表面)
未就绪也用半透明玻璃底,避免「先白再玻璃」闪一下;实心霜面仅 fallback */
.ai-welcome__command .ai-intent-door {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: var(--ai-dock-radius);
padding: 14px 14px;
background: color-mix(in srgb, #fff 78%, #e6edf2);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
var(--ai-paper-shadow);
background: rgba(255, 255, 255, 0.16);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
transition:
background 0.2s var(--ai-ease),
border-color 0.2s var(--ai-ease),
box-shadow 0.2s var(--ai-ease),
transform 0.2s var(--ai-ease);
--lg-foreground: var(--ai-brand, #023e8a);
@ -2904,11 +2931,9 @@ body.command-home #navbar .site-nav-avatar:hover {
position: relative;
padding: 16px 16px;
border-radius: 14px;
background: color-mix(in srgb, #fff 62%, #e8eef3);
border: 1px solid rgba(255, 255, 255, 0.78);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 2px 8px rgba(15, 23, 42, 0.05);
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.48);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.ai-welcome__command .ai-intent-door.is-primary .ai-intent-door__icon {
@ -2925,29 +2950,25 @@ body.command-home #navbar .site-nav-avatar:hover {
}
.ai-welcome__command .ai-intent-door:hover {
border-color: rgba(255, 255, 255, 0.9);
background: color-mix(in srgb, #fff 78%, #e8eef3);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
0 6px 18px rgba(15, 23, 42, 0.07);
border-color: rgba(255, 255, 255, 0.62);
background: rgba(255, 255, 255, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
}
.ai-welcome__command .ai-intent-door.is-primary:hover {
border-color: rgba(255, 255, 255, 0.95);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 1),
0 8px 22px rgba(15, 23, 42, 0.08);
border-color: rgba(255, 255, 255, 0.68);
background: rgba(255, 255, 255, 0.26);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
/* liquid-glass 就绪后透出折射;未就绪 / 降级时保留 CSS 霜面 */
/* liquid-glass 就绪后透出折射 */
.ai-welcome__command .ai-intent-door.lg-surface.is-glass-ready:not(.is-glass-fallback),
.ai-welcome__command .ai-intent-door.lg-surface.is-glass-ready:not(.is-glass-fallback).is-primary {
.ai-welcome__command .ai-intent-door.lg-surface.is-glass-ready:not(.is-glass-fallback).is-primary,
.ai-welcome__command .ai-intent-grid--growth .ai-intent-door.lg-surface.is-glass-ready:not(.is-glass-fallback) {
background: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.ai-welcome__command .ai-intent-door.lg-surface:not(.is-glass-ready),
@ -2955,9 +2976,11 @@ body.command-home #navbar .site-nav-avatar:hover {
visibility: visible !important;
}
.ai-welcome__command .ai-intent-grid--growth .ai-intent-door.lg-surface:not(.is-glass-ready),
/* 仅 WebGPU 不可用时才用实心霜面,避免首屏白闪 */
.ai-welcome__command .ai-intent-door.lg-surface.is-glass-fallback,
.ai-welcome__command .ai-intent-door.lg-surface.is-glass-fallback.is-primary,
.ai-welcome__command .ai-intent-grid--growth .ai-intent-door.lg-surface.is-glass-fallback {
background: color-mix(in srgb, #fff 70%, #e8eef2);
background: color-mix(in srgb, #fff 72%, #e8eef2);
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
@ -3040,23 +3063,28 @@ body.command-home #navbar .site-nav-avatar:hover {
display: block;
filter: drop-shadow(0 12px 24px rgba(var(--ai-sky-rgb), 0.1));
transform-origin: 50% 60%;
animation: aiPlaneIn 1.8s cubic-bezier(0.22, 1, 0.36, 1) both;
animation: aiPlaneIn 1.15s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes aiPlaneIn {
from {
opacity: 0;
transform: translate3d(-10%, 18%, 0) scale(0.72) rotate(-3deg);
transform: translate3d(0, 16px, 0) scale(0.96);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
transform: translate3d(0, 0, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ai-welcome-plane img {
animation: none;
.ai-welcome-plane img,
.ai-welcome__intro,
.ai-welcome__meta,
.ai-welcome-compose-panel,
.ai-intent-group,
.ai-trust-bar {
animation: none !important;
}
}
@ -5216,13 +5244,14 @@ body.command-home * {
-webkit-backdrop-filter: none !important;
}
/* Keep specular sheen; drop animated glass blobs only */
body.command-home .ai-intent-door::after {
/* Keep specular sheen off welcome glass doors (sheen reads as white flash before LG ready) */
body.command-home .ai-intent-door::after,
body.command-home .ai-welcome__command .ai-intent-door::before,
body.command-home .ai-welcome__command .ai-intent-door::after {
display: none !important;
}
body.command-home .ai-intent-door::before,
body.command-home .ai-welcome__command .ai-intent-door::before {
body.command-home .ai-intent-door:not(.lg-surface)::before {
display: block !important;
background: var(--ai-glass-sheen);
}

View File

@ -201,8 +201,24 @@
</span>
<a-tag size="small" color="gray">{{ recordTotal }}</a-tag>
</button>
</div>
<div v-show="recordsExpanded" class="record-body">
<div class="table-toolbar">
<div class="toolbar-left">
<a-input
v-model="recordKeyword"
allow-clear
:placeholder="t('system.batchEmail.recordSearchPlaceholder')"
style="width: 260px"
@press-enter="handleRecordSearch"
@clear="handleRecordSearch"
/>
<a-button class="app-btn-search" @click="handleRecordSearch">
{{ t('system.batchEmail.recordSearch') }}
</a-button>
</div>
<div class="toolbar-right">
<PaginationPro
v-show="recordsExpanded"
:total-count="recordTotal"
v-model:current-page="recordPage"
v-model:page-size="recordPageSize"
@ -211,23 +227,22 @@
@page-size-change="handleRecordPageSizeChange"
/>
</div>
<div v-show="recordsExpanded" class="list-wrap">
<a-table
:columns="recordColumns"
</div>
<a-spin :loading="recordLoading" class="record-spin">
<div ref="recordTableContainerRef" class="list-wrap">
<HandsontablePro
:columns="recordHotColumns"
: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>
:total="recordTotal"
:current-page="recordPage"
:page-size="recordPageSize"
:stretch-columns="true"
:height="recordHandsontableHeight"
:read-only="true"
:show-action-column="false"
/>
</div>
</a-spin>
</div>
</div>
</div>
@ -388,15 +403,18 @@
<script setup lang="ts">
defineOptions({ name: 'SystemBatchEmail' })
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, h, nextTick, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx'
import dayjs from 'dayjs'
import { Message, Modal } from '@arco-design/web-vue'
import { IconDown } from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
checkBatchEmailDuplicates,
deleteBatchEmailTemplate,
getBatchEmailAccount,
isSmtpSecretMask,
@ -407,6 +425,7 @@ import {
sendBatchEmailOne,
SMTP_SECRET_MASK,
testBatchEmailConnection,
type BatchEmailDuplicateHit,
type BatchEmailSendRecord,
type BatchEmailServerType,
} from '@/api/system/batchEmail'
@ -494,10 +513,16 @@ const saveTplLoading = ref(false)
const recordList = ref<BatchEmailSendRecord[]>([])
const recordTotal = ref(0)
const recordPage = ref(1)
const recordPageSize = ref(30)
const recordPageSize = ref(50)
const recordKeyword = ref('')
const recordLoading = ref(false)
const { tableContainerRef: recordTableContainerRef, tableHeight: recordTableHeight } =
useTableContainerHeight()
const recordHandsontableHeight = computed(() => Math.max(recordTableHeight.value || 0, 280))
const recipientPreviewLimit = 8
const SEND_CONCURRENCY = 3
const DUP_WITHIN_DAYS = 30
const DUP_PREVIEW_LIMIT = 8
const sendConfirmVisible = ref(false)
const sendAbortRequested = ref(false)
@ -596,14 +621,18 @@ const recipientPreviewRows = computed(() =>
})),
)
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 },
{ title: t('system.batchEmail.record.content'), dataIndex: 'content', slotName: 'content', ellipsis: true },
{ title: t('system.batchEmail.record.sendTime'), dataIndex: 'sendTime', width: 160 },
{ title: t('system.batchEmail.record.sender'), dataIndex: 'sender', width: 100, ellipsis: true, tooltip: true },
const recordHotColumns = computed(() => [
{ key: 'toEmail', label: t('system.batchEmail.record.toEmail'), width: 180 },
{ key: 'subject', label: t('system.batchEmail.record.subject'), width: 200 },
{ key: 'content', label: t('system.batchEmail.record.content'), width: 320 },
{ key: 'sendTime', label: t('system.batchEmail.record.sendTime'), width: 170 },
{ key: 'sender', label: t('system.batchEmail.record.sender'), width: 120 },
])
function buildDuplicateKey(email: string, subject: string) {
return `${email.trim().toLowerCase()}\u0001${subject.trim()}`
}
function templatesToMap(list: Array<{ name: string; subject: string; content: string }>): Record<string, TemplateData> {
const out: Record<string, TemplateData> = {}
list.forEach((item) => {
@ -768,6 +797,7 @@ async function loadRecords() {
const { items, total } = await listBatchEmailRecords({
page: recordPage.value,
pageSize: recordPageSize.value,
keyword: recordKeyword.value.trim() || undefined,
})
recordList.value = items.map((item) => ({
...item,
@ -782,6 +812,11 @@ async function loadRecords() {
}
}
function handleRecordSearch() {
recordPage.value = 1
void loadRecords()
}
function handleRecordPageSizeChange(size: number) {
recordPageSize.value = size
recordPage.value = 1
@ -1114,7 +1149,108 @@ function openSendConfirm() {
function confirmAndStartSend() {
sendConfirmVisible.value = false
void prepareAndStartSend()
}
function formatDupHitLine(hit: BatchEmailDuplicateHit) {
const time = formatSendTime(hit.sendTime)
return `${hit.toEmail} · ${hit.subject || '-'} · ${time}`
}
async function prepareAndStartSend() {
const recipients = getRecipientsForSend()
const subjBase =
subjectDraft.value.trim() || selectedTemplateName.value || t('system.batchEmail.fallbackSubject')
const pairs: Array<{ to: string; subject: string; key: string }> = []
const seenInBatch = new Set<string>()
const batchDupKeys = new Set<string>()
for (const order of recipients) {
const email = order.寄件人邮箱?.trim() ?? ''
if (!email || !email.includes('@')) continue
const subject = fillPlaceholders(subjBase, order).trim()
const key = buildDuplicateKey(email, subject)
if (seenInBatch.has(key)) {
batchDupKeys.add(key)
continue
}
seenInBatch.add(key)
pairs.push({ to: email, subject, key })
}
let historyHits: BatchEmailDuplicateHit[] = []
try {
if (pairs.length) {
historyHits = await checkBatchEmailDuplicates({
pairs: pairs.map((p) => ({ to: p.to, subject: p.subject })),
withinDays: DUP_WITHIN_DAYS,
})
}
} catch (e: unknown) {
Message.error((e as Error)?.message || t('system.batchEmail.errCheckDup'))
return
}
const historyKeys = new Set(
historyHits.map((h) => buildDuplicateKey(h.toEmail, h.subject)),
)
const dupCount = historyKeys.size + batchDupKeys.size
if (dupCount === 0) {
void runBatchSend()
return
}
// 仅历史命中才弹软拦;本批重复始终靠 sentInBatchKeys 只发首条
if (historyKeys.size === 0) {
if (batchDupKeys.size > 0) {
Message.info(t('system.batchEmail.dupBatchOnlyTip', { n: batchDupKeys.size }))
}
void runBatchSend()
return
}
const previewLines = historyHits.slice(0, DUP_PREVIEW_LIMIT).map(formatDupHitLine)
const more = Math.max(0, historyHits.length - DUP_PREVIEW_LIMIT)
const batchOnlyCount = [...batchDupKeys].filter((k) => !historyKeys.has(k)).length
const contentParts = [
t('system.batchEmail.dupConfirmBody', {
days: DUP_WITHIN_DAYS,
count: historyKeys.size,
}),
]
if (previewLines.length) {
contentParts.push(previewLines.join('\n'))
if (more > 0) {
contentParts.push(t('system.batchEmail.dupConfirmMore', { n: more }))
}
}
if (batchOnlyCount > 0) {
contentParts.push(t('system.batchEmail.dupConfirmBatchOnly', { n: batchOnlyCount }))
}
contentParts.push(t('system.batchEmail.dupConfirmHint'))
Modal.confirm({
title: t('system.batchEmail.dupConfirmTitle'),
content: () =>
h(
'div',
{ style: 'white-space: pre-wrap; line-height: 1.6; max-height: 360px; overflow: auto;' },
contentParts.join('\n\n'),
),
okText: t('system.batchEmail.dupConfirmForce'),
cancelText: t('system.batchEmail.dupConfirmSkip'),
hideCancel: false,
simple: false,
width: 560,
onOk: () => {
void runBatchSend()
},
onCancel: () => {
void runBatchSend({ skipDuplicateKeys: historyKeys })
},
})
}
function cancelBatchSend() {
@ -1165,13 +1301,15 @@ async function runWithConcurrency<T>(
await Promise.all(runners)
}
async function runBatchSend() {
async function runBatchSend(options?: { skipDuplicateKeys?: Set<string> }) {
const recipients = getRecipientsForSend()
const bodyContent = contentDraft.value.trim()
const u = accountUsername.value.trim()
const p = resolvePasswordForRequest()
const subjBase =
subjectDraft.value.trim() || selectedTemplateName.value || t('system.batchEmail.fallbackSubject')
const skipKeys = options?.skipDuplicateKeys
const sentInBatchKeys = new Set<string>()
sending.value = true
sendAbortRequested.value = false
@ -1200,6 +1338,20 @@ async function runBatchSend() {
return
}
const subject = fillPlaceholders(subjBase, order)
const dupKey = buildDuplicateKey(email, subject)
if (skipKeys?.has(dupKey)) {
pushSendFailure(email, t('system.batchEmail.failDupRecent', { days: DUP_WITHIN_DAYS }))
return
}
// 同批第二次起不连发(先占位,避免并发双发)
if (sentInBatchKeys.has(dupKey)) {
pushSendFailure(email, t('system.batchEmail.failDupInBatch'))
return
}
sentInBatchKeys.add(dupKey)
try {
await sendBatchEmailOne({
smtpUsername: u,
smtpPassword: p,
@ -1210,6 +1362,10 @@ async function runBatchSend() {
bodyType: 'plain',
})
sendProgress.success += 1
} catch (sendErr: unknown) {
sentInBatchKeys.delete(dupKey)
throw sendErr
}
} catch (e: unknown) {
pushSendFailure(email, extractErrorMessage(e))
} finally {
@ -1640,7 +1796,6 @@ onMounted(async () => {
gap: 12px;
padding: 12px 16px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-2);
}
.record-toggle {
@ -1662,30 +1817,48 @@ onMounted(async () => {
}
}
.list-wrap {
.record-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 16px 16px;
}
.record-body .table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.record-body .toolbar-left,
.record-body .toolbar-right {
display: flex;
gap: 8px;
align-items: center;
}
.record-spin {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.arco-spin-container) {
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
display: flex;
flex-direction: column;
}
}
.record-table {
.list-wrap {
flex: 1;
min-height: 0;
:deep(.arco-table),
:deep(.arco-table-container) {
height: 100%;
}
}
.record-content-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-input-hidden {