Files
lislgosms/src/apps/admin/AdminReportMaterialsPage.tsx
T

276 lines
15 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 { 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 };
}