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
+20 -2
View File
@@ -6,7 +6,17 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
export type DeletionRequiredSelection = {
action: DeletionResolutionAction;
dependencyKind: string;
label: string;
description: string;
count: number;
};
export type DeletionPreflight = {
type: DeletionTargetType;
@@ -14,13 +24,21 @@ export type DeletionPreflight = {
expectedUpdatedAt: string;
identity: Record<string, string>;
dependencies: DeletionDependency[];
requiredSelections: DeletionRequiredSelection[];
impacts: string[];
blockedReasons: string[];
allowedActions: Array<'delete'>;
recoverability: { mode: 'soft_delete'; description: string };
};
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
export type DeleteTargetRequest = {
expectedUpdatedAt: string;
idempotencyKey: string;
reason?: string;
deleteAssociatedTemplates?: boolean;
deleteAssociatedDrainage?: boolean;
abandonAssociatedReportTasks?: boolean;
};
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
@@ -205,7 +205,6 @@ export function AdminChannelGroupsPage() {
{deletionImpact ? (
<>
<span>{deletionImpact.normalApplicationCount} </span>
<span>{deletionImpact.deletedApplicationCount} </span>
<span>{deletionImpact.channelCount} </span>
<span>{deletionImpact.pendingSupplierSubmitCount} </span>
<p>
+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));
}
+6
View File
@@ -3460,6 +3460,12 @@
.delete-risk-dependencies { display: grid; gap: 8px; list-style: none; padding: 0 !important; }
.delete-risk-dependencies li { align-items: center; background: var(--color-surface-muted); border-radius: var(--radius-md); display: grid; gap: 3px; grid-template-columns: 1fr auto; padding: 10px 12px; }
.delete-risk-dependencies small { color: var(--color-text-muted); grid-column: 1 / -1; overflow-wrap: anywhere; }
.delete-risk-selections { display: grid; gap: 10px; }
.delete-risk-selections > h3, .delete-risk-selections > p { margin: 0; }
.delete-risk-selections label { align-items: flex-start; background: var(--color-surface-muted); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; display: flex; gap: 10px; padding: 11px 12px; }
.delete-risk-selections input { flex: 0 0 auto; margin-top: 3px; }
.delete-risk-selections label > span { display: grid; gap: 4px; }
.delete-risk-selections small { color: var(--color-text-muted); line-height: 1.55; }
.delete-risk-action .ui-textarea { min-height: 88px; }
@media (max-width: 640px) {