feat: redesign report batch workflow

This commit is contained in:
hectorzhao
2026-09-03 11:04:32 +08:00
parent a50aafb1ec
commit dc201bf92e
15 changed files with 582 additions and 150 deletions
+231 -102
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Check, Copy, Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import { adminApi, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
Button,
@@ -39,12 +39,16 @@ export function AdminReportBatchesPage() {
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue });
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [exportDetail, setExportDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusTargets, setStatusTargets] = useState<string[]>([]);
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const [copiedChannelId, setCopiedChannelId] = useState('');
const [downloadBusy, setDownloadBusy] = useState('');
const [statusBusy, setStatusBusy] = useState(false);
const pageSize = 20;
function load(target = page, filters = appliedFilters) {
@@ -82,6 +86,15 @@ export function AdminReportBatchesPage() {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function openExports(batch: ReportMaterialBatch) {
try {
setExportDetail(await adminApi.getReportMaterialBatch(batch.id));
setCopiedChannelId('');
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备文件加载失败');
}
}
async function copyBrief(channelId: string, content: string) {
try {
if (navigator.clipboard?.writeText) {
@@ -103,8 +116,10 @@ export function AdminReportBatchesPage() {
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
const targetIds = new Set(statusTargets);
const chosen = tasks.filter((task) => targetIds.has(task.id));
if (!chosen.length) return;
setStatusBusy(true);
try {
await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({
@@ -118,37 +133,88 @@ export function AdminReportBatchesPage() {
reason: reason.trim() || undefined,
sourceEntry: 'report_task',
});
setStatusTargets([]);
setReason('');
if (detail) await openBatch(detail);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败');
} finally {
setStatusBusy(false);
}
}
async function exportOne(task: ReportTask) {
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
function batchDate(batch: ReportMaterialBatch) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(batch.createdAt));
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}-${value.month}-${value.day}`;
}
function safeDownloadName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
}
async function downloadChannelFile(batch: ReportMaterialBatch, fileId: string, channelName: string) {
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);
setDownloadBusy(fileId);
const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId);
downloadBlob(
blob,
`${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`,
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
setError(failure instanceof Error ? failure.message : '通道报备文件下载失败');
} finally {
setDownloadBusy('');
}
}
async function downloadAll(batch: ReportMaterialBatch) {
try {
setDownloadBusy('all');
const blob = await adminApi.downloadReportMaterialBatch(batch.id);
downloadBlob(blob, `${batchDate(batch)}_${safeDownloadName(batch.batchNo)}_报备文件.zip`);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次报备文件下载失败');
} finally {
setDownloadBusy('');
}
}
const allTasksSelected = tasks.length > 0 && tasks.every((task) => selected.has(task.id));
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: 'count',
title: '报备明细',
render: (item) => (
<div className="report-batch-progress">
<span>
<strong>{item.reportTotal}</strong>
</span>
<span>
<strong>{item.reportingCount ?? 0}</strong>
</span>
<span>
<strong>{item.successCount}</strong>
</span>
<span>
<strong>{item.failedCount ?? 0}</strong>
</span>
</div>
),
},
{ key: 'channels', title: '报备通道', render: (item) => `${item.channelCount}个通道` },
{
key: 'status',
title: '生成状态',
@@ -158,37 +224,33 @@ export function AdminReportBatchesPage() {
</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>
<div className="table-actions">
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
<Button icon={<Download size={14} />} onClick={() => void openExports(item)} size="sm" variant="ghost">
</Button>
</div>
),
},
];
const taskColumns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
title: (
<input
aria-label="全选批次明细"
checked={allTasksSelected}
onChange={() => setSelected(allTasksSelected ? new Set() : new Set(tasks.map((task) => task.id)))}
type="checkbox"
/>
),
width: '44px',
render: (task) => (
<input
@@ -258,14 +320,11 @@ export function AdminReportBatchesPage() {
key: 'actions',
title: '操作',
align: 'right',
render: (task) =>
task.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
) : (
'-'
),
render: (task) => (
<Button onClick={() => setStatusTargets([task.id])} size="sm" variant="ghost">
</Button>
),
},
];
@@ -275,7 +334,7 @@ export function AdminReportBatchesPage() {
<div>
<Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1>
<p></p>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -324,6 +383,7 @@ export function AdminReportBatchesPage() {
/>
{detail ? (
<Modal
className="report-batch-drawer"
footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)}
open
@@ -331,69 +391,138 @@ export function AdminReportBatchesPage() {
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<section className="report-batch-briefs" aria-label="通道报备简报">
<div className="report-batch-briefs__title">
<div>
<h3></h3>
<p className="muted"></p>
</div>
</div>
{detail.briefs?.length ? (
detail.briefs.map((brief) => (
<article className="report-batch-brief" key={brief.channelId}>
<header>
<div>
<strong>{brief.channelName}</strong>
<span>{brief.itemCount} </span>
</div>
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
</header>
<pre>{brief.content}</pre>
</article>
))
) : (
<p className="muted"></p>
)}
</section>
<div className="report-batch-detail-summary">
<span>
<strong>{detail.reportTotal}</strong>
</span>
<span>
<strong>{detail.reportingCount ?? 0}</strong>
</span>
<span>
<strong>{detail.successCount}</strong>
</span>
<span>
<strong>{detail.failedCount ?? 0}</strong>
</span>
</div>
<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 disabled={!selected.size} onClick={() => setStatusTargets([...selected])}>
</Button>
</div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
{statusTargets.length ? (
<Modal
footer={
<>
<Button disabled={statusBusy} onClick={() => setStatusTargets([])} variant="ghost">
</Button>
<Button disabled={statusBusy} onClick={() => void saveStatuses()}>
{statusBusy ? '保存中…' : '确认修改'}
</Button>
</>
}
onClose={() => {
if (!statusBusy) setStatusTargets([]);
}}
open
title={statusTargets.length > 1 ? `批量修改 ${statusTargets.length} 条报备状态` : '修改报备状态'}
>
<div className="page-stack">
<Select
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
label="修改原因(选填)"
onChange={(event) => setReason(event.target.value)}
rows={4}
value={reason}
/>
</div>
</Modal>
) : null}
{exportDetail ? (
<Modal
footer={
<>
<Button onClick={() => setExportDetail(undefined)} variant="ghost">
</Button>
<Button
disabled={downloadBusy === 'all' || !exportDetail.briefs?.length}
icon={<Download size={15} />}
onClick={() => void downloadAll(exportDetail)}
>
{downloadBusy === 'all' ? '打包中…' : '全部下载'}
</Button>
</>
}
onClose={() => setExportDetail(undefined)}
open
size="xl"
title={`报备文件导出 · ${exportDetail.batchNo}`}
>
<section className="report-batch-briefs" aria-label="通道报备简报与文件">
<p className="muted">ZIP压缩包XLSX和TXT</p>
{exportDetail.briefs?.length ? (
exportDetail.briefs.map((brief) => {
const fileAvailable = exportDetail.exportFiles.some(
(file) => file.id === brief.fileId && file.fileObjectId,
);
return (
<article className="report-batch-brief" key={brief.channelId}>
<header>
<div>
<strong>{brief.channelName}</strong>
<span>{brief.itemCount} </span>
</div>
<div className="table-actions">
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
<Button
disabled={!fileAvailable || downloadBusy === brief.fileId}
icon={<Download size={15} />}
onClick={() => void downloadChannelFile(exportDetail, brief.fileId, brief.channelName)}
size="sm"
variant="ghost"
>
{downloadBusy === brief.fileId ? '下载中…' : '下载报备文件'}
</Button>
</div>
</header>
<pre>{brief.content}</pre>
</article>
);
})
) : (
<p className="muted"></p>
)}
</section>
</Modal>
) : null}
</section>
);
}
+56 -2
View File
@@ -9,6 +9,8 @@ import { AdminReportTasksPage } from './AdminReportTasksPage';
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
adminApi: {
changeReportTaskStatuses: vi.fn(),
downloadReportMaterialBatch: vi.fn(),
downloadReportMaterialBatchFile: vi.fn(),
exportSingleReportMaterial: vi.fn(),
getReportMaterialBatch: vi.fn(),
getSingleReportMaterialDetail: vi.fn(),
@@ -49,7 +51,12 @@ describe('report workbench pages', () => {
});
it('selects and clears every detail on the current page', async () => {
adminApi.listReportDetailsPage.mockResolvedValue({ items: [task('1'), task('2')], total: 2, page: 1, pageSize: 10 });
adminApi.listReportDetailsPage.mockResolvedValue({
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 10,
});
render(
<MemoryRouter>
<AdminReportTasksPage />
@@ -120,7 +127,9 @@ describe('report workbench pages', () => {
channelCount: 1,
fileCount: 1,
reportTotal: 1,
reportingCount: 0,
successCount: 0,
failedCount: 0,
successRate: 0,
createdAt: '2026-09-02T01:00:00.000Z',
exportFiles: [],
@@ -155,11 +164,56 @@ describe('report workbench pages', () => {
configurable: true,
value: { writeText: clipboardWriteText },
});
await user.click(await screen.findByRole('button', { name: '打开明细' }));
await user.click(await screen.findByRole('button', { name: '报备文件导出' }));
expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: '复制简报' }));
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible();
});
it('opens batch details in a drawer and keeps status controls in a nested dialog', async () => {
const batch = {
id: 'batch-2',
batchNo: 'RB-DRAWER',
status: 'completed',
selectedCount: 2,
channelCount: 1,
fileCount: 1,
reportTotal: 2,
reportingCount: 1,
successCount: 1,
failedCount: 0,
successRate: 0.5,
createdAt: '2026-09-03T01:00:00.000Z',
exportFiles: [],
briefs: [],
};
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
adminApi.getReportMaterialBatch.mockResolvedValue(batch);
adminApi.listReportMaterialBatchTasks.mockResolvedValue({
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 100,
});
render(
<MemoryRouter>
<AdminReportBatchesPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '打开明细' }));
const drawer = await screen.findByRole('dialog', { name: '批次明细 · RB-DRAWER' });
expect(drawer).toHaveClass('report-batch-drawer');
await user.click(screen.getByRole('checkbox', { name: '全选批次明细' }));
expect(screen.getByRole('button', { name: '批量修改报备状态' })).toBeEnabled();
expect(screen.getAllByRole('button', { name: '修改状态' })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '批量修改报备状态' }));
expect(await screen.findByRole('dialog', { name: '批量修改 2 条报备状态' })).toBeVisible();
expect(screen.getByLabelText('修改后的状态')).toBeVisible();
expect(screen.queryByRole('button', { name: '导出本条' })).not.toBeInTheDocument();
});
});