Initial CMPP frontend prototype
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user