feat: refine report material workflow states

This commit is contained in:
hectorzhao
2026-09-03 12:50:33 +08:00
parent 55915ed826
commit 4ff6ed0786
12 changed files with 237 additions and 31 deletions
@@ -45,7 +45,7 @@ export function AdminEnterpriseSignaturesPage() {
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0);
const [pendingReportDetailTotal, setPendingReportDetailTotal] = useState(0);
const [pendingReportMaterialTotal, setPendingReportMaterialTotal] = useState(0);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
@@ -70,7 +70,7 @@ export function AdminEnterpriseSignaturesPage() {
if (sequence !== listRequestSequence.current) return;
setSignatures(signatureResult.items);
setTotal(signatureResult.total);
setPendingReportDetailTotal(signatureResult.pendingReportDetailTotal);
setPendingReportMaterialTotal(signatureResult.pendingReportMaterialTotal);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
@@ -317,11 +317,11 @@ export function AdminEnterpriseSignaturesPage() {
</div>
<div className="enterprise-signature-pending-summary" role="status">
<span></span>
<strong>{pendingReportDetailTotal}</strong>
<span></span>
<Button onClick={() => navigate('/admin/report-tasks?scope=pending')} size="sm" variant="ghost">
<span></span>
<strong>{pendingReportMaterialTotal}</strong>
<span></span>
<Button onClick={() => navigate('/admin/report-materials')} size="sm" variant="ghost">
</Button>
</div>
+63 -7
View File
@@ -24,6 +24,61 @@ import {
import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId';
type PoolVisualState = {
label: string;
tone: 'neutral' | 'success' | 'warning' | 'danger';
detail?: string;
detailDanger?: boolean;
};
function poolVisualState(
materialVersion: number,
inspection?: ReportMaterialBatchPreflight['items'][number],
): PoolVisualState {
if (!inspection) return { label: `V${materialVersion}资格检查中`, tone: 'neutral' };
const targets = inspection.targets;
const eligibleCount = targets.filter((target) => target.eligible).length;
const blockedTargets = targets.filter((target) => !target.eligible);
const blockedReasons = [...new Set(blockedTargets.flatMap((target) => target.blockedReasons ?? []))];
const allTargetsMatch = (fragment: string) =>
targets.length > 0 &&
targets.every((target) => (target.blockedReasons ?? []).some((reason) => reason.includes(fragment)));
if (allTargetsMatch('已放弃报备')) return { label: '全部放弃', tone: 'neutral' };
if (
targets.length === 0 ||
inspection.blockedReasons.some((reason) =>
['未绑定短信应用', '短信应用未启用', '当前应用没有启用且可路由的通道'].some((fragment) =>
reason.includes(fragment),
),
)
) {
return {
label: '无有效通道',
tone: 'neutral',
detail: inspection.blockedReasons[0],
};
}
if (allTargetsMatch('同一资料版本已在批次')) {
return { label: `V${materialVersion}已生成`, tone: 'success' };
}
if (eligibleCount > 0 && blockedTargets.length > 0) {
return {
label: `V${materialVersion}部分可生成`,
tone: 'warning',
detail: blockedReasons[0],
detailDanger: true,
};
}
if (eligibleCount > 0) return { label: `V${materialVersion}待生成`, tone: 'warning' };
return {
label: `V${materialVersion}资料不完整`,
tone: 'danger',
detail: blockedReasons[0] ?? inspection.blockedReasons[0],
detailDanger: true,
};
}
export function AdminReportMaterialsPage() {
const navigate = useNavigate();
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({
@@ -187,17 +242,18 @@ export function AdminReportMaterialsPage() {
},
{
key: 'eligibility',
title: '版本/资格',
title: '版本/状态',
render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
const visualState = poolVisualState(item.materialVersion, eligibility);
return (
<div>
<Tag tone={eligible ? 'success' : 'warning'}>
V{item.materialVersion} ·{' '}
{eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}
</Tag>
{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}
<Tag tone={visualState.tone}>{visualState.label}</Tag>
{visualState.detail ? (
<div className={visualState.detailDanger ? 'muted status-danger' : 'muted'}>
{visualState.detail}
</div>
) : null}
</div>
);
},
@@ -118,6 +118,59 @@ describe('report workbench pages', () => {
expect(screen.queryByText('选择本页全部可生成资料')).not.toBeInTheDocument();
});
it('uses explicit colors and text for material pool work states', async () => {
const materials = [
['pending', '待生成'],
['partial', '部分可生成'],
['incomplete', '资料不完整'],
['abandoned', '全部放弃'],
['no-route', '无有效通道'],
['generated', '已生成'],
].map(([id]) => ({
id: `signature:${id}`,
tenantId: 'tenant-1',
signatureId: `signature-${id}`,
applicationId: 'app-1',
reportType: 'signature' as const,
name: `资料${id}`,
materialVersion: 2,
changedAt: '2026-09-03T01:00:00.000Z',
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
}));
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
adminApi.listPendingReportMaterials.mockResolvedValue({ items: materials, total: materials.length, page: 1, pageSize: 20 });
adminApi.preflightReportMaterialBatch.mockResolvedValue({
eligible: true,
eligibleTargetCount: 2,
skippedTargetCount: 5,
items: [
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
{ id: 'signature:partial', eligible: true, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(true), target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:incomplete', eligible: false, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:abandoned', eligible: false, blockedReasons: ['该通道报备明细已放弃报备'], targets: [target(false, ['该通道报备明细已放弃报备'])] },
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
{ id: 'signature:generated', eligible: false, blockedReasons: ['同一资料版本已在批次 RB-1 生成'], targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])] },
],
});
render(
<MemoryRouter>
<AdminReportMaterialsPage />
</MemoryRouter>,
);
expect(await screen.findByText('V2待生成')).toHaveClass('ui-tag--warning');
expect(screen.getByText('V2部分可生成')).toHaveClass('ui-tag--warning');
expect(screen.getByText('V2资料不完整')).toHaveClass('ui-tag--danger');
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
screen.getAllByText('缺少必填字段:营业执照').forEach((message) =>
expect(message).toHaveClass('status-danger'),
);
});
it('shows and copies the channel brief returned by the batch detail API', async () => {
const batch = {
id: 'batch-1',