feat: redesign report batch workflow
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 高频查询与按需详情
|
||||
|
||||
|
||||
@@ -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核验后追加;全程禁止发送、补发、重投或重新入队短信。
|
||||
|
||||
@@ -223,6 +223,9 @@ export const adminChannelsReportsApi = {
|
||||
query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {},
|
||||
) => request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
|
||||
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: (
|
||||
id: string,
|
||||
query: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ReportMaterialBatch>();
|
||||
const [exportDetail, setExportDetail] = useState<ReportMaterialBatch>();
|
||||
const [tasks, setTasks] = useState<ReportTask[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [statusTargets, setStatusTargets] = useState<string[]>([]);
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
|
||||
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 {
|
||||
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<TableColumn<ReportMaterialBatch>> = [
|
||||
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
|
||||
{ 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',
|
||||
title: '生成状态',
|
||||
@@ -158,37 +224,33 @@ export function AdminReportBatchesPage() {
|
||||
</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',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (item) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
|
||||
打开明细
|
||||
</Button>
|
||||
<Button icon={<Download size={14} />} onClick={() => void openExports(item)} size="sm" variant="ghost">
|
||||
报备文件导出
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
const taskColumns: Array<TableColumn<ReportTask>> = [
|
||||
{
|
||||
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',
|
||||
render: (task) => (
|
||||
<input
|
||||
@@ -258,13 +320,10 @@ export function AdminReportBatchesPage() {
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (task) =>
|
||||
task.reportType !== 'drainage' ? (
|
||||
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
|
||||
导出本条
|
||||
render: (task) => (
|
||||
<Button onClick={() => setStatusTargets([task.id])} size="sm" variant="ghost">
|
||||
修改状态
|
||||
</Button>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -275,7 +334,7 @@ export function AdminReportBatchesPage() {
|
||||
<div>
|
||||
<Breadcrumb items={['报备工作台', '报备批次']} />
|
||||
<h1>报备批次</h1>
|
||||
<p>查看已生成批次、下载通道文件,并在批次内批量处理通道报备明细。</p>
|
||||
<p>查看已生成批次,在批次内处理通道明细或集中导出报备文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -324,6 +383,7 @@ export function AdminReportBatchesPage() {
|
||||
/>
|
||||
{detail ? (
|
||||
<Modal
|
||||
className="report-batch-drawer"
|
||||
footer={<Button onClick={() => setDetail(undefined)}>关闭</Button>}
|
||||
onClose={() => setDetail(undefined)}
|
||||
open
|
||||
@@ -331,43 +391,53 @@ export function AdminReportBatchesPage() {
|
||||
title={`批次明细 · ${detail.batchNo}`}
|
||||
>
|
||||
<div className="page-stack">
|
||||
<section className="report-batch-briefs" aria-label="通道报备简报">
|
||||
<div className="report-batch-briefs__title">
|
||||
<div>
|
||||
<h3>通道报备简报</h3>
|
||||
<p className="muted">按本批次生成时间及资料快照生成,可直接复制给对应通道供应商。</p>
|
||||
<div className="report-batch-detail-summary">
|
||||
<span>
|
||||
报备总明细数<strong>{detail.reportTotal}</strong>
|
||||
</span>
|
||||
<span>
|
||||
报备中的明细数<strong>{detail.reportingCount ?? 0}</strong>
|
||||
</span>
|
||||
<span>
|
||||
报备成功明细数<strong>{detail.successCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
报备失败数<strong>{detail.failedCount ?? 0}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{detail.briefs?.length ? (
|
||||
detail.briefs.map((brief) => (
|
||||
<article className="report-batch-brief" key={brief.channelId}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{brief.channelName}</strong>
|
||||
<span>{brief.itemCount} 条资料</span>
|
||||
</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">
|
||||
<strong>
|
||||
共 {tasks.length} 条通道明细,已选 {selected.size} 条
|
||||
</strong>
|
||||
<Button disabled={!selected.size} onClick={() => setStatusTargets([...selected])}>
|
||||
批量修改报备状态
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
</Modal>
|
||||
) : 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
|
||||
aria-label="批量修改状态"
|
||||
label="修改后的状态"
|
||||
onChange={(event) => setNextStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '未报备', value: 'pending' },
|
||||
@@ -380,18 +450,77 @@ export function AdminReportBatchesPage() {
|
||||
value={nextStatus}
|
||||
/>
|
||||
<Textarea
|
||||
aria-label="修改原因"
|
||||
label="修改原因(选填)"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="修改原因"
|
||||
rows={2}
|
||||
rows={4}
|
||||
value={reason}
|
||||
/>
|
||||
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
|
||||
批量修改
|
||||
</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>
|
||||
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
</header>
|
||||
<pre>{brief.content}</pre>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="muted">该历史批次暂无通道文件或简报。</p>
|
||||
)}
|
||||
</section>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,8 @@ 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(),
|
||||
@@ -49,7 +51,12 @@ describe('report workbench pages', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<AdminReportTasksPage />
|
||||
@@ -120,7 +127,9 @@ describe('report workbench pages', () => {
|
||||
channelCount: 1,
|
||||
fileCount: 1,
|
||||
reportTotal: 1,
|
||||
reportingCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
successRate: 0,
|
||||
createdAt: '2026-09-02T01:00:00.000Z',
|
||||
exportFiles: [],
|
||||
@@ -155,11 +164,56 @@ describe('report workbench pages', () => {
|
||||
configurable: true,
|
||||
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(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(
|
||||
<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
@@ -5588,16 +5588,74 @@
|
||||
.report-material-detail-list > div.is-missing { background: var(--color-danger-soft); }
|
||||
|
||||
.report-batch-toolbar {
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.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__title { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.report-batch-briefs__title h3 { margin: 0 0 4px; }
|
||||
@@ -5643,6 +5701,8 @@
|
||||
.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 .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 {
|
||||
|
||||
Reference in New Issue
Block a user