feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
pending: '未报备',
waiting_material: '资料待补充',
reporting: '报备中',
approved: '报备通过',
rejected: '报备失败',
abandoned: '已放弃',
};
export function AdminReportBatchesPage() {
const [items, setItems] = useState<ReportMaterialBatch[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const pageSize = 20;
function load(target = page) {
adminApi
.listReportMaterialBatches({
keyword: keyword.trim() || undefined,
startAt: dateRange.start,
endAt: dateRange.end,
page: target,
pageSize,
})
.then((result) => {
setItems(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备批次加载失败'));
}
useEffect(() => {
load(page);
}, [page]);
async function openBatch(batch: ReportMaterialBatch) {
try {
const [batchDetail, taskPage] = await Promise.all([
adminApi.getReportMaterialBatch(batch.id),
adminApi.listReportMaterialBatchTasks(batch.id, { page: 1, pageSize: 100 }),
]);
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
try {
await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
})),
reason: reason.trim() || undefined,
sourceEntry: 'report_task',
});
if (detail) await openBatch(detail);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败');
}
}
async function exportOne(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportMaterialBatch>> = [
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) },
{ key: 'count', title: '明细进度', render: (item) => `${item.successCount}/${item.reportTotal}` },
{ key: 'channels', title: '通道/文件', render: (item) => `${item.channelCount}个通道 · ${item.fileCount}份文件` },
{
key: 'status',
title: '生成状态',
render: (item) => (
<Tag tone={item.status === 'completed' ? 'success' : item.status === 'failed' ? 'danger' : 'warning'}>
{statusLabels[item.status] ?? item.status}
</Tag>
),
},
{
key: 'files',
title: '文件',
render: (item) => (
<div className="table-actions">
{item.exportFiles.map((file) =>
file.fileObjectId ? (
<a href={fileDownloadUrl(file.fileObjectId)} key={file.id}>
<Download size={14} />
{file.fileName}
</a>
) : null,
)}
</div>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
),
},
];
const taskColumns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
width: '44px',
render: (task) => (
<input
aria-label={`选择${task.signature?.name ?? task.id}`}
checked={selected.has(task.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(task.id)) next.delete(task.id);
else next.add(task.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '企业/应用/签名',
render: (task) => (
<div>
<strong>{task.signature?.name ?? '-'}</strong>
<div className="muted">
{task.signature?.tenant?.name ?? '-'} · {task.signature?.application?.name ?? '-'}
</div>
</div>
),
},
{
key: 'channel',
title: '通道/运营商',
render: (task) => (
<div>
{task.channel?.name ?? '-'}
{task.carrier ? (
<div>
<CarrierTag carrier={task.carrier} />
</div>
) : null}
</div>
),
},
{
key: 'version',
title: '资料版本',
render: (task) => `V${task.exportItems?.[0]?.batchItem.materialVersion ?? '-'}`,
},
{
key: 'status',
title: '报备状态',
render: (task) => (
<Tag
tone={
task.status === 'approved'
? 'success'
: task.status === 'failed' || task.status === 'rejected'
? 'danger'
: 'warning'
}
>
{statusLabels[task.status] ?? task.status}
</Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (task) =>
task.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
) : (
'-'
),
},
];
return (
<section className="page-stack report-batch-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备批次号" onChange={(event) => setKeyword(event.target.value)} value={keyword} />
<DateRangeInput label="生成时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else load(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface">
<Table columns={columns} data={items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((value) => value + 1)}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? (
<Modal
footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
</strong>
<Select
aria-label="批量修改状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
aria-label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="修改原因"
rows={2}
value={reason}
/>
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
</Button>
</div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
</section>
);
}