feat: redesign report batch workflow

This commit is contained in:
hectorzhao
2026-09-03 11:04:32 +08:00
parent a50aafb1ec
commit dc201bf92e
15 changed files with 582 additions and 150 deletions
+1
View File
@@ -21,6 +21,7 @@
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jszip": "^3.10.1",
"minio": "^8.0.7", "minio": "^8.0.7",
"pg": "^8.22.0", "pg": "^8.22.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
+1
View File
@@ -29,6 +29,7 @@
"brace-expansion": "file:vendor/brace-expansion-compat", "brace-expansion": "file:vendor/brace-expansion-compat",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jszip": "^3.10.1",
"minio": "^8.0.7", "minio": "^8.0.7",
"pg": "^8.22.0", "pg": "^8.22.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -0,0 +1,61 @@
import JSZip from 'jszip';
import { ReportBatchDownloadService } from './batch-download.service';
describe('ReportBatchDownloadService', () => {
const batch = {
batchNo: 'RB20260903090000TEST',
createdAt: new Date('2026-09-03T01:00:00.000Z'),
exportFiles: [
{ id: 'export-1', channelId: 'channel-1', fileObjectId: 'object-1' },
{ id: 'export-2', channelId: 'channel-2', fileObjectId: 'object-2' },
],
briefs: [
{ channelId: 'channel-1', channelName: '移动/主通道', fileId: 'export-1', content: '移动通道简报' },
{ channelId: 'channel-2', channelName: '联通备用', fileId: 'export-2', content: '联通通道简报' },
],
};
it('renames a channel workbook with date, channel and batch number', async () => {
const files = { getDownload: jest.fn().mockResolvedValue({ content: Buffer.from('xlsx-one') }) };
const service = new ReportBatchDownloadService(files as never);
await expect(service.exportFile(batch, 'export-1')).resolves.toEqual({
fileName: '2026-09-03_移动_主通道_RB20260903090000TEST.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
content: Buffer.from('xlsx-one'),
});
expect(files.getDownload).toHaveBeenCalledWith('object-1');
});
it('packs every channel workbook and brief into one zip', async () => {
const files = {
getDownload: jest.fn((id: string) => Promise.resolve({ content: Buffer.from(`xlsx-${id}`) })),
};
const service = new ReportBatchDownloadService(files as never);
const exported = await service.exportBundle(batch);
const zip = await JSZip.loadAsync(exported.content);
expect(exported.fileName).toBe('2026-09-03_RB20260903090000TEST_报备文件.zip');
expect(Object.keys(zip.files).sort()).toEqual(
[
'2026-09-03_移动_主通道_RB20260903090000TEST.txt',
'2026-09-03_移动_主通道_RB20260903090000TEST.xlsx',
'2026-09-03_联通备用_RB20260903090000TEST.txt',
'2026-09-03_联通备用_RB20260903090000TEST.xlsx',
].sort(),
);
await expect(zip.file('2026-09-03_移动_主通道_RB20260903090000TEST.txt')!.async('string')).resolves.toBe(
'移动通道简报',
);
await expect(zip.file('2026-09-03_联通备用_RB20260903090000TEST.xlsx')!.async('string')).resolves.toBe(
'xlsx-object-2',
);
});
it('rejects an incomplete bundle instead of silently omitting a channel workbook', async () => {
const files = { getDownload: jest.fn() };
const service = new ReportBatchDownloadService(files as never);
await expect(service.exportBundle({ ...batch, exportFiles: [] })).rejects.toThrow('通道“移动/主通道”缺少报备文件');
expect(files.getDownload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import JSZip from 'jszip';
import { FilesService } from '../files/files.service';
import { safeFileName } from './report-materials.helpers';
type BatchDownloadSource = {
batchNo: string;
createdAt: Date | string;
exportFiles: Array<{
id: string;
channelId?: string | null;
fileObjectId?: string | null;
}>;
briefs: Array<{
channelId: string;
channelName: string;
fileId: string;
content: string;
}>;
};
type DownloadedBatchArtifact = {
fileName: string;
contentType: string;
content: Buffer;
};
@Injectable()
export class ReportBatchDownloadService {
constructor(private readonly files: FilesService) {}
async exportFile(batch: BatchDownloadSource, fileId: string): Promise<DownloadedBatchArtifact> {
const brief = batch.briefs.find((item) => item.fileId === fileId);
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
const { content } = await this.files.getDownload(exportFile.fileObjectId);
return {
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
content,
};
}
async exportBundle(batch: BatchDownloadSource): Promise<DownloadedBatchArtifact> {
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
const downloaded = await Promise.all(
batch.briefs.map(async (brief) => {
const exportFile = exportFileById.get(brief.fileId);
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
const workbook = await this.files.getDownload(exportFile.fileObjectId);
return { brief, workbook };
}),
);
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
if (totalBytes > 200 * 1024 * 1024) throw new BadRequestException('批次报备文件超过200MB,无法一次打包');
const zip = new JSZip();
for (const { brief, workbook } of downloaded) {
const baseName = this.entryBaseName(batch, brief.channelName);
zip.file(`${baseName}.xlsx`, workbook.content);
zip.file(`${baseName}.txt`, brief.content);
}
return {
fileName: `${this.batchDate(batch.createdAt)}_${safeFileName(batch.batchNo)}_报备文件.zip`,
contentType: 'application/zip',
content: await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
}),
};
}
private entryBaseName(batch: BatchDownloadSource, channelName: string) {
return `${this.batchDate(batch.createdAt)}_${safeFileName(channelName)}_${safeFileName(batch.batchNo)}`;
}
private batchDate(value: Date | string) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(new Date(value))
.reduce<Record<string, string>>((result, part) => ({ ...result, [part.type]: part.value }), {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
}
@@ -1,56 +1,24 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service'; import { SmsConfigService } from '../sms-config/sms-config.service';
import type { import type {
AnalyzeImportOptions,
CreateImportProfileDto,
CreateReportBatchDto, CreateReportBatchDto,
EmbeddedImage,
ImportCommitDto,
ImportMapping,
PagedQuery, PagedQuery,
ReportBatchInspection, ReportBatchInspection,
ReportBatchTarget, ReportBatchTarget,
ReviewImportItemsDto,
} from './report-materials.contracts'; } from './report-materials.contracts';
import { import {
profileData,
validateProfile,
loadWorkbook,
assertSafeWorkbook,
safeSpreadsheetText,
readEmbeddedImages,
suggestMappings,
remapProfileColumns,
signatureCoreMapping,
drainageCoreMapping,
normalizeHeader,
normalizeFieldCode,
clamp,
normalizePage, normalizePage,
normalizePageSize, normalizePageSize,
dateRange, dateRange,
cellText,
transformValue,
mappedCoreValue,
dynamicValues,
jsonRecord, jsonRecord,
hasValue, hasValue,
isFileRef,
resolveExportValue, resolveExportValue,
applyExportTransform,
styleHeader,
normalizeImageExtension,
imageContentType,
safeFileName,
normalizeBatchIdempotencyKey, normalizeBatchIdempotencyKey,
jsonStringArray, jsonStringArray,
jsonSafe,
} from './report-materials.helpers'; } from './report-materials.helpers';
import { ReportBatchOperationService } from './batch-operation.service'; import { ReportBatchOperationService } from './batch-operation.service';
import { ReportChannelExportService } from './channel-export.service'; import { ReportChannelExportService } from './channel-export.service';
@@ -95,10 +63,16 @@ export class ReportBatchGenerationService {
const reportItems = batch.exportFiles.flatMap((file) => file.items); const reportItems = batch.exportFiles.flatMap((file) => file.items);
const reportTotal = reportItems.length; const reportTotal = reportItems.length;
const successCount = reportItems.filter((item) => item.task.status === 'approved').length; const successCount = reportItems.filter((item) => item.task.status === 'approved').length;
const reportingCount = reportItems.filter((item) =>
['reporting', 'exporting'].includes(item.task.status),
).length;
const failedCount = reportItems.filter((item) => ['failed', 'rejected'].includes(item.task.status)).length;
return { return {
...batch, ...batch,
reportTotal, reportTotal,
reportingCount,
successCount, successCount,
failedCount,
successRate: reportTotal ? successCount / reportTotal : 0, successRate: reportTotal ? successCount / reportTotal : 0,
}; };
}), }),
@@ -116,9 +90,9 @@ export class ReportBatchGenerationService {
if (!batch) throw new NotFoundException('报备批次不存在'); if (!batch) throw new NotFoundException('报备批次不存在');
const tasks = await this.collectBatchTasks(batchId); const tasks = await this.collectBatchTasks(batchId);
const successCount = tasks.filter((task) => task.status === 'approved').length; const successCount = tasks.filter((task) => task.status === 'approved').length;
const channelIds = batch.exportFiles const reportingCount = tasks.filter((task) => ['reporting', 'exporting'].includes(task.status)).length;
.map((file) => file.channelId) const failedCount = tasks.filter((task) => ['failed', 'rejected'].includes(task.status)).length;
.filter((id): id is string => Boolean(id)); const channelIds = batch.exportFiles.map((file) => file.channelId).filter((id): id is string => Boolean(id));
const channels = channelIds.length const channels = channelIds.length
? await this.prisma.smsChannel.findMany({ ? await this.prisma.smsChannel.findMany({
where: { id: { in: channelIds } }, where: { id: { in: channelIds } },
@@ -173,7 +147,9 @@ export class ReportBatchGenerationService {
return { return {
...batch, ...batch,
reportTotal: tasks.length, reportTotal: tasks.length,
reportingCount,
successCount, successCount,
failedCount,
successRate: tasks.length ? successCount / tasks.length : 0, successRate: tasks.length ? successCount / tasks.length : 0,
briefs, briefs,
}; };
@@ -16,6 +16,7 @@ import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ReportMaterialsService } from './report-materials.service'; import { ReportMaterialsService } from './report-materials.service';
import { ReportBatchDownloadService } from './batch-download.service';
import { import {
CreateImportProfileDto, CreateImportProfileDto,
CreateReportBatchDto, CreateReportBatchDto,
@@ -30,7 +31,10 @@ type DownloadResponse = { setHeader(name: string, value: string): void; send(con
@ApiTags('report-materials') @ApiTags('report-materials')
@Controller('admin/report-materials') @Controller('admin/report-materials')
export class ReportMaterialsController { export class ReportMaterialsController {
constructor(private readonly service: ReportMaterialsService) {} constructor(
private readonly service: ReportMaterialsService,
private readonly batchDownloads: ReportBatchDownloadService,
) {}
@Get('pending') @Get('pending')
listPending( listPending(
@@ -156,6 +160,16 @@ export class ReportMaterialsController {
return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) }); return this.service.listBatches({ keyword, startAt, endAt, page: Number(page), pageSize: Number(pageSize) });
} }
@Get('batches/:id/download')
async downloadBatch(@Param('id') id: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id)));
}
@Get('batches/:id/files/:fileId/download')
async downloadBatchFile(@Param('id') id: string, @Param('fileId') fileId: string, @Res() response: DownloadResponse) {
this.sendDownload(response, await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId));
}
@Get('batches/:id') @Get('batches/:id')
getBatch(@Param('id') id: string) { getBatch(@Param('id') id: string) {
return this.service.getBatch(id); return this.service.getBatch(id);
@@ -212,4 +226,13 @@ export class ReportMaterialsController {
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
response.send(exported.content); response.send(exported.content);
} }
private sendDownload(
response: DownloadResponse,
exported: { fileName: string; contentType: string; content: Buffer },
) {
response.setHeader('Content-Type', exported.contentType);
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
response.send(exported.content);
}
} }
@@ -3,11 +3,12 @@ import { FilesModule } from '../files/files.module';
import { SmsConfigModule } from '../sms-config/sms-config.module'; import { SmsConfigModule } from '../sms-config/sms-config.module';
import { ReportMaterialsController } from './report-materials.controller'; import { ReportMaterialsController } from './report-materials.controller';
import { ReportMaterialsService } from './report-materials.service'; import { ReportMaterialsService } from './report-materials.service';
import { ReportBatchDownloadService } from './batch-download.service';
@Module({ @Module({
imports: [FilesModule, SmsConfigModule], imports: [FilesModule, SmsConfigModule],
controllers: [ReportMaterialsController], controllers: [ReportMaterialsController],
providers: [ReportMaterialsService], providers: [ReportMaterialsService, ReportBatchDownloadService],
exports: [ReportMaterialsService], exports: [ReportMaterialsService],
}) })
export class ReportMaterialsModule {} export class ReportMaterialsModule {}
@@ -1,4 +1,5 @@
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import { createHash } from 'node:crypto';
import { ReportMaterialsService } from './report-materials.service'; import { ReportMaterialsService } from './report-materials.service';
import { mappedCorePatchValue } from './report-materials.helpers'; import { mappedCorePatchValue } from './report-materials.helpers';
@@ -601,9 +602,17 @@ describe('ReportMaterialsService', () => {
batchNo: 'RB-STATS-1', batchNo: 'RB-STATS-1',
exportFiles: [ exportFiles: [
{ {
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }], items: [
{ task: { id: 'task-1', status: 'approved' } },
{ task: { id: 'task-2', status: 'rejected' } },
],
},
{
items: [
{ task: { id: 'task-3', status: 'reporting' } },
{ task: { id: 'task-4', status: 'exporting' } },
],
}, },
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
], ],
items: [], items: [],
}, },
@@ -614,7 +623,16 @@ describe('ReportMaterialsService', () => {
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({ await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({
items: [{ id: 'batch-stats-1', reportTotal: 3, successCount: 2, successRate: 2 / 3 }], items: [
{
id: 'batch-stats-1',
reportTotal: 4,
reportingCount: 2,
successCount: 1,
failedCount: 1,
successRate: 1 / 4,
},
],
total: 1, total: 1,
page: 2, page: 2,
pageSize: 10, pageSize: 10,
@@ -698,7 +716,6 @@ describe('ReportMaterialsService', () => {
}); });
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) { function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
const { createHash } = require('node:crypto') as typeof import('node:crypto');
return createHash('sha256') return createHash('sha256')
.update( .update(
JSON.stringify( JSON.stringify(
+6 -2
View File
@@ -5012,7 +5012,7 @@ npm run verify:phase8
| TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;搜索条件下方集中展示当前条件内最新版本尚未生成的通道×运营商明细总数并可下钻,签名列表不展示该字段 | | TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;搜索条件下方集中展示当前条件内最新版本尚未生成的通道×运营商明细总数并可下钻,签名列表不展示该字段 |
| TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 | | TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 |
| TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 | | TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 |
| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表显示真实文件、通道和进度;“打开明细”展示该批次对应任务,可批量修改状态及按签名明细导出 | | TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表不直接铺开文件,仅显示通道数、生成状态及报备总数/报备中/成功/失败四项明细数;“打开明细”从右侧滑出该批次通道明细列表 |
| TC-REPORT-WORKBENCH-006 | 从通道报备明细或短信通道报备详情查看资料 | 字段严格按当前通道签名报备字段/引流字段sortOrder排列,历史未配置字段置后;加载失败、文件缺失和必填缺失显示真实错误 | | TC-REPORT-WORKBENCH-006 | 从通道报备明细或短信通道报备详情查看资料 | 字段严格按当前通道签名报备字段/引流字段sortOrder排列,历史未配置字段置后;加载失败、文件缺失和必填缺失显示真实错误 |
| TC-REPORT-WORKBENCH-007 | 导出一条签名通道运营商明细 | 后端读取真实签名、通道字段和MinIO对象生成单行XLSX;图片嵌入;不改变任务状态、不生成批次、不触发短信链路,并写操作日志 | | TC-REPORT-WORKBENCH-007 | 导出一条签名通道运营商明细 | 后端读取真实签名、通道字段和MinIO对象生成单行XLSX;图片嵌入;不改变任务状态、不生成批次、不触发短信链路,并写操作日志 |
| TC-REPORT-WORKBENCH-008 | 短信通道管理进入报备详情 | 使用后端分页,默认按今日发送条数全量降序后分页;可按关键词、状态、运营商及今日发送区间查询;状态弹窗展示当前上下文和放弃风险 | | TC-REPORT-WORKBENCH-008 | 短信通道管理进入报备详情 | 使用后端分页,默认按今日发送条数全量降序后分页;可按关键词、状态、运营商及今日发送区间查询;状态弹窗展示当前上下文和放弃风险 |
@@ -5022,9 +5022,13 @@ npm run verify:phase8
| TC-REPORT-WORKBENCH-012 | 查看企业签名表头和签名列表 | 签名列不显示升序、降序或其他排序按钮;列表不显示逐行待生成明细字段;默认按创建时间倒序 | | TC-REPORT-WORKBENCH-012 | 查看企业签名表头和签名列表 | 签名列不显示升序、降序或其他排序按钮;列表不显示逐行待生成明细字段;默认按创建时间倒序 |
| TC-REPORT-WORKBENCH-013 | 通道报备明细当前页有10条数据,点击右上角“全选当页” | 当前页10条全部选中,按钮变为“取消全选”,批量数为10;该按钮与“批量修改状态”保持统一操作区间距;翻页或查询后选择清空,不选中其他页面数据 | | TC-REPORT-WORKBENCH-013 | 通道报备明细当前页有10条数据,点击右上角“全选当页” | 当前页10条全部选中,按钮变为“取消全选”,批量数为10;该按钮与“批量修改状态”保持统一操作区间距;翻页或查询后选择清空,不选中其他页面数据 |
| TC-REPORT-WORKBENCH-017 | 报备资料池当前页存在可生成资料,点击右上角“全选当页” | 仅选中本页全部可生成资料,按钮变为“取消全选”,“预检并生成”显示所选数量且与全选按钮保持统一操作区间距;不再显示表格上方的复选框式“选择本页全部可生成资料”,预检及生成接口契约不变 | | TC-REPORT-WORKBENCH-017 | 报备资料池当前页存在可生成资料,点击右上角“全选当页” | 仅选中本页全部可生成资料,按钮变为“取消全选”,“预检并生成”显示所选数量且与全选按钮保持统一操作区间距;不再显示表格上方的复选框式“选择本页全部可生成资料”,预检及生成接口契约不变 |
| TC-REPORT-WORKBENCH-014 | 生成同时包含签名和引流资料、覆盖多个通道的批次 | 每个通道生成一份简报;日期取批次生成日期,批次号一致;签名行和引流行分别按已确认格式展示,且可一键复制完整原文 | | TC-REPORT-WORKBENCH-014 | 生成同时包含签名和引流资料、覆盖多个通道的批次 | 每个通道生成一份简报;日期取批次生成日期,批次号一致;签名行和引流行分别按已确认格式展示;列表不直接展示文件,须从“报备文件导出”弹窗查看、复制或下载 |
| TC-REPORT-WORKBENCH-015 | 通道配置0个、1个或多个名称为“短信内容”的字段 | 0个时简报短信内容为空;1个时取资料中该字段的实际值;多个时严格按`sortOrder ASC, createdAt ASC`取第一个字段在资料中的实际值,后续同名字段不参与;资料未提供首字段时保持为空,不得使用通道缺省值冒充资料内容 | | TC-REPORT-WORKBENCH-015 | 通道配置0个、1个或多个名称为“短信内容”的字段 | 0个时简报短信内容为空;1个时取资料中该字段的实际值;多个时严格按`sortOrder ASC, createdAt ASC`取第一个字段在资料中的实际值,后续同名字段不参与;资料未提供首字段时保持为空,不得使用通道缺省值冒充资料内容 |
| TC-REPORT-WORKBENCH-016 | 批次生成后修改字段库或签名/引流资料 | 历史批次简报仍使用生成时固化的批次快照,不随当前资料改变;不新增数据库migration,不触发短信发送或队列 | | TC-REPORT-WORKBENCH-016 | 批次生成后修改字段库或签名/引流资料 | 历史批次简报仍使用生成时固化的批次快照,不随当前资料改变;不新增数据库migration,不触发短信发送或队列 |
| TC-REPORT-WORKBENCH-018 | 在批次右侧明细滑窗勾选表头全选框 | 当前批次全部通道明细被选中,可点击“批量修改报备状态”;每行仍可单独点击“修改状态”,明细中不显示“导出本条” |
| TC-REPORT-WORKBENCH-019 | 点击批量或单条“修改状态” | 状态选择和修改原因只在独立弹窗中出现;确认后调用既有批量状态接口,写入真实状态记录并刷新批次四项明细数;失败时显示错误,不静默吞错 |
| TC-REPORT-WORKBENCH-020 | 打开“报备文件导出”弹窗并下载单个通道文件 | 每个通道展示一份简报、复制按钮和报备文件下载按钮;XLSX文件名为`YYYY-MM-DD_通道名_批次号.xlsx`,文件来自该批次真实MinIO对象 |
| TC-REPORT-WORKBENCH-021 | 点击报备文件弹窗“全部下载” | 一次下载ZIP,内含每个通道一份XLSX和一份TXT简报;所有条目均按`YYYY-MM-DD_通道名_批次号`命名;任一通道文件缺失、超过100个通道或总文件超过200MB时返回明确错误,不生成不完整压缩包 |
## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情 ## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情
+10
View File
@@ -4361,3 +4361,13 @@ git diff --check
- 通道报备明细右上角操作区改用现有统一`page-heading__actions`样式,修复“全选当页”和“批量修改状态”之间无间隔的问题;仅调整样式类名,不改变选择、翻页清空或批量状态接口。 - 通道报备明细右上角操作区改用现有统一`page-heading__actions`样式,修复“全选当页”和“批量修改状态”之间无间隔的问题;仅调整样式类名,不改变选择、翻页清空或批量状态接口。
- 报备资料池移除表格上方“选择本页全部可生成资料”复选框标签,替换为右上角统一幽灵按钮“全选当页/取消全选”,与“预检并生成”保持统一间距;仍只选择当前页资格预检通过的数据,后端预检及生成接口、参数和业务逻辑不变。 - 报备资料池移除表格上方“选择本页全部可生成资料”复选框标签,替换为右上角统一幽灵按钮“全选当页/取消全选”,与“预检并生成”保持统一间距;仍只选择当前页资格预检通过的数据,后端预检及生成接口、参数和业务逻辑不变。
- 定向报备工作台组件3项、前端全量12文件54项、TypeScript及Vite生产构建通过;Vite仅保留既有Chart分块超过500kB提示。Browser插件不在本会话技能列表,按前端调试流程使用工作区Playwright Chrome运行本地生产预览;1600×1000下两个页面身份、非空、错误层、控制台及点击全选交互通过,两个按钮组间距实测均为8px,截图保存在工作区外,不纳入提交。页面数据仅用于本地布局和交互验证,不作为真实API验收结论。 - 定向报备工作台组件3项、前端全量12文件54项、TypeScript及Vite生产构建通过;Vite仅保留既有Chart分块超过500kB提示。Browser插件不在本会话技能列表,按前端调试流程使用工作区Playwright Chrome运行本地生产预览;1600×1000下两个页面身份、非空、错误层、控制台及点击全选交互通过,两个按钮组间距实测均为8px,截图保存在工作区外,不纳入提交。页面数据仅用于本地布局和交互验证,不作为真实API验收结论。
## 2026-09-03 报备批次明细与文件导出重构(测试环境发布准备)
- 报备批次列表移除逐个文件入口,原进度比改为报备总明细、报备中、成功、失败四项真实任务计数;状态归类为`reporting/exporting``approved``failed/rejected`,历史状态不伪造归类。
- 批次明细改为右侧滑窗,展示四项汇总及通道明细;表头可全选当前加载的批次明细,支持单条或多选后批量修改状态。所有状态选择和原因输入仅在确认弹窗中出现,不再提供“导出本条”。状态写入继续复用现有真实批量接口、权限和事务记录。
- 新增“报备文件导出”弹窗,每个通道集中展示不可变批次简报和对应XLSX下载;“全部下载”由服务端从真实MinIO读取每个通道文件,生成包含每通道一份XLSX及一份TXT简报的ZIP。文件统一命名为`日期_通道名_批次号`,缺少任一真实通道文件时明确失败,不静默生成不完整压缩包;限制100个通道和200MB工作簿总量。
- 未新增数据库字段或migration,未改变部署架构和短信链路。API新增两个只读下载接口,仍通过批次查询校验对象存在并使用既有后台权限;下载不修改任务、余额、通道或客户配置。
- 验证:API全量52套595项、前端全量12文件55项、前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、包体积和`git diff --check`通过;新增ZIP内容/命名/缺文件失败及明细抽屉/状态弹窗/导出弹窗回归。定向ESLint为0错误,仅保留该页面既有Hook依赖警告。Vite仅有既有Chart分块超过500kB提示,入口gzip约107.63KiB。
- 使用本机Playwright Chrome对本地生产构建完成1600×1000与390×844视觉和交互核验:列表无直接文件名,四项计数可见;右侧滑窗紧贴右边且全高,表头全选3条、单条及批量状态弹窗正常;导出弹窗显示通道简报、单通道下载和全部下载,窄屏无横向溢出;浏览器控制台无错误。隔离页面数据仅用于布局验证,不冒充真实API、PostgreSQL或MinIO验收。
- 发布授权仅限测试环境`100.93.204.60`,不推送远端、不访问预生产。部署、真实下载和“同一通道多个签名且包含短信内容”测试批次结果在完成恢复资产、发布及数据库/MinIO核验后追加;全程禁止发送、补发、重投或重新入队短信。
+3
View File
@@ -223,6 +223,9 @@ export const adminChannelsReportsApi = {
query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}, query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {},
) => request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)), ) => request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
getReportMaterialBatch: (id: string) => request<ReportMaterialBatch>(`/admin/report-materials/batches/${id}`), getReportMaterialBatch: (id: string) => request<ReportMaterialBatch>(`/admin/report-materials/batches/${id}`),
downloadReportMaterialBatch: (id: string) => requestBlob(`/admin/report-materials/batches/${id}/download`),
downloadReportMaterialBatchFile: (id: string, fileId: string) =>
requestBlob(`/admin/report-materials/batches/${id}/files/${fileId}/download`),
listReportMaterialBatchTasks: ( listReportMaterialBatchTasks: (
id: string, id: string,
query: { query: {
+2
View File
@@ -144,7 +144,9 @@ export type ReportMaterialBatch = {
channelCount: number; channelCount: number;
fileCount: number; fileCount: number;
reportTotal: number; reportTotal: number;
reportingCount: number;
successCount: number; successCount: number;
failedCount: number;
successRate: number; successRate: number;
createdAt: string; createdAt: string;
completedAt?: string | null; completedAt?: string | null;
+231 -102
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Check, Copy, Download, Eye, Search } from 'lucide-react'; import { Check, Copy, Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi'; import { adminApi, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import { import {
Breadcrumb, Breadcrumb,
Button, Button,
@@ -39,12 +39,16 @@ export function AdminReportBatchesPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue }); const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue });
const [detail, setDetail] = useState<ReportMaterialBatch>(); const [detail, setDetail] = useState<ReportMaterialBatch>();
const [exportDetail, setExportDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]); const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusTargets, setStatusTargets] = useState<string[]>([]);
const [nextStatus, setNextStatus] = useState('reporting'); const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [copiedChannelId, setCopiedChannelId] = useState(''); const [copiedChannelId, setCopiedChannelId] = useState('');
const [downloadBusy, setDownloadBusy] = useState('');
const [statusBusy, setStatusBusy] = useState(false);
const pageSize = 20; const pageSize = 20;
function load(target = page, filters = appliedFilters) { function load(target = page, filters = appliedFilters) {
@@ -82,6 +86,15 @@ export function AdminReportBatchesPage() {
setError(failure instanceof Error ? failure.message : '批次明细加载失败'); setError(failure instanceof Error ? failure.message : '批次明细加载失败');
} }
} }
async function openExports(batch: ReportMaterialBatch) {
try {
setExportDetail(await adminApi.getReportMaterialBatch(batch.id));
setCopiedChannelId('');
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备文件加载失败');
}
}
async function copyBrief(channelId: string, content: string) { async function copyBrief(channelId: string, content: string) {
try { try {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
@@ -103,8 +116,10 @@ export function AdminReportBatchesPage() {
} }
} }
async function saveStatuses() { async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id)); const targetIds = new Set(statusTargets);
const chosen = tasks.filter((task) => targetIds.has(task.id));
if (!chosen.length) return; if (!chosen.length) return;
setStatusBusy(true);
try { try {
await adminApi.changeReportTaskStatuses({ await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({ items: chosen.map((task) => ({
@@ -118,37 +133,88 @@ export function AdminReportBatchesPage() {
reason: reason.trim() || undefined, reason: reason.trim() || undefined,
sourceEntry: 'report_task', sourceEntry: 'report_task',
}); });
setStatusTargets([]);
setReason('');
if (detail) await openBatch(detail); if (detail) await openBatch(detail);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败'); setError(failure instanceof Error ? failure.message : '批量状态修改失败');
} finally {
setStatusBusy(false);
} }
} }
async function exportOne(task: ReportTask) { function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
function batchDate(batch: ReportMaterialBatch) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(batch.createdAt));
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}-${value.month}-${value.day}`;
}
function safeDownloadName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
}
async function downloadChannelFile(batch: ReportMaterialBatch, fileId: string, channelName: string) {
try { try {
const blob = await adminApi.exportSingleReportMaterial({ setDownloadBusy(fileId);
reportType: task.reportType, const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId);
signatureId: task.signatureId, downloadBlob(
channelId: task.channelId, blob,
carrier: task.carrier ?? undefined, `${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`,
drainageItemId: task.drainageItemId ?? undefined, );
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
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);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败'); setError(failure instanceof Error ? failure.message : '通道报备文件下载失败');
} finally {
setDownloadBusy('');
} }
} }
async function downloadAll(batch: ReportMaterialBatch) {
try {
setDownloadBusy('all');
const blob = await adminApi.downloadReportMaterialBatch(batch.id);
downloadBlob(blob, `${batchDate(batch)}_${safeDownloadName(batch.batchNo)}_报备文件.zip`);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次报备文件下载失败');
} finally {
setDownloadBusy('');
}
}
const allTasksSelected = tasks.length > 0 && tasks.every((task) => selected.has(task.id));
const columns: Array<TableColumn<ReportMaterialBatch>> = [ const columns: Array<TableColumn<ReportMaterialBatch>> = [
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> }, { key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) }, { key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) },
{ key: 'count', title: '明细进度', render: (item) => `${item.successCount}/${item.reportTotal}` }, {
{ key: 'channels', title: '通道/文件', render: (item) => `${item.channelCount}个通道 · ${item.fileCount}份文件` }, key: 'count',
title: '报备明细',
render: (item) => (
<div className="report-batch-progress">
<span>
<strong>{item.reportTotal}</strong>
</span>
<span>
<strong>{item.reportingCount ?? 0}</strong>
</span>
<span>
<strong>{item.successCount}</strong>
</span>
<span>
<strong>{item.failedCount ?? 0}</strong>
</span>
</div>
),
},
{ key: 'channels', title: '报备通道', render: (item) => `${item.channelCount}个通道` },
{ {
key: 'status', key: 'status',
title: '生成状态', title: '生成状态',
@@ -158,37 +224,33 @@ export function AdminReportBatchesPage() {
</Tag> </Tag>
), ),
}, },
{
key: 'files',
title: '文件',
render: (item) => (
<div className="table-actions">
{item.exportFiles.map((file) =>
file.fileObjectId ? (
<a href={fileDownloadUrl(file.fileObjectId)} key={file.id}>
<Download size={14} />
{file.fileName}
</a>
) : null,
)}
</div>
),
},
{ {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
align: 'right', align: 'right',
render: (item) => ( render: (item) => (
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost"> <div className="table-actions">
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
</Button>
<Button icon={<Download size={14} />} onClick={() => void openExports(item)} size="sm" variant="ghost">
</Button>
</div>
), ),
}, },
]; ];
const taskColumns: Array<TableColumn<ReportTask>> = [ const taskColumns: Array<TableColumn<ReportTask>> = [
{ {
key: 'select', key: 'select',
title: '', title: (
<input
aria-label="全选批次明细"
checked={allTasksSelected}
onChange={() => setSelected(allTasksSelected ? new Set() : new Set(tasks.map((task) => task.id)))}
type="checkbox"
/>
),
width: '44px', width: '44px',
render: (task) => ( render: (task) => (
<input <input
@@ -258,14 +320,11 @@ export function AdminReportBatchesPage() {
key: 'actions', key: 'actions',
title: '操作', title: '操作',
align: 'right', align: 'right',
render: (task) => render: (task) => (
task.reportType !== 'drainage' ? ( <Button onClick={() => setStatusTargets([task.id])} size="sm" variant="ghost">
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
</Button> ),
) : (
'-'
),
}, },
]; ];
@@ -275,7 +334,7 @@ export function AdminReportBatchesPage() {
<div> <div>
<Breadcrumb items={['报备工作台', '报备批次']} /> <Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1> <h1></h1>
<p></p> <p></p>
</div> </div>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
@@ -324,6 +383,7 @@ export function AdminReportBatchesPage() {
/> />
{detail ? ( {detail ? (
<Modal <Modal
className="report-batch-drawer"
footer={<Button onClick={() => setDetail(undefined)}></Button>} footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)} onClose={() => setDetail(undefined)}
open open
@@ -331,69 +391,138 @@ export function AdminReportBatchesPage() {
title={`批次明细 · ${detail.batchNo}`} title={`批次明细 · ${detail.batchNo}`}
> >
<div className="page-stack"> <div className="page-stack">
<section className="report-batch-briefs" aria-label="通道报备简报"> <div className="report-batch-detail-summary">
<div className="report-batch-briefs__title"> <span>
<div> <strong>{detail.reportTotal}</strong>
<h3></h3> </span>
<p className="muted"></p> <span>
</div> <strong>{detail.reportingCount ?? 0}</strong>
</div> </span>
{detail.briefs?.length ? ( <span>
detail.briefs.map((brief) => ( <strong>{detail.successCount}</strong>
<article className="report-batch-brief" key={brief.channelId}> </span>
<header> <span>
<div> <strong>{detail.failedCount ?? 0}</strong>
<strong>{brief.channelName}</strong> </span>
<span>{brief.itemCount} </span> </div>
</div>
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
</header>
<pre>{brief.content}</pre>
</article>
))
) : (
<p className="muted"></p>
)}
</section>
<div className="report-batch-toolbar"> <div className="report-batch-toolbar">
<strong> <strong>
{tasks.length} {selected.size} {tasks.length} {selected.size}
</strong> </strong>
<Select <Button disabled={!selected.size} onClick={() => setStatusTargets([...selected])}>
aria-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
aria-label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="修改原因"
rows={2}
value={reason}
/>
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
</Button> </Button>
</div> </div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" /> <Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div> </div>
</Modal> </Modal>
) : null} ) : null}
{statusTargets.length ? (
<Modal
footer={
<>
<Button disabled={statusBusy} onClick={() => setStatusTargets([])} variant="ghost">
</Button>
<Button disabled={statusBusy} onClick={() => void saveStatuses()}>
{statusBusy ? '保存中…' : '确认修改'}
</Button>
</>
}
onClose={() => {
if (!statusBusy) setStatusTargets([]);
}}
open
title={statusTargets.length > 1 ? `批量修改 ${statusTargets.length} 条报备状态` : '修改报备状态'}
>
<div className="page-stack">
<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) => setReason(event.target.value)}
rows={4}
value={reason}
/>
</div>
</Modal>
) : null}
{exportDetail ? (
<Modal
footer={
<>
<Button onClick={() => setExportDetail(undefined)} variant="ghost">
</Button>
<Button
disabled={downloadBusy === 'all' || !exportDetail.briefs?.length}
icon={<Download size={15} />}
onClick={() => void downloadAll(exportDetail)}
>
{downloadBusy === 'all' ? '打包中…' : '全部下载'}
</Button>
</>
}
onClose={() => setExportDetail(undefined)}
open
size="xl"
title={`报备文件导出 · ${exportDetail.batchNo}`}
>
<section className="report-batch-briefs" aria-label="通道报备简报与文件">
<p className="muted">ZIP压缩包XLSX和TXT</p>
{exportDetail.briefs?.length ? (
exportDetail.briefs.map((brief) => {
const fileAvailable = exportDetail.exportFiles.some(
(file) => file.id === brief.fileId && file.fileObjectId,
);
return (
<article className="report-batch-brief" key={brief.channelId}>
<header>
<div>
<strong>{brief.channelName}</strong>
<span>{brief.itemCount} </span>
</div>
<div className="table-actions">
<Button
icon={copiedChannelId === brief.channelId ? <Check size={15} /> : <Copy size={15} />}
onClick={() => void copyBrief(brief.channelId, brief.content)}
size="sm"
variant="ghost"
>
{copiedChannelId === brief.channelId ? '已复制' : '复制简报'}
</Button>
<Button
disabled={!fileAvailable || downloadBusy === brief.fileId}
icon={<Download size={15} />}
onClick={() => void downloadChannelFile(exportDetail, brief.fileId, brief.channelName)}
size="sm"
variant="ghost"
>
{downloadBusy === brief.fileId ? '下载中…' : '下载报备文件'}
</Button>
</div>
</header>
<pre>{brief.content}</pre>
</article>
);
})
) : (
<p className="muted"></p>
)}
</section>
</Modal>
) : null}
</section> </section>
); );
} }
+56 -2
View File
@@ -9,6 +9,8 @@ import { AdminReportTasksPage } from './AdminReportTasksPage';
const { adminApi, clipboardWriteText } = vi.hoisted(() => ({ const { adminApi, clipboardWriteText } = vi.hoisted(() => ({
adminApi: { adminApi: {
changeReportTaskStatuses: vi.fn(), changeReportTaskStatuses: vi.fn(),
downloadReportMaterialBatch: vi.fn(),
downloadReportMaterialBatchFile: vi.fn(),
exportSingleReportMaterial: vi.fn(), exportSingleReportMaterial: vi.fn(),
getReportMaterialBatch: vi.fn(), getReportMaterialBatch: vi.fn(),
getSingleReportMaterialDetail: vi.fn(), getSingleReportMaterialDetail: vi.fn(),
@@ -49,7 +51,12 @@ describe('report workbench pages', () => {
}); });
it('selects and clears every detail on the current page', async () => { it('selects and clears every detail on the current page', async () => {
adminApi.listReportDetailsPage.mockResolvedValue({ items: [task('1'), task('2')], total: 2, page: 1, pageSize: 10 }); adminApi.listReportDetailsPage.mockResolvedValue({
items: [task('1'), task('2')],
total: 2,
page: 1,
pageSize: 10,
});
render( render(
<MemoryRouter> <MemoryRouter>
<AdminReportTasksPage /> <AdminReportTasksPage />
@@ -120,7 +127,9 @@ describe('report workbench pages', () => {
channelCount: 1, channelCount: 1,
fileCount: 1, fileCount: 1,
reportTotal: 1, reportTotal: 1,
reportingCount: 0,
successCount: 0, successCount: 0,
failedCount: 0,
successRate: 0, successRate: 0,
createdAt: '2026-09-02T01:00:00.000Z', createdAt: '2026-09-02T01:00:00.000Z',
exportFiles: [], exportFiles: [],
@@ -155,11 +164,56 @@ describe('report workbench pages', () => {
configurable: true, configurable: true,
value: { writeText: clipboardWriteText }, value: { writeText: clipboardWriteText },
}); });
await user.click(await screen.findByRole('button', { name: '打开明细' })); await user.click(await screen.findByRole('button', { name: '报备文件导出' }));
expect(await screen.findByText('测试通道')).toBeVisible(); expect(await screen.findByText('测试通道')).toBeVisible();
expect(screen.getByText(/1\.短信签名:/)).toBeVisible(); expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: '复制简报' })); await user.click(screen.getByRole('button', { name: '复制简报' }));
await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content)); await waitFor(() => expect(clipboardWriteText).toHaveBeenCalledWith(content));
expect(screen.getByRole('button', { name: '已复制' })).toBeVisible(); 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(
<MemoryRouter>
<AdminReportBatchesPage />
</MemoryRouter>,
);
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();
});
}); });
+62 -2
View File
@@ -5588,16 +5588,74 @@
.report-material-detail-list > div.is-missing { background: var(--color-danger-soft); } .report-material-detail-list > div.is-missing { background: var(--color-danger-soft); }
.report-batch-toolbar { .report-batch-toolbar {
align-items: end; align-items: center;
background: var(--color-surface-subtle); background: var(--color-surface-subtle);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
display: flex;
gap: var(--space-3);
justify-content: space-between;
padding: var(--space-4);
}
.report-batch-progress {
display: grid;
gap: 4px 14px;
grid-template-columns: repeat(2, minmax(72px, auto));
}
.report-batch-progress span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.report-batch-progress strong {
color: var(--color-text-strong);
margin-left: 4px;
}
.ui-modal__panel.report-batch-drawer {
animation: report-batch-drawer-in 180ms ease-out;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
height: 100vh;
inset: 0 0 0 auto;
max-height: none;
max-width: min(1180px, calc(100vw - 64px));
position: absolute;
transform: none;
width: min(1080px, calc(100vw - 64px));
}
.ui-modal__panel.report-batch-drawer .ui-modal__body {
max-height: none;
}
@keyframes report-batch-drawer-in {
from { opacity: 0; transform: translateX(32px); }
to { opacity: 1; transform: translateX(0); }
}
.report-batch-detail-summary {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
grid-template-columns: minmax(180px, 1fr) minmax(180px, 0.6fr) minmax(220px, 1fr) auto; grid-template-columns: repeat(4, minmax(0, 1fr));
}
.report-batch-detail-summary span {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
display: grid;
gap: var(--space-2);
padding: var(--space-4); padding: var(--space-4);
} }
.report-batch-detail-summary strong {
color: var(--color-text-strong);
font-size: var(--font-size-xl);
}
.report-batch-briefs { display: grid; gap: 12px; } .report-batch-briefs { display: grid; gap: 12px; }
.report-batch-briefs__title { display: flex; justify-content: space-between; gap: 12px; } .report-batch-briefs__title { display: flex; justify-content: space-between; gap: 12px; }
.report-batch-briefs__title h3 { margin: 0 0 4px; } .report-batch-briefs__title h3 { margin: 0 0 4px; }
@@ -5643,6 +5701,8 @@
.enterprise-signature-pending-summary .ui-button { margin-left: 0; width: 100%; } .enterprise-signature-pending-summary .ui-button { margin-left: 0; width: 100%; }
.report-batch-brief header { align-items: flex-start; flex-direction: column; } .report-batch-brief header { align-items: flex-start; flex-direction: column; }
.report-batch-brief header .ui-button { width: 100%; } .report-batch-brief header .ui-button { width: 100%; }
.ui-modal__panel.report-batch-drawer { border-radius: 0; max-width: none; width: 100%; }
.report-batch-detail-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
.channel-report-table__head { .channel-report-table__head {