feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+85
View File
@@ -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: '关联签名' };
+4 -3
View File
@@ -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()}>
+162
View File
@@ -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
View File
@@ -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,
);
}
+99
View File
@@ -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: '短信应用' };
+82
View File
@@ -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
View File
@@ -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';