feat: 批量邮件记录 Handsontable 查询与近30天软拦防重
All checks were successful
Build and Deploy Front / deploy (push) Successful in 9m36s
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:
parent
0910538e6b
commit
338374397c
@ -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)
|
||||
}
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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': '确认覆盖',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -201,33 +201,48 @@
|
||||
</span>
|
||||
<a-tag size="small" color="gray">{{ recordTotal }}</a-tag>
|
||||
</button>
|
||||
<PaginationPro
|
||||
v-show="recordsExpanded"
|
||||
:total-count="recordTotal"
|
||||
v-model:current-page="recordPage"
|
||||
v-model:page-size="recordPageSize"
|
||||
:page-sizes="[10, 30, 50, 100]"
|
||||
@page-change="loadRecords"
|
||||
@page-size-change="handleRecordPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="recordsExpanded" 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 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
|
||||
:total-count="recordTotal"
|
||||
v-model:current-page="recordPage"
|
||||
v-model:page-size="recordPageSize"
|
||||
:page-sizes="[10, 30, 50, 100]"
|
||||
@page-change="loadRecords"
|
||||
@page-size-change="handleRecordPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a-spin :loading="recordLoading" class="record-spin">
|
||||
<div ref="recordTableContainerRef" class="list-wrap">
|
||||
<HandsontablePro
|
||||
:columns="recordHotColumns"
|
||||
:data="recordList"
|
||||
: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 runBatchSend()
|
||||
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,16 +1338,34 @@ async function runBatchSend() {
|
||||
return
|
||||
}
|
||||
const subject = fillPlaceholders(subjBase, order)
|
||||
await sendBatchEmailOne({
|
||||
smtpUsername: u,
|
||||
smtpPassword: p,
|
||||
serverType: serverType.value,
|
||||
to: email,
|
||||
subject,
|
||||
body,
|
||||
bodyType: 'plain',
|
||||
})
|
||||
sendProgress.success += 1
|
||||
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,
|
||||
serverType: serverType.value,
|
||||
to: email,
|
||||
subject,
|
||||
body,
|
||||
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;
|
||||
padding: 12px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.record-table {
|
||||
.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-table),
|
||||
:deep(.arco-table-container) {
|
||||
height: 100%;
|
||||
:deep(.arco-spin-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.record-content-cell {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user