import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminReportBatchesPage } from './AdminReportBatchesPage';
import { AdminReportMaterialsPage } from './AdminReportMaterialsPage';
import { AdminReportRecordsPage } from './AdminReportRecordsPage';
import { AdminReportTasksPage } from './AdminReportTasksPage';
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
adminApi: {
changeReportTaskStatuses: vi.fn(),
downloadReportMaterialBatch: vi.fn(),
downloadReportMaterialBatchFile: vi.fn(),
exportSingleReportMaterial: vi.fn(),
getReportMaterialBatch: vi.fn(),
getSingleReportMaterialDetail: vi.fn(),
listPendingReportMaterials: vi.fn(),
listReportDetailsPage: vi.fn(),
listReportMaterialBatches: vi.fn(),
listReportMaterialBatchTasks: vi.fn(),
listReportRecordsPage: vi.fn(),
preflightReportMaterialBatch: vi.fn(),
},
clipboardWriteText: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi, fileDownloadUrl: (id: string) => `/api/files/${id}` }));
const task = (id: string) => ({
id,
tenantId: 'tenant-1',
signatureId: `signature-${id}`,
channelId: 'channel-1',
carrier: 'mobile',
reportType: 'signature',
status: 'pending',
createdAt: '2026-09-02T01:00:00.000Z',
updatedAt: '2026-09-02T01:00:00.000Z',
signature: {
id: `signature-${id}`,
name: `签名${id}`,
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
},
channel: { id: 'channel-1', name: '测试通道' },
});
describe('report workbench pages', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
clipboardWriteText.mockReset().mockResolvedValue(undefined);
});
it('selects and clears every detail on the current page', async () => {
adminApi.listReportDetailsPage.mockResolvedValue({
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 25,
});
render(
,
);
const user = userEvent.setup();
const selectPage = await screen.findByRole('button', { name: '全选当页' });
await user.click(selectPage);
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
expect(screen.getByRole('button', { name: '批量修改状态(2)' })).toBeEnabled();
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).toBeChecked());
await user.click(screen.getByRole('button', { name: '取消全选' }));
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
});
it('changes detail page size while retaining applied filters and clearing current-page selection', async () => {
adminApi.listReportDetailsPage.mockImplementation(async ({ page, pageSize }) => ({
items: [task(`${page}-1`), task(`${page}-2`)],
total: 258,
page,
pageSize,
}));
render(
,
);
const user = userEvent.setup();
await screen.findByText('签名1-1');
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: 25, signatureId: 'signature-filter', status: 'pending' }),
);
const sizeControl = screen.getByLabelText(/^每页数量/);
expect(sizeControl.closest('.ui-pagination')).not.toBeNull();
await user.click(sizeControl);
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'10 条/页',
'25 条/页',
'50 条/页',
'100 条/页',
]);
await user.click(screen.getByRole('option', { name: '25 条/页' }));
await user.type(screen.getByRole('textbox', { name: '企业/应用/通道/报备对象' }), '测试企业');
await user.click(screen.getByRole('button', { name: '查询' }));
await user.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('签名2-1');
await user.click(screen.getByRole('button', { name: '全选当页' }));
expect(screen.getByRole('button', { name: '批量修改状态(2)' })).toBeEnabled();
await user.click(sizeControl);
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await screen.findByText('签名1-1');
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({
page: 1,
pageSize: 100,
keyword: '测试企业',
signatureId: 'signature-filter',
status: 'pending',
}),
);
expect(screen.getByRole('button', { name: '批量修改状态(0)' })).toBeDisabled();
expect(screen.getByRole('spinbutton', { name: '跳转页码' })).toHaveAttribute('max', '3');
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
for (const size of [10, 50, 25]) {
await user.click(sizeControl);
await user.click(screen.getByRole('option', { name: `${size} 条/页` }));
await waitFor(() =>
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: size, keyword: '测试企业' }),
),
);
}
});
it('keeps the latest detail page size result when an earlier request finishes last', async () => {
const initialPage = { items: [task('old-25')], total: 258, page: 1, pageSize: 25 };
const latestPage = { items: [task('new-100')], total: 258, page: 1, pageSize: 100 };
let resolveInitial!: (value: typeof initialPage) => void;
let resolveLatest!: (value: typeof latestPage) => void;
adminApi.listReportDetailsPage
.mockImplementationOnce(() => new Promise((resolve) => (resolveInitial = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveLatest = resolve)));
render(
,
);
const user = userEvent.setup();
await user.click(screen.getByLabelText(/^每页数量/));
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
await act(async () => resolveLatest(latestPage));
expect(screen.getByText('签名new-100')).toBeVisible();
await act(async () => resolveInitial(initialPage));
expect(screen.queryByText('签名old-25')).not.toBeInTheDocument();
expect(screen.getByText('签名new-100')).toBeVisible();
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('100 条/页');
});
it('keeps the current detail request failure visible without replacing it with a stale failure', async () => {
let rejectInitial!: (error: Error) => void;
let rejectLatest!: (error: Error) => void;
adminApi.listReportDetailsPage
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectInitial = reject)))
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectLatest = reject)));
render(
,
);
const user = userEvent.setup();
await user.click(screen.getByLabelText(/^每页数量/));
await user.click(screen.getByRole('option', { name: '100 条/页' }));
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
await act(async () => rejectLatest(new Error('当前分页加载失败')));
expect(screen.getByText('当前分页加载失败')).toBeVisible();
await act(async () => rejectInitial(new Error('已过期的分页请求失败')));
expect(screen.getByText('当前分页加载失败')).toBeVisible();
expect(screen.queryByText('已过期的分页请求失败')).not.toBeInTheDocument();
});
it('uses the shared current-page selection button in the material pool heading', async () => {
const material = {
id: 'signature:signature-1',
tenantId: 'tenant-1',
signatureId: 'signature-1',
applicationId: 'app-1',
reportType: 'signature',
name: '测试签名',
detail: '测试用途',
materialVersion: 1,
changedAt: '2026-09-03T01:00:00.000Z',
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
};
adminApi.listPendingReportMaterials.mockResolvedValue({ items: [material], total: 1, page: 1, pageSize: 20 });
adminApi.preflightReportMaterialBatch.mockResolvedValue({
eligible: true,
eligibleTargetCount: 1,
skippedTargetCount: 0,
items: [
{
id: material.id,
eligible: true,
blockedReasons: [],
targets: [{ eligible: true }],
},
],
});
render(
,
);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '全选当页' }));
expect(screen.getByRole('button', { name: '取消全选' })).toBeVisible();
expect(screen.getByRole('button', { name: '预检并生成(1)' })).toBeEnabled();
expect(screen.getByRole('checkbox', { name: '选择测试签名' })).toBeChecked();
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(
,
);
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('keeps the report record list compact while retaining full details in the dialog', async () => {
adminApi.listReportRecordsPage.mockResolvedValue({
items: [
{
id: 'record-1',
taskId: 'report-task-with-a-long-identifier-1',
channelId: 'channel-1',
action: 'manual_status_change',
statusBefore: 'pending',
statusAfter: 'approved',
reason: '通道已确认报备通过',
sourceEntry: 'report_task',
createdAt: '2026-09-03 15:30:00',
channel: { id: 'channel-1', name: '测试通道' },
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
task: task('record-1'),
},
],
total: 1,
page: 1,
pageSize: 10,
});
render(
,
);
expect(await screen.findByRole('columnheader', { name: '变更对象' })).toBeVisible();
expect(screen.getByRole('columnheader', { name: '变更动作' })).toBeVisible();
expect(screen.getByRole('columnheader', { name: '操作人 / 时间' })).toBeVisible();
expect(screen.queryByRole('columnheader', { name: '备注' })).not.toBeInTheDocument();
expect(screen.getByText('报备任务修改')).toBeVisible();
await userEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText('通道已确认报备通过')).length).toBeGreaterThan(0);
});
it('shows and copies the channel brief returned by the batch detail API', async () => {
const batch = {
id: 'batch-1',
batchNo: 'RB20260902090000TEST',
status: 'completed',
selectedCount: 1,
channelCount: 1,
fileCount: 1,
reportTotal: 1,
reportingCount: 0,
successCount: 0,
failedCount: 0,
successRate: 0,
createdAt: '2026-09-02T01:00:00.000Z',
exportFiles: [],
};
const content =
'尊敬的供应商您好,今天是2026年9月2日,辛苦报备以下签名或引流信息:\n' +
'1.短信签名:【测试签名】,短信内容:【测试签名】您本次提交的验证码1234;\n' +
'报备材料在表格里,报备批次号RB20260902090000TEST。';
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
adminApi.getReportMaterialBatch.mockResolvedValue({
...batch,
briefs: [
{
channelId: 'channel-1',
channelName: '测试通道',
fileId: 'file-1',
fileName: '测试通道.xlsx',
itemCount: 1,
content,
},
],
});
adminApi.listReportMaterialBatchTasks.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 });
render(
,
);
const user = userEvent.setup();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
await user.click(await screen.findByRole('button', { name: '报备文件导出' }));
expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByRole('button', { name: '导出格式' })).toHaveTextContent('系统 Excel 文件');
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: '复制简报' }));
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible();
});
it('opens batch details in a drawer and keeps status controls in a nested dialog', async () => {
const batch = {
id: 'batch-2',
batchNo: 'RB-DRAWER',
status: 'completed',
selectedCount: 2,
channelCount: 1,
fileCount: 1,
reportTotal: 2,
reportingCount: 1,
successCount: 1,
failedCount: 0,
successRate: 0.5,
createdAt: '2026-09-03T01:00:00.000Z',
exportFiles: [],
briefs: [],
};
adminApi.listReportMaterialBatches.mockResolvedValue({ items: [batch], total: 1, page: 1, pageSize: 20 });
adminApi.getReportMaterialBatch.mockResolvedValue(batch);
adminApi.listReportMaterialBatchTasks.mockResolvedValue({
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 100,
});
render(
,
);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '打开明细' }));
const drawer = await screen.findByRole('dialog', { name: '批次明细 · RB-DRAWER' });
expect(drawer).toHaveClass('report-batch-drawer');
await user.click(screen.getByRole('checkbox', { name: '全选批次明细' }));
expect(screen.getByRole('button', { name: '批量修改报备状态' })).toBeEnabled();
expect(screen.getAllByRole('button', { name: '修改状态' })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '批量修改报备状态' }));
expect(await screen.findByRole('dialog', { name: '批量修改 2 条报备状态' })).toBeVisible();
expect(screen.getByLabelText('修改后的状态')).toBeVisible();
expect(screen.queryByRole('button', { name: '导出本条' })).not.toBeInTheDocument();
});
});