276 lines
15 KiB
TypeScript
276 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { AlertTriangle, CheckCircle2, Download, Layers3, Search, ShieldCheck } from 'lucide-react';
|
||
import {
|
||
adminApi,
|
||
fileDownloadUrl,
|
||
type ReportMaterialBatch,
|
||
type ReportMaterialBatchPreflight,
|
||
type ReportMaterialBatchResult,
|
||
type ReportMaterialPendingItem,
|
||
} from '@/api/adminApi';
|
||
import {
|
||
Breadcrumb,
|
||
Button,
|
||
DateRangeInput,
|
||
Input,
|
||
Modal,
|
||
Pagination,
|
||
Select,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
type DateRangeValue,
|
||
type TableColumn,
|
||
} from '@/components/ui';
|
||
import { formatDateTime } from '@/utils/dateTime';
|
||
import { createUuid } from '@/utils/randomId';
|
||
|
||
const batchStatusLabels: Record<string, string> = {
|
||
completed: '生成完成',
|
||
partial_failed: '部分生成',
|
||
failed: '生成失败',
|
||
generating: '生成中',
|
||
processing: '生成中',
|
||
};
|
||
|
||
export function AdminReportMaterialsPage() {
|
||
const [activeTab, setActiveTab] = useState<'pending' | 'batches'>('pending');
|
||
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ items: [], total: 0 });
|
||
const [batchData, setBatchData] = useState<{ items: ReportMaterialBatch[]; total: number }>({ items: [], total: 0 });
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [reportType, setReportType] = useState('all');
|
||
const [keyword, setKeyword] = useState('');
|
||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||
const [pendingPage, setPendingPage] = useState(1);
|
||
const [batchPage, setBatchPage] = useState(1);
|
||
const pageSize = 20;
|
||
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('');
|
||
|
||
async function loadPending(
|
||
page = pendingPage,
|
||
filters: {
|
||
keyword?: string;
|
||
dateRange?: DateRangeValue;
|
||
reportType?: string;
|
||
} = {},
|
||
) {
|
||
const nextKeyword = filters.keyword ?? keyword;
|
||
const nextDateRange = filters.dateRange ?? dateRange;
|
||
const nextReportType = filters.reportType ?? reportType;
|
||
try {
|
||
const result = await adminApi.listPendingReportMaterials({
|
||
reportType: nextReportType === 'all' ? undefined : nextReportType as 'signature' | 'drainage',
|
||
keyword: nextKeyword.trim() || undefined,
|
||
startAt: nextDateRange.start,
|
||
endAt: nextDateRange.end,
|
||
page,
|
||
pageSize,
|
||
});
|
||
setPendingData({ items: result.items, total: result.total });
|
||
const eligibility = result.items.length ? await adminApi.preflightReportMaterialBatch({ items: result.items.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)));
|
||
setError('');
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '待生成资料加载失败');
|
||
}
|
||
}
|
||
|
||
async function loadBatches(
|
||
page = batchPage,
|
||
filters: {
|
||
keyword?: string;
|
||
dateRange?: DateRangeValue;
|
||
} = {},
|
||
) {
|
||
const nextKeyword = filters.keyword ?? keyword;
|
||
const nextDateRange = filters.dateRange ?? dateRange;
|
||
try {
|
||
const result = await adminApi.listReportMaterialBatches({
|
||
keyword: nextKeyword.trim() || undefined,
|
||
startAt: nextDateRange.start,
|
||
endAt: nextDateRange.end,
|
||
page,
|
||
pageSize,
|
||
});
|
||
setBatchData({ items: result.items, total: result.total });
|
||
setError('');
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '已生成批次加载失败');
|
||
}
|
||
}
|
||
|
||
function loadActive() {
|
||
if (activeTab === 'pending') void loadPending(pendingPage);
|
||
else void loadBatches(batchPage);
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadActive();
|
||
}, [activeTab, pendingPage, batchPage, reportType]);
|
||
|
||
const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible);
|
||
const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id));
|
||
|
||
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 = pendingData.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:${createUuid()}`);
|
||
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 = pendingData.items.filter((item) => selected.has(item.id));
|
||
if (!chosen.length) return;
|
||
setBusy(true);
|
||
setError('');
|
||
try {
|
||
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());
|
||
await Promise.all([loadPending(pendingPage), loadBatches(1)]);
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '报备批次生成失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(() => [
|
||
{
|
||
key: 'select',
|
||
title: '',
|
||
width: '48px',
|
||
render: (item) => {
|
||
const eligible = poolEligibility.get(item.id)?.eligible;
|
||
return <input aria-label={`选择${item.name}`} checked={selected.has(item.id)} disabled={!eligible} onChange={() => toggle(item.id)} type="checkbox" />;
|
||
},
|
||
},
|
||
{ key: 'name', title: '资料', render: (item) => <div><strong>{item.name}</strong><div className="muted">{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} · {item.detail || '-'}</div></div> },
|
||
{ key: 'tenant', title: '企业/应用', render: (item) => <div><strong>{item.tenant?.name ?? '-'}</strong><div className="muted">{item.application?.name ?? '未指定应用'}</div></div> },
|
||
{ key: 'eligibility', title: '版本/资格', render: (item) => {
|
||
const eligibility = poolEligibility.get(item.id);
|
||
const eligible = eligibility?.eligible;
|
||
return <div><Tag tone={eligible ? 'success' : 'warning'}>V{item.materialVersion} · {eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}</Tag>{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}</div>;
|
||
} },
|
||
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
|
||
], [poolEligibility, selected]);
|
||
|
||
const batchColumns = useMemo<Array<TableColumn<ReportMaterialBatch>>>(() => [
|
||
{ key: 'batchNo', title: '报备批次号', render: (batch) => <strong>{batch.batchNo}</strong> },
|
||
{ key: 'time', title: '生成时间', render: (batch) => formatDateTime(batch.createdAt) },
|
||
{ key: 'reportTotal', title: '报备总数', render: (batch) => batch.reportTotal.toLocaleString('zh-CN') },
|
||
{ key: 'successCount', title: '成功数', render: (batch) => batch.successCount.toLocaleString('zh-CN') },
|
||
{ key: 'successRate', title: '成功率', render: (batch) => `${(batch.successRate * 100).toFixed(2)}%` },
|
||
{ key: 'channels', title: '通道/文件', render: (batch) => `${batch.channelCount}个通道 · ${batch.fileCount}份文件` },
|
||
{ key: 'status', title: '生成状态', render: (batch) => <Tag tone={batch.status === 'completed' ? 'success' : batch.status === 'failed' ? 'danger' : 'warning'}>{batchStatusLabels[batch.status] ?? batch.status}</Tag> },
|
||
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}({file.rowCount}行)</a> : null)}</div> },
|
||
], []);
|
||
|
||
const filter = <div className={`surface report-material-filter report-material-filter--${activeTab}`}>
|
||
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null}
|
||
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} />
|
||
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} />
|
||
<div className="ui-query-actions">
|
||
<Button icon={<Search size={16} />} onClick={() => {
|
||
if (activeTab === 'pending') {
|
||
setPendingPage(1);
|
||
void loadPending(1);
|
||
} else {
|
||
setBatchPage(1);
|
||
void loadBatches(1);
|
||
}
|
||
}}>查询</Button>
|
||
<Button onClick={() => {
|
||
setKeyword('');
|
||
setDateRange({});
|
||
if (activeTab === 'pending') {
|
||
setReportType('all');
|
||
setPendingPage(1);
|
||
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
|
||
} else {
|
||
setBatchPage(1);
|
||
void loadBatches(1, { keyword: '', dateRange: {} });
|
||
}
|
||
}} variant="ghost">重置</Button>
|
||
</div>
|
||
</div>;
|
||
|
||
return <section className="page-stack report-material-page">
|
||
<div className="surface page-heading">
|
||
<div><Breadcrumb items={['报备任务', '待生成报备批次']} /><h1>待生成报备批次</h1><p>审核通过的签名和引流资料先进入待生成池,运营选择资料后按应用路由为各通道生成批量报备文件。</p></div>
|
||
{activeTab === 'pending' ? <Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size})`}</Button> : null}
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
{message ? <p className="form-success">{message}</p> : null}
|
||
<Tabs
|
||
onChange={(value) => {
|
||
setActiveTab(value as 'pending' | 'batches');
|
||
setKeyword('');
|
||
setDateRange({});
|
||
}}
|
||
value={activeTab}
|
||
items={[
|
||
{
|
||
label: `待生成资料(${pendingData.total})`,
|
||
value: 'pending',
|
||
content: <div className="page-stack">{filter}<div className="surface"><label className="table-actions"><input checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))} type="checkbox" />选择本页全部可生成资料</label><Table columns={pendingColumns} data={pendingData.items} emptyText="暂无符合条件的待生成资料" pagination={false} rowKey="id" /></div><Pagination nextDisabled={pendingPage * pageSize >= pendingData.total} onNext={() => setPendingPage((page) => page + 1)} onPageChange={setPendingPage} onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))} page={pendingPage} previousDisabled={pendingPage <= 1} total={pendingData.total} totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))} /></div>,
|
||
},
|
||
{
|
||
label: `已生成批次(${batchData.total})`,
|
||
value: 'batches',
|
||
content: <div className="page-stack">{filter}<div className="surface"><Table columns={batchColumns} data={batchData.items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" /></div><Pagination nextDisabled={batchPage * pageSize >= batchData.total} onNext={() => setBatchPage((page) => page + 1)} onPageChange={setBatchPage} onPrevious={() => setBatchPage((page) => Math.max(1, page - 1))} page={batchPage} previousDisabled={batchPage <= 1} total={batchData.total} totalPages={Math.max(1, Math.ceil(batchData.total / pageSize))} /></div>,
|
||
},
|
||
]}
|
||
/>
|
||
<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 className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><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}
|
||
</div>
|
||
</Modal>
|
||
</section>;
|
||
}
|
||
|
||
function toBatchItem(item: ReportMaterialPendingItem) {
|
||
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
|
||
}
|