feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user