fix: harden channel retry attribution and operations UI

This commit is contained in:
hectorzhao
2026-07-26 21:33:58 +08:00
parent 059b38e8fe
commit 0857de09d8
27 changed files with 888 additions and 117 deletions
+59 -14
View File
@@ -11,10 +11,30 @@ type AdminCustomersPageProps = {
type CustomerRow = TenantManagementRow;
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
function ConfirmModal({
error,
isSubmitting,
message,
onCancel,
onConfirm,
type,
}: {
error: string;
isSubmitting: boolean;
message: string;
onCancel: () => void;
onConfirm: () => void;
type: 'toggle' | 'delete';
}) {
return (
<Modal footer={<><Button onClick={onCancel} variant="ghost"></Button><Button onClick={onConfirm}></Button></>} onClose={onCancel} open title="操作确认">
<Modal
footer={<><Button disabled={isSubmitting} onClick={onCancel} variant="ghost"></Button><Button disabled={isSubmitting} onClick={onConfirm} variant={type === 'delete' ? 'danger' : 'primary'}>{isSubmitting ? '处理中...' : '确认'}</Button></>}
onClose={() => { if (!isSubmitting) onCancel(); }}
open
title={type === 'delete' ? '删除企业' : '变更企业状态'}
>
<p className="admin-confirm-text">{message}</p>
{error ? <p className="form-error" role="alert">{error}</p> : null}
</Modal>
);
}
@@ -26,6 +46,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const [queryStatus, setQueryStatus] = useState('all');
const [filters, setFilters] = useState({ name: '', status: 'all' });
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
const [confirmError, setConfirmError] = useState('');
const [confirming, setConfirming] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
const [error, setError] = useState('');
@@ -82,10 +104,10 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="table-actions">
<Button icon={<DollarSign size={15} />} onClick={() => openRechargeModal(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
<Button onClick={() => openConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', record })} size="sm" variant="danger"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => openConfirmAction({ type: 'delete', record })} size="sm" variant="danger"></Button>
</div>
),
},
@@ -95,15 +117,35 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
setRechargeTarget(record);
}
function submitConfirmAction() {
function openConfirmAction(action: { type: 'toggle' | 'delete'; record: CustomerRow }) {
setConfirmError('');
setConfirmAction(action);
}
async function submitConfirmAction() {
if (!confirmAction) return;
const action = confirmAction.type === 'delete'
? adminApi.deleteTenant(confirmAction.record.id)
: adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
action.then(() => {
setConfirmAction(null);
loadData();
}).catch((failure: Error) => setError(failure.message || '企业状态更新失败'));
setConfirming(true);
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
await adminApi.deleteTenant(confirmAction.record.id);
} else {
await adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '企业操作失败';
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
setConfirming(false);
return;
}
setConfirmAction(null);
try {
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业列表刷新失败');
} finally {
setConfirming(false);
}
}
return (
@@ -140,9 +182,12 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
{confirmAction ? (
<ConfirmModal
error={confirmError}
isSubmitting={confirming}
message={confirmAction.type === 'delete' ? `确认删除企业“${confirmAction.record.name}”吗?` : `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
onCancel={() => setConfirmAction(null)}
onConfirm={submitConfirmAction}
onCancel={() => { setConfirmAction(null); setConfirmError(''); }}
onConfirm={() => void submitConfirmAction()}
type={confirmAction.type}
/>
) : null}
<ManualRechargeDialog
@@ -35,6 +35,20 @@ const deliveryTypeLabel: Record<string, string> = {
uplink: '上行短信',
};
const attemptStatusLabel: Record<string, string> = {
awaiting_ack: '等待客户端确认',
acknowledged: '客户端已确认',
rejected: '客户端拒绝',
failed: '投递失败',
};
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
if (status === 'awaiting_ack') return 'info' as const;
return 'neutral' as const;
}
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
@@ -68,29 +82,33 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe
</div>
<section className="report-history">
<h3></h3>
<div className="downstream-attempt-table" role="table" aria-label="逐次投递记录">
<div className="downstream-attempt-table__header" role="row">
<span> / </span><span> / ACK </span><span> / Sequence / Msg_Id</span><span></span>
</div>
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
{(record.attempts ?? []).map((attempt) => (
<div key={attempt.id} role="row">
<strong> {attempt.attemptNo} <br />{attempt.status}</strong>
<span>
{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}
<br />
ACK{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}
</span>
<span>
{attempt.connectionId ?? '-'}
<br />
Seq{attempt.sequenceId ?? '-'} / Msg{attempt.messageId ?? '-'}
</span>
<span>
ACK Result{attempt.ackResult ?? '-'}
<br />
{attempt.errorMessage ?? attempt.failureType ?? '-'}
</span>
</div>
<article className="downstream-attempt-card" key={attempt.id}>
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
{attempt.attemptNo}
</div>
<div className="downstream-attempt-card__body">
<header>
<strong> {attempt.attemptNo} </strong>
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
</header>
<dl className="downstream-attempt-card__times">
<div><dt></dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
</dl>
<dl className="downstream-attempt-card__identifiers">
<div><dt> ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
</dl>
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
<span>ACK Result{attempt.ackResult ?? '-'}</span>
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
</div>
</div>
</article>
))}
{(record.attempts ?? []).length === 0 ? <p></p> : null}
</div>
@@ -49,6 +49,18 @@ type SignatureFormState = {
type CarrierReportSummary = { status: string; approved: number; total: number };
type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
all: '全网',
};
function carrierLabel(carrier?: string | null) {
if (!carrier) return '未标注运营商';
return carrierLabels[carrier] ?? carrier;
}
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
let label = '未报备';
@@ -477,7 +489,7 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
<button className="admin-report-carrier--unicom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><strong></strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{target.channel.carrier}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.channel.carrier)}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</div>
</Modal>
);
@@ -505,7 +517,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
@@ -524,7 +536,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
<div><span></span><CarrierReportTag summary={summary?.telecom} /></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{target.channel.carrier}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.channel.carrier)}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</Modal>
);
}
@@ -545,7 +557,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
</div>
</Modal>;
@@ -235,7 +235,10 @@ export function AdminGatewaySubmitExceptionsPage() {
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button></div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading"><h2></h2><p className="page-inline-hint">Gateway命令已由后端脱敏</p></div>
<div className="section-heading gateway-exception-list-heading">
<div><h2></h2><p className="page-inline-hint"> Gateway </p></div>
<Tag tone="warning">{total} </Tag>
</div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
+3 -3
View File
@@ -45,7 +45,7 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div>
<div><span></span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{target ?? '-'}</strong></div>
@@ -95,7 +95,7 @@ export function AdminReportRecordsPage() {
}), [dateRange.end, dateRange.start, keyword, records, reportType]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
@@ -118,7 +118,7 @@ export function AdminReportRecordsPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="任务/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Input label="报备任务/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道、动作或备注" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
+2 -2
View File
@@ -183,7 +183,7 @@ export function AdminReportTasksPage() {
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'id', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
@@ -214,7 +214,7 @@ export function AdminReportTasksPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="任务/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Input label="报备任务/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道或报备对象" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
+4 -2
View File
@@ -133,6 +133,7 @@ export function AdminSmsAuditPage() {
width: '54px',
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
},
{ key: 'taskNo', title: '审核任务号', width: '210px', render: (record) => <strong className="admin-task-id">{record.taskNo}</strong> },
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · </button> },
@@ -179,7 +180,7 @@ export function AdminSmsAuditPage() {
]}
value={status}
/>
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
<Input label={status === 'pending_review' ? '提交日期' : '审核日期'} onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button>
@@ -213,6 +214,7 @@ export function AdminSmsAuditPage() {
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
<div><span></span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
@@ -222,7 +224,7 @@ export function AdminSmsAuditPage() {
</div>
</Modal> : null}
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · ${phoneTarget.taskNo}`}>
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}>
<div className="page-stack">
<div className="audit-filter-grid">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
+48 -5
View File
@@ -122,7 +122,35 @@ function getCarrierLabel(carrier?: string | null) {
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
}
function buildRouteRows(record: SmsMessageRecord): RouteRow[] {
function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] {
if (segmentAudits.length > 0) {
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
?? '-',
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) => {
@@ -208,7 +236,7 @@ function SendDetailModal({
segmentLoading: boolean;
onClose: () => void;
}) {
const routeRows = buildRouteRows(record);
const routeRows = buildRouteRows(record, segmentAudits);
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
const displayStatus = getRecordStatus(record);
const receiptNotice = getReceiptNotice(record);
@@ -347,12 +375,26 @@ function SendDetailModal({
);
}
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) };
}
export function AdminSmsRecordsPage() {
const pageSize = 25;
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultSmsRecordDateRange);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [channelKeyword, setChannelKeyword] = useState('');
@@ -445,14 +487,15 @@ export function AdminSmsRecordsPage() {
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
function resetFilters() {
const defaultDateRange = defaultSmsRecordDateRange();
setEnterprise('all');
setApplication('all');
setDateRange({});
setDateRange(defaultDateRange);
setPhoneKeyword('');
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
loadData({});
loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end });
}
return (
+5 -5
View File
@@ -210,7 +210,7 @@ function splitSignature(content: string) {
function TaskDetailTitle({ task }: { task: SmsTask }) {
return (
<div className="admin-task-detail-title">
<h2></h2>
<h2></h2>
<p>
<span>{task.id}</span>
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
@@ -252,7 +252,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
<div className="admin-task-detail-grid">
<section className="admin-task-card">
<h3><Send size={18} /></h3>
<h3><Send size={18} /></h3>
<dl className="admin-task-info-list">
<div>
<dt>/</dt>
@@ -423,7 +423,7 @@ export function AdminSmsTaskProgressPage() {
setSelectedTask(null);
loadTasks();
})
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
}
return (
@@ -436,7 +436,7 @@ export function AdminSmsTaskProgressPage() {
</div>
<div className="surface admin-task-filter">
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
<Input label="发送批次号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入发送批次号" value={keyword} />
<Select
label="选择企业"
onChange={(event) => {
@@ -461,7 +461,7 @@ export function AdminSmsTaskProgressPage() {
<table className="ui-table batch-table admin-task-table">
<thead>
<tr>
<th style={{ width: '170px' }}></th>
<th style={{ width: '170px' }}></th>
<th style={{ width: '180px' }}>/</th>
<th style={{ width: '136px' }}></th>
<th style={{ width: '130px' }}>/</th>
+1 -1
View File
@@ -287,7 +287,7 @@ export function AdminSmsUplinkRecordsPage() {
},
{ key: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
{ key: 'content', title: '上行内容', width: '360px', render: (record) => <span className="uplink-content" title={record.content}>{record.content}</span> },
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
+8 -8
View File
@@ -151,13 +151,13 @@ export function ClientBatchTasksPage() {
if (!source) return;
clientApi.cancelBatchTask(source.backendId)
.then(loadTasks)
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
}
const columns: Array<TableColumn<BatchTask>> = [
{
key: 'id',
title: '任务编号',
title: '发送批次号',
width: '140px',
render: (record) => (
<div className="batch-task-id">
@@ -236,12 +236,12 @@ export function ClientBatchTasksPage() {
<QueryPanel
title="查询条件"
summary={<> <strong>{filteredTasks.length}</strong> </>}
summary={<> <strong>{filteredTasks.length}</strong> </>}
>
<Input
label="任务编号"
label="发送批次号"
onChange={(event) => setKeyword(event.target.value)}
placeholder="输入任务编号搜索"
placeholder="输入发送批次号搜索"
prefix={<Search size={16} />}
value={keyword}
/>
@@ -310,14 +310,14 @@ export function ClientBatchTasksPage() {
onClose={() => setSelectedTask(null)}
open={Boolean(selectedTask)}
size="xl"
title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}
title={<DetailTitle title="发送批次详情" subtitle={selectedTask?.id} />}
>
{selectedTask ? (
<div className="task-detail">
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
<DetailInfoGrid
items={[
{ label: '任务编号', value: selectedTask.id },
{ label: '发送批次号', value: selectedTask.id },
{ label: '应用名称', value: selectedTask.applicationName },
{ label: '提交时间', value: selectedTask.submittedAt },
{
@@ -331,7 +331,7 @@ export function ClientBatchTasksPage() {
},
{ label: '模板字数', value: `${selectedTask.wordCount}`, tone: 'primary' },
{ label: '单个号码计费条数', value: `${Math.max(1, Math.ceil(selectedTask.wordCount / 70))}`, tone: 'primary' },
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '模板内容', value: selectedTask.templateContent, full: true },
]}
+1 -1
View File
@@ -26,7 +26,7 @@ type RecentTaskRow = {
};
const columns: Array<TableColumn<RecentTaskRow>> = [
{ key: 'taskNo', title: '批次号', render: (record) => record.taskNo },
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')}` },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
+1 -1
View File
@@ -177,7 +177,7 @@ export function ClientSendPage() {
<Send size={22} />
</span>
<h1></h1>
{submittedRecord ? <Tag tone="success"> {submittedRecord.taskNo}</Tag> : null}
{submittedRecord ? <Tag tone="success"> {submittedRecord.taskNo}</Tag> : null}
</div>
{error ? <p className="form-error">{error}</p> : null}