fix: improve retry feedback and deletion stability
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
@@ -42,6 +42,17 @@ const attemptStatusLabel: Record<string, string> = {
|
||||
failed: '投递失败',
|
||||
};
|
||||
|
||||
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')}`;
|
||||
}
|
||||
@@ -149,6 +160,10 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
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 loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -200,12 +215,61 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const replayRecord = (record: DownstreamDeliveryRecord) => {
|
||||
const acknowledgedWarning = record.status === 'delivered' ? '该记录已收到客户端确认,' : '';
|
||||
if (!window.confirm(`${acknowledgedWarning}重投可能导致下游业务重复处理,确认继续吗?`)) return;
|
||||
adminApi.requeueDownstreamDelivery(record.id)
|
||||
.then(() => loadData())
|
||||
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
|
||||
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 (
|
||||
@@ -400,16 +464,8 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`即将重投 ${selectedIds.length} 条记录,可能导致下游业务重复处理,确认继续吗?`)) return;
|
||||
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
|
||||
.then((result) => {
|
||||
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount} 条` : '');
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
|
||||
}}
|
||||
disabled={selectedIds.length === 0 || requeueBusy}
|
||||
onClick={openBatchRequeue}
|
||||
variant="secondary"
|
||||
>
|
||||
批量重投
|
||||
@@ -466,9 +522,9 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
<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)}
|
||||
disabled={!replayableStatuses.has(record.status) || requeueBusy}
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => replayRecord(record)}
|
||||
onClick={() => openSingleRequeue(record)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
@@ -493,6 +549,53 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
</div>
|
||||
|
||||
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type GlobalBlacklistItem = DictionaryItem & {
|
||||
phoneNumber?: string;
|
||||
@@ -36,7 +37,7 @@ export function AdminGlobalBlacklistPage() {
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'createdAt', title: '入库时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type SensitiveWordItem = DictionaryItem & {
|
||||
word?: string;
|
||||
@@ -59,7 +60,7 @@ export function AdminSensitiveWordsPage() {
|
||||
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
|
||||
@@ -45,13 +45,17 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
|
||||
const completed = portal === 'admin'
|
||||
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
|
||||
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
|
||||
setResult(completed); onCompleted?.(completed);
|
||||
setResult(completed);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
|
||||
} finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
function close() { if (!submitting) setOpen(false); }
|
||||
function close() {
|
||||
if (submitting) return;
|
||||
setOpen(false);
|
||||
if (result) onCompleted?.(result);
|
||||
}
|
||||
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
|
||||
const footer = (requestClose: () => void) => result ? <Button onClick={close}>关闭</Button> : <>
|
||||
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||
@@ -64,20 +68,25 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
|
||||
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button>
|
||||
<Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
|
||||
<div className="risk-action-content delete-risk-action">
|
||||
{loading ? <p role="status">正在从后台检查引用关系与当前状态…</p> : null}
|
||||
{preflight ? <>
|
||||
<div className="risk-action-identity">
|
||||
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
<section><h3>依赖与资格检查</h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} 项</span>{item.items.length ? <small>{item.items.join(';')}</small> : <small>无活动引用</small>}</li>)}</ul> : null}</section>
|
||||
{preflight.blockedReasons.length
|
||||
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||
: <p className="risk-action-passed"><ShieldCheck size={16} />资格检查通过,可以执行逻辑删除</p>}
|
||||
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
|
||||
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
|
||||
</> : null}
|
||||
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>删除已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
{result ? (
|
||||
<div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>删除已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div>
|
||||
) : (
|
||||
<>
|
||||
{loading ? <p role="status">正在从后台检查引用关系与当前状态…</p> : null}
|
||||
{preflight ? <>
|
||||
<div className="risk-action-identity">
|
||||
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
<section><h3>依赖与资格检查</h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} 项</span>{item.items.length ? <small>{item.items.join(';')}</small> : <small>无活动引用</small>}</li>)}</ul> : null}</section>
|
||||
{preflight.blockedReasons.length
|
||||
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||
: <p className="risk-action-passed"><ShieldCheck size={16} />资格检查通过,可以执行逻辑删除</p>}
|
||||
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
|
||||
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
|
||||
</> : null}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>;
|
||||
|
||||
@@ -6919,6 +6919,72 @@
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.downstream-requeue-confirm,
|
||||
.downstream-requeue-result {
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.downstream-requeue-confirm {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.downstream-requeue-confirm > div,
|
||||
.downstream-requeue-result > div {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downstream-requeue-confirm p,
|
||||
.downstream-requeue-confirm small,
|
||||
.downstream-requeue-confirm strong,
|
||||
.downstream-requeue-result p,
|
||||
.downstream-requeue-result small {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.downstream-requeue-confirm strong {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.downstream-requeue-result--success {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.downstream-requeue-result--partial {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.downstream-requeue-result--failed {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.downstream-requeue-result p,
|
||||
.downstream-requeue-result li {
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.downstream-requeue-result ul {
|
||||
margin: 0;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.downstream-delivery-toolbar > div,
|
||||
.downstream-delivery-list__actions {
|
||||
align-items: center;
|
||||
|
||||
+17
-2
@@ -1,7 +1,22 @@
|
||||
const beijingDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
export function formatDateTime(value?: string | Date | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
const pad = (part: number) => String(part).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
const parts = Object.fromEntries(
|
||||
beijingDateTimeFormatter.formatToParts(date)
|
||||
.filter((part) => part.type !== 'literal')
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user