340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|