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

394 lines
13 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, useMemo, useRef, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
import {
Button,
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> = {
pending_review: '待审核',
partially_reviewed: '部分已审核',
partially_approved: '部分通过',
approved: '已通过',
rejected: '已驳回',
failed: '导入失败',
};
const itemStatusLabels: Record<string, string> = {
pending_review: '待审核',
approved: '已通过',
rejected: '已驳回',
invalid: '数据异常',
};
function itemName(item: ReportImportReviewItem) {
if (item.reportType === 'signature') return String(item.payload.name ?? '-');
return String(item.payload.url ?? '-');
}
function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
if (status === 'approved') return 'success';
if (['rejected', 'failed', 'invalid'].includes(status)) return 'danger';
if (['partially_reviewed', 'partially_approved'].includes(status)) return 'warning';
return status === 'pending_review' ? 'info' : 'neutral';
}
export function ReportImportAuditPanel({
reportType,
initialBatchId,
}: {
reportType: 'signature' | 'drainage';
initialBatchId?: string;
}) {
const initialBatchHandled = useRef(false);
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>(
{ items: [], total: 0, page: 1, pageSize: 20 },
);
const [page, setPage] = useState(1);
const pageSize = 20;
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<ReportImportReviewBatch>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
function load(targetPage = page) {
adminApi
.listReportImportReviewBatches({
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
startAt: submittedDateRange.start,
endAt: submittedDateRange.end,
page: targetPage,
pageSize,
})
.then((result) => {
setData(result);
setError('');
if (!initialBatchHandled.current && initialBatchId) {
setDetail(result.items.find((item) => item.id === initialBatchId));
initialBatchHandled.current = true;
} else if (detail) setDetail(result.items.find((item) => item.id === detail.id));
})
.catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
}
useEffect(() => {
load(page);
}, [page, pageSize, reportType, status, submittedDateRange.end, submittedDateRange.start]);
const pendingItems = detail?.items.filter((item) => item.status === 'pending_review') ?? [];
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selected.has(item.id));
async function review(decision: 'approve' | 'reject') {
if (!detail) return;
const itemIds = selected.size ? [...selected] : pendingItems.map((item) => item.id);
if (!itemIds.length) return;
setBusy(true);
setError('');
try {
await adminApi.reviewReportImportItems(detail.id, { decision, itemIds, reason: reason.trim() || undefined });
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
setSelected(new Set());
setReason('');
setRejectOpen(false);
const refreshed = await adminApi.listReportImportReviewBatches({
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
startAt: submittedDateRange.start,
endAt: submittedDateRange.end,
page,
pageSize,
});
setData(refreshed);
setDetail(refreshed.items.find((item) => item.id === detail.id));
} catch (failure) {
setError(failure instanceof Error ? failure.message : '导入批次审核失败');
} finally {
setBusy(false);
}
}
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(
() => [
{
key: 'file',
title: '导入批次/文件',
render: (record) => (
<div>
<strong>{record.id}</strong>
<div className="muted">{record.fileName}</div>
</div>
),
},
{
key: 'tenant',
title: '企业/应用',
render: (record) => (
<div>
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<div className="muted">{record.application?.name ?? '未指定应用'}</div>
</div>
),
},
{
key: 'summary',
title: '明细统计',
render: (record) => {
const approved = record.items.filter((item) => item.status === 'approved').length;
const pending = record.items.filter((item) => item.status === 'pending_review').length;
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
return `共${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
},
},
{
key: 'status',
title: '状态',
render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag>,
},
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<Button
icon={<Eye size={15} />}
onClick={() => {
setDetail(record);
setSelected(new Set());
}}
size="sm"
variant="ghost"
>
详情
</Button>
),
},
],
[],
);
const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [
{
key: 'select',
title: '',
width: '48px',
render: (item) => (
<input
aria-label={`选择第${item.rowNumber}行`}
checked={selected.has(item.id)}
disabled={item.status !== 'pending_review'}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
})
}
type="checkbox"
/>
),
},
{ key: 'row', title: '文件行', width: '80px', render: (item) => `第${item.rowNumber}行` },
{
key: 'name',
title: reportType === 'signature' ? '签名' : '引流url或号码',
render: (item) => <strong>{itemName(item)}</strong>,
},
{
key: 'operation',
title: '变更类型',
width: '100px',
render: (item) => (item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效'),
},
{
key: 'status',
title: '状态',
width: '110px',
render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag>,
},
{ key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' },
];
return (
<div className="page-stack">
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card">
<div className="ui-filter-row">
<Input
label="批次号/文件名"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索导入批次或文件"
value={keyword}
/>
<Select
label="批次状态"
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
options={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '部分已审核', value: 'partially_reviewed' },
{ label: '已通过', value: 'approved' },
{ label: '部分通过', value: 'partially_approved' },
{ label: '已驳回', value: 'rejected' },
]}
value={status}
/>
<DateRangeInput
label="提交时间"
onChange={(value) => {
setSubmittedDateRange(value);
setPage(1);
}}
value={submittedDateRange}
/>
<div className="audit-filter-actions ui-filter-actions">
<Button
icon={<Search size={16} />}
onClick={() => {
setPage(1);
load(1);
}}
>
查询
</Button>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setSubmittedDateRange({});
setPage(1);
}}
variant="ghost"
>
重置
</Button>
</div>
</div>
</div>
<div className="surface">
<Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= data.total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
previousDisabled={page <= 1}
total={data.total}
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
/>
{detail ? (
<Modal
footer={
<>
<Button onClick={() => setDetail(undefined)} variant="ghost">
关闭
</Button>
<Button
disabled={busy || pendingItems.length === 0}
icon={<X size={15} />}
onClick={() => setRejectOpen(true)}
variant="danger"
>
{selected.size ? `驳回所选(${selected.size}` : `驳回全部可审核项(${pendingItems.length}`}
</Button>
<Button
disabled={busy || pendingItems.length === 0}
icon={<Check size={15} />}
onClick={() => void review('approve')}
variant="success"
>
{selected.size ? `通过所选(${selected.size}` : `通过全部可审核项(${pendingItems.length}`}
</Button>
</>
}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span>导入文件</span>
<strong>{detail.fileName}</strong>
</div>
<div>
<span>企业</span>
<strong>{detail.tenant?.name ?? detail.tenantId}</strong>
</div>
<div>
<span>应用</span>
<strong>{detail.application?.name ?? '未指定应用'}</strong>
</div>
<div>
<span>导入时间</span>
<strong>{formatDateTime(detail.createdAt)}</strong>
</div>
</div>
<label className="table-actions">
<input
checked={allPendingSelected}
onChange={() =>
setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))
}
type="checkbox"
/>
选择全部待审核项
</label>
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
{rejectOpen ? (
<Modal
footer={
<>
<Button onClick={() => setRejectOpen(false)} variant="ghost">
取消
</Button>
<Button disabled={busy} onClick={() => void review('reject')} variant="danger">
确认驳回
</Button>
</>
}
onClose={() => setRejectOpen(false)}
open
title="驳回导入资料"
>
<Textarea
label="驳回原因(选填)"
onChange={(event) => setReason(event.target.value)}
rows={4}
value={reason}
/>
</Modal>
) : null}
</div>
);
}