fix: correlate downstream receipts with submit responses
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user