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

378 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}