feat: harden downstream requeue tasks
This commit is contained in:
@@ -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({}) }),
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user