Files
lislgosms/src/components/ui/DeleteRiskAction.tsx
T

126 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}