839 lines
43 KiB
TypeScript
839 lines
43 KiB
TypeScript
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 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';
|
||
|
||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||
pending: 'warning',
|
||
manual_requeueing: 'info',
|
||
awaiting_ack: 'info',
|
||
delivered: 'success',
|
||
failed: 'danger',
|
||
unconfirmed: 'warning',
|
||
rejected: 'danger',
|
||
};
|
||
|
||
const statusLabel: Record<string, string> = {
|
||
manual_requeueing: '人工重投处理中',
|
||
awaiting_ack: '等待客户端确认',
|
||
delivered: '客户端已确认',
|
||
failed: '投递失败',
|
||
unconfirmed: '客户端未确认',
|
||
rejected: '客户端拒绝',
|
||
};
|
||
|
||
function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
|
||
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
|
||
if (record.manualRetryCount > 0) return '人工重投排队中';
|
||
if (record.retryCount > 0) return '等待自动重试';
|
||
return '待首次投递';
|
||
}
|
||
|
||
const deliveryTypeLabel: Record<string, string> = {
|
||
receipt: '状态回执',
|
||
uplink: '上行短信',
|
||
};
|
||
|
||
const attemptStatusLabel: Record<string, string> = {
|
||
awaiting_ack: '等待客户端确认',
|
||
acknowledged: '客户端已确认',
|
||
rejected: '客户端拒绝',
|
||
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[] };
|
||
|
||
type RequeueResult = {
|
||
status: 'success' | 'partial' | 'failed';
|
||
title: string;
|
||
message: string;
|
||
failures?: string[];
|
||
};
|
||
|
||
function formatLocalDate(value: Date) {
|
||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function recentSevenDays(): DateRangeValue {
|
||
const end = new Date();
|
||
const start = new Date(end);
|
||
start.setDate(end.getDate() - 6);
|
||
return { start: formatLocalDate(start), end: formatLocalDate(end) };
|
||
}
|
||
|
||
function attemptStatusTone(status: string) {
|
||
if (status === 'acknowledged') return 'success' as const;
|
||
if (status === 'rejected' || status === 'failed') return 'danger' as const;
|
||
if (status === 'awaiting_ack') return 'info' as const;
|
||
return 'neutral' as const;
|
||
}
|
||
|
||
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>{deliveryStatusLabel(record)}</strong></div>
|
||
<div><span>消息 ID</span><strong>{record.messageId ?? '-'}</strong></div>
|
||
<div><span>自动重试次数</span><strong>{record.retryCount}</strong></div>
|
||
<div><span>人工重投次数</span><strong>{record.manualRetryCount}</strong></div>
|
||
<div><span>最近人工重投</span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
|
||
<div><span>下次重试</span><strong>{record.nextRetryAt ?? '-'}</strong></div>
|
||
<div><span>自动重试</span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
|
||
<div><span>写出时间</span><strong>{record.sentAt ?? '-'}</strong></div>
|
||
<div><span>确认时间</span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
|
||
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
|
||
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
|
||
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
|
||
<div><span>连接 ID</span><strong>{record.connectionId ?? '-'}</strong></div>
|
||
<div className="detail-grid__wide"><span>最后错误</span><strong>{record.lastError ?? '-'}</strong></div>
|
||
</div>
|
||
<section className="report-history">
|
||
<h3>逐次投递记录</h3>
|
||
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
|
||
{(record.attempts ?? []).map((attempt) => (
|
||
<article className="downstream-attempt-card" key={attempt.id}>
|
||
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
|
||
{attempt.attemptNo}
|
||
</div>
|
||
<div className="downstream-attempt-card__body">
|
||
<header>
|
||
<strong>第 {attempt.attemptNo} 次投递</strong>
|
||
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
|
||
</header>
|
||
<dl className="downstream-attempt-card__times">
|
||
<div><dt>发送时间</dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
|
||
<div><dt>ACK 时间</dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
|
||
<div><dt>ACK 截止</dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
|
||
</dl>
|
||
<dl className="downstream-attempt-card__identifiers">
|
||
<div><dt>连接 ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
|
||
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
|
||
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
|
||
</dl>
|
||
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
|
||
<span>ACK Result:{attempt.ackResult ?? '-'}</span>
|
||
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
))}
|
||
{(record.attempts ?? []).length === 0 ? <p>暂无逐次投递记录</p> : null}
|
||
</div>
|
||
</section>
|
||
<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 [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);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
|
||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||
const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays);
|
||
const [requeueTarget, setRequeueTarget] = useState<RequeueTarget | null>(null);
|
||
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 [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, tenantId]);
|
||
|
||
const loadRequeueTasks = useCallback(() => {
|
||
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,
|
||
}),
|
||
adminApi.listDownstreamDeliveries({
|
||
keyword,
|
||
status,
|
||
deliveryType,
|
||
applicationId,
|
||
tenantId,
|
||
page,
|
||
pageSize,
|
||
createdAtFrom: dateRange.start,
|
||
createdAtTo: dateRange.end,
|
||
}),
|
||
adminApi.listEnterpriseApplications(),
|
||
adminApi.listTenants(),
|
||
])
|
||
.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, tenantId]);
|
||
|
||
useEffect(() => {
|
||
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({ previewToken: taskPreview.previewToken, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
|
||
setTaskPreview(null);
|
||
loadRequeueTasks();
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '后台重投任务创建失败');
|
||
} finally {
|
||
setTaskCreateBusy(false);
|
||
}
|
||
};
|
||
|
||
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),
|
||
[records, replayableStatuses],
|
||
);
|
||
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 totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
|
||
const openSingleRequeue = (record: DownstreamDeliveryRecord) => {
|
||
setRequeueResult(null);
|
||
setRequeueTarget({ kind: 'single', record });
|
||
};
|
||
|
||
const openBatchRequeue = () => {
|
||
if (selectedIds.length === 0) return;
|
||
setRequeueResult(null);
|
||
setRequeueTarget({ kind: 'batch', ids: [...selectedIds] });
|
||
};
|
||
|
||
const closeRequeueDialog = () => {
|
||
if (requeueBusy) return;
|
||
setRequeueTarget(null);
|
||
setRequeueResult(null);
|
||
};
|
||
|
||
const confirmRequeue = async () => {
|
||
if (!requeueTarget || requeueInFlightRef.current) return;
|
||
requeueInFlightRef.current = true;
|
||
setRequeueBusy(true);
|
||
setRequeueResult(null);
|
||
setError('');
|
||
try {
|
||
if (requeueTarget.kind === 'single') {
|
||
const updated = await adminApi.requeueDownstreamDelivery(requeueTarget.record.id);
|
||
setRequeueResult({
|
||
status: 'success',
|
||
title: '重投请求已受理',
|
||
message: `${deliveryTypeLabel[updated.deliveryType] ?? updated.deliveryType}已进入人工重投流程,当前状态为“${deliveryStatusLabel(updated)}”,人工重投次数 ${updated.manualRetryCount} 次。`,
|
||
});
|
||
} else {
|
||
const result = await adminApi.batchRequeueDownstreamDeliveries(requeueTarget.ids);
|
||
const failures = result.results
|
||
.filter((item) => item.status === 'failed')
|
||
.map((item) => `${item.id}:${item.errorMessage || '重投失败'}`);
|
||
setRequeueResult({
|
||
status: result.failedCount === 0 ? 'success' : result.successCount > 0 ? 'partial' : 'failed',
|
||
title: result.failedCount === 0 ? '批量重投已受理' : result.successCount > 0 ? '批量重投部分完成' : '批量重投失败',
|
||
message: `共处理 ${result.total} 条,成功 ${result.successCount} 条,失败 ${result.failedCount} 条。`,
|
||
failures,
|
||
});
|
||
setSelectedIds([]);
|
||
}
|
||
loadData();
|
||
} catch (failure) {
|
||
setRequeueResult({
|
||
status: 'failed',
|
||
title: '重投请求未确认成功',
|
||
message: failure instanceof Error && failure.message ? failure.message : '人工重投失败,请先检查列表状态和系统日志,避免重复提交。',
|
||
});
|
||
} finally {
|
||
requeueInFlightRef.current = false;
|
||
setRequeueBusy(false);
|
||
}
|
||
};
|
||
|
||
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>已收到 Result=0 的 CMPP_DELIVER_RESP。</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 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={[
|
||
{ label: '全部状态', value: 'all' },
|
||
{ label: '待投递', value: 'pending' },
|
||
{ label: '等待客户端确认', value: 'awaiting_ack' },
|
||
{ label: '客户端已确认', value: 'delivered' },
|
||
{ label: '客户端未确认', value: 'unconfirmed' },
|
||
{ label: '客户端拒绝', value: 'rejected' },
|
||
{ 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' },
|
||
...filteredApplications.map((item) => ({ label: item.name, value: item.id })),
|
||
]}
|
||
value={applicationId}
|
||
onChange={(event) => {
|
||
setApplicationId(event.target.value);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<div className="admin-task-filter__actions ui-filter-actions">
|
||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setKeyword('');
|
||
setStatus('all');
|
||
setDeliveryType('all');
|
||
setTenantId('all');
|
||
setApplicationId('all');
|
||
setDateRange(recentSevenDays());
|
||
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 downstream-breakdown-table__head--ack">
|
||
<span>类型</span>
|
||
<span>总量</span>
|
||
<span>待投递</span>
|
||
<span>待确认</span>
|
||
<span>客户端已确认</span>
|
||
<span>未确认/拒绝</span>
|
||
<span>最终失败</span>
|
||
</div>
|
||
{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 ?? 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>
|
||
</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 ?? 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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface admin-task-table-card report-task-table-card">
|
||
<div className="downstream-delivery-toolbar">
|
||
<p>
|
||
已选择 <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)}
|
||
variant="ghost"
|
||
>
|
||
{allSelected ? '取消全选当前页' : '全选当前页'}
|
||
</Button>
|
||
<Button
|
||
icon={<RefreshCw size={14} />}
|
||
disabled={selectedIds.length === 0 || requeueBusy}
|
||
onClick={openBatchRequeue}
|
||
variant="secondary"
|
||
>
|
||
批量重投
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<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'}>{deliveryStatusLabel(record)}</Tag>
|
||
</div>
|
||
<div className="downstream-delivery-list__retry" role="cell">
|
||
<strong>{record.retryCount}</strong>
|
||
<span>自动 / {record.manualRetryCount} 人工</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) || requeueBusy}
|
||
icon={<RefreshCw size={14} />}
|
||
onClick={() => openSingleRequeue(record)}
|
||
size="sm"
|
||
variant="secondary"
|
||
>
|
||
重投
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)) : (
|
||
<div className="downstream-delivery-list__empty">{loading ? '加载中...' : '暂无下游投递记录'}</div>
|
||
)}
|
||
</div>
|
||
<Pagination
|
||
total={total}
|
||
page={page}
|
||
totalPages={totalPages}
|
||
onPageChange={setPage}
|
||
previousDisabled={page <= 1}
|
||
nextDisabled={page >= totalPages}
|
||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||
/>
|
||
</div>
|
||
|
||
<div className="surface admin-task-table-card report-task-table-card">
|
||
<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 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 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}
|
||
</div>
|
||
</article>
|
||
))}
|
||
{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={<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="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>
|
||
<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} /><div><strong>安全边界</strong><p>仅处理待投递、失败、未确认和拒绝记录;不会批量重投客户端已确认或正在等待 ACK 的记录。客户离线时进入等待,不计失败或跳过。</p></div></div>
|
||
</div>
|
||
</Modal>
|
||
) : null}
|
||
{selectedTask ? (
|
||
<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><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.itemCounts?.unprocessed ?? 0}</strong></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}
|
||
{requeueTarget ? (
|
||
<Modal
|
||
footer={requeueResult ? (
|
||
<Button onClick={closeRequeueDialog}>关闭</Button>
|
||
) : (
|
||
<>
|
||
<Button disabled={requeueBusy} onClick={closeRequeueDialog} variant="ghost">取消</Button>
|
||
<Button disabled={requeueBusy} icon={<RefreshCw size={15} />} onClick={() => void confirmRequeue()} variant="warning">
|
||
{requeueBusy ? '重投处理中…' : requeueTarget.kind === 'single' ? '确认重投' : `确认重投 ${requeueTarget.ids.length} 条`}
|
||
</Button>
|
||
</>
|
||
)}
|
||
onClose={closeRequeueDialog}
|
||
open
|
||
title={requeueResult?.title ?? (requeueTarget.kind === 'single' ? '确认人工重投' : '确认批量人工重投')}
|
||
>
|
||
{requeueResult ? (
|
||
<div className={`downstream-requeue-result downstream-requeue-result--${requeueResult.status}`} role={requeueResult.status === 'success' ? 'status' : 'alert'}>
|
||
<span aria-hidden="true">
|
||
{requeueResult.status === 'success' ? <CheckCircle2 size={28} /> : <AlertTriangle size={28} />}
|
||
</span>
|
||
<div>
|
||
<p>{requeueResult.message}</p>
|
||
{requeueResult.failures?.length ? (
|
||
<ul>
|
||
{requeueResult.failures.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}
|
||
</ul>
|
||
) : null}
|
||
{requeueResult.status === 'failed' ? <small>请求结果不明确时,请先刷新并核对人工重投次数,避免重复投递。</small> : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="downstream-requeue-confirm">
|
||
<AlertTriangle aria-hidden="true" size={24} />
|
||
<div>
|
||
<p>
|
||
{requeueTarget.kind === 'single'
|
||
? `${requeueTarget.record.status === 'delivered' ? '该记录已收到客户端确认。' : ''}确认重投这条${deliveryTypeLabel[requeueTarget.record.deliveryType] ?? requeueTarget.record.deliveryType}吗?`
|
||
: `即将重投所选 ${requeueTarget.ids.length} 条下游投递记录。`}
|
||
</p>
|
||
{requeueTarget.kind === 'single' ? <small>消息 ID:{requeueTarget.record.messageId ?? '-'}</small> : null}
|
||
<strong>重投可能导致下游业务重复处理,请确认客户侧能够正确去重。</strong>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|