feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+64 -509
View File
@@ -1,411 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Download, Info, 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, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
import { SendDetailModal } from './sms-records/SendDetailModal';
import { SmsRecordFilter } from './sms-records/SmsRecordFilter';
import { SmsRecordList } from './sms-records/SmsRecordList';
import { defaultSmsRecordDateRange } from './sms-records/smsRecordModel';
import type { ApplicationOption, MessageFilters, TenantOption } from './sms-records/smsRecordTypes';
import './sms-records/AdminSmsRecordsPage.css';
const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
queued: '排队中',
submitted: '已提交',
submit_failed: '提交失败',
unknown: '未知',
failed: '送达失败',
rejected: '已拒绝',
};
const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> = {
delivered: 'success',
queued: 'info',
submitted: 'info',
submit_failed: 'danger',
unknown: 'neutral',
failed: 'danger',
rejected: 'danger',
};
const statusDotClassMap: Record<string, string> = {
delivered: 'is-success',
queued: 'is-unknown',
submitted: 'is-unknown',
submit_failed: 'is-failed',
unknown: 'is-unknown',
failed: 'is-failed',
rejected: 'is-failed',
};
const carrierLabelMap: Record<string, string> = {
mobile: '中国移动',
unicom: '中国联通',
telecom: '中国电信',
all: '三网',
};
type RouteRow = {
id: string;
channel: string;
channelGroup?: string | null;
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 isSubmitFailure(record: SmsMessageRecord) {
return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? '');
}
function getRecordStatus(record: SmsMessageRecord) {
return isSubmitFailure(record) ? 'submit_failed' : record.status;
}
function getRecordStatusLabel(record: SmsMessageRecord) {
return getStatusLabel(getRecordStatus(record));
}
function getReceiptNotice(record: SmsMessageRecord) {
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
);
if (hasPlatformFailureReceipt) {
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
if (deliveries.some((item) => item.status === 'delivered')) {
return '平台已生成失败回执并通知企业';
}
const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status)));
return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`;
}
if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) {
return '运营端通道测试,无需生成客户回执';
}
return null;
}
function getCarrierLabel(carrier?: string | null) {
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
}
function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] {
if (segmentAudits.length > 0) {
const submitById = new Map((record.submitRecords ?? []).map((submit) => [submit.submitId, submit]));
const attempts = new Map<string, SmsMessageSegmentAudit[]>();
segmentAudits.forEach((segment) => {
const current = attempts.get(segment.submitId) ?? [];
current.push(segment);
attempts.set(segment.submitId, current);
});
return Array.from(attempts.entries())
.map(([submitId, segments]) => {
const ordered = [...segments].sort((left, right) => left.segmentIndex - right.segmentIndex);
const sentTimes = ordered.map((segment) => segment.submittedAt).filter(Boolean) as string[];
const receiptTimes = ordered.map((segment) => segment.deliveredAt).filter(Boolean) as string[];
const receiptCodes = Array.from(new Set(ordered.map((segment) => segment.rawStatus).filter(Boolean)));
const submitStatuses = Array.from(new Set(ordered.map((segment) => segment.submitStatus).filter(Boolean)));
return {
id: submitId,
attempt: Math.min(...ordered.map((segment) => segment.attempt)),
channel: ordered.find((segment) => segment.channel?.name)?.channel?.name
?? ordered.find((segment) => segment.channelId)?.channelId
?? '-',
channelGroup: submitById.get(submitId)?.channelGroupName ?? submitById.get(submitId)?.channelGroup?.name,
sentAt: sentTimes.sort()[0],
receiptAt: receiptTimes.sort()[receiptTimes.length - 1],
receiptCode: receiptCodes.join(' / ') || undefined,
submitStatus: submitStatuses.join(' / ') || undefined,
};
})
.sort((left, right) => left.attempt - right.attempt);
}
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 ?? '-',
channelGroup: submit.channelGroupName ?? submit.channelGroup?.name,
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({ record }: { record: SmsMessageRecord }) {
const status = getRecordStatus(record);
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.province ?? '',
getCarrierLabel(record.carrier),
record.billingUnits,
formatCents(record.amountCents),
record.channel?.name ?? record.channelId ?? '',
getRecordStatusLabel(record),
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,
segmentLoading,
onClose,
}: {
record: SmsMessageRecord;
segmentAudits: SmsMessageSegmentAudit[];
segmentLoading: boolean;
onClose: () => void;
}) {
const routeRows = buildRouteRows(record, segmentAudits);
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
const timeDiff = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
return timeDiff || left.segmentIndex - right.segmentIndex || left.id.localeCompare(right.id);
});
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
const displayStatus = getRecordStatus(record);
const receiptNotice = getReceiptNotice(record);
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.messageId}</p></div>}
>
<div className="admin-sms-send-detail">
<div className="admin-sms-detail-overview">
<div>
<span></span>
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
</div>
<div>
<span></span>
<strong>{record.submitStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.receiptStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{record.phoneNumber || '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
</div>
<div>
<span></span>
<strong>{channelGroupNames.join(' / ') || '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.clientSrcId || '-'}</strong>
</div>
<div>
<span></span>
<strong>{sentAccessNumber || '-'}</strong>
</div>
</div>
{receiptNotice ? (
<div className="admin-sms-detail-notice" role="status">
<Info size={20} />
<strong>{receiptNotice}</strong>
</div>
) : null}
<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>
<p className="muted">{route.channelGroup ?? '-'}</p>
<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>
<div>
<dt></dt>
<dd>{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>{getRecordStatusLabel(record)}</strong></div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
</div>
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
<div className="admin-sms-detail-failure" role="alert">
<AlertTriangle size={20} />
<div><span></span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
</div>
) : null}
</section>
<section>
<h3></h3>
{segmentLoading ? <div className="ui-table__empty">...</div> : segmentAudits.length === 0 ? (
<div className="ui-table__empty"></div>
) : (
<div className="admin-sms-segment-list">
{orderedSegmentAudits.map((segment) => (
<article className="admin-sms-segment-card" key={segment.id}>
<header>
<strong> {segment.segmentIndex}/{segment.segmentTotal}</strong>
<div>
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
</div>
</header>
<dl>
<div><dt></dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
<div><dt> ID</dt><dd>{segment.submitId}</dd></div>
<div><dt> MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
<div><dt></dt><dd>{segment.compensationType ?? '-'}</dd></div>
<div><dt></dt><dd>{getTime(segment.createdAt)}</dd></div>
<div><dt></dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
</dl>
</article>
))}
</div>
)}
</section>
</div>
</Modal>
);
}
function dateKey(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function defaultSmsRecordDateRange(): DateRangeValue {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
return { start: dateKey(yesterday), end: dateKey(today) };
}
const pageSize = 25;
export function AdminSmsRecordsPage() {
const pageSize = 25;
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
@@ -422,20 +27,8 @@ export function AdminSmsRecordsPage() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filterTenants, setFilterTenants] = useState<Array<{ id: string; name: string }>>([]);
const [filterApplications, setFilterApplications] = useState<Array<{ id: string; tenantId: string; name: string }>>([]);
type MessageFilters = {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
contentKeyword?: string;
channelKeyword?: string;
carrier?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
status?: string;
};
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
function currentFilters(): MessageFilters {
return {
@@ -471,8 +64,12 @@ export function AdminSmsRecordsPage() {
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenants, applications]) => {
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name })));
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
setFilterTenants(tenants
.filter((item) => item.status !== 'deleted')
.map((item) => ({ id: item.id, name: item.name })));
setFilterApplications(applications
.filter((item) => item.status !== 'deleted')
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
})
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
}, []);
@@ -492,20 +89,20 @@ export function AdminSmsRecordsPage() {
.finally(() => setSegmentLoading(false));
}, [selectedRecord]);
const enterpriseOptions = useMemo(() => {
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))];
}, [filterTenants]);
const enterpriseOptions = useMemo(
() => [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))],
[filterTenants],
);
const applicationOptions = useMemo(() => {
return [{ label: '全部应用', value: 'all' }, ...filterApplications
const applicationOptions = useMemo(
() => [{ label: '全部应用', value: 'all' }, ...filterApplications
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
.map((item) => ({ label: item.name, value: item.id }))];
}, [enterprise, filterApplications]);
.map((item) => ({ label: item.name, value: item.id }))],
[enterprise, filterApplications],
);
const filteredRows = records;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows;
function resetFilters() {
const defaultDateRange = defaultSmsRecordDateRange();
@@ -545,87 +142,45 @@ export function AdminSmsRecordsPage() {
</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} />
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
{ label: '未识别', value: 'unknown' },
]}
value={carrier}
/>
<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: 'submit_failed' },
{ label: '送达失败', value: 'failed' },
]}
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(currentFilters(), 1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<SmsRecordFilter
application={application}
applicationOptions={applicationOptions}
carrier={carrier}
channelKeyword={channelKeyword}
contentKeyword={contentKeyword}
dateRange={dateRange}
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
phoneKeyword={phoneKeyword}
status={status}
onApplicationChange={setApplication}
onCarrierChange={setCarrier}
onChannelKeywordChange={setChannelKeyword}
onContentKeywordChange={setContentKeyword}
onDateRangeChange={setDateRange}
onEnterpriseChange={(value) => {
setEnterprise(value);
setApplication('all');
}}
onPhoneKeywordChange={setPhoneKeyword}
onQuery={() => {
if (page !== 1) setPage(1);
else loadData(currentFilters(), 1);
}}
onReset={resetFilters}
onStatusChange={setStatus}
/>
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={() => void exportRecords()} variant="ghost">CSV</Button>
</div>
<div className="admin-sms-record-list">
{loading ? <div className="ui-table__empty">...</div> : filteredRows.length === 0 ? <div className="ui-table__empty"></div> : visibleRows.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className="admin-sms-record-content">{record.content}</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
<div><span></span><strong>{record.billingUnits} / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => setSelectedRecord(record)} type="button"></button></footer>
</article>
))}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={total}
totalPages={totalPages}
/>
</div>
<SmsRecordList
currentPage={currentPage}
loading={loading}
records={records}
total={total}
totalPages={totalPages}
onExport={() => void exportRecords()}
onOpenDetail={setSelectedRecord}
onPageChange={setPage}
/>
{selectedRecord ? (
<SendDetailModal