fix: connect remaining sms pages to real backend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user