feat: harden downstream requeue tasks

This commit is contained in:
hectorzhao
2026-08-13 12:05:57 +08:00
parent 67fee21616
commit 433b2ee56f
15 changed files with 910 additions and 202 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, DownstreamRequeueFilter, DownstreamRequeuePreview, DownstreamRequeueTask, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, DownstreamRequeueFilter, DownstreamRequeuePreview, DownstreamRequeueTask, DownstreamRequeueTaskItem, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
// Read-heavy operations endpoints are isolated from configuration mutations.
export const adminOperationsApi = {
@@ -74,11 +74,13 @@ export const adminOperationsApi = {
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }),
previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) =>
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', { method: 'POST', body: JSON.stringify({ filter }) }),
createDownstreamRequeueTask: (body: { filter: DownstreamRequeueFilter; snapshotAt: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) =>
createDownstreamRequeueTask: (body: { previewToken: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) =>
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', { method: 'POST', body: JSON.stringify(body) }),
listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DownstreamRequeueTask>>(withQuery('/admin/operations/downstream-requeue-tasks', query)),
getDownstreamRequeueTask: (id: string) => request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
listDownstreamRequeueTaskItems: (id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DownstreamRequeueTaskItem>>(withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query)),
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }),
};
+14 -1
View File
@@ -495,6 +495,7 @@ export type DownstreamRequeueFilter = {
export type DownstreamRequeuePreview = {
snapshotAt: string;
previewToken: string;
matchedCount: number;
replayableCount: number;
skippedCount: number;
@@ -521,11 +522,23 @@ export type DownstreamRequeueTask = {
createdAt: string;
startedAt?: string | null;
finishedAt?: string | null;
pausedAt?: string | null;
tenant?: TenantOption | null;
application?: EnterpriseApplication | null;
createdBy?: { id: string; displayName: string; username: string } | null;
itemCounts?: Record<string, number>;
recentItems?: Array<{ id: string; status: string; skipReason?: string | null; errorMessage?: string | null; delivery: { messageId?: string | null; deliveryType: string; status: string; lastError?: string | null } }>;
};
export type DownstreamRequeueTaskItem = {
id: string;
status: string;
previousStatus: string;
skipReason?: string | null;
errorMessage?: string | null;
claimedAt?: string | null;
completedAt?: string | null;
updatedAt: string;
delivery: { messageId?: string | null; deliveryType: string; status: string; lastError?: string | null };
};
export type DownstreamDeliveryDashboard = {
+147 -31
View File
@@ -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 DownstreamRequeuePreview, type DownstreamRequeueTask, type EnterpriseApplication } from '@/api/adminApi';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type DownstreamRequeueTaskItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -42,6 +42,24 @@ const attemptStatusLabel: Record<string, string> = {
failed: '投递失败',
};
const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
@@ -148,10 +166,12 @@ export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [tenantId, setTenantId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
@@ -170,28 +190,39 @@ export function AdminDownstreamDeliveriesPage() {
const [taskRate, setTaskRate] = useState(10);
const [taskCreateBusy, setTaskCreateBusy] = useState(false);
const [requeueTasks, setRequeueTasks] = useState<DownstreamRequeueTask[]>([]);
const [requeueTaskStatus, setRequeueTaskStatus] = useState('all');
const [requeueTaskPage, setRequeueTaskPage] = useState(1);
const [requeueTaskTotal, setRequeueTaskTotal] = useState(0);
const [selectedTask, setSelectedTask] = useState<DownstreamRequeueTask | null>(null);
const [taskItems, setTaskItems] = useState<DownstreamRequeueTaskItem[]>([]);
const [taskItemStatus, setTaskItemStatus] = useState('all');
const [taskItemKeyword, setTaskItemKeyword] = useState('');
const [taskItemAppliedKeyword, setTaskItemAppliedKeyword] = useState('');
const [taskItemPage, setTaskItemPage] = useState(1);
const [taskItemTotal, setTaskItemTotal] = useState(0);
const currentTaskFilter = useCallback(() => ({
keyword: keyword || undefined,
status,
deliveryType,
tenantId,
applicationId,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status]);
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status, tenantId]);
const loadRequeueTasks = useCallback(() => {
adminApi.listDownstreamRequeueTasks({ page: 1, pageSize: 10 })
.then((response) => setRequeueTasks(response.items))
.catch(() => undefined);
}, []);
adminApi.listDownstreamRequeueTasks({ status: requeueTaskStatus, page: requeueTaskPage, pageSize: 10 })
.then((response) => { setRequeueTasks(response.items); setRequeueTaskTotal(response.total); })
.catch((failure: Error) => setError(failure.message || '后台重投任务加载失败'));
}, [requeueTaskPage, requeueTaskStatus]);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
tenantId,
deliveryType,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
@@ -201,24 +232,27 @@ export function AdminDownstreamDeliveriesPage() {
status,
deliveryType,
applicationId,
tenantId,
page,
pageSize,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}),
adminApi.listEnterpriseApplications(),
adminApi.listTenants(),
])
.then(([dashboardResponse, response, apps]) => {
.then(([dashboardResponse, response, apps, tenantOptions]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setTenants(tenantOptions);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status]);
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status, tenantId]);
useEffect(() => {
loadData();
@@ -247,7 +281,7 @@ export function AdminDownstreamDeliveriesPage() {
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 });
await adminApi.createDownstreamRequeueTask({ previewToken: taskPreview.previewToken, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
setTaskPreview(null);
loadRequeueTasks();
} catch (failure) {
@@ -257,6 +291,28 @@ export function AdminDownstreamDeliveriesPage() {
}
};
const filteredApplications = useMemo(
() => applications.filter((item) => tenantId === 'all' || item.tenantId === tenantId),
[applications, tenantId],
);
const openTaskDetail = async (id: string) => {
try {
const task = await adminApi.getDownstreamRequeueTask(id);
setSelectedTask(task);
setTaskItemStatus('all'); setTaskItemKeyword(''); setTaskItemAppliedKeyword(''); setTaskItemPage(1);
} catch (failure) { setError(failure instanceof Error ? failure.message : '任务详情加载失败'); }
};
const loadTaskItems = useCallback(() => {
if (!selectedTask) return;
adminApi.listDownstreamRequeueTaskItems(selectedTask.id, { status: taskItemStatus, keyword: taskItemAppliedKeyword || undefined, page: taskItemPage, pageSize: 20 })
.then((response) => { setTaskItems(response.items); setTaskItemTotal(response.total); })
.catch((failure: Error) => setError(failure.message || '任务明细加载失败'));
}, [selectedTask, taskItemAppliedKeyword, taskItemPage, taskItemStatus]);
useEffect(() => { loadTaskItems(); }, [loadTaskItems]);
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
const selectableIds = useMemo(
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
@@ -375,6 +431,12 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface ui-filter-row">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
label="企业"
options={[{ label: '全部企业', value: 'all' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={tenantId}
onChange={(event) => { setTenantId(event.target.value); setApplicationId('all'); setPage(1); }}
/>
<Select
label="状态"
options={[
@@ -409,7 +471,7 @@ export function AdminDownstreamDeliveriesPage() {
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
...filteredApplications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
@@ -424,6 +486,7 @@ export function AdminDownstreamDeliveriesPage() {
setKeyword('');
setStatus('all');
setDeliveryType('all');
setTenantId('all');
setApplicationId('all');
setDateRange(recentSevenDays());
setPage(1);
@@ -615,18 +678,33 @@ 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 className="section-heading downstream-requeue-task-heading">
<div><h2></h2><p className="muted"></p></div>
<div className="downstream-requeue-task-heading__actions">
<Select
aria-label="任务状态"
options={[
{ label: '全部状态', value: 'all' }, { label: '排队中', value: 'queued' }, { label: '执行中', value: 'running' },
{ label: '已暂停', value: 'paused' }, { label: '已完成', value: 'completed' }, { label: '部分完成', value: 'partial_completed' }, { label: '已终止', value: 'terminated' },
]}
value={requeueTaskStatus}
onChange={(event) => { setRequeueTaskStatus(event.target.value); setRequeueTaskPage(1); }}
/>
<Button icon={<RefreshCw size={15} />} onClick={loadRequeueTasks} variant="ghost"></Button>
</div>
</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__identity"><strong>{task.taskNo}</strong><span>{formatDateTime(task.createdAt)}</span><small>{task.createdBy?.displayName || task.createdBy?.username || '系统创建'}</small></div>
<div className="downstream-requeue-task-list__scope"><span>{task.tenant?.name ?? '全部企业'} · {task.application?.name ?? '多个应用'}</span><strong>{task.reason}</strong></div>
<div className="downstream-requeue-task-list__progress">
<div><Tag tone={requeueTone(task.status)}>{requeueTaskStatusLabel[task.status] ?? task.status}</Tag><strong>{task.successCount + task.failedCount + task.skippedCount}/{task.totalCount}</strong></div>
<div className="downstream-requeue-task-list__meter"><span style={{ width: `${task.totalCount ? Math.min(100, ((task.successCount + task.failedCount + task.skippedCount) / task.totalCount) * 100) : 0}%` }} /></div>
<small> {task.successCount} · {task.failedCount} · {task.skippedCount} · {task.waitingCount}</small>
</div>
<div className="downstream-requeue-task-list__actions">
<Button onClick={() => void adminApi.getDownstreamRequeueTask(task.id).then(setSelectedTask)} size="sm" variant="ghost"></Button>
<Button icon={<Eye size={14} />} onClick={() => void openTaskDetail(task.id)} 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}
@@ -635,38 +713,76 @@ export function AdminDownstreamDeliveriesPage() {
))}
{requeueTasks.length === 0 ? <p className="muted"></p> : null}
</div>
<Pagination
total={requeueTaskTotal}
page={requeueTaskPage}
totalPages={Math.max(1, Math.ceil(requeueTaskTotal / 10))}
onPageChange={setRequeueTaskPage}
previousDisabled={requeueTaskPage <= 1}
nextDisabled={requeueTaskPage >= Math.max(1, Math.ceil(requeueTaskTotal / 10))}
onPrevious={() => setRequeueTaskPage((current) => Math.max(1, current - 1))}
onNext={() => setRequeueTaskPage((current) => Math.min(Math.max(1, Math.ceil(requeueTaskTotal / 10)), current + 1))}
/>
</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></>}>
<Modal open onClose={() => !taskCreateBusy && setTaskPreview(null)} title={<div className="template-modal-title"><h2></h2><p></p></div>} 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 ? '创建中…' : `创建任务 · ${taskPreview.replayableCount}`}</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 className="downstream-requeue-preview__summary">
<div><span></span><strong>{taskPreview.matchedCount}</strong><small></small></div>
<div className="is-primary"><span></span><strong>{taskPreview.replayableCount}</strong><small></small></div>
<div><span></span><strong>{taskPreview.skippedCount}</strong><small> Gateway</small></div>
<div><span></span><strong>{taskPreview.applicationCount}</strong><small></small></div>
</div>
<div className="detail-grid downstream-requeue-preview__meta">
<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>
<div className="downstream-requeue-preview__distribution"><span></span><div>{Object.entries(taskPreview.statusCounts).map(([key, value]) => <Tag key={key} tone={statusTone[key] ?? 'neutral'}>{statusLabel[key] ?? key} {value}</Tag>)}</div></div>
<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 className="downstream-requeue-warning"><AlertTriangle size={20} /><div><strong></strong><p> ACK 线</p></div></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">
<Modal open onClose={() => setSelectedTask(null)} title={<div className="template-modal-title"><h2></h2><p>{selectedTask.taskNo}</p></div>} size="xl" footer={<Button onClick={() => setSelectedTask(null)}></Button>}>
<div className="report-record-detail downstream-requeue-task-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><Tag tone={requeueTone(selectedTask.status)}>{requeueTaskStatusLabel[selectedTask.status] ?? selectedTask.status}</Tag></strong></div><div className="detail-grid__wide"><span></span><strong>{selectedTask.reason}</strong></div>
<div><span> / </span><strong>{selectedTask.tenant?.name ?? '全部企业'} / {selectedTask.application?.name ?? '多个应用'}</strong></div>
<div><span></span><strong>{selectedTask.createdBy?.displayName || selectedTask.createdBy?.username || '系统创建'}</strong></div>
<div><span></span><strong>{formatDateTime(selectedTask.snapshotAt)}</strong></div><div><span></span><strong> {selectedTask.ratePerSecond} /</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><span></span><strong>{selectedTask.waitingCount}</strong></div><div><span></span><strong>{selectedTask.itemCounts?.unprocessed ?? 0}</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>
{selectedTask.lastError ? <div className="downstream-requeue-detail-alert"><AlertTriangle size={17} /><span>{selectedTask.lastError}</span></div> : null}
<section className="downstream-requeue-detail-section">
<div className="section-heading"><div><h3></h3><p className="muted"> {taskItemTotal} </p></div></div>
<div className="downstream-requeue-detail-filter">
<Select options={[{ label: '全部结果', value: 'all' }, ...Object.entries(requeueItemStatusLabel).map(([value, label]) => ({ value, label }))]} value={taskItemStatus} onChange={(event) => { setTaskItemStatus(event.target.value); setTaskItemPage(1); }} />
<Input placeholder="消息 ID / 错误 / 跳过原因" value={taskItemKeyword} onChange={(event) => setTaskItemKeyword(event.target.value)} />
<Button icon={<Search size={14} />} onClick={() => {
const nextKeyword = taskItemKeyword.trim();
if (taskItemPage !== 1) setTaskItemPage(1);
if (nextKeyword !== taskItemAppliedKeyword) setTaskItemAppliedKeyword(nextKeyword);
else if (taskItemPage === 1) loadTaskItems();
}} variant="secondary"></Button>
</div>
<div className="downstream-requeue-detail-items">
<div className="downstream-requeue-detail-items__head"><span> ID</span><span></span><span></span><span></span><span></span></div>
{taskItems.map((item) => <div key={item.id}><strong>{item.delivery.messageId ?? '-'}</strong><Tag tone={requeueTone(item.status)}>{requeueItemStatusLabel[item.status] ?? item.status}</Tag><span>{statusLabel[item.previousStatus] ?? item.previousStatus}</span><span>{item.skipReason ?? item.errorMessage ?? '—'}</span><time>{formatDateTime(item.updatedAt)}</time></div>)}
{taskItems.length === 0 ? <p className="muted"></p> : null}
</div>
<Pagination
total={taskItemTotal} page={taskItemPage} totalPages={Math.max(1, Math.ceil(taskItemTotal / 20))} onPageChange={setTaskItemPage}
previousDisabled={taskItemPage <= 1} nextDisabled={taskItemPage >= Math.max(1, Math.ceil(taskItemTotal / 20))}
onPrevious={() => setTaskItemPage((current) => Math.max(1, current - 1))}
onNext={() => setTaskItemPage((current) => Math.min(Math.max(1, Math.ceil(taskItemTotal / 20)), current + 1))}
/>
</section>
</div>
</Modal>
) : null}
+38 -9
View File
@@ -9423,12 +9423,41 @@
}
.downstream-page-size { display: inline-flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: 13px; }
.downstream-page-size select { min-width: 76px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); padding: 0 10px; }
.downstream-requeue-task-list { display: grid; gap: 10px; }
.downstream-requeue-task-list article { display: grid; grid-template-columns: minmax(170px, 1fr) minmax(220px, 2fr) minmax(150px, .8fr) auto; gap: 16px; align-items: center; padding: 14px 0; border-top: 1px solid var(--border); }
.downstream-requeue-task-list article > div { display: grid; gap: 4px; }
.downstream-requeue-task-list__actions { display: flex !important; flex-wrap: wrap; justify-content: flex-end; }
.downstream-requeue-preview { display: grid; gap: 18px; }
.downstream-requeue-warning { display: flex; align-items: flex-start; gap: 10px; padding: 12px; border-radius: 10px; background: var(--warning-soft, #fff7ed); color: var(--warning-text, #9a3412); }
.downstream-requeue-detail-items { display: grid; gap: 8px; }
.downstream-requeue-detail-items > div { display: grid; grid-template-columns: minmax(180px, 1fr) 120px minmax(220px, 2fr); gap: 12px; align-items: center; padding: 10px 0; border-top: 1px solid var(--border); }
@media (max-width: 900px) { .downstream-requeue-task-list article { grid-template-columns: 1fr; } .downstream-requeue-task-list__actions { justify-content: flex-start; } .downstream-requeue-detail-items > div { grid-template-columns: 1fr; } }
.downstream-requeue-task-heading { gap: 20px; }
.downstream-requeue-task-heading__actions { display: flex; align-items: center; gap: 8px; }
.downstream-requeue-task-heading__actions .ui-field { min-width: 150px; }
.downstream-requeue-task-list { display: grid; border-top: 1px solid var(--border); }
.downstream-requeue-task-list article { display: grid; grid-template-columns: minmax(190px, .9fr) minmax(260px, 1.35fr) minmax(250px, 1fr) auto; gap: 22px; align-items: center; padding: 18px 4px; border-bottom: 1px solid var(--border); }
.downstream-requeue-task-list article > div { min-width: 0; }
.downstream-requeue-task-list__identity, .downstream-requeue-task-list__scope, .downstream-requeue-task-list__progress { display: grid; gap: 5px; }
.downstream-requeue-task-list__identity > strong { color: var(--text-strong); font-size: 14px; font-variant-numeric: tabular-nums; }
.downstream-requeue-task-list__identity > span, .downstream-requeue-task-list__identity > small, .downstream-requeue-task-list__scope > span, .downstream-requeue-task-list__progress > small { color: var(--text-muted); }
.downstream-requeue-task-list__scope > strong { overflow: hidden; color: var(--text); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.downstream-requeue-task-list__progress > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.downstream-requeue-task-list__meter { height: 5px; overflow: hidden; border-radius: 999px; background: var(--surface-subtle, #eef2f6); }
.downstream-requeue-task-list__meter > span { display: block; height: 100%; border-radius: inherit; background: var(--brand, #2f6f91); transition: width .2s ease; }
.downstream-requeue-task-list__actions { display: flex !important; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.downstream-requeue-preview { display: grid; gap: 20px; }
.downstream-requeue-preview__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.downstream-requeue-preview__summary > div { display: grid; gap: 4px; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-subtle, #f8fafc); }
.downstream-requeue-preview__summary > div.is-primary { border-color: color-mix(in srgb, var(--brand, #2f6f91) 28%, var(--border)); background: color-mix(in srgb, var(--brand, #2f6f91) 7%, white); }
.downstream-requeue-preview__summary span, .downstream-requeue-preview__summary small { color: var(--text-muted); }
.downstream-requeue-preview__summary strong { color: var(--text-strong); font-size: 24px; line-height: 1.15; }
.downstream-requeue-preview__meta { margin: 0; }
.downstream-requeue-preview__distribution { display: grid; gap: 8px; }
.downstream-requeue-preview__distribution > span { color: var(--text-muted); font-size: 13px; }
.downstream-requeue-preview__distribution > div { display: flex; flex-wrap: wrap; gap: 8px; }
.downstream-requeue-warning { display: flex; align-items: flex-start; gap: 10px; padding: 14px; border: 1px solid #fed7aa; border-radius: 10px; background: var(--warning-soft, #fff7ed); color: var(--warning-text, #9a3412); }
.downstream-requeue-warning > div { display: grid; gap: 4px; }
.downstream-requeue-warning p { margin: 0; color: inherit; line-height: 1.6; }
.downstream-requeue-task-detail { display: grid; gap: 20px; }
.downstream-requeue-detail-alert { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warning-soft, #fff7ed); color: var(--warning-text, #9a3412); }
.downstream-requeue-detail-section { display: grid; gap: 14px; }
.downstream-requeue-detail-filter { display: grid; grid-template-columns: 180px minmax(240px, 1fr) auto; gap: 10px; align-items: end; }
.downstream-requeue-detail-items { display: grid; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.downstream-requeue-detail-items > div { display: grid; grid-template-columns: minmax(160px, 1fr) 125px 130px minmax(220px, 1.5fr) 155px; gap: 12px; align-items: center; padding: 11px 14px; border-top: 1px solid var(--border); }
.downstream-requeue-detail-items > div:first-child { border-top: 0; }
.downstream-requeue-detail-items__head { background: var(--surface-subtle, #f8fafc); color: var(--text-muted); font-size: 12px; font-weight: 600; }
.downstream-requeue-detail-items time { color: var(--text-muted); font-size: 12px; }
@media (max-width: 1100px) { .downstream-requeue-task-list article { grid-template-columns: 1fr 1.4fr; } .downstream-requeue-task-list__actions { justify-content: flex-start; } .downstream-requeue-preview__summary { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 780px) { .downstream-requeue-task-heading, .downstream-requeue-task-heading__actions { align-items: stretch; flex-direction: column; } .downstream-requeue-task-list article, .downstream-requeue-preview__summary, .downstream-requeue-detail-filter { grid-template-columns: 1fr; } .downstream-requeue-detail-items { border: 0; overflow: visible; gap: 10px; } .downstream-requeue-detail-items__head { display: none !important; } .downstream-requeue-detail-items > div { grid-template-columns: 1fr; gap: 6px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; } }