feat: enhance admin sms records view

This commit is contained in:
hectorzhao
2026-07-09 12:30:35 +08:00
parent e0579857c6
commit 2a65b41c4e
2 changed files with 332 additions and 51 deletions
+307 -51
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react'; import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi'; import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
delivered: '发送成功', delivered: '发送成功',
@@ -21,11 +21,211 @@ const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> =
rejected: 'danger', rejected: 'danger',
}; };
const statusDotClassMap: Record<string, string> = {
delivered: 'is-success',
queued: 'is-unknown',
submitted: 'is-unknown',
unknown: 'is-unknown',
failed: 'is-failed',
rejected: 'is-failed',
};
const carrierLabelMap: Record<string, string> = {
mobile: '中国移动',
unicom: '中国联通',
telecom: '中国电信',
all: '三网',
};
type RouteRow = {
id: string;
channel: string;
sentAt?: string | null;
receiptAt?: string | null;
receiptCode?: string | null;
submitStatus?: string | null;
};
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function getTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
}
function getStatusLabel(status?: string | null) {
return status ? (statusLabelMap[status] ?? status) : '-';
}
function getCarrierLabel(carrier?: string | null) {
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
}
function buildRouteRows(record: SmsMessageRecord): RouteRow[] {
const receipts = record.receiptRecords ?? [];
const receiptByGatewayId = new Map<string, SmsReceiptRecord>();
receipts.forEach((receipt) => {
if (receipt.gatewayMessageId) {
receiptByGatewayId.set(receipt.gatewayMessageId, receipt);
}
});
const submitRows = (record.submitRecords ?? []).map((submit, index) => {
const receipt = submit.gatewayMessageId ? receiptByGatewayId.get(submit.gatewayMessageId) : undefined;
return {
id: submit.id || String(index + 1),
channel: submit.channel?.name ?? record.channel?.name ?? submit.channelId ?? '-',
sentAt: submit.submittedAt ?? submit.createdAt,
receiptAt: receipt?.deliveredAt,
receiptCode: receipt?.rawStatus ?? receipt?.receiptStatus,
submitStatus: submit.submitStatus,
};
});
if (submitRows.length > 0) {
return submitRows;
}
return [{
id: record.id,
channel: record.channel?.name ?? record.channelId ?? '-',
sentAt: record.submittedAt ?? record.queuedAt,
receiptAt: record.deliveredAt,
receiptCode: record.receiptStatus,
submitStatus: record.submitStatus,
}];
}
function StatusLine({ status }: { status: string }) {
return (
<span className="admin-sms-record-status">
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
{getStatusLabel(status)}
</span>
);
}
function csvCell(value: unknown) {
const text = String(value ?? '');
return `"${text.replace(/"/g, '""')}"`;
}
function downloadCsv(records: SmsMessageRecord[]) {
const rows = [
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
...records.map((record) => [
record.messageId,
record.tenant?.name ?? record.tenantId,
record.application?.name ?? record.applicationId ?? '',
getTime(record.queuedAt),
record.phoneNumber,
record.channel?.sendRegion ?? '',
getCarrierLabel(record.channel?.carrier),
record.billingUnits,
(record.amountCents / 100).toFixed(2),
record.channel?.name ?? record.channelId ?? '',
getStatusLabel(record.status),
getTime(record.deliveredAt),
record.content,
]),
];
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `sms-records-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
URL.revokeObjectURL(url);
}
function SendDetailModal({
record,
segmentAudits,
segmentColumns,
segmentLoading,
onClose,
}: {
record: SmsMessageRecord;
segmentAudits: SmsMessageSegmentAudit[];
segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>>;
segmentLoading: boolean;
onClose: () => void;
}) {
const routeRows = buildRouteRows(record);
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title="发送详情"
>
<div className="admin-sms-send-detail">
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{record.content}</p>
</section>
<section>
<h3></h3>
<div className="admin-sms-route-list">
{routeRows.map((route, index) => (
<article key={route.id}>
<span>{index + 1}</span>
<div>
<strong>{route.channel}</strong>
<dl>
<div>
<dt></dt>
<dd>{getTime(route.sentAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{getTime(route.receiptAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptCode ?? route.submitStatus ?? '-'}</dd>
</div>
</dl>
</div>
</article>
))}
</div>
</section>
<section>
<h3></h3>
<div className="admin-sms-detail-status-grid">
<div><span></span><strong>{record.messageId}</strong></div>
<div><span></span><strong>{getStatusLabel(record.status)}</strong></div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.errorMessage ?? record.errorCode ?? '-'}</strong></div>
</div>
</section>
<section>
<h3></h3>
<Table
columns={segmentColumns}
data={segmentAudits}
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
pagination={false}
rowKey="id"
/>
</section>
</div>
</Modal>
);
}
export function AdminSmsRecordsPage() { export function AdminSmsRecordsPage() {
const [records, setRecords] = useState<SmsMessageRecord[]>([]); const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [phoneKeyword, setPhoneKeyword] = useState(''); const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState(''); const [contentKeyword, setContentKeyword] = useState('');
const [channelKeyword, setChannelKeyword] = useState('');
const [status, setStatus] = useState('all'); const [status, setStatus] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null); const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]); const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
@@ -33,7 +233,12 @@ export function AdminSmsRecordsPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData() { function loadData() {
adminApi.listAdminMessages({ phoneNumber: phoneKeyword || undefined, status: status === 'all' ? undefined : status }) adminApi.listAdminMessages({
tenantId: enterprise === 'all' ? undefined : enterprise,
applicationId: application === 'all' ? undefined : application,
phoneNumber: phoneKeyword || undefined,
status: status === 'all' ? undefined : status,
})
.then((items) => { .then((items) => {
setRecords(items); setRecords(items);
setError(''); setError('');
@@ -60,27 +265,36 @@ export function AdminSmsRecordsPage() {
.finally(() => setSegmentLoading(false)); .finally(() => setSegmentLoading(false));
}, [selectedRecord]); }, [selectedRecord]);
const enterpriseOptions = useMemo(() => {
const tenants = new Map<string, string>();
records.forEach((record) => tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId));
return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))];
}, [records]);
const applicationOptions = useMemo(() => {
const applications = new Map<string, string>();
records
.filter((record) => enterprise === 'all' || record.tenantId === enterprise)
.forEach((record) => {
if (record.applicationId) {
applications.set(record.applicationId, record.application?.name ?? record.applicationId);
}
});
return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))];
}, [enterprise, records]);
const filteredRows = useMemo( const filteredRows = useMemo(
() => records.filter((item) => { () => records.filter((item) => {
const submittedDate = item.queuedAt.slice(0, 10); const submittedDate = getDate(item.queuedAt);
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start; const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end; const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
const matchesContent = !contentKeyword || item.content.includes(contentKeyword); const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesContent; const matchesChannel = !channelKeyword || (item.channel?.name ?? item.channelId ?? '').includes(channelKeyword);
return matchesStartDate && matchesEndDate && matchesContent && matchesChannel;
}), }),
[contentKeyword, dateRange.end, dateRange.start, records], [channelKeyword, 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: '130px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [ const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` }, { key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> }, { key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
@@ -94,9 +308,12 @@ export function AdminSmsRecordsPage() {
]; ];
function resetFilters() { function resetFilters() {
setEnterprise('all');
setApplication('all');
setDateRange({}); setDateRange({});
setPhoneKeyword(''); setPhoneKeyword('');
setContentKeyword(''); setContentKeyword('');
setChannelKeyword('');
setStatus('all'); setStatus('all');
} }
@@ -111,9 +328,20 @@ export function AdminSmsRecordsPage() {
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-sms-record-filter"> <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} /> <DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /> <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) => setContentKeyword(event.target.value)} value={contentKeyword} />
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
<Select <Select
label="发送状态" label="发送状态"
onChange={(event) => setStatus(event.target.value)} onChange={(event) => setStatus(event.target.value)}
@@ -131,42 +359,70 @@ export function AdminSmsRecordsPage() {
</div> </div>
</div> </div>
<div className="surface"> <div className="surface admin-sms-record-table-card">
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" /> <div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} 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.tenant?.name ?? record.tenantId}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
<small>{getDate(record.queuedAt)}<br />{record.queuedAt.slice(11, 19)}</small>
</div>
</td>
<td><p className="admin-sms-record-content">{record.content}</p></td>
<td>
<div className="admin-sms-record-phone">
<strong>{record.phoneNumber}</strong>
<span>{record.channel?.sendRegion ?? '-'} {getCarrierLabel(record.channel?.carrier)}</span>
<small>{record.content.length}/{record.billingUnits}</small>
</div>
</td>
<td>
<div className="admin-sms-record-channel">
<strong>{record.channel?.name ?? record.channelId ?? '-'}</strong>
<StatusLine status={record.status} />
<span>{getTime(record.deliveredAt)}</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> </div>
<Modal {selectedRecord ? (
footer={<Button onClick={() => setSelectedRecord(null)} variant="ghost"></Button>} <SendDetailModal
onClose={() => setSelectedRecord(null)} onClose={() => setSelectedRecord(null)}
open={Boolean(selectedRecord)} record={selectedRecord}
size="xl" segmentAudits={segmentAudits}
title="发送详情" segmentColumns={segmentColumns}
> segmentLoading={segmentLoading}
{selectedRecord ? ( />
<div className="admin-sms-send-detail"> ) : null}
<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>
<section>
<h3></h3>
<Table
columns={segmentColumns}
data={segmentAudits}
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
pagination={false}
rowKey="id"
/>
</section>
</div>
) : null}
</Modal>
</section> </section>
); );
} }
+25
View File
@@ -8163,6 +8163,31 @@ h3 {
padding: var(--space-5); padding: var(--space-5);
} }
.admin-sms-detail-status-grid {
background: var(--color-bg-subtle);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-4) var(--space-6);
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: var(--space-5);
}
.admin-sms-detail-status-grid div {
display: grid;
gap: var(--space-2);
}
.admin-sms-detail-status-grid span {
color: var(--color-text-muted);
font-weight: var(--font-weight-semibold);
}
.admin-sms-detail-status-grid strong {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
overflow-wrap: anywhere;
}
.admin-sms-route-list { .admin-sms-route-list {
display: grid; display: grid;
} }