Files
lislgosms/src/apps/admin/AdminReportTasksPage.tsx
T

656 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } 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';
import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal';
function materialValue(value: unknown) {
const file = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
const fileObjectId = String(file.fileObjectId ?? '');
const fileName = String(file.fileName ?? fileObjectId ?? '-');
if (
fileObjectId &&
(String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName))
)
return (
<div className="report-material-image-value">
<img alt={fileName} src={fileDownloadUrl(fileObjectId, 'inline')} />
<a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>
</div>
);
if (fileObjectId) return <a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>;
return String(value ?? '-');
}
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
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' },
};
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;
return 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 };
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>
{task.reportType !== 'drainage' ? (
<div>
<span>运营商</span>
{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong>历史通道级(未拆分)</strong>}
</div>
) : null}
{task.reportType !== 'drainage' ? (
<div>
<span>当前通过时间</span>
<strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong>
</div>
) : null}
<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>
</div>
</Modal>
);
}
export function AdminReportTasksPage() {
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('scope') === 'pending' ? 'pending' : 'all';
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [status, setStatus] = useState(initialStatus);
const [carrier, setCarrier] = useState('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [material, setMaterial] = useState<SingleReportMaterialDetail | 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);
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const requestId = useRef(0);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
dateRange: {} as DateRangeValue,
reportType: 'all',
status: initialStatus,
carrier: 'all',
});
function loadData(targetPage = page, filters = appliedFilters) {
const id = ++requestId.current;
adminApi
.listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
reportType: filters.reportType === 'all' ? undefined : (filters.reportType as 'signature' | 'drainage'),
status: filters.status === 'all' ? undefined : filters.status,
carrier: filters.carrier === 'all' ? undefined : filters.carrier,
keyword: filters.keyword || undefined,
createdAtFrom: filters.dateRange.start || undefined,
createdAtTo: filters.dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
if (id !== requestId.current) return;
setTasks(result.items);
setTotal(result.total);
setSelected(new Set());
setError('');
})
.catch((failure: Error) => {
if (id === requestId.current) setError(failure.message || '报备明细加载失败');
});
}
useEffect(() => {
loadData(page);
return () => {
requestId.current += 1;
};
}, [page, pageSize]);
async function saveTaskStatus() {
if (!statusTask) return;
const chosen = selected.size ? tasks.filter((task) => selected.has(task.id)) : [statusTask];
setBusy(true);
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: statusReason.trim() || undefined,
sourceEntry: 'report_task',
});
setStatusTask(null);
setStatusReason('');
setSelected(new Set());
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setBusy(false);
}
}
async function openMaterial(task: ReportTask) {
try {
setMaterial(
await adminApi.getSingleReportMaterialDetail({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
}),
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资料加载失败');
}
}
async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) {
try {
setExportBusy(true);
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,
outputFormat,
});
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);
setExportTask(null);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
} finally {
setExportBusy(false);
}
}
const columns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
width: '44px',
render: (record) => (
<input
aria-label={`选择${taskTargetLabel(record)}`}
checked={selected.has(record.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(record.id)) next.delete(record.id);
else next.add(record.id);
return next;
})
}
type="checkbox"
/>
),
},
{
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) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
</div>
),
},
{
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={() => void openMaterial(record)} size="sm" variant="ghost">
查看报备资料
</Button>
{record.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => setExportTask(record)} size="sm" variant="ghost">
导出
</Button>
) : null}
<Button
onClick={() => {
setSelected(new Set());
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}}
size="sm"
variant="ghost"
>
修改状态
</Button>
</div>
),
},
];
const allCurrentPageSelected = tasks.length > 0 && tasks.every((task) => selected.has(task.id));
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 className="page-heading__actions">
<Button
disabled={!tasks.length}
onClick={() => setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))}
variant="ghost"
>
{allCurrentPageSelected ? '取消全选' : '全选当页'}
</Button>
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
批量修改状态({selected.size}
</Button>
</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}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Select
label="报备状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta)
.filter(([value]) => !['exporting', 'rejected'].includes(value))
.map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = { keyword: keyword.trim(), dateRange, reportType, status, carrier };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
}}
>
查询
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setCarrier('all');
setStatus('all');
const filters = {
keyword: '',
dateRange: {} as DateRangeValue,
reportType: 'all',
status: 'all',
carrier: 'all',
};
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
}}
variant="ghost"
>
重置
</Button>
</div>
</div>
<div className="surface report-task-table-card">
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
setSelected(new Set());
}}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
pageSize={pageSize}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
{material ? (
<Modal
footer={<Button onClick={() => setMaterial(null)}>关闭</Button>}
onClose={() => setMaterial(null)}
open
size="xl"
title="查看报备资料"
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span>签名</span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span>企业/应用</span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>通道名称 / 编号 / 版本</span>
<strong>
{material.channel.name} · {material.channel.code} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.name}{field.code}
{field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}”` : ''}
{field.required ? ' *' : ''}
</span>
<strong>{materialValue(field.value)}</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>
{field.name}{field.code},历史字段)
</span>
<strong>{materialValue(field.value)}</strong>
</div>
))}
</div>
</div>
</Modal>
) : 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>{selected.size ? `已选择 ${selected.size} 条明细` : 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>
<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>
{exportTask ? (
<ReportExportFormatModal
busy={exportBusy}
onClose={() => setExportTask(null)}
onConfirm={(format) => void exportMaterial(exportTask, format)}
/>
) : null}
</section>
);
}