fix: correlate downstream receipts with submit responses

This commit is contained in:
hectorzhao
2026-07-14 16:22:41 +08:00
parent 135b4fd24e
commit 1ce02ef206
12 changed files with 487 additions and 91 deletions
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
@@ -113,9 +114,10 @@ export function AdminDownstreamDeliveriesPage() {
loadData();
}, [loadData]);
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
const selectableIds = useMemo(
() => records.filter((item) => ['pending', 'failed', 'unconfirmed', 'rejected'].includes(item.status)).map((item) => item.id),
[records],
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
[records, replayableStatuses],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const summary = dashboard?.summary;
@@ -123,65 +125,16 @@ export function AdminDownstreamDeliveriesPage() {
const retryBuckets = dashboard?.retryBuckets ?? [];
const topApplications = dashboard?.topApplications ?? [];
const columns: Array<TableColumn<DownstreamDeliveryRecord>> = [
{
key: 'select',
title: '选择',
width: '52px',
align: 'center',
render: (record) => (
<input
type="checkbox"
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id),
);
}}
aria-label={`选择${record.id}`}
/>
),
},
{ key: 'createdAt', title: '投递时间', width: '180px', render: (record) => record.createdAt },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'type', title: '类型', width: '110px', render: (record) => deliveryTypeLabel[record.deliveryType] ?? record.deliveryType },
{ key: 'messageId', title: '消息 ID', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
{ key: 'status', title: '状态', width: '150px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
{
key: 'actions',
title: '操作',
width: '170px',
align: 'right',
render: (record) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
icon={<RefreshCw size={14} />}
onClick={() => {
if (!window.confirm('重投可能导致下游业务重复处理,确认继续吗?')) return;
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
}}
size="sm"
variant="secondary"
>
</Button>
</div>
),
},
];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const replayRecord = (record: DownstreamDeliveryRecord) => {
const acknowledgedWarning = record.status === 'delivered' ? '该记录已收到客户端确认,' : '';
if (!window.confirm(`${acknowledgedWarning}重投可能导致下游业务重复处理,确认继续吗?`)) return;
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
};
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
@@ -306,12 +259,12 @@ export function AdminDownstreamDeliveriesPage() {
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--ack" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.awaitingAck}</span>
<span>{item.delivered}</span>
<span>{item.unconfirmed + item.rejected}</span>
<span>{item.failed}</span>
<span>{item.total ?? 0}</span>
<span>{item.pending ?? 0}</span>
<span>{item.awaitingAck ?? 0}</span>
<span>{item.delivered ?? 0}</span>
<span>{(item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
<span>{item.failed ?? 0}</span>
</div>
))}
</div>
@@ -346,10 +299,10 @@ export function AdminDownstreamDeliveriesPage() {
{topApplications.length > 0 ? topApplications.map((item) => (
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
<strong>{item.name}</strong>
<span>{item.pending}</span>
<span>{item.failed + item.unconfirmed + item.rejected}</span>
<span>{item.delivered}</span>
<span>{item.alertCount}</span>
<span>{item.pending ?? 0}</span>
<span>{(item.failed ?? 0) + (item.unconfirmed ?? 0) + (item.rejected ?? 0)}</span>
<span>{item.delivered ?? 0}</span>
<span>{item.alertCount ?? 0}</span>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
@@ -358,11 +311,11 @@ export function AdminDownstreamDeliveriesPage() {
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
{selectedIds.length}
<div className="downstream-delivery-toolbar">
<p>
<strong>{selectedIds.length}</strong>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
@@ -388,7 +341,70 @@ export function AdminDownstreamDeliveriesPage() {
</Button>
</div>
</div>
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
<div className="downstream-delivery-list" role="table" aria-label="下游投递记录">
<div className="downstream-delivery-list__header" role="row">
<span></span>
<span></span>
<span> / </span>
<span> ID</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{records.length > 0 ? records.map((record) => (
<div className="downstream-delivery-list__row" role="row" key={record.id}>
<div className="downstream-delivery-list__select" role="cell">
<input
type="checkbox"
disabled={!replayableStatuses.has(record.status)}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) => event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id));
}}
aria-label={`选择${record.id}`}
/>
</div>
<div className="downstream-delivery-list__meta" role="cell">
<strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong>
<span>{formatDateTime(record.createdAt)}</span>
</div>
<div className="downstream-delivery-list__owner" role="cell">
<strong>{record.tenant?.name ?? '-'}</strong>
<span>{record.application?.name ?? '-'}</span>
</div>
<div className="downstream-delivery-list__message" role="cell">
<strong>{record.messageId ?? '-'}</strong>
</div>
<div role="cell">
<Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag>
</div>
<div className="downstream-delivery-list__retry" role="cell">
<strong>{record.retryCount}</strong>
<span></span>
</div>
<div className={`downstream-delivery-list__error${record.lastError ? '' : ' is-empty'}`} role="cell" title={record.lastError ?? undefined}>
{record.lastError ?? '无'}
</div>
<div className="downstream-delivery-list__actions" role="cell">
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={!replayableStatuses.has(record.status)}
icon={<RefreshCw size={14} />}
onClick={() => replayRecord(record)}
size="sm"
variant="secondary"
>
</Button>
</div>
</div>
)) : (
<div className="downstream-delivery-list__empty">{loading ? '加载中...' : '暂无下游投递记录'}</div>
)}
</div>
<Pagination
total={total}
page={page}
+133
View File
@@ -7817,6 +7817,139 @@ h3 {
font-size: var(--font-size-sm);
}
.downstream-delivery-toolbar {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
gap: var(--space-4);
justify-content: space-between;
padding: var(--space-4) var(--space-5);
}
.downstream-delivery-toolbar p {
color: var(--color-text-muted);
line-height: 1.6;
margin: 0;
}
.downstream-delivery-toolbar p strong {
color: var(--color-text-strong);
}
.downstream-delivery-toolbar > div,
.downstream-delivery-list__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-2);
}
.downstream-delivery-list {
overflow-x: auto;
}
.downstream-delivery-list__header,
.downstream-delivery-list__row {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 36px 140px minmax(150px, 1fr) minmax(165px, 1.05fr) 126px 54px minmax(170px, 1.2fr) 136px;
min-width: 1080px;
padding: var(--space-4);
}
.downstream-delivery-list__header {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.downstream-delivery-list__row {
border-top: 1px solid var(--color-border);
min-height: 104px;
}
.downstream-delivery-list__row:first-of-type {
border-top: 0;
}
.downstream-delivery-list__row:hover {
background: color-mix(in srgb, var(--color-selected-soft) 28%, transparent);
}
.downstream-delivery-list__select,
.downstream-delivery-list__retry {
text-align: center;
}
.downstream-delivery-list__meta,
.downstream-delivery-list__owner,
.downstream-delivery-list__retry {
display: grid;
gap: 6px;
}
.downstream-delivery-list__meta strong,
.downstream-delivery-list__owner strong,
.downstream-delivery-list__retry strong {
color: var(--color-text-strong);
}
.downstream-delivery-list__meta span,
.downstream-delivery-list__owner span,
.downstream-delivery-list__retry span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.downstream-delivery-list__message strong {
color: var(--color-text-strong);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: var(--font-size-sm);
overflow-wrap: anywhere;
}
.downstream-delivery-list__error {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: var(--radius-md);
color: #9a3412;
display: -webkit-box;
line-height: 1.55;
overflow: hidden;
padding: var(--space-3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.downstream-delivery-list__error.is-empty {
background: var(--color-bg-subtle);
border-color: var(--color-border);
color: var(--color-text-subtle);
}
.downstream-delivery-list__actions {
justify-content: flex-end;
}
.downstream-delivery-list__empty {
color: var(--color-text-muted);
padding: 48px var(--space-5);
text-align: center;
}
@media (max-width: 760px) {
.downstream-delivery-toolbar {
align-items: stretch;
flex-direction: column;
}
.downstream-delivery-toolbar > div {
justify-content: flex-end;
}
}
.admin-task-enterprise,
.admin-task-counts,
.admin-task-send-type {