feat: 增强下游重投与签名质量检测
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, 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, 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 = {
|
||||
@@ -53,6 +53,8 @@ export const adminOperationsApi = {
|
||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
resolveGatewaySubmitException: (id: string) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', query)),
|
||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||
@@ -70,4 +72,13 @@ export const adminOperationsApi = {
|
||||
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
batchRequeueDownstreamDeliveries: (ids: string[]) =>
|
||||
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 }) =>
|
||||
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}`),
|
||||
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
|
||||
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
};
|
||||
|
||||
@@ -483,6 +483,51 @@ export type BatchRequeueResponse = {
|
||||
results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>;
|
||||
};
|
||||
|
||||
export type DownstreamRequeueFilter = {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
};
|
||||
|
||||
export type DownstreamRequeuePreview = {
|
||||
snapshotAt: string;
|
||||
matchedCount: number;
|
||||
replayableCount: number;
|
||||
skippedCount: number;
|
||||
applicationCount: number;
|
||||
oldestCreatedAt?: string | null;
|
||||
statusCounts: Record<string, number>;
|
||||
filter: DownstreamRequeueFilter;
|
||||
};
|
||||
|
||||
export type DownstreamRequeueTask = {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
status: string;
|
||||
filterSnapshot: DownstreamRequeueFilter;
|
||||
snapshotAt: string;
|
||||
reason: string;
|
||||
ratePerSecond: number;
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
skippedCount: number;
|
||||
waitingCount: number;
|
||||
lastError?: string | null;
|
||||
createdAt: string;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: 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 DownstreamDeliveryDashboard = {
|
||||
summary: {
|
||||
total: number;
|
||||
|
||||
@@ -407,7 +407,7 @@ function UnreportedSignaturesCard({
|
||||
<div className="signature-quality-card__heading">
|
||||
<div>
|
||||
<div className="section-heading__title"><h2>未报备签名</h2><Tag tone="warning">待处理</Tag></div>
|
||||
<p className="muted">{data?.date ?? '所选日期'} 已进入平台、但短信运营商没有匹配报备成功事实的业务短信。</p>
|
||||
<p className="muted">{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。</p>
|
||||
</div>
|
||||
<div className="signature-quality-card__query">
|
||||
<Input
|
||||
@@ -420,7 +420,7 @@ function UnreportedSignaturesCard({
|
||||
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="signature-quality-card__note"><strong>统计说明:</strong>每条业务短信只计一次;运营商级或仍有效的历史兼容报备已通过时不计入。</div>
|
||||
<div className="signature-quality-card__note"><strong>统计说明:</strong>从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
|
||||
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -153,7 +153,7 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
const [deliveryType, setDeliveryType] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -164,6 +164,28 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
const [requeueBusy, setRequeueBusy] = useState(false);
|
||||
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
|
||||
const requeueInFlightRef = useRef(false);
|
||||
const [taskPreview, setTaskPreview] = useState<DownstreamRequeuePreview | null>(null);
|
||||
const [taskPreviewBusy, setTaskPreviewBusy] = useState(false);
|
||||
const [taskReason, setTaskReason] = useState('');
|
||||
const [taskRate, setTaskRate] = useState(10);
|
||||
const [taskCreateBusy, setTaskCreateBusy] = useState(false);
|
||||
const [requeueTasks, setRequeueTasks] = useState<DownstreamRequeueTask[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<DownstreamRequeueTask | null>(null);
|
||||
|
||||
const currentTaskFilter = useCallback(() => ({
|
||||
keyword: keyword || undefined,
|
||||
status,
|
||||
deliveryType,
|
||||
applicationId,
|
||||
createdAtFrom: dateRange.start,
|
||||
createdAtTo: dateRange.end,
|
||||
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status]);
|
||||
|
||||
const loadRequeueTasks = useCallback(() => {
|
||||
adminApi.listDownstreamRequeueTasks({ page: 1, pageSize: 10 })
|
||||
.then((response) => setRequeueTasks(response.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -202,6 +224,39 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequeueTasks();
|
||||
const timer = window.setInterval(loadRequeueTasks, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadRequeueTasks]);
|
||||
|
||||
const openTaskPreview = async () => {
|
||||
setTaskPreviewBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
setTaskPreview(await adminApi.previewDownstreamRequeueTask(currentTaskFilter()));
|
||||
setTaskReason('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '后台重投范围预检失败');
|
||||
} finally {
|
||||
setTaskPreviewBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createRequeueTask = async () => {
|
||||
if (!taskPreview || taskReason.trim().length < 5) return;
|
||||
setTaskCreateBusy(true);
|
||||
try {
|
||||
await adminApi.createDownstreamRequeueTask({ filter: taskPreview.filter, snapshotAt: taskPreview.snapshotAt, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
|
||||
setTaskPreview(null);
|
||||
loadRequeueTasks();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '后台重投任务创建失败');
|
||||
} finally {
|
||||
setTaskCreateBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
|
||||
const selectableIds = useMemo(
|
||||
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
|
||||
@@ -455,6 +510,17 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
已选择 <strong>{selectedIds.length}</strong> 条;客户端已确认的记录也允许人工重投,请注意重复处理风险。
|
||||
</p>
|
||||
<div>
|
||||
<label className="downstream-page-size">
|
||||
每页
|
||||
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); setSelectedIds([]); }}>
|
||||
<option value={10}>10条</option>
|
||||
<option value={25}>25条</option>
|
||||
<option value={50}>50条</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={taskPreviewBusy} onClick={() => void openTaskPreview()} variant="secondary">
|
||||
{taskPreviewBusy ? '范围预检中…' : '按筛选条件重投'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectableIds.length === 0}
|
||||
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
|
||||
@@ -548,7 +614,62 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading">
|
||||
<div><h2>后台重投任务</h2><p className="muted">大批量事故恢复按筛选快照分批执行,不受投递记录分页影响。</p></div>
|
||||
<Button onClick={loadRequeueTasks} variant="ghost">刷新</Button>
|
||||
</div>
|
||||
<div className="downstream-requeue-task-list">
|
||||
{requeueTasks.map((task) => (
|
||||
<article key={task.id}>
|
||||
<div><strong>{task.taskNo}</strong><span>{formatDateTime(task.createdAt)}</span></div>
|
||||
<div><span>{task.application?.name ?? '多个应用'}</span><small>{task.reason}</small></div>
|
||||
<div><Tag tone={task.status === 'completed' ? 'success' : task.status === 'paused' ? 'warning' : task.status === 'partial_completed' ? 'danger' : 'info'}>{task.status}</Tag><span>{task.successCount + task.failedCount + task.skippedCount}/{task.totalCount}</span></div>
|
||||
<div className="downstream-requeue-task-list__actions">
|
||||
<Button onClick={() => void adminApi.getDownstreamRequeueTask(task.id).then(setSelectedTask)} size="sm" variant="ghost">详情</Button>
|
||||
{task.status === 'running' || task.status === 'queued' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'pause').then(loadRequeueTasks)} size="sm" variant="secondary">暂停</Button> : null}
|
||||
{task.status === 'paused' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'resume').then(loadRequeueTasks)} size="sm" variant="secondary">继续</Button> : null}
|
||||
{['queued', 'running', 'paused'].includes(task.status) ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'terminate').then(loadRequeueTasks)} size="sm" variant="warning">终止</Button> : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{requeueTasks.length === 0 ? <p className="muted">暂无后台重投任务</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
|
||||
{taskPreview ? (
|
||||
<Modal open onClose={() => !taskCreateBusy && setTaskPreview(null)} title="创建下游后台重投任务" size="xl" footer={<><Button disabled={taskCreateBusy} onClick={() => setTaskPreview(null)} variant="ghost">取消</Button><Button disabled={taskCreateBusy || taskPreview.replayableCount === 0 || taskReason.trim().length < 5} onClick={() => void createRequeueTask()} variant="warning">{taskCreateBusy ? '创建中…' : '确认创建任务'}</Button></>}>
|
||||
<div className="downstream-requeue-preview">
|
||||
<div className="detail-grid">
|
||||
<div><span>筛选命中</span><strong>{taskPreview.matchedCount}</strong></div>
|
||||
<div><span>可重投</span><strong>{taskPreview.replayableCount}</strong></div>
|
||||
<div><span>规则跳过</span><strong>{taskPreview.skippedCount}</strong></div>
|
||||
<div><span>涉及应用</span><strong>{taskPreview.applicationCount}</strong></div>
|
||||
<div><span>最早记录</span><strong>{taskPreview.oldestCreatedAt ? formatDateTime(taskPreview.oldestCreatedAt) : '-'}</strong></div>
|
||||
<div><span>快照时间</span><strong>{formatDateTime(taskPreview.snapshotAt)}</strong></div>
|
||||
</div>
|
||||
<p className="muted">状态分布:{Object.entries(taskPreview.statusCounts).map(([key, value]) => `${statusLabel[key] ?? key} ${value}`).join(';') || '无'}</p>
|
||||
<Select label="执行速度" value={String(taskRate)} onChange={(event) => setTaskRate(Number(event.target.value))} options={[{ label: '平稳(每应用10条/秒)', value: '10' }, { label: '快速(每应用20条/秒)', value: '20' }, { label: '低速(每应用5条/秒)', value: '5' }]} />
|
||||
<label className="field"><span>任务原因 *</span><textarea value={taskReason} onChange={(event) => setTaskReason(event.target.value)} placeholder="请填写事故原因、工单号或处理说明(至少5个字)" rows={3} /></label>
|
||||
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><strong>第一版仅处理待投递、失败、未确认和拒绝记录;不会批量重投客户端已确认记录。</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
{selectedTask ? (
|
||||
<Modal open onClose={() => setSelectedTask(null)} title={`后台重投任务 ${selectedTask.taskNo}`} size="xl" footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>}>
|
||||
<div className="report-record-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>状态</span><strong>{selectedTask.status}</strong></div><div><span>原因</span><strong>{selectedTask.reason}</strong></div>
|
||||
<div><span>总数</span><strong>{selectedTask.totalCount}</strong></div><div><span>成功</span><strong>{selectedTask.successCount}</strong></div>
|
||||
<div><span>失败</span><strong>{selectedTask.failedCount}</strong></div><div><span>跳过</span><strong>{selectedTask.skippedCount}</strong></div>
|
||||
<div><span>等待</span><strong>{selectedTask.waitingCount}</strong></div><div><span>速度</span><strong>{selectedTask.ratePerSecond}条/秒</strong></div>
|
||||
</div>
|
||||
<h3>最近任务明细</h3>
|
||||
<div className="downstream-requeue-detail-items">{(selectedTask.recentItems ?? []).map((item) => <div key={item.id}><span>{item.delivery.messageId ?? '-'}</span><Tag tone={item.status === 'success' ? 'success' : item.status === 'failed' ? 'danger' : item.status === 'skipped' ? 'warning' : 'info'}>{item.status}</Tag><span>{item.skipReason ?? item.errorMessage ?? '-'}</span></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
{requeueTarget ? (
|
||||
<Modal
|
||||
footer={requeueResult ? (
|
||||
|
||||
@@ -18,6 +18,13 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
|
||||
resolved: 'success',
|
||||
};
|
||||
|
||||
const resolvedStatusLabel: Record<string, string> = {
|
||||
manually_resolved: '人工标记已处理',
|
||||
accepted: '上游已受理',
|
||||
rejected: '上游已拒绝',
|
||||
timeout: '上游提交超时',
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
@@ -34,10 +41,11 @@ function commandValue(record: GatewaySubmitException, key: string) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
function ExceptionDetailModal({ record, onClose, onRequestRequeue, onRequestResolve }: {
|
||||
record: GatewaySubmitException;
|
||||
onClose: () => void;
|
||||
onRequestRequeue: () => void;
|
||||
onRequestResolve: () => void;
|
||||
}) {
|
||||
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
|
||||
const content = record.messageState?.content ?? commandValue(record, 'content');
|
||||
@@ -50,6 +58,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
{record.status === 'pending' ? <Button icon={<CheckCircle2 size={15} />} onClick={onRequestResolve} variant="secondary">标记已处理</Button> : null}
|
||||
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}>校验并重新入队</Button> : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -76,7 +85,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
<div><span>异常时间</span><strong>{formatTime(record.createdAt)}</strong></div>
|
||||
<div><span>最近重新入队</span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
|
||||
<div><span>处理时间</span><strong>{formatTime(record.resolvedAt)}</strong></div>
|
||||
<div><span>处理结果</span><strong>{record.resolvedStatus ?? '-'}</strong></div>
|
||||
<div><span>处理结果</span><strong>{record.resolvedStatus ? (resolvedStatusLabel[record.resolvedStatus] ?? record.resolvedStatus) : '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>短信内容</span><strong>{content || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败代码</span><strong>{record.failureCode}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败原因</span><strong>{record.failureMessage}</strong></div>
|
||||
@@ -90,6 +99,39 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
);
|
||||
}
|
||||
|
||||
function ResolveModal({ record, submitting, onClose, onSubmit }: {
|
||||
record: GatewaySubmitException;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title="确认标记为已处理"
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button disabled={submitting} onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} icon={<CheckCircle2 size={15} />} onClick={onSubmit}>
|
||||
{submitting ? '处理中...' : '确认已处理'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="downstream-requeue-confirm">
|
||||
<CheckCircle2 aria-hidden="true" size={24} />
|
||||
<div>
|
||||
<p>确认将这条提交异常标记为“已处理”吗?</p>
|
||||
<small>消息编号:{record.messageId ?? record.streamMessageId}</small>
|
||||
<strong>记录和异常证据会继续保留,不会重新发送短信,也不会删除数据。</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RequeueModal({ record, submitting, onClose, onSubmit }: {
|
||||
record: GatewaySubmitException;
|
||||
submitting: boolean;
|
||||
@@ -156,6 +198,8 @@ function GatewaySubmitExceptionPanel() {
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
|
||||
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
|
||||
const [resolveRecord, setResolveRecord] = useState<GatewaySubmitException | null>(null);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const pageSize = 10;
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
@@ -192,7 +236,7 @@ function GatewaySubmitExceptionPanel() {
|
||||
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
|
||||
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
{ key: 'actions', title: '操作', width: '190px', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button>{record.status === 'pending' ? <Button icon={<CheckCircle2 size={14} />} onClick={() => setResolveRecord(record)} size="sm" variant="secondary">已处理</Button> : null}</div> },
|
||||
], []);
|
||||
|
||||
async function submitRequeue(reason: string) {
|
||||
@@ -214,6 +258,22 @@ function GatewaySubmitExceptionPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitResolve() {
|
||||
if (!resolveRecord) return;
|
||||
setResolving(true);
|
||||
try {
|
||||
await adminApi.resolveGatewaySubmitException(resolveRecord.id);
|
||||
setResolveRecord(null);
|
||||
setDetail(null);
|
||||
setError('');
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '标记已处理失败');
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
|
||||
@@ -243,8 +303,9 @@ function GatewaySubmitExceptionPanel() {
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无提交异常'} pagination={false} rowKey="id" />
|
||||
<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>
|
||||
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
|
||||
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} onRequestResolve={() => setResolveRecord(detail)} /> : null}
|
||||
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
|
||||
{resolveRecord ? <ResolveModal record={resolveRecord} submitting={resolving} onClose={() => setResolveRecord(null)} onSubmit={() => void submitResolve()} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9426,3 +9426,14 @@
|
||||
font-size: var(--font-size-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.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; } }
|
||||
|
||||
Reference in New Issue
Block a user