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
+581
View File
@@ -0,0 +1,581 @@
import { Fragment, useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
import {
Button,
DateRangeInput,
InlineTextPreview,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
type DateRangeValue,
} from '@/components/ui';
type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
type SendType = 'immediate' | 'scheduled';
type CarrierStat = {
name: string;
total: number;
success: number;
tone: 'mobile' | 'unicom' | 'telecom';
};
type CityStat = {
city: string;
province: string;
total: number;
success: number;
};
type SmsTask = {
id: string;
enterprise: string;
application: string;
submittedAt: string;
templateContent: string;
phoneCount: number;
wordCount: number;
billingCount: number;
sendType: SendType;
scheduledAt?: string;
submittedCount: number;
submittedSuccess: number;
sentCount: number;
successCount: number;
status: TaskStatus;
carriers: CarrierStat[];
cities: CityStat[];
};
const statusLabels: Record<TaskStatus, string> = {
sending: '发送中',
completed: '已完成',
terminated: '已终止',
failed: '失败',
};
const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
sending: 'info',
completed: 'success',
terminated: 'neutral',
failed: 'danger',
};
const sendTypeLabels: Record<SendType, string> = {
immediate: '立即发送',
scheduled: '定时发送',
};
const taskData: SmsTask[] = [
{
id: 'TASK202603120001',
enterprise: '四川骠骑企业管理',
application: '营销应用1',
submittedAt: '2026-03-12 09:30:15',
templateContent: '【骠骑科技】尊敬的{name},您有一笔{amount}元的订单已确认,预计{date}送达。',
phoneCount: 10000,
wordCount: 68,
billingCount: 14250,
sendType: 'immediate',
submittedCount: 10000,
submittedSuccess: 7500,
sentCount: 7500,
successCount: 7125,
status: 'sending',
carriers: [
{ name: '中国移动', total: 6000, success: 5760, tone: 'mobile' },
{ name: '中国联通', total: 2500, success: 2350, tone: 'unicom' },
{ name: '中国电信', total: 1500, success: 1425, tone: 'telecom' },
],
cities: [
{ city: '成都市', province: '四川省', total: 1500, success: 1440 },
{ city: '重庆市', province: '重庆市', total: 1200, success: 1140 },
{ city: '北京市', province: '北京市', total: 1000, success: 970 },
{ city: '上海市', province: '上海市', total: 1000, success: 960 },
{ city: '深圳市', province: '广东省', total: 800, success: 760 },
{ city: '广州市', province: '广东省', total: 700, success: 658 },
{ city: '杭州市', province: '浙江省', total: 600, success: 576 },
{ city: '南京市', province: '江苏省', total: 500, success: 475 },
{ city: '武汉市', province: '湖北省', total: 500, success: 470 },
],
},
{
id: 'TASK202603120002',
enterprise: '重庆进载数智',
application: '通知应用',
submittedAt: '2026-03-12 10:15:00',
templateContent: '【进载数智】亲爱的用户,您的会员即将到期,请及时续费。',
phoneCount: 5000,
wordCount: 52,
billingCount: 5000,
sendType: 'scheduled',
scheduledAt: '2026-03-13 08:00:00',
submittedCount: 5000,
submittedSuccess: 5000,
sentCount: 5000,
successCount: 4910,
status: 'completed',
carriers: [
{ name: '中国移动', total: 3000, success: 2955, tone: 'mobile' },
{ name: '中国联通', total: 1200, success: 1170, tone: 'unicom' },
{ name: '中国电信', total: 800, success: 785, tone: 'telecom' },
],
cities: [
{ city: '重庆市', province: '重庆市', total: 1200, success: 1180 },
{ city: '成都市', province: '四川省', total: 900, success: 884 },
{ city: '西安市', province: '陕西省', total: 700, success: 688 },
],
},
{
id: 'TASK202603120003',
enterprise: '超感世纪三三网',
application: '推广应用2',
submittedAt: '2026-03-12 11:20:00',
templateContent: '【超感世纪】新用户专享优惠,限时抢购中!点击链接查看详情:...',
phoneCount: 20000,
wordCount: 85,
billingCount: 40000,
sendType: 'immediate',
submittedCount: 20000,
submittedSuccess: 12000,
sentCount: 12000,
successCount: 11160,
status: 'sending',
carriers: [
{ name: '中国移动', total: 12000, success: 11160, tone: 'mobile' },
{ name: '中国联通', total: 5000, success: 4600, tone: 'unicom' },
{ name: '中国电信', total: 3000, success: 2820, tone: 'telecom' },
],
cities: [
{ city: '北京市', province: '北京市', total: 3000, success: 2820 },
{ city: '上海市', province: '上海市', total: 2600, success: 2418 },
{ city: '广州市', province: '广东省', total: 2300, success: 2139 },
],
},
{
id: 'TASK202603120004',
enterprise: '重庆香惠慧',
application: '客服应用',
submittedAt: '2026-03-12 14:05:00',
templateContent: '【香惠慧】您的验证码是{code},请在5分钟内完成验证。',
phoneCount: 3000,
wordCount: 45,
billingCount: 3000,
sendType: 'scheduled',
scheduledAt: '2026-03-12 16:00:00',
submittedCount: 3000,
submittedSuccess: 3000,
sentCount: 3000,
successCount: 2962,
status: 'completed',
carriers: [
{ name: '中国移动', total: 1700, success: 1682, tone: 'mobile' },
{ name: '中国联通', total: 800, success: 790, tone: 'unicom' },
{ name: '中国电信', total: 500, success: 490, tone: 'telecom' },
],
cities: [
{ city: '重庆市', province: '重庆市', total: 1600, success: 1584 },
{ city: '成都市', province: '四川省', total: 800, success: 786 },
{ city: '贵阳市', province: '贵州省', total: 600, success: 592 },
],
},
{
id: 'TASK202603120005',
enterprise: '四川骠骑企业管理',
application: '活动推广',
submittedAt: '2026-03-12 15:30:00',
templateContent: '【骠骑科技】周末特惠活动开始啦!全场商品8折起,更多优惠请访问官网。',
phoneCount: 8000,
wordCount: 72,
billingCount: 16000,
sendType: 'immediate',
submittedCount: 8000,
submittedSuccess: 2000,
sentCount: 2000,
successCount: 1860,
status: 'terminated',
carriers: [
{ name: '中国移动', total: 4800, success: 4464, tone: 'mobile' },
{ name: '中国联通', total: 2000, success: 1840, tone: 'unicom' },
{ name: '中国电信', total: 1200, success: 1116, tone: 'telecom' },
],
cities: [
{ city: '成都市', province: '四川省', total: 1600, success: 1488 },
{ city: '绵阳市', province: '四川省', total: 900, success: 837 },
{ city: '德阳市', province: '四川省', total: 700, success: 651 },
],
},
{
id: 'TASK202603110006',
enterprise: '重庆进载数智',
application: '系统通知',
submittedAt: '2026-03-11 17:45:00',
templateContent: '【进载数智】系统维护通知:我们将于{date}进行系统升级,预计耗时2小时。',
phoneCount: 15000,
wordCount: 63,
billingCount: 15000,
sendType: 'scheduled',
scheduledAt: '2026-03-11 20:00:00',
submittedCount: 15000,
submittedSuccess: 10000,
sentCount: 10000,
successCount: 8200,
status: 'failed',
carriers: [
{ name: '中国移动', total: 9000, success: 7380, tone: 'mobile' },
{ name: '中国联通', total: 3600, success: 2952, tone: 'unicom' },
{ name: '中国电信', total: 2400, success: 1968, tone: 'telecom' },
],
cities: [
{ city: '重庆市', province: '重庆市', total: 2600, success: 2132 },
{ city: '成都市', province: '四川省', total: 1800, success: 1476 },
{ city: '昆明市', province: '云南省', total: 1200, success: 984 },
],
},
];
function formatNumber(value: number) {
return value.toLocaleString('zh-CN');
}
function getProgress(task: SmsTask) {
return Math.round((task.sentCount / task.phoneCount) * 100);
}
function getSuccessRate(task: SmsTask) {
return (task.successCount / task.submittedCount) * 100;
}
function getCityRate(city: CityStat) {
return (city.success / city.total) * 100;
}
function splitSignature(content: string) {
const match = content.match(/^【(.+?)】(.+)$/);
return {
signature: match?.[1],
content: match?.[2] ?? content,
};
}
function TaskDetailTitle({ task }: { task: SmsTask }) {
return (
<div className="admin-task-detail-title">
<h2></h2>
<p>
<span>{task.id}</span>
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
</p>
</div>
);
}
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'primary' }) {
return (
<div className={['admin-task-metric', tone ? `admin-task-metric--${tone}` : ''].filter(Boolean).join(' ')}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
const progress = getProgress(task);
const successRate = getSuccessRate(task);
return (
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={<TaskDetailTitle task={task} />}
>
<div className="admin-task-detail">
<div className="admin-task-metrics">
<MetricCard label="提交总数" value={formatNumber(task.submittedCount)} />
<MetricCard label="提交成功" tone="success" value={formatNumber(task.submittedSuccess)} />
<MetricCard label="发送成功" tone="primary" value={formatNumber(task.successCount)} />
<MetricCard label="计费条数" tone="primary" value={formatNumber(task.billingCount)} />
<MetricCard label="成功率" tone="primary" value={`${successRate.toFixed(2)}%`} />
</div>
<div className="admin-task-detail-grid">
<section className="admin-task-card">
<h3><Send size={18} /></h3>
<dl className="admin-task-info-list">
<div>
<dt>/</dt>
<dd><strong>{task.enterprise}</strong><span>{task.application}</span></dd>
</div>
<div>
<dt></dt>
<dd>{task.submittedAt}</dd>
</div>
<div>
<dt></dt>
<dd><Tag tone={task.sendType === 'immediate' ? 'info' : 'warning'}>{sendTypeLabels[task.sendType]}</Tag></dd>
</div>
</dl>
</section>
<section className="admin-task-card">
<h3><TrendingUp size={18} /></h3>
<div className="admin-task-progress-card">
<div>
<span> {formatNumber(task.sentCount)} / {formatNumber(task.phoneCount)}</span>
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${task.status === 'failed' ? 'terminated' : task.status}`} style={{ width: `${progress}%` }} />
</div>
<div className="admin-task-progress-split">
<span><strong>{formatNumber(task.submittedSuccess)}</strong></span>
<span><strong>{formatNumber(task.successCount)}</strong></span>
</div>
</div>
</section>
<section className="admin-task-card">
<h3><BarChart3 size={18} /></h3>
<div className="admin-task-template-block">
<span></span>
<p className="admin-task-template">{task.templateContent}</p>
</div>
<dl className="admin-task-template-meta">
<div><dt>/</dt><dd>{task.wordCount} <b>·</b> {Math.max(1, Math.ceil(task.wordCount / 67))} /</dd></div>
<div><dt></dt><dd>{formatNumber(task.phoneCount)} </dd></div>
</dl>
<div className="admin-task-billing-note">
<span> 67 1 {Math.max(1, Math.ceil(task.wordCount / 67))} {formatNumber(task.billingCount)} </span>
</div>
</section>
</div>
<section className="admin-task-card admin-task-card--full">
<h3><Smartphone size={18} /></h3>
<div className="admin-carrier-grid">
{task.carriers.map((carrier) => {
const rate = (carrier.success / carrier.total) * 100;
return (
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
<strong>{carrier.name}</strong>
<p><span></span><b>{formatNumber(carrier.total)}</b></p>
<p><span></span><b>{formatNumber(carrier.success)}</b></p>
<div>
<em>{rate.toFixed(1)}%</em>
<span></span>
</div>
</article>
);
})}
</div>
</section>
<section className="admin-task-card admin-task-card--full">
<h3><MapPin size={18} /></h3>
<Table
columns={[
{ key: 'city', title: '城市', render: (record: CityStat) => <strong>{record.city}</strong> },
{ key: 'province', title: '省份', render: (record: CityStat) => <span className="muted">{record.province}</span> },
{ key: 'total', title: '总数', align: 'right', render: (record: CityStat) => formatNumber(record.total) },
{ key: 'success', title: '成功', align: 'right', render: (record: CityStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
{
key: 'rate',
title: '成功率',
align: 'right',
render: (record: CityStat) => <Tag tone={getCityRate(record) >= 95 ? 'success' : 'info'}>{getCityRate(record).toFixed(1)}%</Tag>,
},
]}
data={task.cities}
rowKey={(record) => record.city}
/>
</section>
</div>
</Modal>
);
}
export function AdminSmsTaskProgressPage() {
const [tasks, setTasks] = useState(taskData);
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [tasks]);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasks.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [enterprise, tasks]);
const filteredTasks = useMemo(
() => tasks.filter((item) => {
const submittedDate = item.submittedAt.slice(0, 10);
const matchesKeyword = !keyword || item.id.includes(keyword);
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
const matchesApplication = application === 'all' || item.application === application;
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate;
}),
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
);
function resetFilters() {
setKeyword('');
setEnterprise('all');
setApplication('all');
setSubmittedDateRange({});
}
function terminateTask(taskId: string) {
setTasks((current) => current.map((task) => (
task.id === taskId ? { ...task, status: 'terminated' } : task
)));
}
return (
<section className="page-stack admin-sms-task-page">
<div className="page-heading">
<div>
<div className="breadcrumb-line"> / <strong></strong></div>
<h1></h1>
</div>
</div>
<div className="surface admin-task-filter">
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
<Select
label="选择企业"
onChange={(event) => {
setEnterprise(event.target.value);
setApplication('all');
}}
options={enterpriseOptions}
value={enterprise}
/>
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-task-table-card">
<div className="ui-table-wrap">
<table className="ui-table batch-table admin-task-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th style={{ width: '180px' }}>/</th>
<th style={{ width: '136px' }}></th>
<th style={{ width: '130px' }}>/</th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '190px' }}></th>
<th style={{ width: '100px' }}></th>
<th style={{ textAlign: 'right', width: '120px' }}></th>
</tr>
</thead>
<tbody>
{filteredTasks.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={8}></td>
</tr>
) : filteredTasks.map((record) => {
const progress = getProgress(record);
const { signature, content } = splitSignature(record.templateContent);
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
return (
<Fragment key={record.id}>
<tr
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
onMouseEnter={() => setHoveredTaskId(record.id)}
onMouseLeave={() => setHoveredTaskId(null)}
>
<td><strong className="admin-task-id">{record.id}</strong></td>
<td>
<div className="admin-task-enterprise">
<strong>{record.enterprise}</strong>
<span>{record.application}</span>
</div>
</td>
<td><span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span></td>
<td>
<div className="admin-task-counts">
<strong>{formatNumber(record.phoneCount)}</strong>
<span>{record.wordCount}</span>
</div>
</td>
<td>
<div className="admin-task-send-type">
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
{sendTypeLabels[record.sendType]}
</Tag>
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
</div>
</td>
<td>
<div className="batch-progress admin-task-list-progress">
<div>
<span>{formatNumber(record.sentCount)}/{formatNumber(record.phoneCount)}</span>
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
</div>
</div>
</td>
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
<td style={{ textAlign: 'right' }}>
<div className="admin-task-actions">
<Button icon={<Eye size={15} />} iconOnly onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
icon={<StopCircle size={15} />}
iconOnly
onClick={() => terminateTask(record.id)}
size="sm"
variant="ghost"
>
</Button>
</div>
</td>
</tr>
<tr
className={['batch-template-row', 'admin-task-template-row', rowClass].filter(Boolean).join(' ')}
onMouseEnter={() => setHoveredTaskId(record.id)}
onMouseLeave={() => setHoveredTaskId(null)}
>
<td colSpan={8}>
<InlineTextPreview label="模板内容" leading={signature ? <strong>{signature}</strong> : null}>
{content}
</InlineTextPreview>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
<Pagination total={filteredTasks.length} />
</div>
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
</section>
);
}