feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
@@ -0,0 +1,377 @@
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';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
delivered: 'success',
failed: 'danger',
};
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{record.status}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.deliveredAt ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
deliveryType,
}),
adminApi.listDownstreamDeliveries({
keyword,
status,
deliveryType,
applicationId,
page,
pageSize,
}),
adminApi.listEnterpriseApplications(),
])
.then(([dashboardResponse, response, apps]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
useEffect(() => {
loadData();
}, [loadData]);
const selectableIds = useMemo(
() => records.filter((item) => item.status !== 'delivered').map((item) => item.id),
[records],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const summary = dashboard?.summary;
const typeBreakdown = dashboard?.typeBreakdown ?? [];
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={record.status === 'delivered'}
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: '110px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{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
icon={<RefreshCw size={14} />}
onClick={() => {
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));
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['数据详单', '下游投递记录']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card">
<BarChart3 size={22} />
<div>
<span></span>
<strong>{summary?.total ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={22} />
<div>
<span></span>
<strong>{summary?.pending ?? 0}</strong>
<small> {summary?.stalledPending ?? 0} </small>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<strong>{summary?.delivered ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<AlertTriangle size={22} />
<div>
<span></span>
<strong>{summary?.alertCount ?? 0}</strong>
<small> {summary?.recentFailed ?? 0} </small>
</div>
</div>
</div>
<div className="surface admin-task-filter">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select
label="状态"
options={[
{ label: '全部状态', value: 'all' },
{ label: '待投递', value: 'pending' },
{ label: '已投递', value: 'delivered' },
{ label: '最终失败', value: 'failed' },
]}
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
/>
<Select
label="类型"
options={[
{ label: '全部类型', value: 'all' },
{ label: '状态回执', value: 'receipt' },
{ label: '上行短信', value: 'uplink' },
]}
value={deliveryType}
onChange={(event) => {
setDeliveryType(event.target.value);
setPage(1);
}}
/>
<Select
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setDeliveryType('all');
setApplicationId('all');
setPage(1);
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="overview-grid">
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.delivered}</span>
<span>{item.failed}</span>
</div>
))}
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-bucket-list">
{retryBuckets.map((item) => (
<div className="downstream-bucket-item" key={item.label}>
<span>{item.label}</span>
<strong>{item.count}</strong>
</div>
))}
</div>
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--apps">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{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}</span>
<span>{item.delivered}</span>
<span>{item.alertCount}</span>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
)}
</div>
</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} `pending/failed`
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
variant="ghost"
>
{allSelected ? '取消全选当前页' : '全选当前页'}
</Button>
<Button
icon={<RefreshCw size={14} />}
disabled={selectedIds.length === 0}
onClick={() => {
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
.then((result) => {
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount}` : '');
loadData();
})
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
}}
variant="secondary"
>
</Button>
</div>
</div>
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
<Pagination
total={total}
page={page}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
/>
</div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</section>
);
}