feat: support governed cascade deletion

This commit is contained in:
hectorzhao
2026-08-09 19:08:38 +08:00
parent 6add563ee8
commit 7804f64ced
9 changed files with 476 additions and 47 deletions
+37 -7
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
import { adminApi, clientApi, type DeletionPreflight, type DeletionResolutionAction, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
import { createUuid } from '@/utils/randomId';
import { Button } from './Button';
import { Modal } from './Modal';
@@ -21,12 +21,13 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
const [preflight, setPreflight] = useState<DeletionPreflight | null>(null);
const [result, setResult] = useState<DeletionResult | null>(null);
const [reason, setReason] = useState('');
const [selections, setSelections] = useState<Set<DeletionResolutionAction>>(() => new Set());
const [error, setError] = useState('');
const [idempotencyKey, setIdempotencyKey] = useState('');
async function begin() {
const key = `delete:${targetType}:${targetId}:${createUuid()}`;
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key);
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setSelections(new Set()); setError(''); setIdempotencyKey(key);
try {
const data = portal === 'admin'
? await adminApi.getDeletionPreflight(targetType, targetId)
@@ -38,10 +39,17 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
}
async function confirm() {
if (!preflight?.allowedActions.includes('delete') || reason.trim().length < 4) return;
if (!preflight?.allowedActions.includes('delete') || !allSelectionsConfirmed(preflight, selections)) return;
setSubmitting(true); setError('');
try {
const body = { expectedUpdatedAt: preflight.expectedUpdatedAt, idempotencyKey, reason: reason.trim() };
const body = {
expectedUpdatedAt: preflight.expectedUpdatedAt,
idempotencyKey,
reason: reason.trim() || undefined,
deleteAssociatedTemplates: selections.has('delete_associated_templates'),
deleteAssociatedDrainage: selections.has('delete_associated_drainage'),
abandonAssociatedReportTasks: selections.has('abandon_associated_report_tasks'),
};
const completed = portal === 'admin'
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
@@ -56,10 +64,18 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
setOpen(false);
if (result) onCompleted?.(result);
}
function toggleSelection(action: DeletionResolutionAction, checked: boolean) {
setSelections((current) => {
const next = new Set(current);
if (checked) next.add(action); else next.delete(action);
return next;
});
}
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
const selectionsConfirmed = Boolean(preflight && allSelectionsConfirmed(preflight, selections));
const footer = (requestClose: () => void) => result ? <Button onClick={close}></Button> : <>
<Button disabled={submitting} onClick={requestClose} variant="ghost"></Button>
<Button disabled={loading || submitting || blocked || !preflight || reason.trim().length < 4} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
<Button disabled={loading || submitting || blocked || !preflight || !selectionsConfirmed} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
{submitting ? '删除处理中…' : '确认删除'}
</Button>
</>;
@@ -77,12 +93,22 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
<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>
<section><h3></h3>{preflight.dependencies.filter((item) => item.detailsVisible).length ? <ul className="delete-risk-dependencies">{preflight.dependencies.filter((item) => item.detailsVisible).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>}
{preflight.requiredSelections.length ? <section className="delete-risk-selections"><h3></h3>{preflight.requiredSelections.map((selection) => (
<label key={selection.action}>
<input
checked={selections.has(selection.action)}
onChange={(event) => toggleSelection(selection.action, event.target.checked)}
type="checkbox"
/>
<span><strong>{selection.label}</strong><small>{selection.description}</small></span>
</label>
))}<p className="muted"></p></section> : null}
<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}
{!blocked ? <Textarea label="删除原因(选填)" onChange={(event) => setReason(event.target.value)} placeholder="如填写,将写入审计记录" value={reason} /> : null}
</> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
</>
@@ -93,3 +119,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
}
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', code: '通道编码', tenant: '所属企业', application: '短信应用', signature: '关联签名' };
function allSelectionsConfirmed(preflight: DeletionPreflight, selections: Set<DeletionResolutionAction>) {
return preflight.requiredSelections.every((selection) => selections.has(selection.action));
}