378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { Fragment, useEffect, useState } from 'react';
|
||
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Button,
|
||
DateRangeInput,
|
||
DetailInfoGrid,
|
||
DetailProgressStats,
|
||
DetailSection,
|
||
DetailTitle,
|
||
InlineTextPreview,
|
||
Input,
|
||
Modal,
|
||
Pagination,
|
||
QueryPanel,
|
||
QueryButtons,
|
||
Select,
|
||
Tag,
|
||
type DateRangeValue,
|
||
type TableColumn,
|
||
} from '@/components/ui';
|
||
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
|
||
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
|
||
import { batchTaskStatusMeta, canClientCancelBatchTask, normalizeBatchTaskStatus, type BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
|
||
|
||
type BatchTask = {
|
||
id: string;
|
||
backendId: string;
|
||
applicationName: string;
|
||
submittedAt: string;
|
||
phoneCount: number;
|
||
wordCount: number;
|
||
sendType: 'immediate' | 'scheduled';
|
||
scheduledAt?: string | null;
|
||
sentCount: number;
|
||
deliveredCount: number;
|
||
failedCount: number;
|
||
totalCount: number;
|
||
templateContent: string;
|
||
status: BatchTaskDisplayStatus;
|
||
rawStatus: string;
|
||
reviewReason?: string | null;
|
||
rejectReason?: string | null;
|
||
riskTaskId?: string | null;
|
||
};
|
||
|
||
function splitSignature(content: string) {
|
||
const match = content.match(/^【(.+?)】(.+)$/);
|
||
return {
|
||
signature: match?.[1],
|
||
content: match?.[2] ?? content,
|
||
};
|
||
}
|
||
|
||
function getProgress(task: BatchTask) {
|
||
return task.totalCount > 0 ? Math.round((task.sentCount / task.totalCount) * 100) : 0;
|
||
}
|
||
|
||
function getBillingCount(task: BatchTask) {
|
||
return getDeliveredCount(task);
|
||
}
|
||
|
||
function getDeliveredCount(task: BatchTask) {
|
||
return task.deliveredCount;
|
||
}
|
||
|
||
function mapTask(task: SmsBatchTask): BatchTask {
|
||
const displayStatus = normalizeBatchTaskStatus(task.status);
|
||
return {
|
||
id: task.taskNo || task.id,
|
||
backendId: task.id,
|
||
applicationName: task.application?.name ?? task.applicationId ?? '未绑定应用',
|
||
submittedAt: task.createdAt,
|
||
phoneCount: task.phoneTotal,
|
||
wordCount: [...task.content].length,
|
||
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
|
||
scheduledAt: task.scheduledAt,
|
||
sentCount: displayStatus === 'pending_review' ? 0 : task.progressSent ?? task.submittedTotal ?? 0,
|
||
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
|
||
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
|
||
totalCount: task.progressTotal || task.phoneTotal,
|
||
templateContent: task.content,
|
||
status: displayStatus,
|
||
rawStatus: task.status,
|
||
reviewReason: task.reviewReason,
|
||
rejectReason: task.rejectReason,
|
||
riskTaskId: task.riskTaskId,
|
||
};
|
||
}
|
||
|
||
export function ClientBatchTasksPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const [tasks, setTasks] = useState<BatchTask[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [total, setTotal] = useState(0);
|
||
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
||
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
|
||
const [application, setApplication] = useState('all');
|
||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
|
||
const [applied, setApplied] = useState(() => ({ keyword, application, submittedDateRange }));
|
||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
|
||
|
||
const pageSize = 10;
|
||
|
||
function loadTasks(targetPage = page) {
|
||
setLoading(true);
|
||
clientApi.listBatchTasksPage({
|
||
keyword: applied.keyword.trim() || undefined,
|
||
applicationKeyword: applied.application === 'all' ? undefined : applied.application,
|
||
createdAtFrom: applied.submittedDateRange.start,
|
||
createdAtTo: applied.submittedDateRange.end,
|
||
page: targetPage,
|
||
pageSize,
|
||
})
|
||
.then((result) => {
|
||
setTasks(result.items.map(mapTask));
|
||
setTotal(result.total);
|
||
setError('');
|
||
})
|
||
.catch((reason: Error) => setError(reason.message || '批量任务加载失败'))
|
||
.finally(() => setLoading(false));
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadTasks(page);
|
||
}, [applied, page]);
|
||
|
||
function query() {
|
||
setPage(1);
|
||
setApplied({ keyword, application, submittedDateRange });
|
||
}
|
||
|
||
function reset() {
|
||
const defaults = { keyword: '', application: 'all', submittedDateRange: recentBeijingDateRange(7) };
|
||
setKeyword('');
|
||
setApplication('all');
|
||
setSubmittedDateRange(defaults.submittedDateRange);
|
||
setPage(1);
|
||
setApplied(defaults);
|
||
}
|
||
|
||
useEffect(() => {
|
||
clientApi.listApplicationOptions()
|
||
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
const applicationOptions = [
|
||
{ label: '全部应用', value: 'all' },
|
||
...applications.map((item) => ({ label: item.name, value: item.name })),
|
||
];
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
const currentPage = Math.min(page, totalPages);
|
||
const visibleTasks = tasks;
|
||
|
||
function terminateTask(id: string) {
|
||
const source = tasks.find((item) => item.id === id);
|
||
if (!source) return;
|
||
clientApi.cancelBatchTask(source.backendId)
|
||
.then(() => loadTasks(page))
|
||
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
|
||
}
|
||
|
||
const columns: Array<TableColumn<BatchTask>> = [
|
||
{
|
||
key: 'id',
|
||
title: '发送批次号',
|
||
width: '140px',
|
||
render: (record) => (
|
||
<div className="batch-task-id">
|
||
<strong>{record.id}</strong>
|
||
<Tag tone={batchTaskStatusMeta[record.status].tone}>{batchTaskStatusMeta[record.status].label}{record.status === 'unknown' ? `(${record.rawStatus})` : ''}</Tag>
|
||
</div>
|
||
),
|
||
},
|
||
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
|
||
{ key: 'submittedAt', title: '提交时间', width: '150px', render: (record) => formatDateTime(record.submittedAt) },
|
||
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
||
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
||
{
|
||
key: 'sendType',
|
||
title: '发送时间',
|
||
width: '130px',
|
||
render: (record) => (
|
||
<div className="batch-send-time">
|
||
<span>
|
||
<Clock3 size={14} />
|
||
{record.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
||
</span>
|
||
{record.scheduledAt ? <small>{formatDateTime(record.scheduledAt)}</small> : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'progress',
|
||
title: '发送进度',
|
||
width: '180px',
|
||
render: (record) => {
|
||
const percent = getProgress(record);
|
||
return (
|
||
<div className="batch-progress">
|
||
<div>
|
||
<span>{record.sentCount.toLocaleString('zh-CN')} / {record.totalCount.toLocaleString('zh-CN')}</span>
|
||
<strong>{percent}%</strong>
|
||
</div>
|
||
<div className="batch-progress__track">
|
||
<span className={`batch-progress__bar batch-progress__bar--${record.status}`} style={{ width: `${percent}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
key: 'actions',
|
||
title: '操作',
|
||
align: 'right',
|
||
width: '120px',
|
||
render: (record) => (
|
||
<div className="batch-actions">
|
||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||
{canClientCancelBatchTask(record.rawStatus) ? <Button
|
||
icon={<StopCircle size={14} />}
|
||
onClick={() => terminateTask(record.id)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
终止
|
||
</Button> : null}
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<section className="page-stack">
|
||
<div className="sms-send-title">
|
||
<span className="sms-send-title__icon">
|
||
<FileText size={22} />
|
||
</span>
|
||
<h1>查看批量任务</h1>
|
||
</div>
|
||
|
||
<QueryPanel
|
||
title="查询条件"
|
||
summary={<>共找到 <strong>{total}</strong> 个发送批次</>}
|
||
>
|
||
<Input
|
||
label="发送批次号"
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
query();
|
||
}
|
||
}}
|
||
placeholder="输入发送批次号搜索"
|
||
prefix={<Search size={16} />}
|
||
value={keyword}
|
||
/>
|
||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||
<QueryButtons onQuery={query} onReset={reset} />
|
||
</QueryPanel>
|
||
|
||
<div className="surface batch-table-card">
|
||
{loading ? <p className="muted">正在加载批量任务...</p> : null}
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
<div className="ui-table-wrap">
|
||
<table className="ui-table batch-table">
|
||
<thead>
|
||
<tr>
|
||
{columns.map((column) => (
|
||
<th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visibleTasks.map((record, index) => {
|
||
const { signature, content } = splitSignature(record.templateContent);
|
||
|
||
return (
|
||
<Fragment key={record.id}>
|
||
<tr
|
||
className={['batch-main-row', hoveredTaskId === record.id ? 'batch-row--hovered' : ''].filter(Boolean).join(' ')}
|
||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||
onMouseLeave={() => setHoveredTaskId(null)}
|
||
>
|
||
{columns.map((column) => (
|
||
<td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>
|
||
))}
|
||
</tr>
|
||
<tr
|
||
className={['batch-template-row', hoveredTaskId === record.id ? 'batch-row--hovered' : ''].filter(Boolean).join(' ')}
|
||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||
onMouseLeave={() => setHoveredTaskId(null)}
|
||
>
|
||
<td colSpan={columns.length}>
|
||
<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>
|
||
|
||
<Modal
|
||
footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>}
|
||
onClose={() => setSelectedTask(null)}
|
||
open={Boolean(selectedTask)}
|
||
size="xl"
|
||
title={<DetailTitle title="发送批次详情" subtitle={selectedTask?.id} />}
|
||
>
|
||
{selectedTask ? (
|
||
<div className="task-detail">
|
||
<DetailSection title="基本信息" extra={<Tag tone={batchTaskStatusMeta[selectedTask.status].tone}>{batchTaskStatusMeta[selectedTask.status].label}{selectedTask.status === 'unknown' ? `(${selectedTask.rawStatus})` : ''}</Tag>}>
|
||
<DetailInfoGrid
|
||
items={[
|
||
{ label: '发送批次号', value: selectedTask.id },
|
||
{ label: '应用名称', value: selectedTask.applicationName },
|
||
{ label: '提交时间', value: selectedTask.submittedAt },
|
||
{
|
||
label: '发送方式',
|
||
value: (
|
||
<span className="task-send-type">
|
||
<Clock3 size={16} />
|
||
{selectedTask.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
||
</span>
|
||
),
|
||
},
|
||
{ label: '模板字数', value: `${selectedTask.wordCount} 字`, tone: 'primary' },
|
||
{ label: '单个号码计费条数', value: `${Math.max(1, Math.ceil(selectedTask.wordCount / 70))} 条`, tone: 'primary' },
|
||
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')} 条`, tone: 'primary' },
|
||
{ label: '模板内容', value: selectedTask.templateContent, full: true },
|
||
...(selectedTask.reviewReason ? [{ label: '审核原因', value: selectedTask.reviewReason, full: true }] : []),
|
||
...(selectedTask.rejectReason ? [{ label: '驳回原因', value: selectedTask.rejectReason, full: true }] : []),
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
|
||
<DetailSection title="发送统计">
|
||
<DetailProgressStats
|
||
label="发送进度"
|
||
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
|
||
percent={getProgress(selectedTask)}
|
||
status={selectedTask.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(selectedTask.status) ? 'terminated' : 'sending'}
|
||
stats={[
|
||
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
|
||
{ label: '发送失败数量', value: selectedTask.failedCount.toLocaleString('zh-CN') },
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
</div>
|
||
) : null}
|
||
</Modal>
|
||
</section>
|
||
);
|
||
}
|