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
+341
View File
@@ -0,0 +1,341 @@
import { useMemo, useState } from 'react';
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import {
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Tag,
type DateRangeValue,
} from '@/components/ui';
type SendStatus = 'success' | 'unknown' | 'failed';
type SendRoute = {
id: string;
channel: string;
sentAt: string;
receiptAt: string;
receiptCode: string;
};
type SmsRecord = {
id: string;
enterprise: string;
application: string;
submittedAt: string;
content: string;
phone: string;
carrier: string;
region: string;
wordCount: number;
billingCount: number;
channel: string;
status: SendStatus;
receiptAt: string;
routes: SendRoute[];
};
const statusLabelMap: Record<SendStatus, string> = {
success: '发送成功',
unknown: '未知',
failed: '失败',
};
const statusToneMap: Record<SendStatus, 'success' | 'neutral' | 'danger'> = {
success: 'success',
unknown: 'neutral',
failed: 'danger',
};
const statusDotClassMap: Record<SendStatus, string> = {
success: 'is-success',
unknown: 'is-unknown',
failed: 'is-failed',
};
const recordsSeed: SmsRecord[] = [
{
id: 'SMSR202601020001',
enterprise: '四川骠骑企业管理',
application: '应用1',
submittedAt: '2025-12-31 18:00:02',
content: '【大富翁】尊重的,您好!非常荣幸能邀请您到我们最新活动现场,请仔细阅读此短信,并将链接分享给朋友:https://x.wrtdalent.cn/aedrex',
phone: '13675569095',
carrier: '中国移动',
region: '成都',
wordCount: 100,
billingCount: 1,
channel: '三网西南堡垒卡 上海高流量 C77021',
status: 'success',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '三网行业-黄峰-三网-编号3.3', sentAt: '2026-01-02 18:38:02', receiptAt: '2026-01-02 18:38:05', receiptCode: '2' },
{ id: '2', channel: '三网行业-黄峰(循环号用)-三网-编号3.4', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:07', receiptCode: 'UDJDF' },
{ id: '3', channel: '移动映华北-上海富煌C60289-移动2.7', sentAt: '2026-01-02 18:38:07', receiptAt: '2026-01-02 18:38:15', receiptCode: 'VKIJ' },
{ id: '4', channel: '三网行业-北京富慧互联-三网-编号3.5', sentAt: '2026-01-02 18:38:15', receiptAt: '2026-01-02 18:38:23', receiptCode: 'DELIVRD' },
],
},
{
id: 'SMSR202601020002',
enterprise: '重庆进载数智',
application: '应用2',
submittedAt: '2025-12-31 11:49:10',
content: '【宜信易贷】限额提升:宜昌市用土工业 宜昌市营商会金 宜昌市营销线上信用 共同提出新行业更新工商登记变更名主动、您由是联机工具...',
phone: '18607638087',
carrier: '中国联通',
region: '重庆',
wordCount: 150,
billingCount: 2,
channel: '112383 三网合群 第部 三三 郭划3.5 CMPP2.0(32-27-0)',
status: 'unknown',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '112383 三网合群 第部 三三 郭划3.5', sentAt: '2026-01-02 18:37:58', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
{ id: '2', channel: '三网行业-西南备用-编号2.1', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:10', receiptCode: 'UNKNOWN' },
],
},
{
id: 'SMSR202601020003',
enterprise: '行业',
application: '应用3',
submittedAt: '2025-12-31 11:49:08',
content: '【瓷慧坤营销】联系联络:重点开拓士工业 宜昌市营商会金 宜昌市营销线上信用共同提出新行业更新工商登记变更名',
phone: 'XXXX市 持动',
carrier: '未知',
region: '未知',
wordCount: 80,
billingCount: 1,
channel: '未知',
status: 'unknown',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '未知通道', sentAt: '2026-01-02 18:38:01', receiptAt: '2026-01-02 18:38:05', receiptCode: 'UNKNOWN' },
],
},
{
id: 'SMSR202601020004',
enterprise: '超感世纪互三网',
application: '应用4',
submittedAt: '2025-12-31 11:47:23',
content: '【南京邮银】南京市权益和金融互实消市证就增幅有限公司 为您开通了一款全新、友联、诚信的创新模块:https://baWF.cn/s6/1WNnOaAy5i',
phone: '15250668026',
carrier: '中国电信',
region: '南京',
wordCount: 120,
billingCount: 1,
channel: '1069017 三网行业-北京商在互动-三网-第0.4-CMPP2.0(25-5-0)',
status: 'failed',
receiptAt: '2026-01-02 18:38:05',
routes: [
{ id: '1', channel: '1069017 三网行业-北京商在互动-三网-第0.4', sentAt: '2026-01-02 18:38:00', receiptAt: '2026-01-02 18:38:04', receiptCode: 'UNDELIV' },
{ id: '2', channel: '北京备用-CMPP2.0', sentAt: '2026-01-02 18:38:05', receiptAt: '2026-01-02 18:38:09', receiptCode: 'FAILED' },
],
},
];
function getDate(value: string) {
return value.slice(0, 10);
}
function StatusLine({ status }: { status: SendStatus }) {
return (
<span className="admin-sms-record-status">
<i className={statusDotClassMap[status]} />
{statusLabelMap[status]}
</span>
);
}
function SendDetailModal({ record, onClose }: { record: SmsRecord; onClose: () => void }) {
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title="发送详情"
>
<div className="admin-sms-send-detail">
<section>
<h3></h3>
<p className="admin-sms-detail-content">{record.content}</p>
</section>
<div className="admin-sms-route-list">
{record.routes.map((route) => (
<article key={route.id}>
<span>{route.id}</span>
<div>
<strong>{route.channel}</strong>
<dl>
<div>
<dt></dt>
<dd>{route.sentAt}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptAt}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptCode}</dd>
</div>
</dl>
</div>
</article>
))}
</div>
</div>
</Modal>
);
}
export function AdminSmsRecordsPage() {
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [channelKeyword, setChannelKeyword] = useState('');
const [status, setStatus] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsRecord | null>(null);
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(recordsSeed.map((item) => item.enterprise)));
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, []);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(recordsSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [enterprise]);
const filteredRows = useMemo(
() => recordsSeed.filter((item) => {
const submittedDate = getDate(item.submittedAt);
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
const matchesApplication = application === 'all' || item.application === application;
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesChannel = !channelKeyword || item.channel.includes(channelKeyword);
const matchesStatus = status === 'all' || item.status === status;
return matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate && matchesPhone && matchesContent && matchesChannel && matchesStatus;
}),
[application, channelKeyword, contentKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status],
);
function resetFilters() {
setEnterprise('all');
setApplication('all');
setDateRange({});
setPhoneKeyword('');
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
}
return (
<section className="page-stack admin-sms-records-page">
<div className="page-heading">
<div>
<div className="breadcrumb-line"> / <strong></strong></div>
<h1></h1>
</div>
</div>
<div className="surface admin-sms-record-filter">
<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={setDateRange} value={dateRange} />
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
<Select
label="发送状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部', value: 'all' },
{ label: '发送成功', value: 'success' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
]}
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} variant="ghost">CSV</Button>
</div>
<div className="ui-table-wrap">
<table className="ui-table admin-sms-record-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th></th>
<th style={{ width: '170px' }}></th>
<th style={{ width: '300px' }}></th>
<th style={{ textAlign: 'right', width: '120px' }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={5}></td>
</tr>
) : filteredRows.map((record) => (
<tr key={record.id}>
<td>
<div className="admin-sms-record-sender">
<strong>{record.enterprise}</strong>
<span>{record.application}</span>
<small>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11)}</small>
</div>
</td>
<td><p className="admin-sms-record-content">{record.content}</p></td>
<td>
<div className="admin-sms-record-phone">
<strong>{record.phone}</strong>
<span>{record.region} {record.carrier}</span>
<small>{record.wordCount}/{record.billingCount}</small>
</div>
</td>
<td>
<div className="admin-sms-record-channel">
<strong>{record.channel}</strong>
<StatusLine status={record.status} />
<span>{record.receiptAt}</span>
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={filteredRows.length} />
</div>
{selectedRecord ? <SendDetailModal onClose={() => setSelectedRecord(null)} record={selectedRecord} /> : null}
</section>
);
}