feat: harden platform workflows and UI governance
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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 { 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}:${crypto.randomUUID()}`;
|
||||
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: '关联签名' };
|
||||
@@ -7,6 +7,7 @@ import { Modal } from './Modal';
|
||||
|
||||
type FileActionsProps = {
|
||||
file?: FileRef | null;
|
||||
portal?: 'admin' | 'client';
|
||||
};
|
||||
|
||||
function isImageFile(file: FileRef) {
|
||||
@@ -15,14 +16,14 @@ function isImageFile(file: FileRef) {
|
||||
return contentType.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
|
||||
}
|
||||
|
||||
export function FileActions({ file }: FileActionsProps) {
|
||||
export function FileActions({ file, portal = 'admin' }: FileActionsProps) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
if (!file?.fileObjectId) {
|
||||
return null;
|
||||
}
|
||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
|
||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
|
||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline', portal);
|
||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment', portal);
|
||||
const fileName = displayFileName(file.fileName);
|
||||
return (
|
||||
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Modal } from './Modal';
|
||||
import { Select } from './Select';
|
||||
import { Textarea } from './Textarea';
|
||||
|
||||
export type ManualRechargeTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
type ManualRechargeDialogProps = {
|
||||
initialTargetId?: string;
|
||||
lockTarget?: boolean;
|
||||
onClose: () => void;
|
||||
onCompleted: () => void | Promise<void>;
|
||||
open: boolean;
|
||||
targets: ManualRechargeTarget[];
|
||||
};
|
||||
|
||||
export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onClose, onCompleted, open, targets }: ManualRechargeDialogProps) {
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [review, setReview] = useState<ManualRechargePreflight | null>(null);
|
||||
const [result, setResult] = useState<ManualRechargeResult | null>(null);
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTenantId(initialTargetId ?? targets[0]?.id ?? '');
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
setSubmitting(false);
|
||||
}, [initialTargetId, open]);
|
||||
|
||||
if (!open) return null;
|
||||
const selectedTarget = targets.find((target) => target.id === tenantId);
|
||||
|
||||
function resetReview() {
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
}
|
||||
|
||||
function closeAndDestroyDraft() {
|
||||
if (submitting) return;
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
resetReview();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function preflight() {
|
||||
if (!tenantId || !Number.isFinite(Number(amount)) || !isValidMoneyInput(amount, { allowNegative: true, allowZero: false })) {
|
||||
setError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
||||
setReview(preview);
|
||||
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!review || !idempotencyKey) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextResult = await adminApi.createManualRecharge({
|
||||
tenantId,
|
||||
amountCents: review.amountCents,
|
||||
expectedAccountUpdatedAt: review.expectedAccountUpdatedAt,
|
||||
idempotencyKey,
|
||||
remark,
|
||||
});
|
||||
setResult(nextResult);
|
||||
await onCompleted();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footer = (requestClose: () => void) => result ? (
|
||||
<Button onClick={closeAndDestroyDraft}>完成</Button>
|
||||
) : review ? (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={resetReview} variant="ghost">返回修改</Button>
|
||||
<Button disabled={submitting} onClick={() => { void submit(); }}>{submitting ? '入账中...' : review.direction === 'topup' ? '确认充值' : '确认冲正'}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} onClick={() => { void preflight(); }}>{submitting ? '核对中...' : '下一步:核对信息'}</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dirty={!result && !submitting && Boolean(amount.trim() || remark.trim() || review)}
|
||||
footer={({ requestClose }) => footer(requestClose)}
|
||||
onClose={closeAndDestroyDraft}
|
||||
open
|
||||
size="md"
|
||||
title={result ? '人工充值结果' : review ? '确认人工充值' : '企业人工充值'}
|
||||
>
|
||||
{result ? (
|
||||
<div className="manual-recharge-result" role="status">
|
||||
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
|
||||
<span>订单号:{result.orderNo}</span>
|
||||
<span>充值后余额:¥{formatCents(result.balanceAfterCents)}</span>
|
||||
<span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
|
||||
</div>
|
||||
) : review ? (
|
||||
<div className="manual-recharge-review">
|
||||
<p>请核对以下资金变更,确认后将立即写入企业账户。</p>
|
||||
<dl>
|
||||
<div><dt>充值对象</dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
|
||||
<div><dt>操作方向</dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
|
||||
<div><dt>当前现金余额</dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
|
||||
<div><dt>本次变动</dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
|
||||
<div><dt>预计现金余额</dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
|
||||
</dl>
|
||||
{remark.trim() ? <p className="manual-recharge-review__remark"><strong>备注:</strong>{remark.trim()}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-system-modal-form">
|
||||
{lockTarget ? (
|
||||
<Input disabled label="企业名称" value={selectedTarget?.name ?? ''} />
|
||||
) : (
|
||||
<Select label="企业名称" onChange={(event) => { setTenantId(event.target.value); resetReview(); }} options={targets.map((target) => ({ label: target.name, value: target.id }))} required value={tenantId} />
|
||||
)}
|
||||
<Input disabled label="当前现金余额" prefix="¥" value={formatCents(selectedTarget?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => { setAmount(event.target.value); resetReview(); }} prefix="¥" required step="0.0001" type="number" value={amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => { setRemark(event.target.value); resetReview(); }} rows={4} value={remark} />
|
||||
</div>
|
||||
)}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+226
-21
@@ -1,51 +1,256 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export type ModalCloseControls = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
footer?: ReactNode | ((controls: ModalCloseControls) => ReactNode);
|
||||
size?: 'md' | 'xl';
|
||||
onClose: () => void;
|
||||
dirty?: boolean;
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
closeGuardTitle?: string;
|
||||
closeGuardDescription?: string;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
|
||||
const focusableSelector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable="true"]',
|
||||
].join(',');
|
||||
|
||||
const modalStack: HTMLElement[] = [];
|
||||
let documentLockCount = 0;
|
||||
let bodyOverflow = '';
|
||||
let bodyPaddingRight = '';
|
||||
let backgroundState: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = [];
|
||||
|
||||
function modalLayer() {
|
||||
let layer = document.getElementById('ui-modal-layer');
|
||||
if (!layer) {
|
||||
layer = document.createElement('div');
|
||||
layer.id = 'ui-modal-layer';
|
||||
document.body.appendChild(layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
function lockDocument(layer: HTMLElement) {
|
||||
documentLockCount += 1;
|
||||
if (documentLockCount !== 1) return;
|
||||
|
||||
bodyOverflow = document.body.style.overflow;
|
||||
bodyPaddingRight = document.body.style.paddingRight;
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
|
||||
backgroundState = Array.from(document.body.children)
|
||||
.filter((child): child is HTMLElement => child instanceof HTMLElement && child !== layer)
|
||||
.map((element) => ({ element, inert: element.inert, ariaHidden: element.getAttribute('aria-hidden') }));
|
||||
backgroundState.forEach(({ element }) => {
|
||||
element.inert = true;
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
function unlockDocument() {
|
||||
documentLockCount = Math.max(0, documentLockCount - 1);
|
||||
if (documentLockCount !== 0) return;
|
||||
|
||||
document.body.style.overflow = bodyOverflow;
|
||||
document.body.style.paddingRight = bodyPaddingRight;
|
||||
backgroundState.forEach(({ element, inert, ariaHidden }) => {
|
||||
element.inert = inert;
|
||||
if (ariaHidden === null) element.removeAttribute('aria-hidden');
|
||||
else element.setAttribute('aria-hidden', ariaHidden);
|
||||
});
|
||||
backgroundState = [];
|
||||
}
|
||||
|
||||
function focusableElements(root: HTMLElement) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector))
|
||||
.filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
onClose,
|
||||
dirty = false,
|
||||
initialFocusRef,
|
||||
closeGuardTitle = '放弃未保存的修改?',
|
||||
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||
}: ModalProps) {
|
||||
const titleId = useId();
|
||||
const guardTitleId = useId();
|
||||
const guardDescriptionId = useId();
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const guardRef = useRef<HTMLElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const [showCloseGuard, setShowCloseGuard] = useState(false);
|
||||
const [layer] = useState(() => modalLayer());
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (dirty) {
|
||||
guardRestoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setShowCloseGuard(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [dirty, onClose]);
|
||||
|
||||
const discardAndClose = useCallback(() => {
|
||||
setShowCloseGuard(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setShowCloseGuard(false);
|
||||
return undefined;
|
||||
}
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return undefined;
|
||||
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
modalStack.push(panel);
|
||||
lockDocument(layer);
|
||||
|
||||
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
||||
requestAnimationFrame(() => focusTarget.focus());
|
||||
|
||||
return () => {
|
||||
const stackIndex = modalStack.lastIndexOf(panel);
|
||||
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
||||
unlockDocument();
|
||||
const restoreTarget = restoreFocusRef.current;
|
||||
queueMicrotask(() => {
|
||||
if (restoreTarget?.isConnected && !restoreTarget.inert) restoreTarget.focus();
|
||||
else modalStack[modalStack.length - 1]?.focus();
|
||||
});
|
||||
};
|
||||
}, [initialFocusRef, layer, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || modalStack[modalStack.length - 1] !== panel) return;
|
||||
const trapRoot = showCloseGuard ? guardRef.current : panel;
|
||||
if (!trapRoot) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (showCloseGuard) setShowCloseGuard(false);
|
||||
else requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const items = focusableElements(trapRoot);
|
||||
if (!items.length) {
|
||||
event.preventDefault();
|
||||
trapRoot.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !trapRoot.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || !trapRoot.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, requestClose, showCloseGuard]);
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
useEffect(() => {
|
||||
if (!showCloseGuard) return;
|
||||
const panel = panelRef.current;
|
||||
if (panel) panel.inert = true;
|
||||
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
|
||||
return () => {
|
||||
if (panel) panel.inert = false;
|
||||
const restoreTarget = guardRestoreFocusRef.current;
|
||||
queueMicrotask(() => {
|
||||
if (restoreTarget?.isConnected && panel?.isConnected) restoreTarget.focus();
|
||||
});
|
||||
};
|
||||
}, [showCloseGuard]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
if (!open) return null;
|
||||
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
|
||||
|
||||
return createPortal(
|
||||
<div className="ui-modal" role="presentation">
|
||||
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
|
||||
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
|
||||
<div className="ui-modal" data-ui-modal-root>
|
||||
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) requestClose();
|
||||
}} />
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')}
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="ui-modal__header">
|
||||
<div className="ui-modal__title">{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
|
||||
<div className="ui-modal__title" id={titleId}>{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</header>
|
||||
<div className="ui-modal__body">{children}</div>
|
||||
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
|
||||
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
|
||||
</section>
|
||||
{showCloseGuard ? (
|
||||
<div className="ui-modal__guard-layer">
|
||||
<section
|
||||
aria-describedby={guardDescriptionId}
|
||||
aria-labelledby={guardTitleId}
|
||||
aria-modal="true"
|
||||
className="ui-modal__guard"
|
||||
ref={guardRef}
|
||||
role="alertdialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AlertTriangle aria-hidden="true" size={22} />
|
||||
<div>
|
||||
<h2 id={guardTitleId}>{closeGuardTitle}</h2>
|
||||
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
||||
</div>
|
||||
<footer>
|
||||
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">继续编辑</Button>
|
||||
<Button onClick={discardAndClose} variant="danger">放弃并关闭</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
layer,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Check, ShieldCheck } from 'lucide-react';
|
||||
import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function RiskAction({
|
||||
targetType,
|
||||
targetId,
|
||||
disabled,
|
||||
children = '通过',
|
||||
icon = <Check size={15} />,
|
||||
onCompleted,
|
||||
}: {
|
||||
targetType: 'signature' | 'template';
|
||||
targetId: string;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
onCompleted?: (result: ReviewDecisionResult) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ReviewPreflight | null>(null);
|
||||
const [result, setResult] = useState<ReviewDecisionResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
|
||||
async function begin() {
|
||||
const key = `review:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setError('');
|
||||
setPreflight(null);
|
||||
setIdempotencyKey(key);
|
||||
try {
|
||||
setPreflight(await adminApi.getReviewPreflight(targetType, targetId));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '审核资格预检失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preflight || !preflight.allowedActions.includes('approve')) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const completed = await adminApi.submitReviewDecision(targetType, targetId, {
|
||||
decision: 'approve',
|
||||
expectedUpdatedAt: preflight.expectedUpdatedAt,
|
||||
idempotencyKey,
|
||||
});
|
||||
setResult(completed);
|
||||
onCompleted?.(completed);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '审核提交失败,请刷新后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!submitting) setOpen(false);
|
||||
}
|
||||
|
||||
const blocked = Boolean(preflight && !preflight.allowedActions.includes('approve'));
|
||||
const footer = result ? <Button onClick={close}>关闭</Button> : <>
|
||||
<Button disabled={submitting} onClick={close} variant="ghost">取消</Button>
|
||||
<Button disabled={loading || submitting || blocked || !preflight} icon={<ShieldCheck size={16} />} onClick={() => void confirm()} variant="success">
|
||||
{submitting ? '提交审核中…' : '确认通过'}
|
||||
</Button>
|
||||
</>;
|
||||
|
||||
return <>
|
||||
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="success">{children}</Button>
|
||||
<Modal footer={footer} onClose={close} open={open} title="审核通过确认">
|
||||
<div className="risk-action-content">
|
||||
{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.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>
|
||||
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul></section>
|
||||
</> : 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: '唯一标识', tenant: '所属企业', application: '短信应用' };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, RotateCcw } from 'lucide-react';
|
||||
import type { SystemLogExportResult } from '@/api/adminApi';
|
||||
import { Button } from './Button';
|
||||
|
||||
type Filters = { keyword?: string; level?: string; module?: string; range?: string };
|
||||
|
||||
export function SystemLogExport({
|
||||
portal,
|
||||
filters,
|
||||
exportLogs,
|
||||
}: {
|
||||
portal: 'admin' | 'client';
|
||||
filters: Filters;
|
||||
exportLogs: (filters: Filters) => Promise<SystemLogExportResult>;
|
||||
}) {
|
||||
const storageKey = `cmpp:${portal}:system-log-export-recovery`;
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [result, setResult] = useState<SystemLogExportResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [retryFilters, setRetryFilters] = useState<Filters | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
setRetryFilters(JSON.parse(saved) as Filters);
|
||||
setError('上次导出未完成,筛选条件已保留,可直接重试。');
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
async function run(nextFilters: Filters) {
|
||||
setExporting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setRetryFilters(nextFilters);
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(nextFilters));
|
||||
try {
|
||||
const exported = await exportLogs(nextFilters);
|
||||
setResult(exported);
|
||||
setRetryFilters(null);
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '日志导出失败,请重试');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(new Blob([`\uFEFF${result.content}`], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = result.fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="system-log-export">
|
||||
<Button disabled={exporting} icon={<Download size={17} />} onClick={() => run(filters)} variant="secondary">
|
||||
{exporting ? '导出中…' : '导出日志'}
|
||||
</Button>
|
||||
{result ? (
|
||||
<div className="system-log-export__result" role="status">
|
||||
<span>导出完成,共 {result.recordCount} 条{result.truncated ? '(已截取前 10000 条)' : ''},操作单号 {result.operationId}</span>
|
||||
<Button icon={<Download size={15} />} onClick={download} size="sm" variant="ghost">下载文件</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="system-log-export__error" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button disabled={exporting} icon={<RotateCcw size={15} />} onClick={() => run(retryFilters ?? filters)} size="sm" variant="ghost">重试</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,11 @@ export { DateRangeInput } from './DateRangeInput';
|
||||
export { DateTimeInput } from './DateTimeInput';
|
||||
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
||||
export { FileActions } from './FileActions';
|
||||
export { SystemLogExport } from './SystemLogExport';
|
||||
export { RiskAction } from './RiskAction';
|
||||
export { DeleteRiskAction } from './DeleteRiskAction';
|
||||
export { ManualRechargeDialog } from './ManualRechargeDialog';
|
||||
export type { ManualRechargeTarget } from './ManualRechargeDialog';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||
|
||||
Reference in New Issue
Block a user