Files
lislgosms/src/apps/admin/AdminDownstreamDeliveriesPage.tsx
T

839 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}