feat: redesign report batch workflow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user