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 = { delivered: '发送成功', queued: '排队中', submitted: '已提交', unknown: '未知', failed: '失败', rejected: '已拒绝', }; const statusToneMap: Record = { delivered: 'success', queued: 'info', submitted: 'info', unknown: 'neutral', failed: 'danger', rejected: 'danger', }; const statusDotClassMap: Record = { delivered: 'is-success', queued: 'is-unknown', submitted: 'is-unknown', unknown: 'is-unknown', failed: 'is-failed', rejected: 'is-failed', }; const carrierLabelMap: Record = { 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(); 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 ( {getStatusLabel(status)} ); } 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>; segmentLoading: boolean; onClose: () => void; }) { const routeRows = buildRouteRows(record); return ( 关闭} onClose={onClose} open size="xl" title="发送详情" >

短信内容

{record.content}

通道发送与回执

{routeRows.map((route, index) => (
{index + 1}
{route.channel}
发送时间
{getTime(route.sentAt)}
回执时间
{getTime(route.receiptAt)}
回执码
{route.receiptCode ?? '-'}
))}

状态信息

消息编号{record.messageId}
发送状态{getStatusLabel(record.status)}
提交状态{record.submitStatus ?? '-'}
回执状态{record.receiptStatus ?? '-'}
失败原因{record.errorMessage ?? record.errorCode ?? '-'}

分片补偿审计

); } export function AdminSmsRecordsPage() { const [records, setRecords] = useState([]); const [enterprise, setEnterprise] = useState('all'); const [application, setApplication] = useState('all'); const [dateRange, setDateRange] = useState({}); const [phoneKeyword, setPhoneKeyword] = useState(''); const [contentKeyword, setContentKeyword] = useState(''); const [channelKeyword, setChannelKeyword] = useState(''); const [status, setStatus] = useState('all'); const [selectedRecord, setSelectedRecord] = useState(null); const [segmentAudits, setSegmentAudits] = useState([]); 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(); 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(); 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> = [ { key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` }, { key: 'submitId', title: '提交ID', width: '190px', render: (record) => {record.submitId} }, { 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) => {record.submitStatus} }, { key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? {record.receiptStatus} : '-' }, { 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 (

短信记录

{error ?

{error}

: null}
setApplication(event.target.value)} options={applicationOptions} value={application} /> setPhoneKeyword(event.target.value)} prefix={} value={phoneKeyword} /> setContentKeyword(event.target.value)} value={contentKeyword} /> setChannelKeyword(event.target.value)} value={channelKeyword} />
{filteredRows.length === 0 ? ( ) : filteredRows.map((record) => ( ))}
发送者 短信内容 手机号码 通道与发送状态 操作
暂无短信记录
{record.tenant?.name ?? record.tenantId} {record.application?.name ?? record.applicationId ?? '-'} {getDate(record.queuedAt)}
{getClock(record.queuedAt)}

{record.content}

{record.phoneNumber} {record.channel?.sendRegion ?? '-'} {getCarrierLabel(record.channel?.carrier)} {record.content.length}字/{record.billingUnits}条
{record.channel?.name ?? record.channelId ?? '-'} {getTime(record.deliveredAt)}
{selectedRecord ? ( setSelectedRecord(null)} record={selectedRecord} segmentAudits={segmentAudits} segmentColumns={segmentColumns} segmentLoading={segmentLoading} /> ) : null} ); }