126 lines
7.3 KiB
TypeScript
126 lines
7.3 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
|
||
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';
|
||
import { Textarea } from './Textarea';
|
||
|
||
export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = <Trash2 size={15} />, disabled, onCompleted }: {
|
||
portal: 'admin' | 'client';
|
||
targetType: DeletionTargetType;
|
||
targetId: string;
|
||
children?: ReactNode;
|
||
icon?: ReactNode;
|
||
disabled?: boolean;
|
||
onCompleted?: (result: DeletionResult) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
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(''); setSelections(new Set()); setError(''); setIdempotencyKey(key);
|
||
try {
|
||
const data = portal === 'admin'
|
||
? await adminApi.getDeletionPreflight(targetType, targetId)
|
||
: await clientApi.getDeletionPreflight(targetType as Exclude<DeletionTargetType, 'channel'>, targetId);
|
||
setPreflight(data);
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '删除资格预检失败');
|
||
} finally { setLoading(false); }
|
||
}
|
||
|
||
async function confirm() {
|
||
if (!preflight?.allowedActions.includes('delete') || !allSelectionsConfirmed(preflight, selections)) return;
|
||
setSubmitting(true); setError('');
|
||
try {
|
||
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);
|
||
setResult(completed);
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
|
||
} finally { setSubmitting(false); }
|
||
}
|
||
|
||
function close() {
|
||
if (submitting) return;
|
||
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 || !selectionsConfirmed} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
|
||
{submitting ? '删除处理中…' : '确认删除'}
|
||
</Button>
|
||
</>;
|
||
|
||
return <>
|
||
<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">
|
||
{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.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="如填写,将写入审计记录" value={reason} /> : null}
|
||
</> : null}
|
||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</>;
|
||
}
|
||
|
||
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));
|
||
}
|