429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
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: '发送成功',
|
|
queued: '排队中',
|
|
submitted: '已提交',
|
|
unknown: '未知',
|
|
failed: '失败',
|
|
rejected: '已拒绝',
|
|
};
|
|
|
|
const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
|
|
delivered: 'success',
|
|
queued: 'info',
|
|
submitted: 'info',
|
|
unknown: 'neutral',
|
|
failed: '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() {
|
|
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[]>([]);
|
|
const [segmentLoading, setSegmentLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
function loadData() {
|
|
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('');
|
|
})
|
|
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedRecord) {
|
|
setSegmentAudits([]);
|
|
return;
|
|
}
|
|
setSegmentLoading(true);
|
|
adminApi.listMessageSegmentAudits({ messageRecordId: selectedRecord.id })
|
|
.then((items) => {
|
|
setSegmentAudits(items);
|
|
setError('');
|
|
})
|
|
.catch((failure: Error) => setError(failure.message || '分片审计加载失败'))
|
|
.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 = getDate(item.queuedAt);
|
|
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
|
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
|
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
|
const matchesChannel = !channelKeyword || (item.channel?.name ?? item.channelId ?? '').includes(channelKeyword);
|
|
return matchesStartDate && matchesEndDate && matchesContent && matchesChannel;
|
|
}),
|
|
[channelKeyword, contentKeyword, dateRange.end, dateRange.start, records],
|
|
);
|
|
|
|
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> },
|
|
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
|
|
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
|
|
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
|
|
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
|
|
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
|
|
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
|
|
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
|
|
];
|
|
|
|
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>
|
|
<Breadcrumb items={['数据详单', '短信记录']} />
|
|
<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: 'delivered' },
|
|
{ label: '未知', value: 'unknown' },
|
|
{ label: '失败', value: 'failed' },
|
|
]}
|
|
value={status}
|
|
/>
|
|
<div className="admin-sms-record-filter__actions">
|
|
<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} />} 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>
|
|
|
|
{selectedRecord ? (
|
|
<SendDetailModal
|
|
onClose={() => setSelectedRecord(null)}
|
|
record={selectedRecord}
|
|
segmentAudits={segmentAudits}
|
|
segmentColumns={segmentColumns}
|
|
segmentLoading={segmentLoading}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|