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(); }); });