feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+69 -563
View File
@@ -1,369 +1,16 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
import { adminApi, type BatchTaskMessagePage, 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';
import { useEffect, useMemo, useState } from 'react';
import { adminApi } from '@/api/adminApi';
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
import { SmsTaskFilter } from './sms-task-progress/SmsTaskFilter';
import { SmsTaskTable } from './sms-task-progress/SmsTaskTable';
import { TaskDetailModal } from './sms-task-progress/TaskDetailModal';
import { TaskPhoneListModal } from './sms-task-progress/TaskPhoneListModal';
import { TerminateTaskModal } from './sms-task-progress/TerminateTaskModal';
import { mapTask } from './sms-task-progress/taskModel';
import type { SmsTask } from './sms-task-progress/taskTypes';
import './sms-task-progress/AdminSmsTaskProgressPage.css';
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<TaskStatus, string> = {
sending: '发送中',
completed: '已完成',
terminated: '已终止',
failed: '失败',
};
const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
sending: 'info',
completed: 'success',
terminated: 'neutral',
failed: 'danger',
};
const sendTypeLabels: Record<SendType, string> = {
immediate: '立即发送',
scheduled: '定时发送',
};
const carrierLabels: Record<string, { label: string; tone: CarrierStat['tone'] }> = {
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 messageStatusLabel(status: string) {
return {
pending_review: '待人工审核',
queued: '已入队',
scheduled: '等待定时发送',
submitted: '供应商已受理',
delivered: '送达成功',
submit_failed: '提交失败',
failed: '回执失败',
rejected: '已拒绝',
timeout: '超时',
canceled: '已取消',
}[status] ?? status;
}
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<string, CarrierStat>();
(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<string, RegionStat>();
(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<string, CarrierStat>();
(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<string, RegionStat>();
(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 (
<div className="admin-task-detail-title">
<h2></h2>
<p>
<span>{task.id}</span>
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
</p>
</div>
);
}
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
return (
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
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 (
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={<TaskDetailTitle task={task} />}
>
<div className="admin-task-detail">
<div className="admin-task-metrics">
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
</div>
<div className="admin-task-detail-grid">
<section className="admin-task-card">
<h3><Send size={18} /></h3>
<dl className="admin-task-info-list">
<div>
<dt>/</dt>
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
</div>
<div>
<dt></dt>
<dd>{formatTime(task.submittedAt)}</dd>
</div>
<div>
<dt></dt>
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
</div>
</dl>
</section>
<section className="admin-task-card">
<h3><TrendingUp size={18} /></h3>
<div className="admin-task-progress-card">
<div>
<span> {formatNumber(task.sentCount)} / {formatNumber(task.phoneCount)}</span>
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
</div>
<div className="admin-task-progress-split">
<span><strong>{formatNumber(task.submittedSuccess)}</strong></span>
<span><strong>{formatNumber(task.successCount)}</strong></span>
</div>
</div>
</section>
<section className="admin-task-card">
<h3><BarChart3 size={18} /></h3>
<div className="admin-task-template-block">
<span></span>
<p className="admin-task-template">{task.templateContent}</p>
</div>
<dl className="admin-task-template-meta">
<div><dt>/</dt><dd>{task.wordCount} <b>·</b> {perPhoneBillingUnits} /</dd></div>
<div><dt></dt><dd>{formatNumber(task.phoneCount)} </dd></div>
</dl>
<div className="admin-task-billing-note">
<span> 67 1 {perPhoneBillingUnits} {formatNumber(task.billingCount)} </span>
</div>
</section>
</div>
<section className="admin-task-card admin-task-card--full">
<h3><Smartphone size={18} /></h3>
{task.carriers.length === 0 ? (
<div className="admin-uplink-empty-match"></div>
) : (
<div className="admin-carrier-grid">
{task.carriers.map((carrier) => {
const rate = carrier.total > 0 ? (carrier.success / carrier.total) * 100 : 0;
return (
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
<strong>{carrier.name}</strong>
<p><span></span><b>{formatNumber(carrier.total)}</b></p>
<p><span></span><b>{formatNumber(carrier.success)}</b></p>
<div>
<em>{rate.toFixed(1)}%</em>
<span></span>
</div>
</article>
);
})}
</div>
)}
</section>
<section className="admin-task-card admin-task-card--full">
<h3><MapPin size={18} /></h3>
<Table
columns={[
{ key: 'region', title: '省份', render: (record: RegionStat) => <strong>{record.region}</strong> },
{ key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) },
{ key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
{
key: 'rate',
title: '成功率',
align: 'right',
render: (record: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
},
]}
data={task.regions}
emptyText="暂无已识别省份记录"
rowKey={(record) => record.region}
/>
</section>
</div>
</Modal>
);
}
const pageSize = 10;
export function AdminSmsTaskProgressPage() {
const [tasks, setTasks] = useState<SmsTask[]>([]);
@@ -375,17 +22,12 @@ export function AdminSmsTaskProgressPage() {
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const [phoneTarget, setPhoneTarget] = useState<SmsTask | null>(null);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneData, setPhoneData] = useState<BatchTaskMessagePage>({ items: [], total: 0, page: 1, pageSize: 20 });
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const [filterTenants, setFilterTenants] = useState<string[]>([]);
const [filterApplications, setFilterApplications] = useState<Array<{ tenantName: string; name: string }>>([]);
const pageSize = 10;
function loadTasks(targetPage = page) {
setLoading(true);
@@ -416,35 +58,27 @@ export function AdminSmsTaskProgressPage() {
.then(([tenants, applications]) => {
const tenantNameById = new Map(tenants.map((item) => [item.id, item.name]));
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => item.name));
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name })));
setFilterApplications(applications
.filter((item) => item.status !== 'deleted')
.map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name })));
})
.catch((failure: Error) => setError(failure.message || '任务筛选项加载失败'));
}, []);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
if (!target) return;
adminApi.listAdminBatchTaskMessages(target.backendId, { phone: phoneKeyword || undefined, page, pageSize })
.then(setPhoneData)
.catch((failure: Error) => setError(failure.message || '发送批次号码列表加载失败'));
}
useEffect(() => {
if (phoneTarget) loadPhones(phoneTarget, phonePage, phonePageSize);
}, [phoneTarget, phonePage, phonePageSize]);
const enterpriseOptions = useMemo(() => {
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))];
}, [filterTenants]);
const enterpriseOptions = useMemo(
() => [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))],
[filterTenants],
);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(filterApplications.filter((item) => enterprise === 'all' || item.tenantName === enterprise).map((item) => item.name)));
const names = Array.from(new Set(filterApplications
.filter((item) => enterprise === 'all' || item.tenantName === enterprise)
.map((item) => item.name)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [enterprise, filterApplications]);
const filteredTasks = tasks;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = filteredTasks;
function resetFilters() {
setKeyword('');
@@ -453,8 +87,8 @@ export function AdminSmsTaskProgressPage() {
setSubmittedDateRange({});
}
function terminateTask(taskId: string) {
adminApi.terminateAdminBatchTask(taskId)
function terminateTask(task: SmsTask) {
adminApi.terminateAdminBatchTask(task.backendId)
.then(() => {
setTerminateTarget(null);
setSelectedTask(null);
@@ -472,185 +106,57 @@ export function AdminSmsTaskProgressPage() {
</div>
</div>
<div className="surface admin-task-filter">
<Input label="发送批次号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入发送批次号" value={keyword} />
<Select
label="选择企业"
onChange={(event) => {
setEnterprise(event.target.value);
setApplication('all');
}}
options={enterpriseOptions}
value={enterprise}
/>
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadTasks(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<SmsTaskFilter
application={application}
applicationOptions={applicationOptions}
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
keyword={keyword}
submittedDateRange={submittedDateRange}
onApplicationChange={setApplication}
onEnterpriseChange={(value) => {
setEnterprise(value);
setApplication('all');
}}
onKeywordChange={setKeyword}
onQuery={() => {
if (page !== 1) setPage(1);
else loadTasks(1);
}}
onReset={resetFilters}
onSubmittedDateRangeChange={setSubmittedDateRange}
/>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-table-card">
<div className="ui-table-wrap">
<table className="ui-table batch-table admin-task-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th style={{ width: '180px' }}>/</th>
<th style={{ width: '136px' }}></th>
<th style={{ width: '130px' }}>/</th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '190px' }}></th>
<th style={{ width: '130px' }}></th>
<th style={{ textAlign: 'right', width: '170px' }}></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
) : filteredTasks.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={8}></td></tr>
) : visibleTasks.map((record) => {
const progress = getProgress(record);
const { signature, content } = splitSignature(record.templateContent);
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
return (
<Fragment key={record.backendId}>
<tr
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
onMouseEnter={() => setHoveredTaskId(record.id)}
onMouseLeave={() => setHoveredTaskId(null)}
>
<td><strong className="admin-task-id">{record.id}</strong></td>
<td>
<div className="admin-task-enterprise">
<strong>{record.enterprise}</strong>
<span>{record.application}</span>
</div>
</td>
<td><span>{formatTime(record.submittedAt)}</span></td>
<td>
<div className="admin-task-counts">
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{formatNumber(record.phoneCount)} · </button>
<span>{record.wordCount}</span>
</div>
</td>
<td>
<div className="admin-task-send-type">
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
{sendTypeLabels[record.sendType]}
</Tag>
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
</div>
</td>
<td>
<div className="batch-progress admin-task-list-progress">
<div>
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
</div>
</div>
</td>
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
<td style={{ textAlign: 'right' }}>
<div className="admin-task-actions">
<Button icon={<Eye size={15} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
icon={<StopCircle size={15} />}
onClick={() => setTerminateTarget(record)}
size="sm"
variant="ghost"
>
</Button>
</div>
</td>
</tr>
<tr
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
onMouseEnter={() => setHoveredTaskId(record.id)}
onMouseLeave={() => setHoveredTaskId(null)}
>
<td colSpan={8}>
<InlineTextPreview label="模板内容" leading={signature ? <strong>{signature}</strong> : null}>
{content}
</InlineTextPreview>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= 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={total}
/>
</div>
<SmsTaskTable
currentPage={currentPage}
hoveredTaskId={hoveredTaskId}
loading={loading}
tasks={tasks}
total={total}
totalPages={totalPages}
onHoverTask={setHoveredTaskId}
onOpenDetail={setSelectedTask}
onOpenPhones={setPhoneTarget}
onPageChange={setPage}
onTerminate={setTerminateTarget}
/>
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 发送批次号 ${phoneTarget.id}`}>
<div className="page-stack">
<div className="audit-filter-grid">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
</div>
<Table
columns={[
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
]}
data={phoneData.items}
emptyText="暂无号码记录"
rowKey="id"
/>
<Pagination
nextDisabled={phonePage * phonePageSize >= phoneData.total}
onNext={() => setPhonePage((current) => current + 1)}
onPageChange={setPhonePage}
onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))}
page={phonePage}
previousDisabled={phonePage <= 1}
total={phoneData.total}
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
/>
</div>
</Modal> : null}
{phoneTarget ? (
<TaskPhoneListModal
onClose={() => setPhoneTarget(null)}
onError={setError}
task={phoneTarget}
/>
) : null}
{terminateTarget ? (
<Modal
footer={(
<>
<Button onClick={() => setTerminateTarget(null)} variant="ghost"></Button>
<Button onClick={() => terminateTask(terminateTarget.backendId)} variant="danger"></Button>
</>
)}
onClose={() => setTerminateTarget(null)}
open
title="确认终止短信任务"
>
<div className="admin-confirm-text">
<strong>{terminateTarget.id}</strong>
</div>
</Modal>
<TerminateTaskModal
onCancel={() => setTerminateTarget(null)}
onConfirm={() => terminateTask(terminateTarget)}
task={terminateTarget}
/>
) : null}
</section>
);