feat: 增强下游重投与签名质量检测
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -153,7 +153,7 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
const [deliveryType, setDeliveryType] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -164,6 +164,28 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
const [requeueBusy, setRequeueBusy] = useState(false);
|
||||
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
|
||||
const requeueInFlightRef = useRef(false);
|
||||
const [taskPreview, setTaskPreview] = useState<DownstreamRequeuePreview | null>(null);
|
||||
const [taskPreviewBusy, setTaskPreviewBusy] = useState(false);
|
||||
const [taskReason, setTaskReason] = useState('');
|
||||
const [taskRate, setTaskRate] = useState(10);
|
||||
const [taskCreateBusy, setTaskCreateBusy] = useState(false);
|
||||
const [requeueTasks, setRequeueTasks] = useState<DownstreamRequeueTask[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<DownstreamRequeueTask | null>(null);
|
||||
|
||||
const currentTaskFilter = useCallback(() => ({
|
||||
keyword: keyword || undefined,
|
||||
status,
|
||||
deliveryType,
|
||||
applicationId,
|
||||
createdAtFrom: dateRange.start,
|
||||
createdAtTo: dateRange.end,
|
||||
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status]);
|
||||
|
||||
const loadRequeueTasks = useCallback(() => {
|
||||
adminApi.listDownstreamRequeueTasks({ page: 1, pageSize: 10 })
|
||||
.then((response) => setRequeueTasks(response.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -202,6 +224,39 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequeueTasks();
|
||||
const timer = window.setInterval(loadRequeueTasks, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadRequeueTasks]);
|
||||
|
||||
const openTaskPreview = async () => {
|
||||
setTaskPreviewBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
setTaskPreview(await adminApi.previewDownstreamRequeueTask(currentTaskFilter()));
|
||||
setTaskReason('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '后台重投范围预检失败');
|
||||
} finally {
|
||||
setTaskPreviewBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createRequeueTask = async () => {
|
||||
if (!taskPreview || taskReason.trim().length < 5) return;
|
||||
setTaskCreateBusy(true);
|
||||
try {
|
||||
await adminApi.createDownstreamRequeueTask({ filter: taskPreview.filter, snapshotAt: taskPreview.snapshotAt, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
|
||||
setTaskPreview(null);
|
||||
loadRequeueTasks();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '后台重投任务创建失败');
|
||||
} finally {
|
||||
setTaskCreateBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
|
||||
const selectableIds = useMemo(
|
||||
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
|
||||
@@ -455,6 +510,17 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
已选择 <strong>{selectedIds.length}</strong> 条;客户端已确认的记录也允许人工重投,请注意重复处理风险。
|
||||
</p>
|
||||
<div>
|
||||
<label className="downstream-page-size">
|
||||
每页
|
||||
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); setSelectedIds([]); }}>
|
||||
<option value={10}>10条</option>
|
||||
<option value={25}>25条</option>
|
||||
<option value={50}>50条</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={taskPreviewBusy} onClick={() => void openTaskPreview()} variant="secondary">
|
||||
{taskPreviewBusy ? '范围预检中…' : '按筛选条件重投'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectableIds.length === 0}
|
||||
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
|
||||
@@ -548,7 +614,62 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading">
|
||||
<div><h2>后台重投任务</h2><p className="muted">大批量事故恢复按筛选快照分批执行,不受投递记录分页影响。</p></div>
|
||||
<Button onClick={loadRequeueTasks} variant="ghost">刷新</Button>
|
||||
</div>
|
||||
<div className="downstream-requeue-task-list">
|
||||
{requeueTasks.map((task) => (
|
||||
<article key={task.id}>
|
||||
<div><strong>{task.taskNo}</strong><span>{formatDateTime(task.createdAt)}</span></div>
|
||||
<div><span>{task.application?.name ?? '多个应用'}</span><small>{task.reason}</small></div>
|
||||
<div><Tag tone={task.status === 'completed' ? 'success' : task.status === 'paused' ? 'warning' : task.status === 'partial_completed' ? 'danger' : 'info'}>{task.status}</Tag><span>{task.successCount + task.failedCount + task.skippedCount}/{task.totalCount}</span></div>
|
||||
<div className="downstream-requeue-task-list__actions">
|
||||
<Button onClick={() => void adminApi.getDownstreamRequeueTask(task.id).then(setSelectedTask)} size="sm" variant="ghost">详情</Button>
|
||||
{task.status === 'running' || task.status === 'queued' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'pause').then(loadRequeueTasks)} size="sm" variant="secondary">暂停</Button> : null}
|
||||
{task.status === 'paused' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'resume').then(loadRequeueTasks)} size="sm" variant="secondary">继续</Button> : null}
|
||||
{['queued', 'running', 'paused'].includes(task.status) ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'terminate').then(loadRequeueTasks)} size="sm" variant="warning">终止</Button> : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{requeueTasks.length === 0 ? <p className="muted">暂无后台重投任务</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
|
||||
{taskPreview ? (
|
||||
<Modal open onClose={() => !taskCreateBusy && setTaskPreview(null)} title="创建下游后台重投任务" size="xl" footer={<><Button disabled={taskCreateBusy} onClick={() => setTaskPreview(null)} variant="ghost">取消</Button><Button disabled={taskCreateBusy || taskPreview.replayableCount === 0 || taskReason.trim().length < 5} onClick={() => void createRequeueTask()} variant="warning">{taskCreateBusy ? '创建中…' : '确认创建任务'}</Button></>}>
|
||||
<div className="downstream-requeue-preview">
|
||||
<div className="detail-grid">
|
||||
<div><span>筛选命中</span><strong>{taskPreview.matchedCount}</strong></div>
|
||||
<div><span>可重投</span><strong>{taskPreview.replayableCount}</strong></div>
|
||||
<div><span>规则跳过</span><strong>{taskPreview.skippedCount}</strong></div>
|
||||
<div><span>涉及应用</span><strong>{taskPreview.applicationCount}</strong></div>
|
||||
<div><span>最早记录</span><strong>{taskPreview.oldestCreatedAt ? formatDateTime(taskPreview.oldestCreatedAt) : '-'}</strong></div>
|
||||
<div><span>快照时间</span><strong>{formatDateTime(taskPreview.snapshotAt)}</strong></div>
|
||||
</div>
|
||||
<p className="muted">状态分布:{Object.entries(taskPreview.statusCounts).map(([key, value]) => `${statusLabel[key] ?? key} ${value}`).join(';') || '无'}</p>
|
||||
<Select label="执行速度" value={String(taskRate)} onChange={(event) => setTaskRate(Number(event.target.value))} options={[{ label: '平稳(每应用10条/秒)', value: '10' }, { label: '快速(每应用20条/秒)', value: '20' }, { label: '低速(每应用5条/秒)', value: '5' }]} />
|
||||
<label className="field"><span>任务原因 *</span><textarea value={taskReason} onChange={(event) => setTaskReason(event.target.value)} placeholder="请填写事故原因、工单号或处理说明(至少5个字)" rows={3} /></label>
|
||||
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><strong>第一版仅处理待投递、失败、未确认和拒绝记录;不会批量重投客户端已确认记录。</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
{selectedTask ? (
|
||||
<Modal open onClose={() => setSelectedTask(null)} title={`后台重投任务 ${selectedTask.taskNo}`} size="xl" footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>}>
|
||||
<div className="report-record-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>状态</span><strong>{selectedTask.status}</strong></div><div><span>原因</span><strong>{selectedTask.reason}</strong></div>
|
||||
<div><span>总数</span><strong>{selectedTask.totalCount}</strong></div><div><span>成功</span><strong>{selectedTask.successCount}</strong></div>
|
||||
<div><span>失败</span><strong>{selectedTask.failedCount}</strong></div><div><span>跳过</span><strong>{selectedTask.skippedCount}</strong></div>
|
||||
<div><span>等待</span><strong>{selectedTask.waitingCount}</strong></div><div><span>速度</span><strong>{selectedTask.ratePerSecond}条/秒</strong></div>
|
||||
</div>
|
||||
<h3>最近任务明细</h3>
|
||||
<div className="downstream-requeue-detail-items">{(selectedTask.recentItems ?? []).map((item) => <div key={item.id}><span>{item.delivery.messageId ?? '-'}</span><Tag tone={item.status === 'success' ? 'success' : item.status === 'failed' ? 'danger' : item.status === 'skipped' ? 'warning' : 'info'}>{item.status}</Tag><span>{item.skipReason ?? item.errorMessage ?? '-'}</span></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
{requeueTarget ? (
|
||||
<Modal
|
||||
footer={requeueResult ? (
|
||||
|
||||
Reference in New Issue
Block a user