feat: support governed cascade deletion
This commit is contained in:
+20
-2
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user