diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index c88b824..dc80fb8 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -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 = { delivered: '发送成功', @@ -21,11 +21,211 @@ const statusToneMap: Record = 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 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(); + 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 ( + + + {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 ?? route.submitStatus ?? '-'}
+
+
+
+
+ ))} +
+
+ +
+

状态信息

+
+
消息编号{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([]); @@ -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(); + 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 = 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> = [ - { key: 'messageId', title: '消息编号', width: '220px', render: (record) => {record.messageId} }, - { key: 'phone', title: '手机号', width: '140px', render: (record) => record.phoneNumber }, - { key: 'content', title: '短信内容', render: (record) => {record.content} }, - { 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) => {statusLabelMap[record.status] ?? record.status} }, - { key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => }, - ]; - const segmentColumns: Array> = [ { key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` }, { key: 'submitId', title: '提交ID', width: '190px', render: (record) => {record.submitId} }, @@ -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 ?

{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)}
{record.queuedAt.slice(11, 19)}
+
+

{record.content}

+
+ {record.phoneNumber} + {record.channel?.sendRegion ?? '-'} {getCarrierLabel(record.channel?.carrier)} + {record.content.length}字/{record.billingUnits}条 +
+
+
+ {record.channel?.name ?? record.channelId ?? '-'} + + {getTime(record.deliveredAt)} +
+
+ +
+
+ - setSelectedRecord(null)} variant="ghost">关闭} - onClose={() => setSelectedRecord(null)} - open={Boolean(selectedRecord)} - size="xl" - title="发送详情" - > - {selectedRecord ? ( -
-
-

短信内容

-

{selectedRecord.content}

-
-
-

状态信息

-

消息编号:{selectedRecord.messageId}

-

状态:{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}

-

失败原因:{selectedRecord.errorMessage ?? '-'}

-
-
-

分片补偿审计

- - - - ) : null} - + {selectedRecord ? ( + setSelectedRecord(null)} + record={selectedRecord} + segmentAudits={segmentAudits} + segmentColumns={segmentColumns} + segmentLoading={segmentLoading} + /> + ) : null} ); } diff --git a/src/styles/global.css b/src/styles/global.css index 3d42725..fcd4713 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; }