fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+69 -277
View File
@@ -1,242 +1,73 @@
import { useMemo, useState } from 'react';
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Tag,
type DateRangeValue,
} from '@/components/ui';
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue, type TableColumn, Table } 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: '发送成功',
const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
queued: '排队中',
submitted: '已提交',
unknown: '未知',
failed: '失败',
rejected: '已拒绝',
};
const statusToneMap: Record<SendStatus, 'success' | 'neutral' | 'danger'> = {
success: 'success',
const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
delivered: 'success',
queued: 'info',
submitted: 'info',
unknown: 'neutral',
failed: 'danger',
rejected: '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 [records, setRecords] = useState<SmsMessageRecord[]>([]);
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 [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [error, setError] = useState('');
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 }))];
function loadData() {
adminApi.listAdminMessages({ phoneNumber: phoneKeyword || undefined, status: status === 'all' ? undefined : status })
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
}
useEffect(() => {
loadData();
}, []);
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;
() => records.filter((item) => {
const submittedDate = item.queuedAt.slice(0, 10);
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;
return matchesStartDate && matchesEndDate && matchesContent;
}),
[application, channelKeyword, contentKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status],
[contentKeyword, dateRange.end, dateRange.start, records],
);
const columns: Array<TableColumn<SmsMessageRecord>> = [
{ key: 'messageId', title: '消息编号', width: '220px', render: (record) => <strong>{record.messageId}</strong> },
{ key: 'phone', title: '手机号', width: '140px', render: (record) => record.phoneNumber },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'billing', title: '计费', width: '120px', render: (record) => `${record.billingUnits} 条 / ¥${(record.amountCents / 100).toFixed(2)}` },
{ key: 'queuedAt', title: '提交时间', width: '190px', render: (record) => record.queuedAt },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
function resetFilters() {
setEnterprise('all');
setApplication('all');
setDateRange({});
setPhoneKeyword('');
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
}
@@ -248,95 +79,56 @@ export function AdminSmsRecordsPage() {
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<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: 'delivered' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
]}
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={loadData}></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>
<div className="surface">
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
<Pagination total={filteredRows.length} />
</div>
{selectedRecord ? <SendDetailModal onClose={() => setSelectedRecord(null)} record={selectedRecord} /> : null}
<Modal
footer={<Button onClick={() => setSelectedRecord(null)} variant="ghost"></Button>}
onClose={() => setSelectedRecord(null)}
open={Boolean(selectedRecord)}
size="xl"
title="发送详情"
>
{selectedRecord ? (
<div className="admin-sms-send-detail">
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{selectedRecord.content}</p>
</section>
<section>
<h3></h3>
<p>{selectedRecord.messageId}</p>
<p>{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}</p>
<p>{selectedRecord.errorMessage ?? '-'}</p>
</section>
</div>
) : null}
</Modal>
</section>
);
}