feat: complete reporting and filing workflows
This commit is contained in:
@@ -1,123 +1,66 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, Eye, FileUp, Search } from 'lucide-react';
|
||||
import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { Eye, Search } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||
pending: { label: '待处理', tone: 'neutral' },
|
||||
ready: { label: '待导出', tone: 'info' },
|
||||
exported: { label: '已导出', tone: 'warning' },
|
||||
reporting: { label: '报备中', tone: 'warning' },
|
||||
partial: { label: '部分完成', tone: 'info' },
|
||||
completed: { label: '已完成', tone: 'success' },
|
||||
approved: { label: '报备通过', tone: 'success' },
|
||||
pending: { label: '未报备', tone: 'neutral' },
|
||||
waiting_material: { label: '资料待补充', tone: 'warning' },
|
||||
reporting: { label: '报备中', tone: 'warning' },
|
||||
exporting: { label: '报备中', tone: 'warning' },
|
||||
approved: { label: '报备通过', tone: 'success' },
|
||||
failed: { label: '报备失败', tone: 'danger' },
|
||||
rejected: { label: '报备失败', tone: 'danger' },
|
||||
abandoned: { label: '已放弃', tone: 'neutral' },
|
||||
failed: { label: '有失败', tone: 'danger' },
|
||||
waiting_review: { label: '等待运营审核', tone: 'warning' },
|
||||
};
|
||||
|
||||
type ReceiptImportPayload = {
|
||||
delimiter: ',' | '\t';
|
||||
fileContent: string;
|
||||
fileName: string;
|
||||
fileObjectId: string;
|
||||
remark: string;
|
||||
const actionLabels: Record<string, string> = {
|
||||
create: '创建报备明细',
|
||||
batch_export: '生成报备批次',
|
||||
export: '生成报备文件',
|
||||
manual_status_change: '人工修改状态',
|
||||
receipt_import: '历史回执导入',
|
||||
};
|
||||
|
||||
function taskTargetLabel(task: ReportTask) {
|
||||
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
|
||||
if (task.drainageInfo) return task.drainageInfo.siteName || task.drainageInfo.url;
|
||||
const payload = task.signature?.drainageInfo;
|
||||
const links = payload && Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
const item = links.find((link) => String(link.id ?? '') === task.drainageItemId);
|
||||
return String(item?.siteName ?? item?.url ?? task.drainageItemId ?? '引流信息');
|
||||
}
|
||||
|
||||
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileObject, setFileObject] = useState<FileObject | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [remark, setRemark] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const fileRef: FileRef | null = fileObject
|
||||
? { contentType: fileObject.contentType, fileName: fileObject.fileName, fileObjectId: fileObject.id }
|
||||
: null;
|
||||
|
||||
async function uploadReceiptFile(nextFile: File | undefined) {
|
||||
setFile(nextFile ?? null);
|
||||
setFileObject(null);
|
||||
setError('');
|
||||
if (!nextFile) {
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await adminApi.uploadFileObject(nextFile, { purpose: 'report_receipt', prefix: `report-receipts/${task.id}` });
|
||||
setFileObject(uploaded);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '报备回执上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!file || !fileObject) {
|
||||
return;
|
||||
}
|
||||
const delimiter: ',' | '\t' = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
|
||||
onSubmit({
|
||||
delimiter,
|
||||
fileContent: await file.text(),
|
||||
fileName: file.name,
|
||||
fileObjectId: fileObject.id,
|
||||
remark,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!fileObject || uploading} onClick={() => { void submitImport(); }}>{uploading ? '上传中...' : '确认导入'}</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>导入报备回执</h2><p>上传运营商回执并记录状态。</p></div>}
|
||||
>
|
||||
<div className="report-receipt-modal">
|
||||
<label className="report-upload-drop">
|
||||
<FileUp size={38} />
|
||||
<strong>{file?.name || '选择回执文件'}</strong>
|
||||
<span>支持 CSV、TSV、TXT 文本回执,需包含状态/结果列。</span>
|
||||
<FileActions file={fileRef} />
|
||||
<input
|
||||
accept=".csv,.tsv,.txt,text/csv,text/plain"
|
||||
onChange={(event) => { void uploadReceiptFile(event.target.files?.[0]); }}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return task.drainageInfo?.siteName || task.drainageInfo?.url || task.drainageItemId || '引流信息';
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
|
||||
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备任务详情</h2><p>{task.id}</p></div>}>
|
||||
<div className="admin-task-detail report-task-detail">
|
||||
<div className="admin-task-metrics">
|
||||
<div className="admin-task-metric"><span>{task.reportType === 'drainage' ? '引流信息' : '签名'}</span><strong>{taskTargetLabel(task)}</strong></div>
|
||||
<div className="admin-task-metric admin-task-metric--primary"><span>通道</span><strong>{task.channel?.name ?? task.channelId}</strong></div>
|
||||
<div className="admin-task-metric admin-task-metric--success"><span>当前状态</span><strong>{status.label}</strong></div>
|
||||
const source = task.exportItems?.[0];
|
||||
return <Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title="报备明细详情">
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div><span>报备对象</span><strong>{taskTargetLabel(task)}</strong></div>
|
||||
<div><span>资料类型</span><strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong></div>
|
||||
<div><span>企业</span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
|
||||
<div><span>企业应用</span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
|
||||
<div><span>通道</span><strong>{task.channel?.name ?? task.channelId}</strong></div>
|
||||
<div><span>当前状态</span><Tag tone={status.tone}>{status.label}</Tag></div>
|
||||
<div><span>创建时间</span><strong>{formatDateTime(task.createdAt)}</strong></div>
|
||||
<div><span>最后更新时间</span><strong>{formatDateTime(task.updatedAt)}</strong></div>
|
||||
<div><span>资料版本</span><strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong></div>
|
||||
<div><span>所属批次</span><strong>{source?.batchItem.batch.batchNo ?? '-'}</strong></div>
|
||||
<div><span>报备文件行</span><strong>{source ? `第${source.rowNumber}行` : '-'}</strong></div>
|
||||
<div><span>当前说明</span><strong>{task.reason || '-'}</strong></div>
|
||||
</div>
|
||||
{source?.exportFile.fileObjectId ? <div className="surface" style={{ padding: 16 }}><a href={fileDownloadUrl(source.exportFile.fileObjectId)}>下载报备文件:{source.exportFile.fileName}</a></div> : null}
|
||||
<div className="surface" style={{ padding: 16 }}>
|
||||
<h3>状态记录</h3>
|
||||
<div className="page-stack" style={{ marginTop: 12 }}>
|
||||
{(task.records ?? []).length ? task.records!.map((record) => <div className="detail-grid" key={record.id}>
|
||||
<div><span>时间</span><strong>{formatDateTime(record.createdAt)}</strong></div>
|
||||
<div><span>动作</span><strong>{actionLabels[record.action] ?? record.action}</strong></div>
|
||||
<div><span>状态变化</span><strong>{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} → {statusMeta[record.statusAfter]?.label ?? record.statusAfter}</strong></div>
|
||||
<div><span>说明</span><strong>{record.reason || '-'}</strong></div>
|
||||
</div>) : <p className="muted">暂无状态记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function AdminReportTasksPage() {
|
||||
@@ -125,12 +68,12 @@ export function AdminReportTasksPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null);
|
||||
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
|
||||
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
|
||||
const [nextStatus, setNextStatus] = useState('approved');
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' })
|
||||
@@ -138,98 +81,93 @@ export function AdminReportTasksPage() {
|
||||
setTasks(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务加载失败'));
|
||||
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [reportType]);
|
||||
useEffect(loadData, [reportType]);
|
||||
|
||||
const filteredTasks = useMemo(() => tasks.filter((task) => {
|
||||
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageItemId ?? ''}`;
|
||||
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageInfo?.siteName ?? ''}${task.drainageInfo?.url ?? ''}${task.signature?.tenant?.name ?? ''}${task.signature?.application?.name ?? ''}`;
|
||||
const date = task.createdAt?.slice(0, 10) ?? '';
|
||||
return (!keyword || text.includes(keyword))
|
||||
&& (!dateRange.start || date >= dateRange.start)
|
||||
&& (!dateRange.end || date <= dateRange.end);
|
||||
}), [dateRange.end, dateRange.start, keyword, tasks]);
|
||||
|
||||
function exportTask(task: ReportTask) {
|
||||
adminApi.createReportExport(task.id, { fileName: `${task.id}.xlsx`, rowCount: 0 })
|
||||
.then(loadData)
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
|
||||
}
|
||||
|
||||
function importReceipt(payload: ReceiptImportPayload) {
|
||||
if (!receiptTask) return;
|
||||
adminApi.importReportReceipt(receiptTask.id, {
|
||||
delimiter: payload.delimiter,
|
||||
fileContent: payload.fileContent,
|
||||
fileName: payload.fileName,
|
||||
fileObjectId: payload.fileObjectId,
|
||||
reason: payload.remark,
|
||||
})
|
||||
.then(() => {
|
||||
setReceiptTask(null);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '报备回执导入失败'));
|
||||
}
|
||||
|
||||
function saveTaskStatus() {
|
||||
async function saveTaskStatus() {
|
||||
if (!statusTask) return;
|
||||
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'report_task' })
|
||||
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
|
||||
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
|
||||
setBusy(true);
|
||||
try {
|
||||
await adminApi.changeReportTaskStatuses({
|
||||
items: [{
|
||||
signatureId: statusTask.signatureId,
|
||||
channelId: statusTask.channelId,
|
||||
reportType: statusTask.reportType,
|
||||
drainageItemId: statusTask.drainageItemId ?? undefined,
|
||||
status: nextStatus,
|
||||
}],
|
||||
reason: statusReason.trim() || undefined,
|
||||
sourceEntry: 'report_task',
|
||||
});
|
||||
setStatusTask(null);
|
||||
setStatusReason('');
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ReportTask>> = [
|
||||
{ key: 'id', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{ key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '240px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button onClick={() => { setStatusTask(record); setNextStatus(record.status); }} size="sm" variant="ghost">修改状态</Button>
|
||||
<Button icon={<Download size={14} />} onClick={() => exportTask(record)} size="sm" variant="ghost">生成同范围任务</Button>
|
||||
<Button icon={<FileUp size={14} />} onClick={() => setReceiptTask(record)} size="sm" variant="ghost">导入回执</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
|
||||
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel?.name ?? record.channelId },
|
||||
{ key: 'batch', title: '批次/版本', render: (record) => {
|
||||
const source = record.exportItems?.[0];
|
||||
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · 第{source.rowNumber}行</div></div> : '-';
|
||||
} },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost">详情</Button><Button onClick={() => {
|
||||
setStatusTask(record);
|
||||
setNextStatus(record.status);
|
||||
setStatusReason('');
|
||||
}} size="sm" variant="ghost">修改状态</Button></div> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-task-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['报备任务']} />
|
||||
<h1>签名与引流信息报备任务</h1>
|
||||
return <section className="page-stack admin-sms-task-page report-task-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)} placeholder="搜索报备明细" value={keyword} />
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}>查询</Button><Button onClick={() => {
|
||||
setKeyword('');
|
||||
setDateRange({});
|
||||
setReportType('all');
|
||||
}} variant="ghost">重置</Button></div>
|
||||
</div>
|
||||
<div className="surface report-task-table-card"><Table columns={columns} data={filteredTasks} emptyText="暂无报备明细" rowKey="id" /></div>
|
||||
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
||||
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost">取消</Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
|
||||
{statusTask ? <div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div><span>报备对象</span><strong>{taskTargetLabel(statusTask)}</strong></div>
|
||||
<div><span>通道</span><strong>{statusTask.channel?.name ?? statusTask.channelId}</strong></div>
|
||||
<div><span>当前状态</span><strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="报备任务号/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道或报备对象" value={keyword} />
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<Table columns={columns} data={filteredTasks} emptyText="暂无报备任务" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null}
|
||||
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
||||
<Modal footer={<><Button onClick={() => setStatusTask(null)} variant="ghost">取消</Button><Button onClick={saveTaskStatus}>保存</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="人工修正报备任务状态"><div className="admin-system-modal-form"><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) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
|
||||
</section>
|
||||
);
|
||||
<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) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
|
||||
</div> : null}
|
||||
</Modal>
|
||||
</section>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user