feat: enhance admin sms records view
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
|
||||
import { Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '发送成功',
|
||||
@@ -21,11 +21,211 @@ const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> =
|
||||
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() {
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
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<SmsMessageRecord | null>(null);
|
||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||
@@ -33,7 +233,12 @@ export function AdminSmsRecordsPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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) => {
|
||||
setRecords(items);
|
||||
setError('');
|
||||
@@ -60,27 +265,36 @@ export function AdminSmsRecordsPage() {
|
||||
.finally(() => setSegmentLoading(false));
|
||||
}, [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(
|
||||
() => records.filter((item) => {
|
||||
const submittedDate = item.queuedAt.slice(0, 10);
|
||||
const submittedDate = getDate(item.queuedAt);
|
||||
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
||||
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>> = [
|
||||
{ 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> },
|
||||
@@ -94,9 +308,12 @@ export function AdminSmsRecordsPage() {
|
||||
];
|
||||
|
||||
function resetFilters() {
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
}
|
||||
|
||||
@@ -111,9 +328,20 @@ export function AdminSmsRecordsPage() {
|
||||
{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)}
|
||||
@@ -131,42 +359,70 @@ export function AdminSmsRecordsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
|
||||
<div className="surface admin-sms-record-table-card">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
<Table
|
||||
columns={segmentColumns}
|
||||
data={segmentAudits}
|
||||
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
<SendDetailModal
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
record={selectedRecord}
|
||||
segmentAudits={segmentAudits}
|
||||
segmentColumns={segmentColumns}
|
||||
segmentLoading={segmentLoading}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8163,6 +8163,31 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user