import { Fragment, useEffect, useMemo, useState } from 'react'; import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react'; import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { Breadcrumb, Button, DateRangeInput, InlineTextPreview, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, } from '@/components/ui'; type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed'; type SendType = 'immediate' | 'scheduled'; type CarrierStat = { name: string; total: number; success: number; tone: 'mobile' | 'unicom' | 'telecom'; }; type RegionStat = { region: string; total: number; success: number; }; type SmsTask = { id: string; backendId: string; enterprise: string; application: string; submittedAt: string; templateContent: string; phoneCount: number; wordCount: number; billingCount: number; sendType: SendType; scheduledAt?: string | null; submittedCount: number; submittedSuccess: number; sentCount: number; successCount: number; failedCount: number; status: TaskStatus; rawStatus: string; carriers: CarrierStat[]; regions: RegionStat[]; }; const statusLabels: Record = { sending: '发送中', completed: '已完成', terminated: '已终止', failed: '失败', }; const statusTones: Record = { sending: 'info', completed: 'success', terminated: 'neutral', failed: 'danger', }; const sendTypeLabels: Record = { immediate: '立即发送', scheduled: '定时发送', }; const carrierLabels: Record = { mobile: { label: '中国移动', tone: 'mobile' }, unicom: { label: '中国联通', tone: 'unicom' }, telecom: { label: '中国电信', tone: 'telecom' }, all: { label: '三网通道', tone: 'mobile' }, }; function formatNumber(value: number) { return value.toLocaleString('zh-CN'); } function formatTime(value?: string | null) { return formatDateTime(value); } function normalizeTaskStatus(status: string): TaskStatus { if (['finished', 'completed', 'done'].includes(status)) return 'completed'; if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated'; if (['failed', 'rejected'].includes(status)) return 'failed'; return 'sending'; } function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) { return (messages ?? []).filter((message) => statuses.includes(message.status)).length; } function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] { const stats = new Map(); (messages ?? []).forEach((message) => { const carrier = message.carrier ?? 'unknown'; const meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const }; const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone }; current.total += 1; if (message.status === 'delivered') current.success += 1; stats.set(carrier, current); }); return Array.from(stats.values()); } function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] { const stats = new Map(); (messages ?? []).forEach((message) => { const region = message.province ?? '未识别省份'; const current = stats.get(region) ?? { region, total: 0, success: 0 }; current.total += 1; if (message.status === 'delivered') current.success += 1; stats.set(region, current); }); return Array.from(stats.values()).sort((a, b) => b.total - a.total); } function buildCarrierStatsFromAggregates(stats: SmsBatchTask['messageStats']): CarrierStat[] { const totals = new Map(); (stats ?? []).forEach((item) => { const carrier = item.carrier ?? 'unknown'; const meta = carrierLabels[carrier] ?? { label: carrier || '未识别', tone: 'mobile' as const }; const current = totals.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone }; current.total += item._count._all; if (item.status === 'delivered') current.success += item._count._all; totals.set(carrier, current); }); return Array.from(totals.values()); } function buildRegionStatsFromAggregates(stats: SmsBatchTask['messageStats']): RegionStat[] { const totals = new Map(); (stats ?? []).forEach((item) => { const region = item.province ?? '未识别省份'; const current = totals.get(region) ?? { region, total: 0, success: 0 }; current.total += item._count._all; if (item.status === 'delivered') current.success += item._count._all; totals.set(region, current); }); return Array.from(totals.values()).sort((a, b) => b.total - a.total); } function mapTask(task: SmsBatchTask): SmsTask { const messages = task.messages ?? []; const submittedStatuses = ['submitted', 'delivered', 'failed', 'unknown', 'timeout', 'submit_failed']; const failedStatuses = ['failed', 'submit_failed', 'rejected', 'timeout']; const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses); const successCount = task.successTotal ?? countMessages(messages, ['delivered']); const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses); // submittedTotal already includes unknown and timeout records, so never add them again. const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0)); const billingCount = (task.messageStats ?? []).reduce((sum, item) => sum + (item._sum.billingUnits ?? 0), 0) || messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0) || task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67))); return { id: task.taskNo || task.id, backendId: task.id, enterprise: task.tenant?.name ?? task.tenantId, application: task.application?.name ?? task.applicationId ?? '未绑定应用', submittedAt: task.createdAt, templateContent: task.content, phoneCount: task.phoneTotal, wordCount: [...task.content].length, billingCount, sendType: task.scheduledAt ? 'scheduled' : 'immediate', scheduledAt: task.scheduledAt, submittedCount, submittedSuccess: submittedCount, sentCount: Math.min(task.phoneTotal, processedCount), successCount, failedCount, status: normalizeTaskStatus(task.status), rawStatus: task.status, carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages), regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages), }; } function getProgress(task: SmsTask) { return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0; } function getSuccessRate(task: SmsTask) { return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0; } function getRegionRate(region: RegionStat) { return region.total > 0 ? (region.success / region.total) * 100 : 0; } function splitSignature(content: string) { const match = content.match(/^【(.+?)】(.+)$/); return { signature: match?.[1], content: match?.[2] ?? content, }; } function TaskDetailTitle({ task }: { task: SmsTask }) { return (

任务详情

{task.id} {statusLabels[task.status]}

); } function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) { return (
{label} {value}
); } function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) { const progress = getProgress(task); const successRate = getSuccessRate(task); const perPhoneBillingUnits = Math.max(1, Math.ceil(task.wordCount / 67)); return ( 关闭} onClose={onClose} open size="xl" title={} >

任务信息

企业/应用
{task.enterprise}{task.application}
提交时间
{formatTime(task.submittedAt)}
发送方式
{sendTypeLabels[task.sendType]}

发送进度

已处理 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)} {progress}%
已提交{formatNumber(task.submittedSuccess)} 已成功{formatNumber(task.successCount)}

模板信息

短信模板内容

{task.templateContent}

字符数/计费条数
{task.wordCount} 字符 · {perPhoneBillingUnits} 条/号码
发送号码数
{formatNumber(task.phoneCount)} 个
计费规则:每 67 字为 1 条短信。本次任务单号码 {perPhoneBillingUnits} 条,预计总计费 {formatNumber(task.billingCount)} 条

号码运营商分布

{task.carriers.length === 0 ? (
暂无已识别运营商记录
) : (
{task.carriers.map((carrier) => { const rate = carrier.total > 0 ? (carrier.success / carrier.total) * 100 : 0; return (
{carrier.name}

总数{formatNumber(carrier.total)}

成功{formatNumber(carrier.success)}

{rate.toFixed(1)}% 成功率
); })}
)}

号码省份分布

{record.region} }, { key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) }, { key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => {formatNumber(record.success)} }, { key: 'rate', title: '成功率', align: 'right', render: (record: RegionStat) => = 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%, }, ]} data={task.regions} emptyText="暂无已识别省份记录" rowKey={(record) => record.region} /> ); } export function AdminSmsTaskProgressPage() { const [tasks, setTasks] = useState([]); const [keyword, setKeyword] = useState(''); const [enterprise, setEnterprise] = useState('all'); const [application, setApplication] = useState('all'); const [submittedDateRange, setSubmittedDateRange] = useState({}); const [hoveredTaskId, setHoveredTaskId] = useState(null); const [page, setPage] = useState(1); const [selectedTask, setSelectedTask] = useState(null); const [terminateTarget, setTerminateTarget] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); function loadTasks() { setLoading(true); adminApi.listAdminBatchTasks() .then((items) => { setTasks(items.map(mapTask)); setError(''); }) .catch((reason: Error) => setError(reason.message || '短信任务进度加载失败')) .finally(() => setLoading(false)); } useEffect(() => { loadTasks(); }, []); const enterpriseOptions = useMemo(() => { const names = Array.from(new Set(tasks.map((item) => item.enterprise))); return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))]; }, [tasks]); const applicationOptions = useMemo(() => { const names = Array.from(new Set(tasks.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application))); return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))]; }, [enterprise, tasks]); const filteredTasks = useMemo( () => tasks.filter((item) => { const submittedDate = item.submittedAt.slice(0, 10); const matchesKeyword = !keyword || item.id.includes(keyword); const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise; const matchesApplication = application === 'all' || item.application === application; const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start; const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end; return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate; }), [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks], ); const pageSize = 10; const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize)); const currentPage = Math.min(page, totalPages); const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize); useEffect(() => { setPage(1); }, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]); function resetFilters() { setKeyword(''); setEnterprise('all'); setApplication('all'); setSubmittedDateRange({}); } function terminateTask(taskId: string) { adminApi.terminateAdminBatchTask(taskId) .then(() => { setTerminateTarget(null); setSelectedTask(null); loadTasks(); }) .catch((reason: Error) => setError(reason.message || '任务终止失败')); } return (

短信任务进度

setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} /> setApplication(event.target.value)} options={applicationOptions} value={application} />
{error ?

{error}

: null}
{loading ? ( ) : filteredTasks.length === 0 ? ( ) : visibleTasks.map((record) => { const progress = getProgress(record); const { signature, content } = splitSignature(record.templateContent); const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : ''; return ( setHoveredTaskId(record.id)} onMouseLeave={() => setHoveredTaskId(null)} > setHoveredTaskId(record.id)} onMouseLeave={() => setHoveredTaskId(null)} > ); })}
任务编号 企业/应用 提交时间 号码数/字符数 发送方式 进度 状态 操作
正在加载真实短信任务...
暂无短信任务
{record.id}
{record.enterprise} {record.application}
{formatTime(record.submittedAt)}
{formatNumber(record.phoneCount)} {record.wordCount}字
{record.sendType === 'scheduled' ? : null} {sendTypeLabels[record.sendType]} {record.scheduledAt ? {formatTime(record.scheduledAt)} : null}
{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)} {progress}%
{statusLabels[record.status]}
【{signature}】 : null}> {content}
= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={filteredTasks.length} /> {selectedTask ? setSelectedTask(null)} task={selectedTask} /> : null} {terminateTarget ? ( )} onClose={() => setTerminateTarget(null)} open title="确认终止短信任务" >
确认终止任务 {terminateTarget.id} 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
) : null} ); }