Files
lislgosms/src/apps/admin/AdminSmsRecordsPage.tsx
T

455 lines
17 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 formatLocalDateTime(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(date);
const partMap = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
}
function getDate(value?: string | null) {
return formatLocalDateTime(value)?.slice(0, 10) ?? '';
}
function getTime(value?: string | null) {
return formatLocalDateTime(value) ?? '-';
}
function getClock(value?: string | null) {
return formatLocalDateTime(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,
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: receipts[0]?.rawStatus,
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 ?? '-'}</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.listOperationMessages({
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 />{getClock(record.queuedAt)}</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>
);
}