87 lines
5.4 KiB
TypeScript
87 lines
5.4 KiB
TypeScript
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 { 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 [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);
|
||
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') || reason.trim().length < 4) return;
|
||
setSubmitting(true); setError('');
|
||
try {
|
||
const body = { expectedUpdatedAt: preflight.expectedUpdatedAt, idempotencyKey, reason: reason.trim() };
|
||
const completed = portal === 'admin'
|
||
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
|
||
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
|
||
setResult(completed); onCompleted?.(completed);
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
|
||
} finally { setSubmitting(false); }
|
||
}
|
||
|
||
function close() { if (!submitting) setOpen(false); }
|
||
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>
|
||
<Button disabled={loading || submitting || blocked || !preflight || reason.trim().length < 4} 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">
|
||
{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}
|
||
</div>
|
||
</Modal>
|
||
</>;
|
||
}
|
||
|
||
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', code: '通道编码', tenant: '所属企业', application: '短信应用', signature: '关联签名' };
|