feat: redesign report batch workflow
This commit is contained in:
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 高频查询与按需详情
|
||||||
|
|
||||||
|
|||||||
@@ -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核验后追加;全程禁止发送、补发、重投或重新入队短信。
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user