fix: improve retry feedback and deletion stability

This commit is contained in:
hectorzhao
2026-08-02 20:39:49 +08:00
parent ecc3d7a504
commit 94e997ec4d
9 changed files with 273 additions and 41 deletions
+122 -19
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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: '操作',
+25 -16
View File
@@ -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>
</>;
+66
View File
@@ -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
View File
@@ -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}`;
}