diff --git a/api/package-lock.json b/api/package-lock.json index d3418b4..9965527 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -21,6 +21,7 @@ "class-validator": "^0.14.3", "exceljs": "^4.4.0", "ioredis": "^5.11.1", + "jszip": "^3.10.1", "minio": "^8.0.7", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", diff --git a/api/package.json b/api/package.json index 356956d..2205473 100644 --- a/api/package.json +++ b/api/package.json @@ -29,6 +29,7 @@ "brace-expansion": "file:vendor/brace-expansion-compat", "exceljs": "^4.4.0", "ioredis": "^5.11.1", + "jszip": "^3.10.1", "minio": "^8.0.7", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", diff --git a/api/src/report-materials/batch-download.service.spec.ts b/api/src/report-materials/batch-download.service.spec.ts new file mode 100644 index 0000000..c850c63 --- /dev/null +++ b/api/src/report-materials/batch-download.service.spec.ts @@ -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(); + }); +}); diff --git a/api/src/report-materials/batch-download.service.ts b/api/src/report-materials/batch-download.service.ts new file mode 100644 index 0000000..5f8e56e --- /dev/null +++ b/api/src/report-materials/batch-download.service.ts @@ -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 { + 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 { + 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>((result, part) => ({ ...result, [part.type]: part.value }), {}); + return `${parts.year}-${parts.month}-${parts.day}`; + } +} diff --git a/api/src/report-materials/batch-generation.service.ts b/api/src/report-materials/batch-generation.service.ts index 4604ed1..18ed3a8 100644 --- a/api/src/report-materials/batch-generation.service.ts +++ b/api/src/report-materials/batch-generation.service.ts @@ -1,56 +1,24 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import ExcelJS from 'exceljs'; import { createHash, randomUUID } from 'node:crypto'; -import { extname } from 'node:path'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; import { SmsConfigService } from '../sms-config/sms-config.service'; import type { - AnalyzeImportOptions, - CreateImportProfileDto, CreateReportBatchDto, - EmbeddedImage, - ImportCommitDto, - ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, - ReviewImportItemsDto, } from './report-materials.contracts'; import { - profileData, - validateProfile, - loadWorkbook, - assertSafeWorkbook, - safeSpreadsheetText, - readEmbeddedImages, - suggestMappings, - remapProfileColumns, - signatureCoreMapping, - drainageCoreMapping, - normalizeHeader, - normalizeFieldCode, - clamp, normalizePage, normalizePageSize, dateRange, - cellText, - transformValue, - mappedCoreValue, - dynamicValues, jsonRecord, hasValue, - isFileRef, resolveExportValue, - applyExportTransform, - styleHeader, - normalizeImageExtension, - imageContentType, - safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, - jsonSafe, } from './report-materials.helpers'; import { ReportBatchOperationService } from './batch-operation.service'; import { ReportChannelExportService } from './channel-export.service'; @@ -95,10 +63,16 @@ export class ReportBatchGenerationService { const reportItems = batch.exportFiles.flatMap((file) => file.items); const reportTotal = reportItems.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 { ...batch, reportTotal, + reportingCount, successCount, + failedCount, successRate: reportTotal ? successCount / reportTotal : 0, }; }), @@ -116,9 +90,9 @@ export class ReportBatchGenerationService { if (!batch) throw new NotFoundException('报备批次不存在'); const tasks = await this.collectBatchTasks(batchId); const successCount = tasks.filter((task) => task.status === 'approved').length; - const channelIds = batch.exportFiles - .map((file) => file.channelId) - .filter((id): id is string => Boolean(id)); + const reportingCount = tasks.filter((task) => ['reporting', 'exporting'].includes(task.status)).length; + const failedCount = tasks.filter((task) => ['failed', 'rejected'].includes(task.status)).length; + const channelIds = batch.exportFiles.map((file) => file.channelId).filter((id): id is string => Boolean(id)); const channels = channelIds.length ? await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } }, @@ -173,7 +147,9 @@ export class ReportBatchGenerationService { return { ...batch, reportTotal: tasks.length, + reportingCount, successCount, + failedCount, successRate: tasks.length ? successCount / tasks.length : 0, briefs, }; diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index c064b7d..132dc3b 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -16,6 +16,7 @@ import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { ReportMaterialsService } from './report-materials.service'; +import { ReportBatchDownloadService } from './batch-download.service'; import { CreateImportProfileDto, CreateReportBatchDto, @@ -30,7 +31,10 @@ type DownloadResponse = { setHeader(name: string, value: string): void; send(con @ApiTags('report-materials') @Controller('admin/report-materials') export class ReportMaterialsController { - constructor(private readonly service: ReportMaterialsService) {} + constructor( + private readonly service: ReportMaterialsService, + private readonly batchDownloads: ReportBatchDownloadService, + ) {} @Get('pending') listPending( @@ -156,6 +160,16 @@ export class ReportMaterialsController { 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') getBatch(@Param('id') id: string) { return this.service.getBatch(id); @@ -212,4 +226,13 @@ export class ReportMaterialsController { response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); 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); + } } diff --git a/api/src/report-materials/report-materials.module.ts b/api/src/report-materials/report-materials.module.ts index da1b796..3c3ad78 100644 --- a/api/src/report-materials/report-materials.module.ts +++ b/api/src/report-materials/report-materials.module.ts @@ -3,11 +3,12 @@ import { FilesModule } from '../files/files.module'; import { SmsConfigModule } from '../sms-config/sms-config.module'; import { ReportMaterialsController } from './report-materials.controller'; import { ReportMaterialsService } from './report-materials.service'; +import { ReportBatchDownloadService } from './batch-download.service'; @Module({ imports: [FilesModule, SmsConfigModule], controllers: [ReportMaterialsController], - providers: [ReportMaterialsService], + providers: [ReportMaterialsService, ReportBatchDownloadService], exports: [ReportMaterialsService], }) export class ReportMaterialsModule {} diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index 17437f1..b82c9a6 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -1,4 +1,5 @@ import ExcelJS from 'exceljs'; +import { createHash } from 'node:crypto'; import { ReportMaterialsService } from './report-materials.service'; import { mappedCorePatchValue } from './report-materials.helpers'; @@ -601,9 +602,17 @@ describe('ReportMaterialsService', () => { batchNo: 'RB-STATS-1', 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: [], }, @@ -614,7 +623,16 @@ describe('ReportMaterialsService', () => { const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); 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, page: 2, pageSize: 10, @@ -698,7 +716,6 @@ describe('ReportMaterialsService', () => { }); function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) { - const { createHash } = require('node:crypto') as typeof import('node:crypto'); return createHash('sha256') .update( JSON.stringify( diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index b185ed9..1a8e12f 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5012,7 +5012,7 @@ npm run verify:phase8 | TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;搜索条件下方集中展示当前条件内最新版本尚未生成的通道×运营商明细总数并可下钻,签名列表不展示该字段 | | TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 | | TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 | -| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表显示真实文件、通道和进度;“打开明细”展示该批次对应任务,可批量修改状态及按签名明细导出 | +| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表不直接铺开文件,仅显示通道数、生成状态及报备总数/报备中/成功/失败四项明细数;“打开明细”从右侧滑出该批次通道明细列表 | | TC-REPORT-WORKBENCH-006 | 从通道报备明细或短信通道报备详情查看资料 | 字段严格按当前通道签名报备字段/引流字段sortOrder排列,历史未配置字段置后;加载失败、文件缺失和必填缺失显示真实错误 | | TC-REPORT-WORKBENCH-007 | 导出一条签名通道运营商明细 | 后端读取真实签名、通道字段和MinIO对象生成单行XLSX;图片嵌入;不改变任务状态、不生成批次、不触发短信链路,并写操作日志 | | TC-REPORT-WORKBENCH-008 | 短信通道管理进入报备详情 | 使用后端分页,默认按今日发送条数全量降序后分页;可按关键词、状态、运营商及今日发送区间查询;状态弹窗展示当前上下文和放弃风险 | @@ -5022,9 +5022,13 @@ npm run verify:phase8 | TC-REPORT-WORKBENCH-012 | 查看企业签名表头和签名列表 | 签名列不显示升序、降序或其他排序按钮;列表不显示逐行待生成明细字段;默认按创建时间倒序 | | TC-REPORT-WORKBENCH-013 | 通道报备明细当前页有10条数据,点击右上角“全选当页” | 当前页10条全部选中,按钮变为“取消全选”,批量数为10;该按钮与“批量修改状态”保持统一操作区间距;翻页或查询后选择清空,不选中其他页面数据 | | 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-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 高频查询与按需详情 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 336493b..24be3f8 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4361,3 +4361,13 @@ git diff --check - 通道报备明细右上角操作区改用现有统一`page-heading__actions`样式,修复“全选当页”和“批量修改状态”之间无间隔的问题;仅调整样式类名,不改变选择、翻页清空或批量状态接口。 - 报备资料池移除表格上方“选择本页全部可生成资料”复选框标签,替换为右上角统一幽灵按钮“全选当页/取消全选”,与“预检并生成”保持统一间距;仍只选择当前页资格预检通过的数据,后端预检及生成接口、参数和业务逻辑不变。 - 定向报备工作台组件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核验后追加;全程禁止发送、补发、重投或重新入队短信。 diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index 04bf2d7..e5113dd 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -223,6 +223,9 @@ export const adminChannelsReportsApi = { query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}, ) => request>(withQuery('/admin/report-materials/batches', query)), getReportMaterialBatch: (id: string) => request(`/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: ( id: string, query: { diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts index e2fb314..57f6b68 100644 --- a/src/api/types/channels-reports.ts +++ b/src/api/types/channels-reports.ts @@ -144,7 +144,9 @@ export type ReportMaterialBatch = { channelCount: number; fileCount: number; reportTotal: number; + reportingCount: number; successCount: number; + failedCount: number; successRate: number; createdAt: string; completedAt?: string | null; diff --git a/src/apps/admin/AdminReportBatchesPage.tsx b/src/apps/admin/AdminReportBatchesPage.tsx index 8e825c2..fd70d0f 100644 --- a/src/apps/admin/AdminReportBatchesPage.tsx +++ b/src/apps/admin/AdminReportBatchesPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from '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 { Breadcrumb, Button, @@ -39,12 +39,16 @@ export function AdminReportBatchesPage() { const [total, setTotal] = useState(0); const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue }); const [detail, setDetail] = useState(); + const [exportDetail, setExportDetail] = useState(); const [tasks, setTasks] = useState([]); const [selected, setSelected] = useState>(new Set()); + const [statusTargets, setStatusTargets] = useState([]); const [nextStatus, setNextStatus] = useState('reporting'); const [reason, setReason] = useState(''); const [error, setError] = useState(''); const [copiedChannelId, setCopiedChannelId] = useState(''); + const [downloadBusy, setDownloadBusy] = useState(''); + const [statusBusy, setStatusBusy] = useState(false); const pageSize = 20; function load(target = page, filters = appliedFilters) { @@ -82,6 +86,15 @@ export function AdminReportBatchesPage() { 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) { try { if (navigator.clipboard?.writeText) { @@ -103,8 +116,10 @@ export function AdminReportBatchesPage() { } } 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; + setStatusBusy(true); try { await adminApi.changeReportTaskStatuses({ items: chosen.map((task) => ({ @@ -118,37 +133,88 @@ export function AdminReportBatchesPage() { reason: reason.trim() || undefined, sourceEntry: 'report_task', }); + setStatusTargets([]); + setReason(''); if (detail) await openBatch(detail); } catch (failure) { 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 { - const blob = await adminApi.exportSingleReportMaterial({ - reportType: task.reportType, - signatureId: task.signatureId, - channelId: task.channelId, - carrier: task.carrier ?? undefined, - 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); + setDownloadBusy(fileId); + const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId); + downloadBlob( + blob, + `${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`, + ); } 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> = [ { key: 'batchNo', title: '报备批次号', render: (item) => {item.batchNo} }, { 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) => ( +
+ + 总明细 {item.reportTotal} + + + 报备中 {item.reportingCount ?? 0} + + + 成功 {item.successCount} + + + 失败 {item.failedCount ?? 0} + +
+ ), + }, + { key: 'channels', title: '报备通道', render: (item) => `${item.channelCount}个通道` }, { key: 'status', title: '生成状态', @@ -158,37 +224,33 @@ export function AdminReportBatchesPage() { ), }, - { - key: 'files', - title: '文件', - render: (item) => ( -
- {item.exportFiles.map((file) => - file.fileObjectId ? ( - - - {file.fileName} - - ) : null, - )} -
- ), - }, { key: 'actions', title: '操作', align: 'right', render: (item) => ( - +
+ + +
), }, ]; const taskColumns: Array> = [ { key: 'select', - title: '', + title: ( + setSelected(allTasksSelected ? new Set() : new Set(tasks.map((task) => task.id)))} + type="checkbox" + /> + ), width: '44px', render: (task) => ( - task.reportType !== 'drainage' ? ( - - ) : ( - '-' - ), + render: (task) => ( + + ), }, ]; @@ -275,7 +334,7 @@ export function AdminReportBatchesPage() {

报备批次

-

查看已生成批次、下载通道文件,并在批次内批量处理通道报备明细。

+

查看已生成批次,在批次内处理通道明细或集中导出报备文件。

{error ?

{error}

: null} @@ -324,6 +383,7 @@ export function AdminReportBatchesPage() { /> {detail ? ( setDetail(undefined)}>关闭} onClose={() => setDetail(undefined)} open @@ -331,69 +391,138 @@ export function AdminReportBatchesPage() { title={`批次明细 · ${detail.batchNo}`} >
-
-
-
-

通道报备简报

-

按本批次生成时间及资料快照生成,可直接复制给对应通道供应商。

-
-
- {detail.briefs?.length ? ( - detail.briefs.map((brief) => ( -
-
-
- {brief.channelName} - {brief.itemCount} 条资料 -
- -
-
{brief.content}
-
- )) - ) : ( -

该历史批次暂无通道简报。

- )} -
+
+ + 报备总明细数{detail.reportTotal} + + + 报备中的明细数{detail.reportingCount ?? 0} + + + 报备成功明细数{detail.successCount} + + + 报备失败数{detail.failedCount ?? 0} + +
共 {tasks.length} 条通道明细,已选 {selected.size} 条 -