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
+40 -10
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
@@ -17,38 +17,68 @@ export function AdminReportMaterialsPage() {
const [keyword, setKeyword] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
const [operationKey, setOperationKey] = useState('');
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
function loadData() {
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
.then(async ([pendingItems, batchItems]) => {
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
})
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
}
useEffect(loadData, [reportType]);
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
async function beginCreateBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
setOperationKey(`report-batch:${crypto.randomUUID()}`);
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
finally { setPreflightBusy(false); }
}
async function createBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
setBusy(true); setError(''); setMessage('');
try {
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
finally { setBusy(false); }
}
return <section className="page-stack report-material-page">
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p> XLSX </p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备${selected.size}`}</Button></div></div>
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p></p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成${selected.size}`}</Button></div></div>
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost"></Button></div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span></span><span></span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span> / </span><span></span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join('')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2></h2><p> XLSX</p></div><Tag tone="neutral">{batches.length} </Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · {batch.selectedCount ?? 0} · {batch.channelCount ?? 0} </small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}{String(file.rowCount ?? 0)} </a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
<div className="report-batch-preflight">{preflightBusy ? <p role="status"></p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
</Modal>
</section>;
}
function toBatchItem(item: ReportMaterialPendingItem) {
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
}