Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
import { Fragment, useMemo, useState } from 'react';
import { Clock3, Eye, FileText, Search, StopCircle, TrendingUp } from 'lucide-react';
import {
Button,
DateRangeInput,
DetailInfoGrid,
DetailProgressStats,
DetailSection,
DetailTitle,
getRateTone,
InlineTextPreview,
Input,
Modal,
Pagination,
ProgressBar,
QueryPanel,
RateCard,
RateOverview,
Select,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { clientService, type BatchTask, type BatchTaskStatus } from '@/mock';
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
sending: 'info',
terminated: 'danger',
};
const statusLabelMap: Record<BatchTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
terminated: '已终止',
};
const carrierStats = [
{ name: '中国移动', success: 738, total: 750, rate: 98.4 },
{ name: '中国联通', success: 443, total: 450, rate: 98.44 },
{ name: '中国电信', success: 294, total: 300, rate: 98 },
];
const cityStats = [
{ city: '北京', total: 300, success: 295 },
{ city: '上海', total: 280, success: 276 },
{ city: '深圳', total: 250, success: 246 },
{ city: '广州', total: 220, success: 215 },
{ city: '杭州', total: 200, success: 197 },
{ city: '成都', total: 150, success: 148 },
{ city: '武汉', total: 100, success: 98 },
];
function splitSignature(content: string) {
const match = content.match(/^【(.+?)】(.+)$/);
return {
signature: match?.[1],
content: match?.[2] ?? content,
};
}
function getProgress(task: BatchTask) {
return Math.round((task.sentCount / task.totalCount) * 100);
}
function getBillingCount(task: BatchTask) {
return getDeliveredCount(task);
}
function getDeliveredCount(task: BatchTask) {
if (task.status === 'completed') {
return Math.round(task.totalCount * 0.9833);
}
return task.sentCount;
}
export function ClientBatchTasksPage() {
const [tasks, setTasks] = useState(() => clientService.getBatchTasks());
const [keyword, setKeyword] = useState('');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.applicationName)));
return [
{ label: '全部应用', value: 'all' },
...names.map((name) => ({ label: name, value: name })),
];
}, [tasks]);
const filteredTasks = tasks.filter((item) => {
const matchesKeyword = !keyword || item.id.includes(keyword);
const matchesApplication = application === 'all' || item.applicationName === application;
const submittedDate = item.submittedAt.slice(0, 10);
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
});
function terminateTask(id: string) {
setTasks(clientService.terminateBatchTask(id));
}
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: '110px', render: (record) => record.applicationName },
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
{ key: 'phoneCount', title: '发送号码数', width: '95px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
{ key: 'wordCount', title: '单号码字数', width: '86px', 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>{filteredTasks.length}</strong> </>}
>
<Input
label="任务编号"
onChange={(event) => setKeyword(event.target.value)}
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} />
</QueryPanel>
<div className="surface batch-table-card">
<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>
{filteredTasks.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 total={filteredTasks.length} />
</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') },
]}
/>
</DetailSection>
<DetailSection title={<><TrendingUp size={20} /> </>}>
{(() => {
const overallRate = (getDeliveredCount(selectedTask) / selectedTask.totalCount) * 100;
return (
<RateOverview
label="总体成功率"
metrics={[
{ label: '成功总数', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
{ label: '总计', value: selectedTask.totalCount.toLocaleString('zh-CN') },
]}
rate={overallRate}
tone={getRateTone(overallRate)}
/>
);
})()}
<h4></h4>
<div className="carrier-rate-grid">
{carrierStats.map((item) => (
<RateCard
key={item.name}
meta={<><span>: {item.success}</span><span>: {item.total}</span></>}
rate={item.rate}
title={item.name}
tone={getRateTone(item.rate)}
/>
))}
</div>
<h4></h4>
<div className="ui-table-wrap">
<table className="ui-table city-rate-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{cityStats.map((item) => {
const rate = (item.success / item.total) * 100;
return (
<tr key={item.city}>
<td><strong>{item.city}</strong></td>
<td>{item.total}</td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{item.success}</span></td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{rate.toFixed(2)}%</span></td>
<td><ProgressBar percent={rate} tone={getRateTone(rate)} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</DetailSection>
</div>
) : null}
</Modal>
</section>
);
}