Files
lislgosms/src/apps/client/ClientBatchTasksPage.tsx
T

371 lines
14 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,
Select,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
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: BatchTaskStatus;
};
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
sending: 'info',
terminated: 'danger',
};
const statusLabelMap: Record<BatchTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
terminated: '已终止',
};
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 normalizeTaskStatus(status: string): BatchTaskStatus {
if (['completed', 'done'].includes(status)) return 'completed';
if (['cancelled', 'terminated', 'rejected', 'failed'].includes(status)) return 'terminated';
return 'sending';
}
function mapTask(task: SmsBatchTask): BatchTask {
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: 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: normalizeTaskStatus(task.status),
};
}
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>({});
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: keyword.trim() || undefined,
applicationKeyword: application === 'all' ? undefined : application,
createdAtFrom: submittedDateRange.start,
createdAtTo: 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);
}, [page]);
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={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
</div>
),
},
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => 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>{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>
<Button
disabled={record.status !== 'sending'}
icon={<StopCircle size={14} />}
onClick={() => terminateTask(record.id)}
size="sm"
variant="ghost"
>
终止
</Button>
</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') {
setPage(1);
loadTasks(1);
}
}}
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} />
<Button onClick={() => { setPage(1); loadTasks(1); }} variant="primary">查询</Button>
</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={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</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 },
]}
/>
</DetailSection>
<DetailSection title="发送统计">
<DetailProgressStats
label="发送进度"
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
percent={getProgress(selectedTask)}
status={selectedTask.status}
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>
);
}