Compare commits
3
Commits
dada0d978b
...
fb39c8b606
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb39c8b606 | ||
|
|
48d0363920 | ||
|
|
bc18c7ff12 |
@@ -12,6 +12,8 @@ import {
|
|||||||
CreateSensitiveWordDto,
|
CreateSensitiveWordDto,
|
||||||
DictionariesService,
|
DictionariesService,
|
||||||
DictionaryStatusDto,
|
DictionaryStatusDto,
|
||||||
|
ReorderCommonReportFieldsDto,
|
||||||
|
UpdateDrainageFieldDto,
|
||||||
} from './dictionaries.service';
|
} from './dictionaries.service';
|
||||||
|
|
||||||
@ApiTags('dictionaries')
|
@ApiTags('dictionaries')
|
||||||
@@ -128,6 +130,15 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createDrainageField(body);
|
return this.dictionaries.createDrainageField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('drainage-fields/:id')
|
||||||
|
updateDrainageField(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateDrainageFieldDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.updateDrainageField(id, body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('drainage-fields/:id')
|
@Delete('drainage-fields/:id')
|
||||||
deleteDrainageField(@Param('id') id: string) {
|
deleteDrainageField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteDrainageField(id);
|
return this.dictionaries.deleteDrainageField(id);
|
||||||
@@ -168,6 +179,14 @@ export class DictionariesController {
|
|||||||
return this.dictionaries.createCommonReportField(body);
|
return this.dictionaries.createCommonReportField(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('common-report-fields/order')
|
||||||
|
reorderCommonReportFields(
|
||||||
|
@Body() body: ReorderCommonReportFieldsDto,
|
||||||
|
@CurrentSessionUserId() operatorId?: string,
|
||||||
|
) {
|
||||||
|
return this.dictionaries.reorderCommonReportFields(body, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('common-report-fields/:id')
|
@Delete('common-report-fields/:id')
|
||||||
deleteCommonReportField(@Param('id') id: string) {
|
deleteCommonReportField(@Param('id') id: string) {
|
||||||
return this.dictionaries.deleteCommonReportField(id);
|
return this.dictionaries.deleteCommonReportField(id);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||||
},
|
},
|
||||||
channelReportField: {
|
channelReportField: {
|
||||||
@@ -46,6 +47,7 @@ function createPrismaMock() {
|
|||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
||||||
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
smsApplication: {
|
smsApplication: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||||
@@ -76,6 +78,42 @@ describe('DictionariesService', () => {
|
|||||||
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||||
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const tx = {
|
||||||
|
commonReportField: {
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
|
||||||
|
},
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
|
||||||
|
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { id: 'common-2' },
|
||||||
|
data: { sortOrder: 10 },
|
||||||
|
});
|
||||||
|
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: 'common-1' },
|
||||||
|
data: { sortOrder: 20 },
|
||||||
|
});
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
|
||||||
|
});
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||||
|
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
|
||||||
|
).resolves.toEqual([{ id: 'common-1' }]);
|
||||||
|
await expect(
|
||||||
|
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
|
||||||
|
).rejects.toThrow('排序范围已变化');
|
||||||
|
});
|
||||||
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
@@ -130,6 +168,32 @@ describe('DictionariesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null };
|
||||||
|
prisma.drainageField.findUnique.mockResolvedValue(existing as never);
|
||||||
|
const tx = {
|
||||||
|
drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) },
|
||||||
|
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.updateDrainageField('field-1', {
|
||||||
|
code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ',
|
||||||
|
}, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' }));
|
||||||
|
expect(tx.drainageField.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'field-1' },
|
||||||
|
data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' },
|
||||||
|
});
|
||||||
|
expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) });
|
||||||
|
|
||||||
|
prisma.channelReportField.count.mockResolvedValue(1);
|
||||||
|
await expect(service.updateDrainageField('field-1', {
|
||||||
|
code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '',
|
||||||
|
})).rejects.toThrow('不能修改字段代码或类型');
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const tx = {
|
const tx = {
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
|
||||||
|
|
||||||
export interface UpsertDrainageDetectionRuleDto {
|
export interface UpsertDrainageDetectionRuleDto {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReorderCommonReportFieldsDto {
|
||||||
|
reportType: 'signature' | 'drainage';
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DictionaryStatusDto {
|
export interface DictionaryStatusDto {
|
||||||
status?: string;
|
status?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
@@ -399,6 +406,58 @@ export class DictionariesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
|
||||||
|
const code = data.code?.trim();
|
||||||
|
const name = data.name?.trim();
|
||||||
|
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
|
||||||
|
throw new BadRequestException('code must contain only Arabic numerals and English letters');
|
||||||
|
}
|
||||||
|
if (!name) throw new BadRequestException('name is required');
|
||||||
|
if (!['string', 'image', 'file'].includes(data.fieldType)) {
|
||||||
|
throw new BadRequestException('fieldType must be string, image or file');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('报备字段不存在');
|
||||||
|
const [usageCount, commonUsageCount] = await Promise.all([
|
||||||
|
this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }),
|
||||||
|
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
|
||||||
|
]);
|
||||||
|
if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
|
||||||
|
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.drainageField.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
fieldType: data.fieldType,
|
||||||
|
description: data.description?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'drainage_field.update',
|
||||||
|
resource: 'drainage_field',
|
||||||
|
resourceId: id,
|
||||||
|
detail: {
|
||||||
|
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
|
||||||
|
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
throw new ConflictException('字段代码已存在');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDrainageField(id: string) {
|
async deleteDrainageField(id: string) {
|
||||||
const [usageCount, commonUsageCount] = await Promise.all([
|
const [usageCount, commonUsageCount] = await Promise.all([
|
||||||
this.prisma.channelReportField.count({
|
this.prisma.channelReportField.count({
|
||||||
@@ -541,6 +600,40 @@ export class DictionariesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
|
||||||
|
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
|
||||||
|
throw new BadRequestException('通用字段排序参数无效');
|
||||||
|
}
|
||||||
|
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.commonReportField.findMany({
|
||||||
|
where: { reportType: data.reportType, status: 'active' },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
const existingIds = existing.map((field) => field.id);
|
||||||
|
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
|
||||||
|
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
for (const [index, id] of data.ids.entries()) {
|
||||||
|
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
|
||||||
|
}
|
||||||
|
await tx.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'common_report_field.reorder',
|
||||||
|
resource: 'common_report_field',
|
||||||
|
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tx.commonReportField.findMany({
|
||||||
|
where: { reportType: data.reportType, status: 'active' },
|
||||||
|
include: { drainageField: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
|
}
|
||||||
|
|
||||||
deleteCommonReportField(id: string) {
|
deleteCommonReportField(id: string) {
|
||||||
return this.prisma.commonReportField.delete({ where: { id } });
|
return this.prisma.commonReportField.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,6 +511,11 @@ describe('OperationsService', () => {
|
|||||||
todaySpendCents: 24000n,
|
todaySpendCents: 24000n,
|
||||||
balanceCents: 1000000n,
|
balanceCents: 1000000n,
|
||||||
creditCents: 50000n,
|
creditCents: 50000n,
|
||||||
|
}]).mockResolvedValueOnce([{
|
||||||
|
segmentCount: 20n,
|
||||||
|
deliveredSegmentCount: 18n,
|
||||||
|
billedCents: 360n,
|
||||||
|
costCents: 216n,
|
||||||
}]).mockResolvedValueOnce([
|
}]).mockResolvedValueOnce([
|
||||||
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
||||||
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
||||||
@@ -541,7 +546,15 @@ describe('OperationsService', () => {
|
|||||||
templates: 1,
|
templates: 1,
|
||||||
total: 5,
|
total: 5,
|
||||||
},
|
},
|
||||||
today: expect.objectContaining({ returnedCents: 10 }),
|
today: expect.objectContaining({
|
||||||
|
returnedCents: 10,
|
||||||
|
segmentCount: 20,
|
||||||
|
deliveredSegmentCount: 18,
|
||||||
|
arrivalRate: 90,
|
||||||
|
billedCents: 360,
|
||||||
|
profitCents: 144,
|
||||||
|
profitRate: 40,
|
||||||
|
}),
|
||||||
hourlySendTrend: expect.arrayContaining([
|
hourlySendTrend: expect.arrayContaining([
|
||||||
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
||||||
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
||||||
@@ -592,7 +605,11 @@ describe('OperationsService', () => {
|
|||||||
updatedAt: { gte: expect.any(Date) },
|
updatedAt: { gte: expect.any(Date) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||||
|
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
|
||||||
|
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
|
||||||
|
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
|
||||||
|
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
|
||||||
expect(hourlyTrendQuery.sql).toContain(
|
expect(hourlyTrendQuery.sql).toContain(
|
||||||
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
recentTasks,
|
recentTasks,
|
||||||
recentRecharges,
|
recentRecharges,
|
||||||
enterpriseSpendRows,
|
enterpriseSpendRows,
|
||||||
|
todayBusinessMetricsRows,
|
||||||
downstreamPendingCount,
|
downstreamPendingCount,
|
||||||
downstreamFailedCount,
|
downstreamFailedCount,
|
||||||
downstreamDeliveredCount,
|
downstreamDeliveredCount,
|
||||||
@@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||||
`),
|
`),
|
||||||
|
this.prisma.$queryRaw<Array<{
|
||||||
|
segmentCount: bigint;
|
||||||
|
deliveredSegmentCount: bigint;
|
||||||
|
billedCents: bigint;
|
||||||
|
costCents: bigint;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
WITH segment_metrics AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(segment.id)::bigint AS "segmentCount",
|
||||||
|
COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount"
|
||||||
|
FROM "SmsMessageSegmentAudit" segment
|
||||||
|
JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId"
|
||||||
|
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||||
|
AND message."queuedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||||
|
),
|
||||||
|
message_revenue AS (
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||||
|
THEN message."billingUnits" * message."unitPrice" ELSE 0 END
|
||||||
|
), 0)::bigint AS "billedCents"
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||||
|
AND message."queuedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||||
|
),
|
||||||
|
submit_cost AS (
|
||||||
|
SELECT COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||||
|
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||||
|
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||||
|
ELSE 0
|
||||||
|
END), 0)::bigint AS "costCents"
|
||||||
|
FROM "SmsSubmitRecord" submit
|
||||||
|
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::integer AS audit_count,
|
||||||
|
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
|
||||||
|
FROM "SmsMessageSegmentAudit" audit
|
||||||
|
WHERE audit."submitRecordId" = submit.id
|
||||||
|
) segment_receipts ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||||
|
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||||
|
AND receipt."channelId" = submit."channelId"
|
||||||
|
AND receipt."receiptStatus" = 'delivered'
|
||||||
|
) AS delivered
|
||||||
|
) legacy_receipt ON TRUE
|
||||||
|
WHERE submit."submitStatus" = 'accepted'
|
||||||
|
AND message."queuedAt" >= ${businessDay.startAt}
|
||||||
|
AND message."queuedAt" < ${businessDay.endAt}
|
||||||
|
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
segment_metrics."segmentCount",
|
||||||
|
segment_metrics."deliveredSegmentCount",
|
||||||
|
message_revenue."billedCents",
|
||||||
|
submit_cost."costCents"
|
||||||
|
FROM segment_metrics, message_revenue, submit_cost
|
||||||
|
`),
|
||||||
this.prisma.cmppDownstreamDelivery.count({
|
this.prisma.cmppDownstreamDelivery.count({
|
||||||
where: { tenantId: query.tenantId, status: 'pending' },
|
where: { tenantId: query.tenantId, status: 'pending' },
|
||||||
}),
|
}),
|
||||||
@@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||||
|
const todayBusinessMetrics = todayBusinessMetricsRows[0];
|
||||||
|
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
|
||||||
|
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
|
||||||
|
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
|
||||||
|
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
|
||||||
|
const profitCents = billedCents - costCents;
|
||||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||||
@@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) {
|
|||||||
spendCents: todayTotals.amountCents,
|
spendCents: todayTotals.amountCents,
|
||||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||||
billingUnits: todayTotals.billingUnits,
|
billingUnits: todayTotals.billingUnits,
|
||||||
|
segmentCount,
|
||||||
|
deliveredSegmentCount,
|
||||||
|
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
|
||||||
|
billedCents,
|
||||||
|
profitCents,
|
||||||
|
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
|
||||||
},
|
},
|
||||||
uplinkCount,
|
uplinkCount,
|
||||||
billing: billingAggregate,
|
billing: billingAggregate,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { safeFileName } from './report-materials.helpers';
|
import { safeFileName } from './report-materials.helpers';
|
||||||
|
import type { ReportWorkbookFormat } from './report-materials.contracts';
|
||||||
|
import { convertWorkbookOutput } from './workbook-compatibility';
|
||||||
|
|
||||||
type BatchDownloadSource = {
|
type BatchDownloadSource = {
|
||||||
batchNo: string;
|
batchNo: string;
|
||||||
@@ -29,7 +31,11 @@ type DownloadedBatchArtifact = {
|
|||||||
export class ReportBatchDownloadService {
|
export class ReportBatchDownloadService {
|
||||||
constructor(private readonly files: FilesService) {}
|
constructor(private readonly files: FilesService) {}
|
||||||
|
|
||||||
async exportFile(batch: BatchDownloadSource, fileId: string): Promise<DownloadedBatchArtifact> {
|
async exportFile(
|
||||||
|
batch: BatchDownloadSource,
|
||||||
|
fileId: string,
|
||||||
|
outputFormat: ReportWorkbookFormat = 'excel_drawing',
|
||||||
|
): Promise<DownloadedBatchArtifact> {
|
||||||
const brief = batch.briefs.find((item) => item.fileId === fileId);
|
const brief = batch.briefs.find((item) => item.fileId === fileId);
|
||||||
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
|
const exportFile = batch.exportFiles.find((item) => item.id === fileId);
|
||||||
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
|
if (!brief || !exportFile?.fileObjectId) throw new NotFoundException('批次报备文件不存在');
|
||||||
@@ -37,11 +43,14 @@ export class ReportBatchDownloadService {
|
|||||||
return {
|
return {
|
||||||
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
|
fileName: `${this.entryBaseName(batch, brief.channelName)}.xlsx`,
|
||||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
content,
|
content: await convertWorkbookOutput(content, outputFormat),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportBundle(batch: BatchDownloadSource): Promise<DownloadedBatchArtifact> {
|
async exportBundle(
|
||||||
|
batch: BatchDownloadSource,
|
||||||
|
outputFormat: ReportWorkbookFormat = 'excel_drawing',
|
||||||
|
): Promise<DownloadedBatchArtifact> {
|
||||||
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
|
if (!batch.briefs.length) throw new BadRequestException('该批次暂无可导出的通道文件或简报');
|
||||||
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
|
if (batch.briefs.length > 100) throw new BadRequestException('单次最多打包100个通道文件');
|
||||||
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
|
const exportFileById = new Map(batch.exportFiles.map((item) => [item.id, item]));
|
||||||
@@ -50,7 +59,10 @@ export class ReportBatchDownloadService {
|
|||||||
const exportFile = exportFileById.get(brief.fileId);
|
const exportFile = exportFileById.get(brief.fileId);
|
||||||
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
|
if (!exportFile?.fileObjectId) throw new BadRequestException(`通道“${brief.channelName}”缺少报备文件`);
|
||||||
const workbook = await this.files.getDownload(exportFile.fileObjectId);
|
const workbook = await this.files.getDownload(exportFile.fileObjectId);
|
||||||
return { brief, workbook };
|
return {
|
||||||
|
brief,
|
||||||
|
workbook: { ...workbook, content: await convertWorkbookOutput(workbook.content, outputFormat) },
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
|
const totalBytes = downloaded.reduce((sum, item) => sum + item.workbook.content.length, 0);
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
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 ExcelJS from 'exceljs';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { extname } from 'node:path';
|
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 { SingleReportMaterialDto } from './report-materials.contracts';
|
||||||
AnalyzeImportOptions,
|
|
||||||
CreateImportProfileDto,
|
|
||||||
CreateReportBatchDto,
|
|
||||||
EmbeddedImage,
|
|
||||||
ImportCommitDto,
|
|
||||||
ImportMapping,
|
|
||||||
PagedQuery,
|
|
||||||
ReportBatchInspection,
|
|
||||||
ReportBatchTarget,
|
|
||||||
ReviewImportItemsDto,
|
|
||||||
SingleReportMaterialDto,
|
|
||||||
} from './report-materials.contracts';
|
|
||||||
import {
|
import {
|
||||||
profileData,
|
|
||||||
validateProfile,
|
|
||||||
loadWorkbook,
|
|
||||||
assertSafeWorkbook,
|
|
||||||
safeSpreadsheetText,
|
|
||||||
readEmbeddedImages,
|
|
||||||
suggestMappings,
|
|
||||||
remapProfileColumns,
|
|
||||||
signatureCoreMapping,
|
|
||||||
drainageCoreMapping,
|
|
||||||
normalizeHeader,
|
|
||||||
normalizeFieldCode,
|
|
||||||
clamp,
|
|
||||||
normalizePage,
|
|
||||||
normalizePageSize,
|
|
||||||
dateRange,
|
|
||||||
cellText,
|
|
||||||
transformValue,
|
|
||||||
mappedCoreValue,
|
|
||||||
dynamicValues,
|
|
||||||
jsonRecord,
|
jsonRecord,
|
||||||
hasValue,
|
hasValue,
|
||||||
isFileRef,
|
isFileRef,
|
||||||
@@ -47,12 +14,9 @@ import {
|
|||||||
applyExportTransform,
|
applyExportTransform,
|
||||||
styleHeader,
|
styleHeader,
|
||||||
normalizeImageExtension,
|
normalizeImageExtension,
|
||||||
imageContentType,
|
|
||||||
safeFileName,
|
safeFileName,
|
||||||
normalizeBatchIdempotencyKey,
|
|
||||||
jsonStringArray,
|
|
||||||
jsonSafe,
|
|
||||||
} from './report-materials.helpers';
|
} from './report-materials.helpers';
|
||||||
|
import { convertWorkbookOutput } from './workbook-compatibility';
|
||||||
import type { ReportBatchGenerationService } from './batch-generation.service';
|
import type { ReportBatchGenerationService } from './batch-generation.service';
|
||||||
|
|
||||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||||
@@ -97,7 +61,7 @@ export class ReportChannelExportService {
|
|||||||
);
|
);
|
||||||
const smsContentField = fields.find((field) => field.name.trim() === '短信内容');
|
const smsContentField = fields.find((field) => field.name.trim() === '短信内容');
|
||||||
const smsContentValue = smsContentField
|
const smsContentValue = smsContentField
|
||||||
? resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? ''
|
? (resolveExportValue(item.snapshot, smsContentField.code, smsContentField.name) ?? '')
|
||||||
: '';
|
: '';
|
||||||
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
|
const smsContent = isFileRef(smsContentValue) ? '' : String(smsContentValue ?? '');
|
||||||
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
const missing = fields.filter((field, index) => field.required && !hasValue(values[index]));
|
||||||
@@ -292,6 +256,11 @@ export class ReportChannelExportService {
|
|||||||
});
|
});
|
||||||
const configuredCodes = new Set(fields.map((field) => field.code));
|
const configuredCodes = new Set(fields.map((field) => field.code));
|
||||||
const values = jsonRecord(snapshot.values);
|
const values = jsonRecord(snapshot.values);
|
||||||
|
const historicalCodes = Object.keys(values).filter((code) => !configuredCodes.has(code));
|
||||||
|
const historicalDefinitions = historicalCodes.length
|
||||||
|
? await this.prisma.drainageField.findMany({ where: { code: { in: historicalCodes } } })
|
||||||
|
: [];
|
||||||
|
const historicalDefinitionByCode = new Map(historicalDefinitions.map((field) => [field.code, field]));
|
||||||
const materialFields = fields.map((field) => {
|
const materialFields = fields.map((field) => {
|
||||||
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
|
const submittedValue = resolveExportValue(snapshot, field.code, field.name);
|
||||||
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
|
const value = hasValue(submittedValue) ? submittedValue : field.defaultValue;
|
||||||
@@ -312,9 +281,9 @@ export class ReportChannelExportService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const historicalFields = Object.entries(values)
|
const historicalFields = Object.entries(values)
|
||||||
.filter(([code, value]) => !configuredCodes.has(code) && hasValue(value))
|
.filter(([code]) => !configuredCodes.has(code) && historicalDefinitionByCode.get(code)?.status !== 'deleted')
|
||||||
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
|
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
|
||||||
.map(([code, value]) => ({ code, name: code, value }));
|
.map(([code, value]) => ({ code, name: historicalDefinitionByCode.get(code)?.name ?? code, value }));
|
||||||
return {
|
return {
|
||||||
reportType,
|
reportType,
|
||||||
signatureId: signature.id,
|
signatureId: signature.id,
|
||||||
@@ -331,7 +300,8 @@ export class ReportChannelExportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||||
|
if (!data) throw new BadRequestException('导出参数不能为空');
|
||||||
if ((data.reportType ?? 'signature') !== 'signature')
|
if ((data.reportType ?? 'signature') !== 'signature')
|
||||||
throw new BadRequestException('首版仅支持单条签名报备资料导出');
|
throw new BadRequestException('首版仅支持单条签名报备资料导出');
|
||||||
const detail = await this.getSingleMaterialDetail(data);
|
const detail = await this.getSingleMaterialDetail(data);
|
||||||
@@ -379,7 +349,7 @@ export class ReportChannelExportService {
|
|||||||
}
|
}
|
||||||
row.height = targetHeight;
|
row.height = targetHeight;
|
||||||
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
|
const fileName = `${safeFileName(detail.channel.name)}-${safeFileName(detail.signatureName)}-V${detail.materialVersion}.xlsx`;
|
||||||
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
const content = await convertWorkbookOutput(Buffer.from(await workbook.xlsx.writeBuffer()), data.outputFormat);
|
||||||
await this.prisma.operationLog.create({
|
await this.prisma.operationLog.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: detail.tenant.id,
|
tenantId: detail.tenant.id,
|
||||||
|
|||||||
@@ -1,117 +1,177 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import ExcelJS from 'exceljs';
|
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { extname } from 'node:path';
|
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 { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts';
|
import type { AnalyzeImportOptions, CreateImportProfileDto, ImportMapping } 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 {
|
||||||
|
profileData,
|
||||||
|
validateProfile,
|
||||||
|
suggestMappings,
|
||||||
|
remapProfileColumns,
|
||||||
|
clamp,
|
||||||
|
cellText,
|
||||||
|
} from './report-materials.helpers';
|
||||||
|
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||||
|
|
||||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||||
export class ReportImportParserService {
|
export class ReportImportParserService {
|
||||||
constructor(private readonly prisma: PrismaService, private readonly files: FilesService, private readonly smsConfig: SmsConfigService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly files: FilesService,
|
||||||
|
private readonly smsConfig: SmsConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
listImportProfiles(reportType?: 'signature' | 'drainage') {
|
listImportProfiles(reportType?: 'signature' | 'drainage') {
|
||||||
return this.prisma.reportMaterialImportProfile.findMany({
|
return this.prisma.reportMaterialImportProfile.findMany({
|
||||||
where: { reportType, status: 'active' },
|
where: { reportType, status: 'active' },
|
||||||
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
|
include: { columns: { orderBy: [{ sortOrder: 'asc' }, { sourceColumnIndex: 'asc' }] } },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveImportProfile(data: CreateImportProfileDto) {
|
async saveImportProfile(data: CreateImportProfileDto) {
|
||||||
validateProfile(data);
|
validateProfile(data);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const profile = data.id
|
const profile = data.id
|
||||||
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
|
? await tx.reportMaterialImportProfile.update({ where: { id: data.id }, data: profileData(data) })
|
||||||
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
|
: await tx.reportMaterialImportProfile.create({ data: profileData(data) });
|
||||||
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
|
await tx.reportMaterialImportProfileColumn.deleteMany({ where: { profileId: profile.id } });
|
||||||
await tx.reportMaterialImportProfileColumn.createMany({
|
await tx.reportMaterialImportProfileColumn.createMany({
|
||||||
data: data.columns.map((column, index) => ({
|
data: data.columns.map((column, index) => ({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
sourceHeader: column.sourceHeader,
|
sourceHeader: column.sourceHeader,
|
||||||
sourceHeaderPath: column.sourceHeaderPath,
|
sourceHeaderPath: column.sourceHeaderPath,
|
||||||
sourceColumnIndex: column.sourceColumnIndex,
|
sourceColumnIndex: column.sourceColumnIndex,
|
||||||
targetFieldCode: column.targetFieldCode,
|
targetFieldCode: column.targetFieldCode,
|
||||||
targetKind: column.targetKind,
|
targetKind: column.targetKind,
|
||||||
fieldType: column.fieldType,
|
fieldType: column.fieldType,
|
||||||
required: column.required ?? false,
|
required: column.required ?? false,
|
||||||
transform: column.transform,
|
transform: column.transform,
|
||||||
sortOrder: column.sortOrder ?? (index + 1) * 10,
|
sortOrder: column.sortOrder ?? (index + 1) * 10,
|
||||||
})),
|
})),
|
||||||
});
|
|
||||||
return tx.reportMaterialImportProfile.findUnique({ where: { id: profile.id }, include: { columns: { orderBy: { sortOrder: 'asc' } } } });
|
|
||||||
});
|
});
|
||||||
}
|
return tx.reportMaterialImportProfile.findUnique({
|
||||||
|
where: { id: profile.id },
|
||||||
|
include: { columns: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async analyzeImport(file: { originalname: string; mimetype: string; size: number; buffer: Buffer }, options: AnalyzeImportOptions) {
|
async analyzeImport(
|
||||||
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
|
||||||
if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage');
|
options: AnalyzeImportOptions,
|
||||||
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件');
|
) {
|
||||||
const workbook = await loadWorkbook(file.buffer);
|
if (!options.tenantId) throw new BadRequestException('tenantId is required');
|
||||||
assertSafeWorkbook(workbook);
|
if (!options.applicationId) throw new BadRequestException('applicationId is required');
|
||||||
const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
await this.smsConfig.getApplication(options.applicationId, options.tenantId);
|
||||||
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
if (!['signature', 'drainage'].includes(options.reportType))
|
||||||
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
throw new BadRequestException('reportType must be signature or drainage');
|
||||||
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
|
if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b)
|
||||||
const headerRowCount = clamp(options.headerRowCount, 1, 5);
|
throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||||
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
|
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(file.buffer);
|
||||||
const images = readEmbeddedImages(workbook, worksheet);
|
const profile = options.profileId
|
||||||
const columnCount = Math.min(worksheet.columnCount, 200);
|
? await this.prisma.reportMaterialImportProfile.findUnique({
|
||||||
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
where: { id: options.profileId },
|
||||||
const sourceColumnIndex = offset + 1;
|
include: { columns: { orderBy: { sortOrder: 'asc' } } },
|
||||||
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) => cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex))).filter(Boolean);
|
})
|
||||||
const sourceHeaderPath = [...new Set(parts)].join('/');
|
: null;
|
||||||
return {
|
const selectedSheetName = options.sheetName || profile?.sheetName || undefined;
|
||||||
sourceColumnIndex,
|
const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0];
|
||||||
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
|
if (!worksheet) throw new BadRequestException('工作簿没有可读取的工作表');
|
||||||
sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`,
|
const headerRowCount = clamp(options.headerRowCount, 1, 5);
|
||||||
sourceHeaderPath,
|
const dataStartRow = Math.max(options.dataStartRow, headerRowCount + 1);
|
||||||
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
|
const images = compatibleImages(workbook, worksheet, wpsImagesBySheet);
|
||||||
};
|
const columnCount = Math.min(worksheet.columnCount, 200);
|
||||||
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
|
const columns = Array.from({ length: columnCount }, (_, offset) => {
|
||||||
const previewRows = [];
|
const sourceColumnIndex = offset + 1;
|
||||||
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
|
const parts = Array.from({ length: headerRowCount }, (_, headerOffset) =>
|
||||||
const values = Object.fromEntries(columns.map((column) => [String(column.sourceColumnIndex), cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex))]));
|
cellText(worksheet.getCell(headerOffset + 1, sourceColumnIndex)),
|
||||||
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
|
).filter(Boolean);
|
||||||
if (Object.values(values).some(Boolean) || imageColumns.length) previewRows.push({ rowNumber, values, imageColumns });
|
const sourceHeaderPath = [...new Set(parts)].join('/');
|
||||||
}
|
return {
|
||||||
const sourceFile = await this.files.upload({ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' }, file);
|
sourceColumnIndex,
|
||||||
const profileMappings = profile?.columns.map((column) => ({
|
columnLetter: worksheet.getColumn(sourceColumnIndex).letter,
|
||||||
sourceHeader: column.sourceHeader,
|
sourceHeader: parts.at(-1) || `第${sourceColumnIndex}列`,
|
||||||
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
|
sourceHeaderPath,
|
||||||
sourceColumnIndex: column.sourceColumnIndex,
|
imageCount: images.filter((image) => image.column === sourceColumnIndex).length,
|
||||||
targetFieldCode: column.targetFieldCode,
|
};
|
||||||
targetKind: column.targetKind as ImportMapping['targetKind'],
|
}).filter((column) => column.sourceHeaderPath || column.imageCount > 0);
|
||||||
fieldType: column.fieldType as ImportMapping['fieldType'],
|
const previewRows = [];
|
||||||
required: column.required,
|
for (let rowNumber = dataStartRow; rowNumber <= Math.min(worksheet.rowCount, dataStartRow + 9); rowNumber += 1) {
|
||||||
transform: column.transform ?? undefined,
|
const values = Object.fromEntries(
|
||||||
sortOrder: column.sortOrder,
|
columns.map((column) => [
|
||||||
}));
|
String(column.sourceColumnIndex),
|
||||||
const suggestedMappings = profileMappings?.length ? remapProfileColumns(profileMappings, columns) : suggestMappings(columns, options.reportType);
|
cellText(worksheet.getCell(rowNumber, column.sourceColumnIndex)),
|
||||||
const batch = await this.prisma.reportMaterialImportBatch.create({
|
]),
|
||||||
data: {
|
);
|
||||||
tenantId: options.tenantId,
|
const imageColumns = images.filter((image) => image.row === rowNumber).map((image) => image.column);
|
||||||
applicationId: options.applicationId,
|
if (Object.values(values).some(Boolean) || imageColumns.length)
|
||||||
profileId: options.profileId,
|
previewRows.push({ rowNumber, values, imageColumns });
|
||||||
fileObjectId: sourceFile.id,
|
|
||||||
fileName: sourceFile.fileName,
|
|
||||||
reportType: options.reportType,
|
|
||||||
sheetName: worksheet.name,
|
|
||||||
headerRowCount,
|
|
||||||
dataStartRow,
|
|
||||||
mapping: suggestedMappings as Prisma.InputJsonValue,
|
|
||||||
preview: { sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length } as Prisma.InputJsonValue,
|
|
||||||
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.prisma.operationLog.create({ data: {
|
|
||||||
tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id,
|
|
||||||
detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue,
|
|
||||||
} });
|
|
||||||
return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings };
|
|
||||||
}
|
}
|
||||||
|
const sourceFile = await this.files.upload(
|
||||||
|
{ tenantId: options.tenantId, purpose: 'report_material_import', prefix: 'report-material-imports' },
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
const profileMappings = profile?.columns.map((column) => ({
|
||||||
|
sourceHeader: column.sourceHeader,
|
||||||
|
sourceHeaderPath: column.sourceHeaderPath ?? undefined,
|
||||||
|
sourceColumnIndex: column.sourceColumnIndex,
|
||||||
|
targetFieldCode: column.targetFieldCode,
|
||||||
|
targetKind: column.targetKind as ImportMapping['targetKind'],
|
||||||
|
fieldType: column.fieldType as ImportMapping['fieldType'],
|
||||||
|
required: column.required,
|
||||||
|
transform: column.transform ?? undefined,
|
||||||
|
sortOrder: column.sortOrder,
|
||||||
|
}));
|
||||||
|
const suggestedMappings = profileMappings?.length
|
||||||
|
? remapProfileColumns(profileMappings, columns)
|
||||||
|
: suggestMappings(columns, options.reportType);
|
||||||
|
const batch = await this.prisma.reportMaterialImportBatch.create({
|
||||||
|
data: {
|
||||||
|
tenantId: options.tenantId,
|
||||||
|
applicationId: options.applicationId,
|
||||||
|
profileId: options.profileId,
|
||||||
|
fileObjectId: sourceFile.id,
|
||||||
|
fileName: sourceFile.fileName,
|
||||||
|
reportType: options.reportType,
|
||||||
|
sheetName: worksheet.name,
|
||||||
|
headerRowCount,
|
||||||
|
dataStartRow,
|
||||||
|
mapping: suggestedMappings as Prisma.InputJsonValue,
|
||||||
|
preview: {
|
||||||
|
sheets: workbook.worksheets.map((sheet) => sheet.name),
|
||||||
|
columns,
|
||||||
|
rows: previewRows,
|
||||||
|
imageCount: images.length,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: options.tenantId,
|
||||||
|
userId: options.operatorId,
|
||||||
|
action: 'report_material.import_analyzed',
|
||||||
|
resource: 'report_material_import',
|
||||||
|
resourceId: batch.id,
|
||||||
|
detail: {
|
||||||
|
fileName: sourceFile.fileName,
|
||||||
|
filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name },
|
||||||
|
successCount: previewRows.length,
|
||||||
|
failedCount: 0,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...batch,
|
||||||
|
sourceFile,
|
||||||
|
sheets: workbook.worksheets.map((sheet) => sheet.name),
|
||||||
|
columns,
|
||||||
|
rows: previewRows,
|
||||||
|
imageCount: images.length,
|
||||||
|
suggestedMappings,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,10 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, 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 { 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 { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
|
||||||
AnalyzeImportOptions,
|
|
||||||
CreateImportProfileDto,
|
|
||||||
CreateReportBatchDto,
|
|
||||||
EmbeddedImage,
|
|
||||||
ImportCommitDto,
|
|
||||||
ImportMapping,
|
|
||||||
PagedQuery,
|
|
||||||
ReportBatchInspection,
|
|
||||||
ReportBatchTarget,
|
|
||||||
ReviewImportItemsDto,
|
|
||||||
} 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,
|
||||||
@@ -42,18 +15,11 @@ import {
|
|||||||
dynamicValues,
|
dynamicValues,
|
||||||
jsonRecord,
|
jsonRecord,
|
||||||
hasValue,
|
hasValue,
|
||||||
isFileRef,
|
|
||||||
resolveExportValue,
|
|
||||||
applyExportTransform,
|
|
||||||
styleHeader,
|
|
||||||
normalizeImageExtension,
|
normalizeImageExtension,
|
||||||
imageContentType,
|
imageContentType,
|
||||||
safeFileName,
|
|
||||||
normalizeBatchIdempotencyKey,
|
|
||||||
jsonStringArray,
|
|
||||||
jsonSafe,
|
|
||||||
} from './report-materials.helpers';
|
} from './report-materials.helpers';
|
||||||
import { ReportImportParserService } from './import-parser.service';
|
import { ReportImportParserService } from './import-parser.service';
|
||||||
|
import { compatibleImages, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||||
|
|
||||||
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
/** R4 report-materials domain service composed behind ReportMaterialsService. */
|
||||||
export class ReportImportReviewService {
|
export class ReportImportReviewService {
|
||||||
@@ -76,11 +42,10 @@ export class ReportImportReviewService {
|
|||||||
columns: data.mappings,
|
columns: data.mappings,
|
||||||
});
|
});
|
||||||
const { content } = await this.files.getDownload(batch.fileObjectId);
|
const { content } = await this.files.getDownload(batch.fileObjectId);
|
||||||
const workbook = await loadWorkbook(content);
|
const { workbook, wpsImagesBySheet } = await loadCompatibleWorkbook(content);
|
||||||
assertSafeWorkbook(workbook);
|
|
||||||
const worksheet = workbook.getWorksheet(batch.sheetName);
|
const worksheet = workbook.getWorksheet(batch.sheetName);
|
||||||
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
if (!worksheet) throw new BadRequestException('导入工作表不存在');
|
||||||
const images = readEmbeddedImages(workbook, worksheet);
|
const images = compatibleImages(workbook, worksheet, wpsImagesBySheet);
|
||||||
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
const imageByCell = new Map(images.map((image) => [`${image.row}:${image.column}`, image]));
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
const failures: Array<{ rowNumber: number; reason: string }> = [];
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface SingleReportMaterialDto {
|
|||||||
reportType?: 'signature' | 'drainage';
|
reportType?: 'signature' | 'drainage';
|
||||||
drainageItemId?: string;
|
drainageItemId?: string;
|
||||||
batchItemId?: string;
|
batchItemId?: string;
|
||||||
|
outputFormat?: ReportWorkbookFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReportBatchTarget = {
|
export type ReportBatchTarget = {
|
||||||
@@ -94,7 +95,7 @@ export type ReportBatchInspection = {
|
|||||||
|
|
||||||
export type AnalyzeImportOptions = {
|
export type AnalyzeImportOptions = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
applicationId?: string;
|
applicationId: string;
|
||||||
reportType: 'signature' | 'drainage';
|
reportType: 'signature' | 'drainage';
|
||||||
sheetName?: string;
|
sheetName?: string;
|
||||||
headerRowCount: number;
|
headerRowCount: number;
|
||||||
@@ -104,3 +105,5 @@ export type AnalyzeImportOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
export type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer };
|
||||||
|
|
||||||
|
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ export class ReportMaterialsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('imports/analyze')
|
@Post('imports/analyze')
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }),
|
||||||
|
)
|
||||||
analyzeImport(
|
analyzeImport(
|
||||||
@UploadedFile() file: UploadedWorkbook,
|
@UploadedFile() file: UploadedWorkbook,
|
||||||
@Body() body: Record<string, string>,
|
@Body() body: Record<string, string>,
|
||||||
@@ -102,7 +104,7 @@ export class ReportMaterialsController {
|
|||||||
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
if (!file) throw new BadRequestException('请选择 XLSX 文件');
|
||||||
return this.service.analyzeImport(file, {
|
return this.service.analyzeImport(file, {
|
||||||
tenantId: body.tenantId,
|
tenantId: body.tenantId,
|
||||||
applicationId: body.applicationId || undefined,
|
applicationId: body.applicationId,
|
||||||
reportType: body.reportType as 'signature' | 'drainage',
|
reportType: body.reportType as 'signature' | 'drainage',
|
||||||
sheetName: body.sheetName || undefined,
|
sheetName: body.sheetName || undefined,
|
||||||
headerRowCount: Number(body.headerRowCount || 1),
|
headerRowCount: Number(body.headerRowCount || 1),
|
||||||
@@ -161,13 +163,25 @@ export class ReportMaterialsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('batches/:id/download')
|
@Get('batches/:id/download')
|
||||||
async downloadBatch(@Param('id') id: string, @Res() response: DownloadResponse) {
|
async downloadBatch(
|
||||||
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id)));
|
@Param('id') id: string,
|
||||||
|
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
|
||||||
|
@Res() response: DownloadResponse,
|
||||||
|
) {
|
||||||
|
this.sendDownload(response, await this.batchDownloads.exportBundle(await this.service.getBatch(id), outputFormat));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batches/:id/files/:fileId/download')
|
@Get('batches/:id/files/:fileId/download')
|
||||||
async downloadBatchFile(@Param('id') id: string, @Param('fileId') fileId: string, @Res() response: DownloadResponse) {
|
async downloadBatchFile(
|
||||||
this.sendDownload(response, await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId));
|
@Param('id') id: string,
|
||||||
|
@Param('fileId') fileId: string,
|
||||||
|
@Query('outputFormat') outputFormat: 'excel_drawing' | 'wps_cell_image' | undefined,
|
||||||
|
@Res() response: DownloadResponse,
|
||||||
|
) {
|
||||||
|
this.sendDownload(
|
||||||
|
response,
|
||||||
|
await this.batchDownloads.exportFile(await this.service.getBatch(id), fileId, outputFormat),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batches/:id')
|
@Get('batches/:id')
|
||||||
@@ -214,7 +228,7 @@ export class ReportMaterialsController {
|
|||||||
@Post('single-export')
|
@Post('single-export')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
async exportSingleMaterial(
|
async exportSingleMaterial(
|
||||||
@Body() body: SingleReportMaterialDto,
|
@Body() body: SingleReportMaterialDto | undefined,
|
||||||
@CurrentSessionUserId() operatorId: string | undefined,
|
@CurrentSessionUserId() operatorId: string | undefined,
|
||||||
@Res() response: DownloadResponse,
|
@Res() response: DownloadResponse,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ import { ReportMaterialsService } from './report-materials.service';
|
|||||||
import { mappedCorePatchValue } from './report-materials.helpers';
|
import { mappedCorePatchValue } from './report-materials.helpers';
|
||||||
|
|
||||||
describe('ReportMaterialsService', () => {
|
describe('ReportMaterialsService', () => {
|
||||||
|
it('requires an enterprise application before parsing an import workbook', async () => {
|
||||||
|
const service = new ReportMaterialsService({} as never, { upload: jest.fn() } as never, {} as never);
|
||||||
|
await expect(
|
||||||
|
service.analyzeImport(
|
||||||
|
{ originalname: '资料.xlsx', mimetype: '', size: 2, buffer: Buffer.from('PK') },
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: '',
|
||||||
|
reportType: 'signature',
|
||||||
|
headerRowCount: 1,
|
||||||
|
dataStartRow: 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow('applicationId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
|
||||||
|
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
|
||||||
|
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not clear an existing core field when the import column is unmapped or blank', () => {
|
it('does not clear an existing core field when the import column is unmapped or blank', () => {
|
||||||
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
|
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
@@ -54,7 +75,7 @@ describe('ReportMaterialsService', () => {
|
|||||||
const service = new ReportMaterialsService(
|
const service = new ReportMaterialsService(
|
||||||
{ reportMaterialImportProfile: { findUnique: jest.fn() } } as never,
|
{ reportMaterialImportProfile: { findUnique: jest.fn() } } as never,
|
||||||
files as never,
|
files as never,
|
||||||
{} as never,
|
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -65,9 +86,9 @@ describe('ReportMaterialsService', () => {
|
|||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
buffer,
|
buffer,
|
||||||
},
|
},
|
||||||
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
{ tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
||||||
),
|
),
|
||||||
).rejects.toThrow('公式或可执行单元格');
|
).rejects.toThrow('公式');
|
||||||
expect(files.upload).not.toHaveBeenCalled();
|
expect(files.upload).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,23 +106,21 @@ describe('ReportMaterialsService', () => {
|
|||||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
const prisma = {
|
const prisma = {
|
||||||
reportMaterialImportProfile: {
|
reportMaterialImportProfile: {
|
||||||
findUnique: jest
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
sheetName: '签名资料',
|
||||||
.mockResolvedValue({
|
columns: [
|
||||||
sheetName: '签名资料',
|
{
|
||||||
columns: [
|
sourceHeader: '短信签名',
|
||||||
{
|
sourceHeaderPath: '短信签名',
|
||||||
sourceHeader: '短信签名',
|
sourceColumnIndex: 9,
|
||||||
sourceHeaderPath: '短信签名',
|
targetFieldCode: 'signature_name',
|
||||||
sourceColumnIndex: 9,
|
targetKind: 'signatureName',
|
||||||
targetFieldCode: 'signature_name',
|
fieldType: 'string',
|
||||||
targetKind: 'signatureName',
|
required: true,
|
||||||
fieldType: 'string',
|
sortOrder: 10,
|
||||||
required: true,
|
},
|
||||||
sortOrder: 10,
|
],
|
||||||
},
|
}),
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
reportMaterialImportBatch: {
|
reportMaterialImportBatch: {
|
||||||
create: jest
|
create: jest
|
||||||
@@ -113,15 +132,17 @@ describe('ReportMaterialsService', () => {
|
|||||||
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
||||||
};
|
};
|
||||||
const files = {
|
const files = {
|
||||||
upload: jest
|
upload: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
id: 'source-1',
|
||||||
.mockResolvedValue({
|
fileName: '签名资料.xlsx',
|
||||||
id: 'source-1',
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
fileName: '签名资料.xlsx',
|
}),
|
||||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
const service = new ReportMaterialsService(
|
||||||
|
prisma as never,
|
||||||
|
files as never,
|
||||||
|
{ getApplication: jest.fn().mockResolvedValue({ id: 'app-1' }) } as never,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.analyzeImport(
|
const result = await service.analyzeImport(
|
||||||
{
|
{
|
||||||
@@ -176,42 +197,38 @@ describe('ReportMaterialsService', () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
id: 'signature-1',
|
||||||
.mockResolvedValue({
|
tenantId: 'tenant-1',
|
||||||
id: 'signature-1',
|
applicationId: 'app-1',
|
||||||
tenantId: 'tenant-1',
|
name: '测试签名',
|
||||||
applicationId: 'app-1',
|
purpose: '验证码',
|
||||||
name: '测试签名',
|
auditStatus: 'approved',
|
||||||
purpose: '验证码',
|
pendingReport: true,
|
||||||
auditStatus: 'approved',
|
materialVersion: 3,
|
||||||
pendingReport: true,
|
drainageInfo: {
|
||||||
materialVersion: 3,
|
signatureReportValues: {
|
||||||
drainageInfo: {
|
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
|
||||||
signatureReportValues: {
|
sms_content_primary: '第一条短信内容',
|
||||||
license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' },
|
sms_content_secondary: '第二条短信内容',
|
||||||
sms_content_primary: '第一条短信内容',
|
|
||||||
sms_content_secondary: '第二条短信内容',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tenant: { name: '测试企业' },
|
},
|
||||||
application: { name: '测试应用', status: 'active' },
|
tenant: { name: '测试企业' },
|
||||||
}),
|
application: { name: '测试应用', status: 'active' },
|
||||||
|
}),
|
||||||
update: jest.fn().mockResolvedValue({}),
|
update: jest.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
channelRouteRule: {
|
channelRouteRule: {
|
||||||
findMany: jest
|
findMany: jest.fn().mockResolvedValue([
|
||||||
.fn()
|
{
|
||||||
.mockResolvedValue([
|
carrier: 'mobile',
|
||||||
{
|
group: {
|
||||||
carrier: 'mobile',
|
status: 'active',
|
||||||
group: {
|
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
|
||||||
status: 'active',
|
|
||||||
items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
reportMaterialBatchItem: {
|
reportMaterialBatchItem: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
@@ -226,52 +243,54 @@ describe('ReportMaterialsService', () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
channelReportField: {
|
channelReportField: {
|
||||||
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) => Promise.resolve([
|
findMany: jest.fn().mockImplementation(({ where }: { where: { channelId: string } }) =>
|
||||||
{
|
Promise.resolve([
|
||||||
code: 'sign',
|
{
|
||||||
name: '短信签名',
|
code: 'sign',
|
||||||
exportName: '通道签名',
|
name: '短信签名',
|
||||||
required: true,
|
exportName: '通道签名',
|
||||||
columnWidth: 18,
|
required: true,
|
||||||
imageWidth: 120,
|
columnWidth: 18,
|
||||||
imageHeight: 80,
|
imageWidth: 120,
|
||||||
transform: null,
|
imageHeight: 80,
|
||||||
defaultValue: null,
|
transform: null,
|
||||||
},
|
defaultValue: null,
|
||||||
{
|
},
|
||||||
code: 'license',
|
{
|
||||||
name: '营业执照',
|
code: 'license',
|
||||||
exportName: '营业执照图片',
|
name: '营业执照',
|
||||||
required: true,
|
exportName: '营业执照图片',
|
||||||
columnWidth: 24,
|
required: true,
|
||||||
imageWidth: 120,
|
columnWidth: 24,
|
||||||
imageHeight: 80,
|
imageWidth: 120,
|
||||||
transform: null,
|
imageHeight: 80,
|
||||||
defaultValue: null,
|
transform: null,
|
||||||
},
|
defaultValue: null,
|
||||||
{
|
},
|
||||||
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
|
{
|
||||||
name: '短信内容',
|
code: where.channelId === 'channel-b' ? 'sms_content_missing' : 'sms_content_primary',
|
||||||
exportName: '短信内容一',
|
name: '短信内容',
|
||||||
required: false,
|
exportName: '短信内容一',
|
||||||
columnWidth: 24,
|
required: false,
|
||||||
imageWidth: 120,
|
columnWidth: 24,
|
||||||
imageHeight: 80,
|
imageWidth: 120,
|
||||||
transform: null,
|
imageHeight: 80,
|
||||||
defaultValue: '第一条短信内容',
|
transform: null,
|
||||||
},
|
defaultValue: '第一条短信内容',
|
||||||
{
|
},
|
||||||
code: 'sms_content_secondary',
|
{
|
||||||
name: '短信内容',
|
code: 'sms_content_secondary',
|
||||||
exportName: '短信内容二',
|
name: '短信内容',
|
||||||
required: false,
|
exportName: '短信内容二',
|
||||||
columnWidth: 24,
|
required: false,
|
||||||
imageWidth: 120,
|
columnWidth: 24,
|
||||||
imageHeight: 80,
|
imageWidth: 120,
|
||||||
transform: null,
|
imageHeight: 80,
|
||||||
defaultValue: '第二条短信内容',
|
transform: null,
|
||||||
},
|
defaultValue: '第二条短信内容',
|
||||||
])),
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
channelSignatureReportTask: {
|
channelSignatureReportTask: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
@@ -294,15 +313,13 @@ describe('ReportMaterialsService', () => {
|
|||||||
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||||
};
|
};
|
||||||
const files = {
|
const files = {
|
||||||
getDownload: jest
|
getDownload: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
fileObject: { fileName: 'license.png', contentType: 'image/png' },
|
||||||
.mockResolvedValue({
|
content: Buffer.from(
|
||||||
fileObject: { fileName: 'license.png', contentType: 'image/png' },
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
|
||||||
content: Buffer.from(
|
'base64',
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=',
|
),
|
||||||
'base64',
|
}),
|
||||||
),
|
|
||||||
}),
|
|
||||||
upload: jest
|
upload: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
|
.mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
|
||||||
@@ -365,35 +382,31 @@ describe('ReportMaterialsService', () => {
|
|||||||
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
|
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)),
|
||||||
},
|
},
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
id: 'signature-2',
|
||||||
.mockResolvedValue({
|
tenantId: 'tenant-1',
|
||||||
id: 'signature-2',
|
applicationId: 'app-1',
|
||||||
tenantId: 'tenant-1',
|
name: '测试签名',
|
||||||
applicationId: 'app-1',
|
auditStatus: 'approved',
|
||||||
name: '测试签名',
|
pendingReport: true,
|
||||||
auditStatus: 'approved',
|
materialVersion: 1,
|
||||||
pendingReport: true,
|
drainageInfo: {},
|
||||||
materialVersion: 1,
|
tenant: { name: '企业' },
|
||||||
drainageInfo: {},
|
application: { name: '应用', status: 'active' },
|
||||||
tenant: { name: '企业' },
|
}),
|
||||||
application: { name: '应用', status: 'active' },
|
|
||||||
}),
|
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
channelRouteRule: {
|
channelRouteRule: {
|
||||||
findMany: jest
|
findMany: jest.fn().mockResolvedValue([
|
||||||
.fn()
|
{
|
||||||
.mockResolvedValue([
|
carrier: 'mobile',
|
||||||
{
|
group: {
|
||||||
carrier: 'mobile',
|
status: 'active',
|
||||||
group: {
|
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
|
||||||
status: 'active',
|
|
||||||
items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
},
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
reportMaterialBatchItem: {
|
reportMaterialBatchItem: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
@@ -415,13 +428,11 @@ describe('ReportMaterialsService', () => {
|
|||||||
reportExportFileItem: { createMany: jest.fn() },
|
reportExportFileItem: { createMany: jest.fn() },
|
||||||
};
|
};
|
||||||
const files = {
|
const files = {
|
||||||
upload: jest
|
upload: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
id: 'file-2',
|
||||||
.mockResolvedValue({
|
fileName: 'empty.xlsx',
|
||||||
id: 'file-2',
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
fileName: 'empty.xlsx',
|
}),
|
||||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
||||||
|
|
||||||
@@ -441,21 +452,19 @@ describe('ReportMaterialsService', () => {
|
|||||||
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
||||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||||
operationLog: {
|
operationLog: {
|
||||||
findFirst: jest
|
findFirst: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
id: 'operation-existing',
|
||||||
.mockResolvedValue({
|
detail: {
|
||||||
id: 'operation-existing',
|
status: 'completed',
|
||||||
detail: {
|
fingerprint: expect.anything(),
|
||||||
|
result: {
|
||||||
|
id: 'batch-existing',
|
||||||
|
batchNo: 'RB-EXISTING',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
fingerprint: expect.anything(),
|
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
|
||||||
result: {
|
|
||||||
id: 'batch-existing',
|
|
||||||
batchNo: 'RB-EXISTING',
|
|
||||||
status: 'completed',
|
|
||||||
result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
reportMaterialBatch: { create: jest.fn() },
|
reportMaterialBatch: { create: jest.fn() },
|
||||||
};
|
};
|
||||||
@@ -602,10 +611,7 @@ describe('ReportMaterialsService', () => {
|
|||||||
batchNo: 'RB-STATS-1',
|
batchNo: 'RB-STATS-1',
|
||||||
exportFiles: [
|
exportFiles: [
|
||||||
{
|
{
|
||||||
items: [
|
items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }],
|
||||||
{ task: { id: 'task-1', status: 'approved' } },
|
|
||||||
{ task: { id: 'task-2', status: 'rejected' } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class ReportMaterialsService {
|
|||||||
return this.channelExport.getSingleMaterialDetail(data);
|
return this.channelExport.getSingleMaterialDetail(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||||
return this.channelExport.exportSingleMaterial(data, operatorId);
|
return this.channelExport.exportSingleMaterial(data, operatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { compatibleImages, convertWorkbookOutput, loadCompatibleWorkbook } from './workbook-compatibility';
|
||||||
|
|
||||||
|
const PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZrYQAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function standardWorkbook() {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('签名报备');
|
||||||
|
sheet.getCell('A1').value = '营业执照';
|
||||||
|
sheet.getCell('A2').value = 'license.png';
|
||||||
|
const imageId = workbook.addImage({ buffer: PNG as never, extension: 'png' });
|
||||||
|
sheet.addImage(imageId, { tl: { col: 0, row: 1 }, br: { col: 0.9, row: 1.9 }, editAs: 'oneCell' } as never);
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WPS workbook compatibility', () => {
|
||||||
|
it('converts a standard Drawing image to DISPIMG and reads it back from cellimages.xml', async () => {
|
||||||
|
const converted = await convertWorkbookOutput(await standardWorkbook(), 'wps_cell_image');
|
||||||
|
const zip = await JSZip.loadAsync(converted);
|
||||||
|
await expect(zip.file('xl/cellimages.xml')!.async('string')).resolves.toContain('ID_');
|
||||||
|
const loaded = await loadCompatibleWorkbook(converted);
|
||||||
|
const images = compatibleImages(
|
||||||
|
loaded.workbook,
|
||||||
|
loaded.workbook.getWorksheet('签名报备')!,
|
||||||
|
loaded.wpsImagesBySheet,
|
||||||
|
);
|
||||||
|
expect(images).toHaveLength(1);
|
||||||
|
expect(images[0]).toMatchObject({ row: 2, column: 1, extension: 'png' });
|
||||||
|
expect(images[0].buffer.equals(PNG)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects ordinary formulas instead of weakening spreadsheet safety', async () => {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.addWorksheet('危险').getCell('A1').value = { formula: 'HYPERLINK("https://example.com")' };
|
||||||
|
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
await expect(loadCompatibleWorkbook(content)).rejects.toThrow('不允许的公式');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { EmbeddedImage, ReportWorkbookFormat } from './report-materials.contracts';
|
||||||
|
import { normalizeImageExtension, readEmbeddedImages } from './report-materials.helpers';
|
||||||
|
|
||||||
|
const MAX_WORKBOOK_BYTES = 100 * 1024 * 1024;
|
||||||
|
const MAX_EXPANDED_BYTES = 500 * 1024 * 1024;
|
||||||
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_IMAGE_BYTES = 300 * 1024 * 1024;
|
||||||
|
const DISPIMG_FORMULA = /^_xlfn\.DISPIMG\(["'](ID_[A-F0-9]{32})["'],1\)$/i;
|
||||||
|
|
||||||
|
function decodeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributes(source: string) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...source.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], decodeXml(match[2])]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagePath(target: string) {
|
||||||
|
return `xl/${target.replace(/^\/?xl\//, '').replace(/^\//, '')}`.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinates(address: string) {
|
||||||
|
const match = /^([A-Z]+)(\d+)$/.exec(address.toUpperCase());
|
||||||
|
if (!match) return null;
|
||||||
|
let column = 0;
|
||||||
|
for (const character of match[1]) column = column * 26 + character.charCodeAt(0) - 64;
|
||||||
|
return { row: Number(match[2]), column };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function text(zip: JSZip, path: string) {
|
||||||
|
return zip.file(path)?.async('string') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validImageSignature(extension: string, buffer: Buffer) {
|
||||||
|
if (extension === 'png')
|
||||||
|
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
|
if (extension === 'jpeg') return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||||
|
if (extension === 'gif') return buffer.subarray(0, 3).toString('ascii') === 'GIF';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWorkbookValues(workbook: ExcelJS.Workbook, allowedWpsIds: Set<string>) {
|
||||||
|
for (const worksheet of workbook.worksheets) {
|
||||||
|
worksheet.eachRow((row) =>
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
const value = cell.value;
|
||||||
|
if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
|
||||||
|
const formula =
|
||||||
|
typeof (value as { formula?: unknown }).formula === 'string'
|
||||||
|
? (value as { formula: string }).formula.trim()
|
||||||
|
: '';
|
||||||
|
const match = DISPIMG_FORMULA.exec(formula);
|
||||||
|
if (!match || !allowedWpsIds.has(match[1]))
|
||||||
|
throw new BadRequestException(`工作表 ${worksheet.name} 包含不允许的公式`);
|
||||||
|
}
|
||||||
|
const valueText = typeof value === 'string' ? value.trimStart() : '';
|
||||||
|
if (/^[=+@]/.test(valueText) || /^-[^\d.]/.test(valueText))
|
||||||
|
throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectWpsImages(zip: JSZip) {
|
||||||
|
const cellImagesXml = await text(zip, 'xl/cellimages.xml');
|
||||||
|
if (!cellImagesXml) return { allowedIds: new Set<string>(), imagesBySheet: new Map<string, EmbeddedImage[]>() };
|
||||||
|
const relXml = await text(zip, 'xl/_rels/cellimages.xml.rels');
|
||||||
|
const relTargets = new Map<string, string>();
|
||||||
|
for (const match of relXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||||
|
const attrs = attributes(match[1]);
|
||||||
|
if (attrs.Id && attrs.Target && /\/image$/.test(attrs.Type ?? ''))
|
||||||
|
relTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||||
|
}
|
||||||
|
const imageTargets = new Map<string, string>();
|
||||||
|
for (const match of cellImagesXml.matchAll(
|
||||||
|
/<etc:cellImage\b[^>]*>[\s\S]*?<xdr:cNvPr\b([^>]*)\/?>(?:[\s\S]*?)<a:blip\b([^>]*)\/?>(?:[\s\S]*?)<\/etc:cellImage>/g,
|
||||||
|
)) {
|
||||||
|
const id = attributes(match[1]).name;
|
||||||
|
const target = relTargets.get(attributes(match[2])['r:embed']);
|
||||||
|
if (id && target) imageTargets.set(id, target);
|
||||||
|
}
|
||||||
|
const workbookXml = await text(zip, 'xl/workbook.xml');
|
||||||
|
const workbookRelsXml = await text(zip, 'xl/_rels/workbook.xml.rels');
|
||||||
|
const sheetRelTargets = new Map<string, string>();
|
||||||
|
for (const match of workbookRelsXml.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||||
|
const attrs = attributes(match[1]);
|
||||||
|
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
|
||||||
|
sheetRelTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||||
|
}
|
||||||
|
const allowedIds = new Set<string>();
|
||||||
|
const imagesBySheet = new Map<string, EmbeddedImage[]>();
|
||||||
|
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
|
||||||
|
const attrs = attributes(match[1]);
|
||||||
|
const path = sheetRelTargets.get(attrs['r:id']);
|
||||||
|
if (!attrs.name || !path) continue;
|
||||||
|
const sheetXml = await text(zip, path);
|
||||||
|
const images: EmbeddedImage[] = [];
|
||||||
|
for (const cell of sheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||||
|
const formulaText = decodeXml(cell[2].match(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/)?.[1]?.trim() ?? '');
|
||||||
|
if (!formulaText) continue;
|
||||||
|
const formula = DISPIMG_FORMULA.exec(formulaText);
|
||||||
|
if (!formula) throw new BadRequestException(`工作簿包含不允许的公式:${formulaText.slice(0, 80)}`);
|
||||||
|
const target = imageTargets.get(formula[1]);
|
||||||
|
const cellPosition = coordinates(attributes(cell[1]).r);
|
||||||
|
if (!target || !cellPosition) throw new BadRequestException('WPS单元格图片关系不完整');
|
||||||
|
const imageEntry = zip.file(target);
|
||||||
|
if (!imageEntry) throw new BadRequestException('WPS单元格图片文件缺失');
|
||||||
|
const extension = normalizeImageExtension(target.split('.').pop() ?? 'png');
|
||||||
|
if (!['png', 'jpeg', 'gif'].includes(extension)) throw new BadRequestException('WPS单元格图片格式不受支持');
|
||||||
|
const imageBuffer = await imageEntry.async('nodebuffer');
|
||||||
|
if (imageBuffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张WPS图片不能超过20MB');
|
||||||
|
if (!validImageSignature(extension, imageBuffer)) throw new BadRequestException('WPS单元格图片内容与格式不匹配');
|
||||||
|
images.push({ ...cellPosition, extension, buffer: imageBuffer });
|
||||||
|
allowedIds.add(formula[1]);
|
||||||
|
}
|
||||||
|
imagesBySheet.set(attrs.name, images);
|
||||||
|
}
|
||||||
|
const totalImageBytes = [...imagesBySheet.values()].flat().reduce((sum, image) => sum + image.buffer.length, 0);
|
||||||
|
if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) throw new BadRequestException('WPS图片总量不能超过300MB');
|
||||||
|
return { allowedIds, imagesBySheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCompatibleWorkbook(buffer: Buffer) {
|
||||||
|
if (buffer.length > MAX_WORKBOOK_BYTES) throw new BadRequestException('导入文件不能超过100MB');
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('仅支持有效的 XLSX 文件');
|
||||||
|
}
|
||||||
|
const expandedBytes = Object.values(zip.files).reduce(
|
||||||
|
(sum, entry) =>
|
||||||
|
sum + Number((entry as unknown as { _data?: { uncompressedSize?: number } })._data?.uncompressedSize ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (Object.keys(zip.files).length > 3000) throw new BadRequestException('工作簿ZIP条目数量超过安全限制');
|
||||||
|
if (expandedBytes > MAX_EXPANDED_BYTES) throw new BadRequestException('工作簿解压后超过500MB安全限制');
|
||||||
|
if (Object.keys(zip.files).some((name) => /(^|\/)(vbaProject|externalLinks|embeddings|activeX)(\/|\.)/i.test(name)))
|
||||||
|
throw new BadRequestException('工作簿包含不允许的外部对象或宏');
|
||||||
|
const wps = await inspectWpsImages(zip);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer as never);
|
||||||
|
validateWorkbookValues(workbook, wps.allowedIds);
|
||||||
|
return { workbook, wpsImagesBySheet: wps.imagesBySheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compatibleImages(
|
||||||
|
workbook: ExcelJS.Workbook,
|
||||||
|
worksheet: ExcelJS.Worksheet,
|
||||||
|
wpsImagesBySheet: Map<string, EmbeddedImage[]>,
|
||||||
|
) {
|
||||||
|
const merged = new Map<string, EmbeddedImage>();
|
||||||
|
const wpsImages = wpsImagesBySheet.get(worksheet.name) ?? [];
|
||||||
|
for (const image of readEmbeddedImages(workbook, worksheet)) {
|
||||||
|
if (wpsImages.length && image.buffer.length <= 128) continue;
|
||||||
|
const extension = normalizeImageExtension(image.extension);
|
||||||
|
if (image.buffer.length > MAX_IMAGE_BYTES) throw new BadRequestException('单张工作簿图片不能超过20MB');
|
||||||
|
if (!validImageSignature(extension, image.buffer)) throw new BadRequestException('工作簿图片内容与格式不匹配');
|
||||||
|
merged.set(`${image.row}:${image.column}`, image);
|
||||||
|
}
|
||||||
|
for (const image of wpsImages) merged.set(`${image.row}:${image.column}`, image);
|
||||||
|
return [...merged.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertWorkbookOutput(content: Buffer, outputFormat: ReportWorkbookFormat = 'excel_drawing') {
|
||||||
|
if (outputFormat === 'excel_drawing') return content;
|
||||||
|
if (outputFormat !== 'wps_cell_image') throw new BadRequestException('不支持的报备文件格式');
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(content as never);
|
||||||
|
const pictures: Array<{
|
||||||
|
sheetName: string;
|
||||||
|
address: string;
|
||||||
|
extension: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
id: string;
|
||||||
|
relId: string;
|
||||||
|
mediaPath: string;
|
||||||
|
}> = [];
|
||||||
|
for (const sheet of workbook.worksheets) {
|
||||||
|
for (const image of readEmbeddedImages(workbook, sheet)) {
|
||||||
|
const extension = normalizeImageExtension(image.extension);
|
||||||
|
pictures.push({
|
||||||
|
sheetName: sheet.name,
|
||||||
|
address: `${sheet.getColumn(image.column).letter}${image.row}`,
|
||||||
|
extension,
|
||||||
|
buffer: image.buffer,
|
||||||
|
id: `ID_${randomUUID().replace(/-/g, '').toUpperCase()}`,
|
||||||
|
relId: `rId${pictures.length + 1}`,
|
||||||
|
mediaPath: `xl/media/wps-cell-image-${pictures.length + 1}.${extension === 'jpeg' ? 'jpg' : extension}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pictures.length) return content;
|
||||||
|
const zip = await JSZip.loadAsync(content);
|
||||||
|
Object.keys(zip.files)
|
||||||
|
.filter((name) => /^xl\/media\//.test(name))
|
||||||
|
.forEach((name) => zip.remove(name));
|
||||||
|
const workbookXml = await text(zip, 'xl/workbook.xml');
|
||||||
|
let workbookRels = await text(zip, 'xl/_rels/workbook.xml.rels');
|
||||||
|
const relTargets = new Map<string, string>();
|
||||||
|
for (const match of workbookRels.matchAll(/<Relationship\b([^>]*)\/?>(?:<\/Relationship>)?/g)) {
|
||||||
|
const attrs = attributes(match[1]);
|
||||||
|
if (attrs.Id && attrs.Target && /\/worksheet$/.test(attrs.Type ?? ''))
|
||||||
|
relTargets.set(attrs.Id, packagePath(attrs.Target));
|
||||||
|
}
|
||||||
|
const sheetTargets = new Map<string, string>();
|
||||||
|
for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?>(?:<\/sheet>)?/g)) {
|
||||||
|
const attrs = attributes(match[1]);
|
||||||
|
const target = relTargets.get(attrs['r:id']);
|
||||||
|
if (attrs.name && target) sheetTargets.set(attrs.name, target);
|
||||||
|
}
|
||||||
|
for (const [sheetName, path] of sheetTargets) {
|
||||||
|
const sheetPictures = pictures.filter((picture) => picture.sheetName === sheetName);
|
||||||
|
if (!sheetPictures.length) continue;
|
||||||
|
let sheetXml = await text(zip, path);
|
||||||
|
sheetXml = sheetXml.replace(/<drawing\b[^>]*\/?>(?:<\/drawing>)?/g, '');
|
||||||
|
for (const picture of sheetPictures) {
|
||||||
|
const fullCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)>[\\s\\S]*?<\\/c>`);
|
||||||
|
const emptyCell = new RegExp(`<c\\b([^>]*\\br="${picture.address}"[^>]*)\\/>`);
|
||||||
|
const replaceCell = (opening: string) =>
|
||||||
|
`<c${opening.replace(/\s+t="[^"]*"/g, '')} t="str"><f>_xlfn.DISPIMG("${picture.id}",1)</f><v>=DISPIMG("${picture.id}",1)</v></c>`;
|
||||||
|
if (fullCell.test(sheetXml))
|
||||||
|
sheetXml = sheetXml.replace(fullCell, (_match, opening: string) => replaceCell(opening));
|
||||||
|
else if (emptyCell.test(sheetXml))
|
||||||
|
sheetXml = sheetXml.replace(emptyCell, (_match, opening: string) => replaceCell(opening));
|
||||||
|
else throw new BadRequestException(`无法生成WPS单元格图片:${sheetName}!${picture.address}`);
|
||||||
|
zip.file(picture.mediaPath, picture.buffer);
|
||||||
|
}
|
||||||
|
zip.file(path, sheetXml);
|
||||||
|
const sheetRelsPath = path.replace(/\/([^/]+)$/, '/_rels/$1.rels');
|
||||||
|
const sheetRels = await text(zip, sheetRelsPath);
|
||||||
|
if (sheetRels)
|
||||||
|
zip.file(
|
||||||
|
sheetRelsPath,
|
||||||
|
sheetRels.replace(/<Relationship\b[^>]*Type="[^"]*\/drawing"[^>]*\/?>(?:<\/Relationship>)?/g, ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Object.keys(zip.files)
|
||||||
|
.filter((name) => /^xl\/drawings\//.test(name))
|
||||||
|
.forEach((name) => zip.remove(name));
|
||||||
|
const cellImages = pictures
|
||||||
|
.map(
|
||||||
|
(picture, index) =>
|
||||||
|
`<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${picture.id}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${picture.relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="9525"><a:noFill/></a:ln></xdr:spPr></xdr:pic></etc:cellImage>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
zip.file(
|
||||||
|
'xl/cellimages.xml',
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><etc:cellImages xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">${cellImages}</etc:cellImages>`,
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
'xl/_rels/cellimages.xml.rels',
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${pictures.map((picture) => `<Relationship Id="${picture.relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${picture.mediaPath.replace(/^xl\//, '')}"/>`).join('')}</Relationships>`,
|
||||||
|
);
|
||||||
|
const nextRel = Math.max(0, ...[...workbookRels.matchAll(/Id="rId(\d+)"/g)].map((match) => Number(match[1]))) + 1;
|
||||||
|
workbookRels = workbookRels.replace(
|
||||||
|
'</Relationships>',
|
||||||
|
`<Relationship Id="rId${nextRel}" Type="http://www.wps.cn/officeDocument/2020/cellImage" Target="cellimages.xml"/></Relationships>`,
|
||||||
|
);
|
||||||
|
zip.file('xl/_rels/workbook.xml.rels', workbookRels);
|
||||||
|
let contentTypes = await text(zip, '[Content_Types].xml');
|
||||||
|
contentTypes = contentTypes.replace(
|
||||||
|
/<Override\b[^>]*PartName="\/xl\/drawings\/[^"]+"[^>]*\/?>(?:<\/Override>)?/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (!contentTypes.includes('/xl/cellimages.xml'))
|
||||||
|
contentTypes = contentTypes.replace(
|
||||||
|
'</Types>',
|
||||||
|
'<Override PartName="/xl/cellimages.xml" ContentType="application/vnd.wps-officedocument.cellimage+xml"/></Types>',
|
||||||
|
);
|
||||||
|
zip.file('[Content_Types].xml', contentTypes);
|
||||||
|
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ PROD_ADMIN_PASSWORD='change-me'
|
|||||||
|
|
||||||
`API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。
|
`API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。
|
||||||
|
|
||||||
HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入走`sms.lisglo.com`私有API,因此该虚拟主机的`client_max_body_size`必须不低于`30m`,标准bootstrap配置为`50m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载客户文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽到25MiB。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。
|
HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入和运营端报备资料导入走`sms.lisglo.com`私有API;报备XLSX允许最大100MiB并另设500MiB解压后总量限制,因此该虚拟主机的`client_max_body_size`必须不低于`110m`(包含multipart开销),标准bootstrap配置为`110m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。
|
||||||
|
|
||||||
Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:<channelId>` 权威上限,实际预约使用 `rate:gateway:channel:<channelId>`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。V2起Submit Worker使用持续补位有界池并逐条ACK,`GATEWAY_SUBMIT_WORKER_CONCURRENCY`缺省64、最大1024;调整前必须同时核对供应商连接数、窗口、TPS限制、Gateway RSS和`cmpp_gateway_submit_worker_slots`,不能用放大并发绕过通道限速。
|
Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:<channelId>` 权威上限,实际预约使用 `rate:gateway:channel:<channelId>`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。V2起Submit Worker使用持续补位有界池并逐条ACK,`GATEWAY_SUBMIT_WORKER_CONCURRENCY`缺省64、最大1024;调整前必须同时核对供应商连接数、窗口、TPS限制、Gateway RSS和`cmpp_gateway_submit_worker_slots`,不能用放大并发绕过通道限速。
|
||||||
|
|
||||||
|
|||||||
@@ -5048,3 +5048,25 @@ npm run verify:phase8
|
|||||||
| TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 |
|
| TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 |
|
||||||
| TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 |
|
| TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 |
|
||||||
| TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 |
|
| TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 |
|
||||||
|
|
||||||
|
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-DASHBOARD-FRAGMENT-001 | 准备当天单分片、长短信多分片及成功/失败回执后打开运营看板,并用PostgreSQL独立聚合复核 | “今日消息分片数”取真实`SmsMessageSegmentAudit`行数;“今日到达率”等于成功到达分片数/发送总分片数,零分片时为0;原总体成功率仍按业务短信统计 |
|
||||||
|
| TC-DASHBOARD-PROFIT-001 | 准备当天成功短信、返还流水和不同客户价/通道成本快照后打开运营看板 | 今日返还取真实返还流水;今日计收按最终成功短信计费条数×客户价快照;成本按成功分片×通道成本快照,利润与利润率计算一致,收入为0时利润率为0 |
|
||||||
|
| TC-ENTERPRISE-SIGNATURE-HOVER-001 | 在企业签名列表悬停被截断的签名 | 可查看完整签名、企业、应用、用途、审核状态及创建/更新时间;不增加接口或使用静态数据 |
|
||||||
|
| TC-NAV-DENSITY-001 | 在桌面端和390px窄屏展开运营端长菜单 | 一级分组、二级菜单上下间距更紧凑,菜单仍可滚动,图标、文本、角标、选中态及点击导航完整可用 |
|
||||||
|
| TC-SIGNATURE-QUALITY-MATRIX-001 | 打开任一签名发送质量详情,切换整体统计与按引流切分 | 通道×运营商矩阵保留通道、运营商、引流状态、提交次数、成功率、平均到达时间和提交失败数;表头与通道列滚动时可辨识,成功率层级清晰;请求及后端接口不变 |
|
||||||
|
| TC-REPORT-FIELD-EDIT-001 | 编辑未引用字段的代码、名称、类型和说明后刷新页面 | 修改通过真实PUT接口写入PostgreSQL并记录操作日志,刷新后仍存在;非法代码、空名称、非法类型和重复代码由API拒绝 |
|
||||||
|
| TC-REPORT-FIELD-EDIT-002 | 编辑已被通道或通用字段引用的字段 | 页面锁定代码和类型,允许修改名称和说明;绕过前端直接修改代码或类型时API返回400,既有通道映射和历史资料不受影响 |
|
||||||
|
| TC-REPORT-FIELD-ORDER-001 | 分别在签名、引流通用字段中点击上移/下移并刷新 | 仅在当前资料类型内整体重排,真实`sortOrder`按新顺序持久化;并发导致集合变化时明确失败,不产生部分更新 |
|
||||||
|
| TC-REPORT-WORKBENCH-025 | 在通道报备明细点击单条导出,并分别模拟空请求体和缺失必填资料 | 正常请求以JSON Content-Type提交并下载真实XLSX;空请求体返回可读400而不是500;资料错误明确返回且不静默生成空文件,不改变报备状态或触发短信链路 |
|
||||||
|
| TC-REPORT-WPS-001 | 上传`行业报备.xlsx`等使用`_xlfn.DISPIMG`、`xl/cellimages.xml`及关系文件的WPS XLSX | 仅精确白名单内且图片关系完整的DISPIMG公式可解析;图片按真实单元格映射进入预览与审核,透明Drawing占位图不计入;普通公式、宏、外部对象、缺失关系和非法图片格式均明确拒绝 |
|
||||||
|
| TC-REPORT-WPS-002 | 分别上传10MB以上且不超过100MB、超过100MB、解压后超过500MB的报备XLSX | 第一类可进入解析;后两类分别在上传层或解包层明确拒绝;Nginx私有站点允许100MB业务文件及multipart开销,不扩大公网单条API边界 |
|
||||||
|
| TC-REPORT-WPS-003 | 在批次和单条报备导出选择“系统 Excel 文件”或“WPS 单元格图片文件” | 默认保持ExcelJS Drawing格式;WPS选项生成DISPIMG与cellimages关系且图片按对应单元格可回读;两种格式均来自同一真实资料快照,不改变报备状态或短信链路 |
|
||||||
|
| TC-REPORT-IMPORT-APPLICATION-001 | 未选择企业应用、选择其他企业的应用、选择当前企业应用后解析导入 | 前两种前后端均阻止导入;合法应用可解析并把applicationId写入导入批次,后续补资料只作用于该企业应用范围 |
|
||||||
|
| TC-REPORT-FIELD-MODAL-001 | 打开签名或引流字段配置弹窗,添加字段并检查通用字段与移除按钮 | 左侧字段卡片高度不因添加改变;右侧默认包含同资料类型的通用字段;移除按钮为通用宽度、无红色填充,保存仍调用真实通道字段接口 |
|
||||||
|
| TC-REPORT-MATERIAL-DETAIL-001 | 查看包含当前图片字段、未删除历史字段及旧图片引用的报备资料 | 当前字段展示名称、代码、导出名及图片预览;未删除历史字段继续展示且同时显示字段名称和代码;图片可内联查看并保留下载入口,缺失内容显示明确占位 |
|
||||||
|
| TC-REPORT-CHANNEL-IDENTITY-001 | 打开短信通道管理的报备详情 | 页面同时明确展示“通道名称”和“通道编号”,列表、筛选、状态修改和窄屏布局不受影响 |
|
||||||
|
| TC-DASHBOARD-METRIC-ORDER-001 | 打开运营看板并按从左到右、从上到下读取指标 | 顺序为发送总量、消息分片数、总体成功率、到达率、活跃签名、消费金额、返还金额、计收金额、利润、利润率;所有数值继续来自真实API口径 |
|
||||||
|
|||||||
@@ -4414,3 +4414,39 @@ git diff --check
|
|||||||
- 本地生产预览连接真实本地API、PostgreSQL和Redis,运营端登录后读取11条真实状态记录;列表及详情交互正常,收起导航后1280px浏览器视口无需横向滚动且7列完整可见。未使用mock、静态数据或localStorage作为功能验收结论。
|
- 本地生产预览连接真实本地API、PostgreSQL和Redis,运营端登录后读取11条真实状态记录;列表及详情交互正常,收起导航后1280px浏览器视口无需横向滚动且7列完整可见。未使用mock、静态数据或localStorage作为功能验收结论。
|
||||||
- 功能提交为`fe3c6e5b589cf475c611f5189e9243e657b5deac`,未推送远端。测试环境采用前端静态资源增量发布,没有执行数据库迁移,也没有重启API、Gateway、Worker、PostgreSQL、Redis或MinIO;恢复点为`/opt/cmpp-platform-backups/report-record-list-20260903T1654Z`。
|
- 功能提交为`fe3c6e5b589cf475c611f5189e9243e657b5deac`,未推送远端。测试环境采用前端静态资源增量发布,没有执行数据库迁移,也没有重启API、Gateway、Worker、PostgreSQL、Redis或MinIO;恢复点为`/opt/cmpp-platform-backups/report-record-list-20260903T1654Z`。
|
||||||
- 发布包大小2734330字节,SHA-256为`d2381d1b31cc49418f370b66131dd74e180ba4448c75f7d5795fcc787b48b2c8`;测试环境最终`.deployed-commit=fe3c6e5b589cf475c611f5189e9243e657b5deac`。内外API健康HTTP 200,12项相关服务均active,三条Redis Stream仍为`pending=0 / lag=0`,发布窗口无error级journal;全程未发送、补发、重投或重新入队短信。
|
- 发布包大小2734330字节,SHA-256为`d2381d1b31cc49418f370b66131dd74e180ba4448c75f7d5795fcc787b48b2c8`;测试环境最终`.deployed-commit=fe3c6e5b589cf475c611f5189e9243e657b5deac`。内外API健康HTTP 200,12项相关服务均active,三条Redis Stream仍为`pending=0 / lag=0`,发布窗口无error级journal;全程未发送、补发、重投或重新入队短信。
|
||||||
|
|
||||||
|
## 2026-09-03 报备工作台累计版本与状态记录精简(预生产发布完成)
|
||||||
|
|
||||||
|
- 用户明确授权部署预生产。发布前重新确认本地`HEAD=dada0d978bb05d7046469c1ec77371ddb2fb03bc`,预生产原标记为`9a15d28da56af62225856b361ce2946466cc7210`;累计差异为17个提交、98个文件,包含整套报备工作台、签名资料逻辑、查询与监控适配,并非仅状态记录页面。没有新增migration,原有未跟踪方案文档未进入Git归档;本轮未推送远端。
|
||||||
|
- 预生产发布前数据盘UUID`ef4ee3bb-a19b-4aeb-b00c-aa2b995611c2`、PostgreSQL/Redis/MinIO三处绑定挂载、存储保护脚本、systemd drop-in和固定备份入口均通过。95项migration已齐全,API、Gateway、Redis和12项服务正常,三条Redis Stream均`pending=0 / lag=0`,供应商连接为`desired=9 / connected=9`。
|
||||||
|
- 完整恢复点为`/opt/cmpp-platform-backups/preprod-report-workbench-20260903T123848Z-before-dada0d9`,约605MB,包含PostgreSQL custom dump、Redis RDB、运行目录、环境/systemd/Nginx/Fail2ban/nftables配置、数据盘保护文件、原部署标记、服务/Stream/连接基线、候选构建和发布日志。840项`pg_restore --list`、52647项运行tar、253项配置tar以及最终SHA-256清单均通过;旧运行目录保留为`/opt/cmpp-platform.previous-preprod-9a15d28-retry-20260903T1252Z`。
|
||||||
|
- 精确Git归档为2735075字节,SHA-256为`84cbbc5620ccd0ea57b762269e3a0c91be96cbd70677b143bef91f4cd3b8aaa9`。候选目录通过依赖安全、部署契约、Prisma生成/状态、前端、API、Gateway和Security Agent生产构建;部署时仍为95项migration且无待执行项。
|
||||||
|
- 首次切换因包装脚本的`umask 077`被构建继承,候选目录无法由`cmpp-api`用户穿透,API在60秒内未健康并触发自动回滚。旧版本已恢复,但回滚构建同样继承限制权限,导致`api/dist`暂时仅root可读;按证据恢复运行文件的读取/执行权限后,旧版API、Gateway、四个Worker及Stream全部恢复。根因明确后改用标准`umask 022`,并在切换前以`cmpp-api`身份检查入口和Prisma依赖可读性,第二次发布成功。
|
||||||
|
- 预生产最终`.deployed-commit=dada0d978bb05d7046469c1ec77371ddb2fb03bc`。PostgreSQL、Redis、MinIO、API、四个Worker、Gateway、Security Agent、Nginx和Node Exporter共12项服务均`active`,七项应用服务`NRestarts=0 / Result=success`;内外`12026`、`sms.lisglo.com`、API、Gateway和Callback健康均通过,发布后error级journal为空。
|
||||||
|
- 服务重启后3条富泷供应商通道一度认证失败,供应商连接从9/9暂为6/9;旧版本回滚阶段同样出现,未修改通道配置或凭据。系统按既有计划自动重试,于20:56:52恢复`desired=9 / connected=9`并持续稳定;Gateway当前4条下游连接均在线且持续收发心跳。三条Redis Stream最终仍为`pending=0 / lag=0`,其entries-read仅随正常连接恢复、回执和协议日志前进;本轮未发送、补发、重投或重新入队短信。
|
||||||
|
- 工作站从预生产实际回读主资源`index-DHpz4GTj.js`、`index-rk3aXfEF.css`和状态记录分块`AdminReportRecordsPage-Bo0P2l0_.js`,均HTTP 200,长度分别为370340、236129和8658字节;登录后的状态记录真实交互沿用本地真实API/PostgreSQL/Redis验收结果,未在没有预生产运营登录态时冒充线上登录验收。
|
||||||
|
|
||||||
|
## 2026-09-04 运营看板、签名与报备配置增强(本地修改)
|
||||||
|
|
||||||
|
- 运营看板新增今日消息分片数和按分片计算的今日到达率,并按当天实时短信、分片审计、客户价及通道成本快照展示今日返还、计收、利润和利润率;聚合直接查询真实业务表,不依赖尚未生成的T+1日报,不改变原业务短信成功率口径。
|
||||||
|
- 企业签名列表的签名悬停信息补齐完整签名、企业、应用、用途、审核状态及创建/更新时间;导航分组和菜单项纵向间距收紧,保留滚动、折叠、角标和响应式逻辑。
|
||||||
|
- 签名质量详情的通道×运营商矩阵仅调整前端展示:表头和通道列吸附、单元指标分层、成功率颜色提示更清晰;整体/引流切分、所有原字段和现有后端接口均保留。
|
||||||
|
- 报备字段定义新增真实编辑接口和操作日志。未引用字段可调整代码、名称、类型和说明;已被通道或通用配置引用时只允许改名称和说明,前后端同时保护映射关键字段。通用字段可在签名/引流各自范围内原子调整顺序,集合变化时拒绝部分更新;未新增数据库字段或migration。
|
||||||
|
- 通道报备明细单条导出的500根因定位为Blob POST传JSON字符串时未设置`Content-Type: application/json`,Nest未解析请求体。前端请求头已修复,后端为空请求增加可读400保护;导出仍读取真实字段、资料和文件,不改变报备状态,不发送、补发、重投或重新入队短信。
|
||||||
|
- 测试环境`100.93.204.60`当前ICMP和22端口可达,但约642ms且现有密钥认证失败;本轮没有部署授权,也没有把登录后的测试环境页面冒充已验收。
|
||||||
|
- 定向API 3套64项、定向前端3文件23项、全量API 52套605项、前端12文件61项通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、包体积和`git diff --check`通过。Vite仅保留既有Chart分块超过500kB提示,入口gzip 107.68KiB,符合250KiB预算。
|
||||||
|
- Browser插件本轮不可用,按前端调试流程使用工作区Playwright Chromium验证本地生产构建。1600×1000运营看板和质量矩阵、390×844运营看板页面身份、非空、错误层、控制台及横向溢出检查通过;菜单项上下内边距实测7px,矩阵完整保留通道提交、成功率、平均到达和提交失败信息。截图数据只用于布局验证,不冒充真实业务数据。
|
||||||
|
- 本地真实PostgreSQL启动后,新看板聚合代码直接执行成功,返回当日零短信下分片数、到达率、计收、利润和利润率均为0,确认SQL语法、表关联和零分母处理可运行;本地API健康HTTP 200。Redis未启动时API持续输出连接拒绝,故未将该不完整本地栈作为页面功能验收,验证后已关闭本地API、预览和PostgreSQL。
|
||||||
|
- 本轮只做本地提交,不推送、不部署,不访问或修改测试/预生产业务数据;不发送、补发、重投或重新入队短信,不修改余额、通道或客户配置。本节与源码、测试用例一并纳入本轮本地提交。
|
||||||
|
|
||||||
|
## 2026-09-04 WPS报备资料兼容与工作台修复(本地验证完成,待测试环境发布)
|
||||||
|
|
||||||
|
- 以本地`main`的`48d0363`为基线实施,本地相对`origin/main`领先2个提交且未落后;没有pull、切分支或回退。既有未跟踪`docs/report-material-pool-remediation-plan-20260903.md`保持原样,本轮方案文档单独纳入精确提交范围。
|
||||||
|
- WPS导入新增原始OOXML解析:精确识别`_xlfn.DISPIMG("ID_...",1)`,经`xl/cellimages.xml`及关系文件定位媒体,再与ExcelJS标准Drawing图片按单元格合并;普通公式、宏/外部对象、不完整关系和非白名单图片继续拒绝,没有整体放宽公式安全校验。
|
||||||
|
- 真实样本`C:\Users\hectorzhao\Downloads\行业报备.xlsx`为20,976,525字节,解析得到工作表“行业”、17行、13列、43张业务图片,图片总字节20,672,756;A1的84字节透明Drawing占位图被排除。该只读样本未写回或覆盖。
|
||||||
|
- 报备导入前端和API均将企业应用改为必选,并由后端校验应用属于所选企业;XLSX压缩文件上限调整为100MiB,增加500MiB解压总量限制。Nginx私有站点模板同步调整为110m以容纳multipart开销;这会提高单请求资源峰值,因此仍保留文件数、部件数、压缩/解压体积和格式安全限制。
|
||||||
|
- 批次报备文件弹窗与两个单条导出入口均可选择“系统Excel Drawing”或“WPS单元格图片”;默认保持现有Excel格式,WPS格式由同一真实资料快照生成并可被新解析器回读,不改变数据库资料、报备状态或短信链路。
|
||||||
|
- 报备字段库“添加字段”移入字段定义区域;通道字段配置弹窗加载真实通用字段作为右侧默认项,固定左侧卡片最小高度,移除按钮改为非危险填充的通用宽度。通道详情同时展示名称和编号;资料弹窗展示图片、字段名称/代码/导出名,并为未删除历史字段回填字段库名称。
|
||||||
|
- 运营看板指标按业务阅读顺序调整为发送总量、分片数、总体成功率、到达率、活跃签名、消费、返还、计收、利润和利润率;只调整排列,不改变上一提交新增的真实聚合口径。
|
||||||
|
- 定向前端4文件12项、前端全量13文件63项、定向API3套20项、API全量53套608项通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、增量ESLint/Prettier、入口包体积和`git diff --check`通过。ESLint仅保留3条既有Hook依赖warning;Vite仅保留既有Chart分块超过500kB提示,入口gzip 107.80KiB,低于250KiB预算。
|
||||||
|
- Browser插件及工作区Playwright依赖在当前会话不可用,尚未把组件测试或本地构建冒充真实页面验收;测试环境部署后的登录页、真实API/服务、控制台和登录后交互仍需继续核验。测试机当前网络可达,但已有非交互密钥认证返回`Permission denied (publickey,password)`,正在复用工作站既有安全认证方式,不在命令或日志中写入密码。
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
# WPS 单元格图片导入与双格式导出实现方案
|
||||||
|
|
||||||
|
日期:2026-09-04
|
||||||
|
状态:已按方案实施并通过本地验证
|
||||||
|
范围:报备资料导入、单条报备资料导出、批次通道文件下载和批次 ZIP 下载
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在不改变现有报备资料、材料版本、审核、批次和通道报备状态逻辑的前提下:
|
||||||
|
|
||||||
|
1. 导入自动识别并解析两类 `.xlsx` 图片:
|
||||||
|
- ExcelJS 当前支持的标准 Drawing 图片;
|
||||||
|
- WPS `DISPIMG + xl/cellimages.xml` 单元格图片。
|
||||||
|
2. 导出时让用户选择:
|
||||||
|
- Excel 通用格式:保持系统现有标准 Drawing 图片格式;
|
||||||
|
- WPS 单元格图片格式:生成与样本相同机制的 `DISPIMG` 单元格图片文件。
|
||||||
|
3. 保持公式安全检查严格有效。只允许与包内受控图片一一对应的 WPS `DISPIMG`,不得直接放宽为允许任意公式。
|
||||||
|
4. 两种格式使用相同的真实 PostgreSQL 资料、批次快照和 MinIO 文件,格式选择不得改变业务数据、任务状态或历史批次内容。
|
||||||
|
|
||||||
|
本方案不涉及短信发送、Redis Stream、队列、计费、余额、通道连接参数或部署架构调整。
|
||||||
|
|
||||||
|
## 2. 样本核验结果
|
||||||
|
|
||||||
|
样本:`C:\Users\hectorzhao\Downloads\行业报备.xlsx`
|
||||||
|
|
||||||
|
| 项目 | 实测结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文件大小 | 20,976,525 字节 |
|
||||||
|
| SHA-256 | `D5B5029B044EF5B8A86A68D4FE33E4F80D3AAB3A9B547D228145158D4B1518C1` |
|
||||||
|
| 工作表 | `行业` |
|
||||||
|
| 使用范围 | `A1:M17`,1 行表头、16 行数据 |
|
||||||
|
| WPS 图片公式 | 43 个,位于 H2:K17 的非空图片单元格 |
|
||||||
|
| `cellimages.xml` 图片项 | 43 个 |
|
||||||
|
| 图片关系 | 43 个,和公式 ID 一一对应 |
|
||||||
|
| 业务图片 | `xl/media/image2.png` 至 `image44.png`,均为 PNG |
|
||||||
|
| 业务图片原始字节合计 | 20,672,756 字节,约占整个文件 98.55% |
|
||||||
|
| 图片像素范围 | 宽 461~1226,高 276~840 |
|
||||||
|
| 标准 Drawing | 3 个,均指向 A1 的同一张 1×1、84 字节 PNG 占位图 |
|
||||||
|
|
||||||
|
样本中每个图片单元格保存的不是标准 Drawing 锚点,而是以下公式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_xlfn.DISPIMG("ID_7C706627234641A7BCEA9177C8A3EACB",1)
|
||||||
|
```
|
||||||
|
|
||||||
|
其引用链为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sheet1.xml 中的单元格公式
|
||||||
|
-> 公式内 ID_xxx
|
||||||
|
-> xl/cellimages.xml 中 xdr:cNvPr@name
|
||||||
|
-> a:blip@r:embed
|
||||||
|
-> xl/_rels/cellimages.xml.rels
|
||||||
|
-> xl/media/imageN.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`xl/_rels/workbook.xml.rels` 还包含 WPS 专用关系:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Type="http://www.wps.cn/officeDocument/2020/cellImage"
|
||||||
|
Target="cellimages.xml"
|
||||||
|
```
|
||||||
|
|
||||||
|
`[Content_Types].xml` 包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContentType="application/vnd.wps-officedocument.cellimage+xml"
|
||||||
|
PartName="/xl/cellimages.xml"
|
||||||
|
```
|
||||||
|
|
||||||
|
样本中的图片显示尺寸较小,但 PNG 原始像素和原始字节仍完整保存在 XLSX 中。调整单元格显示尺寸不等于压缩图片,WPS 格式选择也不应被描述为压缩功能。
|
||||||
|
|
||||||
|
## 3. 与系统现有图片处理方式的差异
|
||||||
|
|
||||||
|
### 3.1 当前导入
|
||||||
|
|
||||||
|
当前实现使用 ExcelJS 加载工作簿,然后:
|
||||||
|
|
||||||
|
1. `assertSafeWorkbook` 拒绝任何公式或疑似公式文本;
|
||||||
|
2. `readEmbeddedImages` 只读取 `worksheet.getImages()`;
|
||||||
|
3. 根据 Drawing 左上角锚点换算图片所在行列;
|
||||||
|
4. 提交导入时将图片上传至真实 MinIO,并把 `fileObjectId/fileName/contentType` 写入待审核资料。
|
||||||
|
|
||||||
|
对本次样本的实际结果是:
|
||||||
|
|
||||||
|
- ExcelJS 将 43 个 `DISPIMG` 识别为公式,因此现有安全检查会直接拒绝文件;
|
||||||
|
- ExcelJS `worksheet.getImages()` 只返回 3 个 A1 的 1×1 占位 Drawing;
|
||||||
|
- 43 个真实业务图片虽然进入 ExcelJS 的媒体集合,但没有单元格锚点,当前代码无法关联到 H2:K17;
|
||||||
|
- 即使简单放宽公式检查,图片列仍会被识别为无图片,单元格文本还可能变成 `=DISPIMG(...)`,不能得到真实文件对象;
|
||||||
|
- 当前上传上限为 10 MiB,而样本约 20.0 MiB,会在工作簿解析前被上传中间件拒绝。
|
||||||
|
|
||||||
|
### 3.2 当前导出
|
||||||
|
|
||||||
|
当前导出使用 ExcelJS:
|
||||||
|
|
||||||
|
1. 从真实 MinIO 下载 PNG/JPEG/GIF 原文件;
|
||||||
|
2. `workbook.addImage` 注册媒体;
|
||||||
|
3. `worksheet.addImage` 以 `oneCell` Drawing 锚点覆盖到目标单元格;
|
||||||
|
4. 按通道配置调整列宽、图片显示范围和行高;
|
||||||
|
5. 图片单元格本身仍保留文件名文本。
|
||||||
|
|
||||||
|
该格式是标准 DrawingML 图片:Excel 和 WPS 通常都能打开,兼容范围更广;但图片本质是浮动绘图对象,不是 WPS 的单元格图片值。
|
||||||
|
|
||||||
|
### 3.3 差异结论
|
||||||
|
|
||||||
|
| 对比项 | 系统现有 Excel 格式 | 样本 WPS 格式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单元格内容 | 文件名文本 | `DISPIMG` 公式 |
|
||||||
|
| 图片定位 | 工作表 Drawing 锚点 | 公式 ID 关联 `cellimages.xml` |
|
||||||
|
| 图片关系文件 | `xl/drawings/*.xml(.rels)` | `xl/cellimages.xml` 和专用 rels |
|
||||||
|
| ExcelJS 直接读取 | 支持 | 不支持单元格映射 |
|
||||||
|
| Microsoft Excel 兼容性 | 较好 | 取决于 Excel 版本,可能显示 `_xlfn.DISPIMG` 或不显示图片 |
|
||||||
|
| WPS 行/列语义 | 浮动对象 | WPS 原生单元格图片 |
|
||||||
|
| 图片原始大小 | 默认保留原图 | 样本同样保留原图 |
|
||||||
|
|
||||||
|
因此不能用“允许 DISPIMG 公式”代替 WPS 图片支持,也不能把两种格式合并为同一解析路径。
|
||||||
|
|
||||||
|
## 4. 产品交互方案
|
||||||
|
|
||||||
|
### 4.1 导入
|
||||||
|
|
||||||
|
导入不要求用户预先选择格式。上传后由后端自动检测:
|
||||||
|
|
||||||
|
- `excel_drawing`:仅存在标准 Drawing 图片;
|
||||||
|
- `wps_cell_image`:存在有效 `cellimages.xml` 和匹配的 `DISPIMG`;
|
||||||
|
- `mixed`:两种图片同时存在;
|
||||||
|
- `none`:没有图片。
|
||||||
|
|
||||||
|
解析结果页显示只读提示,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
已识别:WPS 单元格图片格式,43 张图片
|
||||||
|
```
|
||||||
|
|
||||||
|
混合格式按单元格合并。若同一单元格同时存在 WPS 单元格图片和标准 Drawing,视为冲突并要求用户修正,不静默选择其中一个。
|
||||||
|
|
||||||
|
### 4.2 单条报备资料导出
|
||||||
|
|
||||||
|
“导出报备资料”点击后打开格式选择弹窗:
|
||||||
|
|
||||||
|
- `Excel 通用格式`,默认选中;说明“图片为标准 Excel 图片,兼容 Excel 和 WPS”;
|
||||||
|
- `WPS 单元格图片格式`;说明“图片作为 WPS 单元格图片,Microsoft Excel 兼容性取决于版本”。
|
||||||
|
|
||||||
|
确认后才请求导出。关闭弹窗不发请求,生成中禁止重复提交,失败保留选择并展示后端真实错误。
|
||||||
|
|
||||||
|
### 4.3 报备批次导出
|
||||||
|
|
||||||
|
现有“报备文件导出”已经是弹窗,不再叠加第二层弹窗。在弹窗顶部增加同一格式选择:
|
||||||
|
|
||||||
|
- 下载单个通道文件时应用当前选择;
|
||||||
|
- “全部下载”时,ZIP 内所有 XLSX 使用同一种选择格式;
|
||||||
|
- TXT 简报不受影响。
|
||||||
|
|
||||||
|
Excel 通用格式继续使用现有文件名。WPS 文件增加 `_WPS` 后缀以免用户混淆,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-09-04_通道名_RBxxxx_WPS.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
默认值始终为 Excel 通用格式,不改变既有用户的下载结果和接口行为。
|
||||||
|
|
||||||
|
## 5. 后端实现设计
|
||||||
|
|
||||||
|
### 5.1 增加统一格式枚举
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
|
||||||
|
```
|
||||||
|
|
||||||
|
所有入口统一校验该枚举,缺省为 `excel_drawing`。不接受任意字符串、文件扩展名或由前端提交的 OOXML 片段。
|
||||||
|
|
||||||
|
建议接口调整:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/admin/report-materials/single-export
|
||||||
|
body.outputFormat = excel_drawing | wps_cell_image
|
||||||
|
|
||||||
|
GET /api/admin/report-materials/batches/:id/files/:fileId/download
|
||||||
|
?outputFormat=excel_drawing|wps_cell_image
|
||||||
|
|
||||||
|
GET /api/admin/report-materials/batches/:id/download
|
||||||
|
?outputFormat=excel_drawing|wps_cell_image
|
||||||
|
```
|
||||||
|
|
||||||
|
权限、租户、批次、通道和文件归属校验保持现有逻辑;格式参数不参与业务对象定位。
|
||||||
|
|
||||||
|
### 5.2 导入前置 OOXML 检查器
|
||||||
|
|
||||||
|
在 ExcelJS 之前增加 `WorkbookPackageInspector`,使用项目已有 `jszip` 读取原始 XLSX 包,只做受限结构解析:
|
||||||
|
|
||||||
|
1. 验证 ZIP 路径规范化,拒绝绝对路径、`..` 路径穿越和重复关键部件;
|
||||||
|
2. 拒绝宏、ActiveX、OLE、外部链接、外部关系和非预期可执行部件;
|
||||||
|
3. 读取 workbook、worksheet、关系文件、`cellimages.xml` 和媒体清单;
|
||||||
|
4. 从原始 worksheet XML 提取真实公式单元格,避免 ExcelJS 因合并单元格复制公式造成重复图片;
|
||||||
|
5. 建立 `sheetName + row + column -> image` 映射;
|
||||||
|
6. 输出检测格式、图片数、冲突和安全诊断,再交给现有 ExcelJS 文本/样式解析流程。
|
||||||
|
|
||||||
|
本次样本在原始 XML 中是 43 个公式;ExcelJS 模型中会因合并单元格显示为 49 个公式。因此 WPS 图片定位必须以原始 worksheet XML 为准。
|
||||||
|
|
||||||
|
### 5.3 WPS 单元格图片解析器
|
||||||
|
|
||||||
|
新增 `WpsCellImageReader`,按以下顺序解析:
|
||||||
|
|
||||||
|
1. 从 workbook relationships 找到且仅找到一个 WPS cellImage 部件;
|
||||||
|
2. 解析 `cellimages.xml` 中每个 `xdr:cNvPr@name` 和 `a:blip@r:embed`;
|
||||||
|
3. 通过 `cellimages.xml.rels` 解析内部媒体路径;
|
||||||
|
4. 只接受包内图片关系,不允许 `TargetMode="External"`;
|
||||||
|
5. 从每个原始工作表单元格提取严格格式的 `DISPIMG` ID;
|
||||||
|
6. 校验公式 ID、cellImage ID、relationship ID 和媒体对象一一可解析;
|
||||||
|
7. 返回与现有 `EmbeddedImage` 相同的 `{row, column, extension, buffer}`,让后续映射、MinIO 上传和审核流程继续复用。
|
||||||
|
|
||||||
|
不把 `cellimages.xml` 中的 `a:xfrm` 坐标当作单元格地址。样本的业务单元格位置来自公式本身,ID 才是图片关联键。
|
||||||
|
|
||||||
|
### 5.4 公式安全策略
|
||||||
|
|
||||||
|
保留现有“默认拒绝所有公式”原则,仅为已验证的 WPS 图片单元格建立精确白名单。允许条件必须全部满足:
|
||||||
|
|
||||||
|
1. 原始公式完整匹配:
|
||||||
|
|
||||||
|
```text
|
||||||
|
^_xlfn\.DISPIMG\("ID_[A-F0-9]{32}",1\)$
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 公式所在工作表和单元格已由 OOXML 检查器登记;
|
||||||
|
3. ID 在 `cellimages.xml` 中唯一存在;
|
||||||
|
4. 图片关系是包内图片且目标文件通过类型、大小和签名校验;
|
||||||
|
5. 一个公式只引用一张图片,一个 ID 不允许被不受控地复用到多个业务单元格;
|
||||||
|
6. 除该单元格外,工作簿不存在其他公式、共享公式、数组公式、外部链接或疑似公式文本。
|
||||||
|
|
||||||
|
不要修改为“公式名包含 DISPIMG 即放行”,也不要只依赖 ExcelJS 解析后的 `cell.value.result`。
|
||||||
|
|
||||||
|
### 5.5 标准 Drawing 与 WPS 图片统一
|
||||||
|
|
||||||
|
`readEmbeddedImages` 调整为接收前置检查结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
标准 Drawing 解析结果
|
||||||
|
+ WPS cell image 解析结果
|
||||||
|
-> 按 sheet/row/column 合并
|
||||||
|
-> 冲突检测
|
||||||
|
-> 统一 EmbeddedImage[]
|
||||||
|
```
|
||||||
|
|
||||||
|
样本中 A1 的 3 个 1×1 Drawing 是占位对象,不应当被当作 H2:K17 的图片,也不应以“图片数量大于零”证明 WPS 图片已解析。是否在 WPS 导出中生成类似占位 Drawing,应通过最小原型在目标 WPS 版本中验证后决定;第一版不盲目复制样本中的冗余占位对象。
|
||||||
|
|
||||||
|
### 5.6 上传大小与资源保护
|
||||||
|
|
||||||
|
样本超过当前 10 MiB 上限。按最终业务要求将报备 XLSX 单文件上限调整为 100 MiB,同时增加解包级限制,而不是只提高 Multer 上限:
|
||||||
|
|
||||||
|
- ZIP 压缩文件最大 100 MiB;
|
||||||
|
- 解压后总大小最大 500 MiB;
|
||||||
|
- ZIP 项目数最大 1,000;
|
||||||
|
- 单张图片最大 10 MiB;
|
||||||
|
- 单工作簿图片最大 500 张;
|
||||||
|
- 工作表最大 20 张、单表最大 20,000 行和 200 列;
|
||||||
|
- XML 单部件最大 10 MiB;
|
||||||
|
- 图片必须校验文件签名和实际 MIME,不能只信扩展名或 relationship;
|
||||||
|
- 分析和提交阶段均执行同一检查,不能只在预览阶段检查;
|
||||||
|
- 超限返回明确错误码和当前限制,不发生部分 MinIO 上传或部分数据库落库。
|
||||||
|
|
||||||
|
100 MiB 文件在 ExcelJS、JSZip 和图片 Buffer 并存时会产生数倍内存峰值。实施时需要记录 1、5、20、50、100 MiB 样本的解析耗时和 Node RSS,必要时限制并发分析数;不能仅根据压缩文件大小估算内存。
|
||||||
|
|
||||||
|
### 5.7 WPS 导出生成器
|
||||||
|
|
||||||
|
保留 ExcelJS 作为工作簿表格、样式、列宽、行高和标准格式的唯一生成入口,再增加 `WpsCellImageWorkbookTransformer` 对生成结果做受控 OOXML 后处理:
|
||||||
|
|
||||||
|
1. 先按现有代码生成标准 XLSX;
|
||||||
|
2. 根据标准 Drawing 锚点确定每张报备图片的目标单元格;
|
||||||
|
3. 为每张图片生成唯一 `ID_` 加 32 位大写十六进制标识;
|
||||||
|
4. 创建 `xl/cellimages.xml`;
|
||||||
|
5. 创建 `xl/_rels/cellimages.xml.rels`;
|
||||||
|
6. 在 workbook relationships 增加 WPS cellImage 关系;
|
||||||
|
7. 在 `[Content_Types].xml` 增加 WPS cellImage 类型;
|
||||||
|
8. 将目标单元格改写为严格的 `_xlfn.DISPIMG("ID",1)` 公式及缓存显示值;
|
||||||
|
9. 移除已经转换的 Drawing 图片锚点和失去引用的媒体关系;
|
||||||
|
10. 保留表头、文本、列宽、行高、冻结窗格、批次快照内容和未转换对象;
|
||||||
|
11. 重新打包并再次执行包结构、关系完整性和公式安全自检。
|
||||||
|
|
||||||
|
这样可以复用现有成熟导出逻辑,避免为 WPS 另写一套字段取值、转换、图片下载和样式代码。
|
||||||
|
|
||||||
|
### 5.8 当前批次文件的处理
|
||||||
|
|
||||||
|
格式选择发生在下载时,不修改历史批次和 MinIO 原文件:
|
||||||
|
|
||||||
|
- Excel 通用格式:直接返回现有 MinIO XLSX,字节和哈希保持不变;
|
||||||
|
- WPS 单元格图片格式:读取该批次现有 XLSX,在内存或受控临时目录中转换后返回;
|
||||||
|
- 批次 ZIP:逐通道转换 XLSX,再和原 TXT 简报一起打包;任一文件转换失败则整个 ZIP 明确失败,不输出不完整包;
|
||||||
|
- 第一版不缓存 WPS 派生文件,不新增数据库记录,也不覆盖原 `ReportExportFile`;如真实性能证明需要缓存,再单独设计派生文件生命周期。
|
||||||
|
|
||||||
|
单条导出则先按现有逻辑生成标准工作簿,再根据选择决定是否转换为 WPS 格式。
|
||||||
|
|
||||||
|
## 6. 前端修改范围
|
||||||
|
|
||||||
|
预计涉及:
|
||||||
|
|
||||||
|
- `src/apps/admin/AdminReportTasksPage.tsx`:单条导出格式弹窗;
|
||||||
|
- `src/apps/admin/AdminChannelReportPage.tsx`:单条导出格式弹窗;
|
||||||
|
- `src/apps/admin/AdminReportBatchesPage.tsx`:现有导出弹窗增加格式选择;
|
||||||
|
- `src/api/admin/channels-reports.api.ts`:传递 `outputFormat`;
|
||||||
|
- 报备导入分析弹窗:显示自动识别的图片格式和数量,展示不支持/冲突原因;
|
||||||
|
- 共用一个格式选择组件和类型,不在三个页面复制状态及文案。
|
||||||
|
|
||||||
|
桌面端和 390px 窄屏均需验证弹窗选项、说明、生成中、失败重试和下载行为。格式选择不写入 localStorage,重新打开默认回到 Excel 通用格式。
|
||||||
|
|
||||||
|
## 7. 后端修改范围
|
||||||
|
|
||||||
|
预计涉及:
|
||||||
|
|
||||||
|
- `api/src/report-materials/report-materials.controller.ts`:上传上限和导出格式参数;
|
||||||
|
- `api/src/report-materials/report-materials.contracts.ts`:格式枚举及导入诊断类型;
|
||||||
|
- `api/src/report-materials/report-materials.helpers.ts`:保留通用 helper,公式检查改为接收精确白名单;
|
||||||
|
- `api/src/report-materials/import-parser.service.ts`、`import-review.service.ts`:分析及提交阶段统一解析;
|
||||||
|
- `api/src/report-materials/channel-export.service.ts`:单条导出选择;
|
||||||
|
- `api/src/report-materials/batch-download.service.ts`:批次单文件和 ZIP 选择;
|
||||||
|
- 新增独立 OOXML 包检查、WPS 图片读取和 WPS 输出转换服务;
|
||||||
|
- 继续复用 `FilesService`、MinIO、操作日志和现有字段映射。
|
||||||
|
|
||||||
|
不要把 ZIP/XML 细节继续堆入已经较大的 report-materials service。解析器和转换器应是无数据库副作用的纯服务,方便使用合成工作簿做完整安全测试。
|
||||||
|
|
||||||
|
## 8. 数据库、MinIO 和兼容性判断
|
||||||
|
|
||||||
|
### 8.1 数据库
|
||||||
|
|
||||||
|
第一版不需要 Prisma migration:
|
||||||
|
|
||||||
|
- 导入格式、图片数量和诊断可写入现有导入批次 `preview` JSON;
|
||||||
|
- 导入后的图片仍是现有 `FileObject`;
|
||||||
|
- 导出格式是一次下载请求参数,不改变材料版本和批次记录。
|
||||||
|
|
||||||
|
若以后要求“记住每个通道默认格式”或缓存 WPS 派生文件,才需要单独设计配置或文件记录,不在本次顺带增加。
|
||||||
|
|
||||||
|
### 8.2 MinIO
|
||||||
|
|
||||||
|
- 导入后继续逐张保存真实图片对象;
|
||||||
|
- 原始导入 XLSX 继续按现有流程保存;
|
||||||
|
- WPS 导出第一版不永久保存,不覆盖现有批次文件;
|
||||||
|
- 任何解析失败都不得留下无法关联的部分文件。若提交阶段已上传部分行图片后某行失败,应沿用现有逐行结果并增加可追踪清理策略测试。
|
||||||
|
|
||||||
|
### 8.3 格式兼容
|
||||||
|
|
||||||
|
- `excel_drawing` 是跨 Excel/WPS 的默认格式;
|
||||||
|
- `wps_cell_image` 明确标注为 WPS 优先格式;
|
||||||
|
- 两者扩展名均为 `.xlsx`,但内部结构不同;
|
||||||
|
- 不承诺旧版 Microsoft Excel 原生显示 WPS `DISPIMG`;
|
||||||
|
- WPS 目标版本、Windows Excel 365 和 LibreOffice 至少各做一次打开结果记录,不能只用 ExcelJS 回读判定可交付。
|
||||||
|
|
||||||
|
## 9. 错误处理
|
||||||
|
|
||||||
|
建议增加稳定错误码:
|
||||||
|
|
||||||
|
| 错误码 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `WORKBOOK_TOO_LARGE` | 压缩文件超过上限 |
|
||||||
|
| `WORKBOOK_EXPANDED_TOO_LARGE` | 解包总量超过上限 |
|
||||||
|
| `WORKBOOK_UNSAFE_PART` | 宏、外链、OLE 或危险部件 |
|
||||||
|
| `WORKBOOK_FORMULA_NOT_ALLOWED` | 存在非白名单公式 |
|
||||||
|
| `WPS_CELL_IMAGE_RELATION_INVALID` | ID、关系或媒体缺失/重复 |
|
||||||
|
| `WPS_CELL_IMAGE_CONFLICT` | 同一单元格存在两种图片 |
|
||||||
|
| `WORKBOOK_IMAGE_TYPE_UNSUPPORTED` | 图片真实类型不支持 |
|
||||||
|
| `WORKBOOK_IMAGE_LIMIT_EXCEEDED` | 图片数量或单图大小超限 |
|
||||||
|
| `WPS_EXPORT_CONVERSION_FAILED` | 标准文件转 WPS 失败 |
|
||||||
|
|
||||||
|
错误信息要指出工作表、单元格或部件,但不得回显原始图片内容、服务器路径或敏感文件对象信息。
|
||||||
|
|
||||||
|
## 10. 测试方案
|
||||||
|
|
||||||
|
### 10.1 解析器单元测试
|
||||||
|
|
||||||
|
使用代码生成的小型 OOXML fixture,不把包含真实企业和个人信息的样本提交到仓库:
|
||||||
|
|
||||||
|
1. 2 张标准 Drawing 图片;
|
||||||
|
2. 2 张有效 WPS 单元格图片;
|
||||||
|
3. 标准/WPS 混合但不冲突;
|
||||||
|
4. 同单元格冲突;
|
||||||
|
5. 公式 ID 不存在;
|
||||||
|
6. cellImage ID 重复;
|
||||||
|
7. relationship 缺失、外链、路径穿越;
|
||||||
|
8. 图片扩展名与文件签名不一致;
|
||||||
|
9. 普通公式、共享公式、数组公式和伪造 `DISPIMG`;
|
||||||
|
10. 合并单元格下不重复计数;
|
||||||
|
11. ZIP 项目数、单图、总解包大小超限;
|
||||||
|
12. PNG、JPEG、GIF 的支持结果。
|
||||||
|
|
||||||
|
### 10.2 当前样本回归
|
||||||
|
|
||||||
|
只在本地受控测试中使用当前样本,验收:
|
||||||
|
|
||||||
|
- 自动识别 `wps_cell_image`;
|
||||||
|
- 识别 1 个工作表、16 条数据、43 张业务图片;
|
||||||
|
- 43 个公式 ID、cellImage 和媒体关系全部匹配;
|
||||||
|
- 不把 A1 的 3 个 1×1 占位 Drawing 计入业务字段;
|
||||||
|
- 图片列、行号和预览映射正确;
|
||||||
|
- 提交前后图片哈希一致;
|
||||||
|
- 非图片字段和手机号等文本/数字读取不被 WPS 解析器改变。
|
||||||
|
|
||||||
|
### 10.3 真实后端集成测试
|
||||||
|
|
||||||
|
在明确授权的测试环境使用真实 API、PostgreSQL 和 MinIO:
|
||||||
|
|
||||||
|
1. 分析样本但不审核应用,确认原业务签名不变化;
|
||||||
|
2. 使用专用测试企业/应用提交少量合成行,确认 FileObject、导入批次和待审核项一致;
|
||||||
|
3. 审核通过后确认图片字段落入真实材料、材料版本只按既有规则变化一次;
|
||||||
|
4. 失败行不清空已有补资料字段;
|
||||||
|
5. 删除测试数据前单独确认清理边界,不触碰真实客户资料。
|
||||||
|
|
||||||
|
### 10.4 双格式导出测试
|
||||||
|
|
||||||
|
同一批次快照分别导出两种格式:
|
||||||
|
|
||||||
|
- 文本、字段顺序、默认值、转换、行数和图片内容哈希一致;
|
||||||
|
- Excel 格式仍使用标准 Drawing,既有文件字节不被修改;
|
||||||
|
- WPS 格式中每个图片单元格的公式、ID、关系和媒体一一对应;
|
||||||
|
- WPS 中图片按单元格显示,排序、筛选、调整行高后行为符合目标版本;
|
||||||
|
- Excel 365 打开两种格式并记录 WPS 格式的实际兼容结果;
|
||||||
|
- 单通道下载和全部 ZIP 下载均验证文件名、数量、内容和失败原子性;
|
||||||
|
- 没有图片的工作簿两种格式内容一致,WPS 格式不生成空的 cellImage 部件。
|
||||||
|
|
||||||
|
### 10.5 性能和资源
|
||||||
|
|
||||||
|
记录 1、5、20、50、100 MiB 文件的:
|
||||||
|
|
||||||
|
- 上传和分析总耗时;
|
||||||
|
- JSZip 检查耗时;
|
||||||
|
- ExcelJS 加载耗时;
|
||||||
|
- 峰值 RSS;
|
||||||
|
- 43、100、500 张图片时的处理时间;
|
||||||
|
- 批次 ZIP 多通道转换的总耗时和临时空间。
|
||||||
|
|
||||||
|
达到上限时应快速、明确失败,不使 API 进程因并发大文件出现长时间无响应。
|
||||||
|
|
||||||
|
### 10.6 前端和门禁
|
||||||
|
|
||||||
|
- 导入自动识别提示、映射预览、空数据、失败、权限和超限状态;
|
||||||
|
- 三个导出入口的弹窗、默认选项、取消、生成中、失败重试和成功下载;
|
||||||
|
- 1600px 桌面和 390px 窄屏;
|
||||||
|
- 浏览器 Network 参数、响应文件名和 Console;
|
||||||
|
- API 定向与全量测试、前后端 TypeScript、Vite 构建、Prisma validate、依赖安全、部署契约和 `git diff --check`;
|
||||||
|
- 同步 `docs/system-functional-test-cases.md` 和 `docs/testing-progress.md`。
|
||||||
|
|
||||||
|
构建、ExcelJS 回读或合成 fixture 通过都不能代替真实 WPS 打开和真实 API/PostgreSQL/MinIO 验收。
|
||||||
|
|
||||||
|
## 11. 实施顺序
|
||||||
|
|
||||||
|
1. 先做无业务依赖的最小 WPS OOXML 读写原型,使用 2 行、PNG/JPEG/GIF 小图在目标 WPS 和 Excel 365 实际打开。
|
||||||
|
2. 固化包安全限制和 `DISPIMG` 精确白名单。
|
||||||
|
3. 实现 `WorkbookPackageInspector` 与 `WpsCellImageReader`,接入分析和提交两阶段。
|
||||||
|
4. 调整上传上限并加入解包、图片和内存保护。
|
||||||
|
5. 实现标准 XLSX 到 WPS 单元格图片的纯转换器。
|
||||||
|
6. 接入单条导出、批次单文件和批次 ZIP 三个后端入口。
|
||||||
|
7. 实现共用格式选择 UI 和导入识别提示。
|
||||||
|
8. 完成合成 fixture、当前样本、真实 MinIO、真实数据库、浏览器及 WPS/Excel 客户端验收。
|
||||||
|
9. 更新测试用例和测试进度;是否提交、推送、部署分别等待明确授权。
|
||||||
|
|
||||||
|
## 12. 影响与风险结论
|
||||||
|
|
||||||
|
- 前端:中等,涉及三个导出入口和导入提示,但不改变报备业务表格主流程。
|
||||||
|
- 后端:较高,涉及不受 ExcelJS 支持的 WPS 私有 OOXML 扩展、ZIP 安全、内存峰值和批量转换。
|
||||||
|
- 数据库:预计无 migration。
|
||||||
|
- MinIO:复用现有对象;WPS 派生文件第一版不持久化。
|
||||||
|
- 短信链路:无影响,不应触发发送、补发、重投或重新入队。
|
||||||
|
- 最大风险不是 UI,而是错误放宽公式安全检查、ZIP 资源消耗、WPS 与 Excel 客户端兼容差异,以及把占位 Drawing 误认成业务图片。
|
||||||
|
|
||||||
|
最小充分范围是“自动读取 WPS 单元格图片 + 保留现有 Excel 导出 + 按需生成 WPS 变体”。不在本次加入图片压缩、通道默认格式、派生文件缓存、历史数据回写或更多办公格式。
|
||||||
@@ -162,7 +162,7 @@ export const adminChannelsReportsApi = {
|
|||||||
file: File,
|
file: File,
|
||||||
body: {
|
body: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
applicationId?: string;
|
applicationId: string;
|
||||||
reportType: 'signature' | 'drainage';
|
reportType: 'signature' | 'drainage';
|
||||||
sheetName?: string;
|
sheetName?: string;
|
||||||
headerRowCount?: number;
|
headerRowCount?: number;
|
||||||
@@ -170,7 +170,7 @@ export const adminChannelsReportsApi = {
|
|||||||
profileId?: string;
|
profileId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
assertUploadFileSize(file);
|
assertUploadFileSize(file, { bytes: 100 * 1024 * 1024, message: '报备资料文件大小不能超过 100MB' });
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('file', file);
|
form.set('file', file);
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
@@ -223,9 +223,13 @@ 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`),
|
downloadReportMaterialBatch: (id: string, outputFormat: 'excel_drawing' | 'wps_cell_image' = 'excel_drawing') =>
|
||||||
downloadReportMaterialBatchFile: (id: string, fileId: string) =>
|
requestBlob(withQuery(`/admin/report-materials/batches/${id}/download`, { outputFormat })),
|
||||||
requestBlob(`/admin/report-materials/batches/${id}/files/${fileId}/download`),
|
downloadReportMaterialBatchFile: (
|
||||||
|
id: string,
|
||||||
|
fileId: string,
|
||||||
|
outputFormat: 'excel_drawing' | 'wps_cell_image' = 'excel_drawing',
|
||||||
|
) => requestBlob(withQuery(`/admin/report-materials/batches/${id}/files/${fileId}/download`, { outputFormat })),
|
||||||
listReportMaterialBatchTasks: (
|
listReportMaterialBatchTasks: (
|
||||||
id: string,
|
id: string,
|
||||||
query: {
|
query: {
|
||||||
@@ -314,6 +318,7 @@ export const adminChannelsReportsApi = {
|
|||||||
carrier?: 'mobile' | 'unicom' | 'telecom';
|
carrier?: 'mobile' | 'unicom' | 'telecom';
|
||||||
drainageItemId?: string;
|
drainageItemId?: string;
|
||||||
batchItemId?: string;
|
batchItemId?: string;
|
||||||
|
outputFormat?: 'excel_drawing' | 'wps_cell_image';
|
||||||
}) => requestBlob('/admin/report-materials/single-export', { method: 'POST', body: JSON.stringify(body) }),
|
}) => requestBlob('/admin/report-materials/single-export', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
createReportTask: (body: {
|
createReportTask: (body: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ export const adminGovernanceApi = {
|
|||||||
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||||
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) =>
|
||||||
|
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
||||||
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
|
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
|
||||||
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
|
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
|
||||||
@@ -208,6 +210,11 @@ export const adminGovernanceApi = {
|
|||||||
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
|
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
|
||||||
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
||||||
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) =>
|
||||||
|
request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
||||||
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
|
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
|
||||||
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -140,6 +140,23 @@ describe('request tenant and error boundaries', () => {
|
|||||||
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
|
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks string-body blob requests as JSON so download endpoints receive their payload', async () => {
|
||||||
|
let receivedContentType = '';
|
||||||
|
let receivedBody: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/admin/report-export', async ({ request: incoming }) => {
|
||||||
|
receivedContentType = incoming.headers.get('content-type') ?? '';
|
||||||
|
receivedBody = await incoming.json();
|
||||||
|
return new HttpResponse('xlsx-data', { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
(await requestBlob('/admin/report-export', { method: 'POST', body: JSON.stringify({ reportType: 'signature' }) })).text(),
|
||||||
|
).resolves.toBe('xlsx-data');
|
||||||
|
expect(receivedContentType).toContain('application/json');
|
||||||
|
expect(receivedBody).toEqual({ reportType: 'signature' });
|
||||||
|
});
|
||||||
|
|
||||||
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
|
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
|
||||||
writeSession({
|
writeSession({
|
||||||
portal: 'admin',
|
portal: 'admin',
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
|||||||
|
|
||||||
export async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
export async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
if (typeof options.body === 'string' && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
const portal = requestPortal(path);
|
const portal = requestPortal(path);
|
||||||
const session = portal ? readSession(portal) : null;
|
const session = portal ? readSession(portal) : null;
|
||||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ export type DashboardResponse = {
|
|||||||
spendCents: number;
|
spendCents: number;
|
||||||
returnedCents: number;
|
returnedCents: number;
|
||||||
billingUnits: number;
|
billingUnits: number;
|
||||||
|
segmentCount: number;
|
||||||
|
deliveredSegmentCount: number;
|
||||||
|
arrivalRate: number;
|
||||||
|
billedCents: number;
|
||||||
|
profitCents: number;
|
||||||
|
profitRate: number;
|
||||||
};
|
};
|
||||||
uplinkCount: number;
|
uplinkCount: number;
|
||||||
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
|
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
|
||||||
|
|||||||
@@ -607,12 +607,10 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
|
|||||||
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
|
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signature-quality-matrix__metric">
|
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}>
|
||||||
<strong>{total.toLocaleString('zh-CN')} 次</strong>
|
<div><small>通道提交</small><strong>{total.toLocaleString('zh-CN')} 次</strong></div>
|
||||||
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
|
<div><small>成功率</small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div>
|
||||||
{successRate.toFixed(1)}%
|
<div><small>平均到达</small><span>{formatDuration(metric?.averageArrivalMs)}</span></div>
|
||||||
</span>
|
|
||||||
<small>{formatDuration(metric?.averageArrivalMs)}</small>
|
|
||||||
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
|
fileDownloadUrl,
|
||||||
type AdminChannel,
|
type AdminChannel,
|
||||||
type ChannelReportField,
|
type ChannelReportField,
|
||||||
type ClientSmsSignature,
|
type ClientSmsSignature,
|
||||||
|
type CommonReportField,
|
||||||
type DictionaryItem,
|
type DictionaryItem,
|
||||||
type ReportTask,
|
type ReportTask,
|
||||||
type SingleReportMaterialDetail,
|
type SingleReportMaterialDetail,
|
||||||
@@ -14,6 +16,7 @@ import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag,
|
|||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { successRateClassName } from '@/utils/successRate';
|
import { successRateClassName } from '@/utils/successRate';
|
||||||
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
||||||
|
import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal';
|
||||||
|
|
||||||
type ReportType = 'signature' | 'drainage';
|
type ReportType = 'signature' | 'drainage';
|
||||||
type DrainageItem = Record<string, unknown> & {
|
type DrainageItem = Record<string, unknown> & {
|
||||||
@@ -62,6 +65,24 @@ function ReportStatus({ value }: { value?: string }) {
|
|||||||
return <Tag tone={meta.tone}>{meta.label}</Tag>;
|
return <Tag tone={meta.tone}>{meta.label}</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MaterialFieldValue({ value }: { value: unknown }) {
|
||||||
|
const file = asRecord(value);
|
||||||
|
const fileObjectId = String(file.fileObjectId ?? '');
|
||||||
|
const fileName = String(file.fileName ?? fileObjectId ?? '-');
|
||||||
|
const isImage =
|
||||||
|
String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName);
|
||||||
|
if (fileObjectId && isImage) {
|
||||||
|
return (
|
||||||
|
<div className="report-material-image-value">
|
||||||
|
<img alt={fileName} src={fileDownloadUrl(fileObjectId, 'inline')} />
|
||||||
|
<a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fileObjectId) return <a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>;
|
||||||
|
return <>{String(value ?? '-')}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function DeliveryStats({ task }: { task: ReportTask }) {
|
function DeliveryStats({ task }: { task: ReportTask }) {
|
||||||
const stats = task.deliveryStats ?? {
|
const stats = task.deliveryStats ?? {
|
||||||
submitFailureCount: 0,
|
submitFailureCount: 0,
|
||||||
@@ -197,6 +218,7 @@ export function AdminChannelReportPage() {
|
|||||||
const [tasks, setTasks] = useState<ReportTask[]>([]);
|
const [tasks, setTasks] = useState<ReportTask[]>([]);
|
||||||
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
||||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||||
|
const [commonFields, setCommonFields] = useState<CommonReportField[]>([]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [status, setStatus] = useState('all');
|
const [status, setStatus] = useState('all');
|
||||||
const [carrier, setCarrier] = useState('all');
|
const [carrier, setCarrier] = useState('all');
|
||||||
@@ -204,7 +226,13 @@ export function AdminChannelReportPage() {
|
|||||||
const [todaySendMax, setTodaySendMax] = useState('');
|
const [todaySendMax, setTodaySendMax] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' });
|
const [appliedFilters, setAppliedFilters] = useState({
|
||||||
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
carrier: 'all',
|
||||||
|
todaySendMin: '',
|
||||||
|
todaySendMax: '',
|
||||||
|
});
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
|
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
|
||||||
const [detail, setDetail] = useState<{
|
const [detail, setDetail] = useState<{
|
||||||
@@ -218,9 +246,12 @@ export function AdminChannelReportPage() {
|
|||||||
const [statusReason, setStatusReason] = useState('');
|
const [statusReason, setStatusReason] = useState('');
|
||||||
const [configType, setConfigType] = useState<ReportType>();
|
const [configType, setConfigType] = useState<ReportType>();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [exportTask, setExportTask] = useState<ReportTask>();
|
||||||
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
|
|
||||||
function loadData(targetPage = page, filters = appliedFilters) {
|
function loadData(targetPage = page, filters = appliedFilters) {
|
||||||
adminApi.listReportTasksPage({
|
adminApi
|
||||||
|
.listReportTasksPage({
|
||||||
channelId,
|
channelId,
|
||||||
keyword: filters.keyword || undefined,
|
keyword: filters.keyword || undefined,
|
||||||
status: filters.status === 'all' ? undefined : filters.status,
|
status: filters.status === 'all' ? undefined : filters.status,
|
||||||
@@ -240,16 +271,24 @@ export function AdminChannelReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields()])
|
void Promise.all([
|
||||||
.then(([channelItems, fieldItems, libraryItems]) => {
|
adminApi.listChannels(),
|
||||||
|
adminApi.listChannelReportFields(channelId),
|
||||||
|
adminApi.listDrainageFields(),
|
||||||
|
adminApi.listCommonReportFields(),
|
||||||
|
])
|
||||||
|
.then(([channelItems, fieldItems, libraryItems, commonItems]) => {
|
||||||
setChannel(channelItems.find((item) => item.id === channelId));
|
setChannel(channelItems.find((item) => item.id === channelId));
|
||||||
setFields(fieldItems);
|
setFields(fieldItems);
|
||||||
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
|
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
|
||||||
|
setCommonFields(commonItems);
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
|
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
useEffect(() => { loadData(page); }, [channelId, page]);
|
useEffect(() => {
|
||||||
|
loadData(page);
|
||||||
|
}, [channelId, page]);
|
||||||
const visibleTasks = tasks;
|
const visibleTasks = tasks;
|
||||||
|
|
||||||
async function openMaterial(task: ReportTask) {
|
async function openMaterial(task: ReportTask) {
|
||||||
@@ -269,8 +308,9 @@ export function AdminChannelReportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportMaterial(task: ReportTask) {
|
async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) {
|
||||||
try {
|
try {
|
||||||
|
setExportBusy(true);
|
||||||
const blob = await adminApi.exportSingleReportMaterial({
|
const blob = await adminApi.exportSingleReportMaterial({
|
||||||
reportType: task.reportType,
|
reportType: task.reportType,
|
||||||
signatureId: task.signatureId,
|
signatureId: task.signatureId,
|
||||||
@@ -278,6 +318,7 @@ export function AdminChannelReportPage() {
|
|||||||
carrier: task.carrier ?? undefined,
|
carrier: task.carrier ?? undefined,
|
||||||
drainageItemId: task.drainageItemId ?? undefined,
|
drainageItemId: task.drainageItemId ?? undefined,
|
||||||
batchItemId: task.exportItems?.[0]?.batchItem.id,
|
batchItemId: task.exportItems?.[0]?.batchItem.id,
|
||||||
|
outputFormat,
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
@@ -285,8 +326,11 @@ export function AdminChannelReportPage() {
|
|||||||
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
|
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
|
||||||
anchor.click();
|
anchor.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
setExportTask(undefined);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
|
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +374,7 @@ export function AdminChannelReportPage() {
|
|||||||
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
|
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<h1>{channel?.name ?? '通道报备详情'}</h1>
|
<h1>通道报备详情</h1>
|
||||||
<div className="channel-report-config-actions">
|
<div className="channel-report-config-actions">
|
||||||
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
|
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
|
||||||
配置签名报备字段
|
配置签名报备字段
|
||||||
@@ -341,7 +385,7 @@ export function AdminChannelReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个
|
通道名称:{channel?.name ?? '-'} · 通道编号:{channel?.code ?? channelId} · 已配置字段 {fields.length} 个
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
@@ -445,9 +489,7 @@ export function AdminChannelReportPage() {
|
|||||||
visibleTasks.map((task) => {
|
visibleTasks.map((task) => {
|
||||||
const signature = task.signature as ClientSmsSignature | undefined;
|
const signature = task.signature as ClientSmsSignature | undefined;
|
||||||
const drainage =
|
const drainage =
|
||||||
task.reportType === 'drainage'
|
task.reportType === 'drainage' ? (task.drainageInfo as DrainageItem | undefined) : undefined;
|
||||||
? task.drainageInfo as DrainageItem | undefined
|
|
||||||
: undefined;
|
|
||||||
const reportedAt = task.approvedAt;
|
const reportedAt = task.approvedAt;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -486,7 +528,7 @@ export function AdminChannelReportPage() {
|
|||||||
查看报备资料
|
查看报备资料
|
||||||
</button>
|
</button>
|
||||||
{task.reportType !== 'drainage' ? (
|
{task.reportType !== 'drainage' ? (
|
||||||
<button onClick={() => void exportMaterial(task)} type="button">
|
<button onClick={() => setExportTask(task)} type="button">
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
导出
|
导出
|
||||||
</button>
|
</button>
|
||||||
@@ -540,9 +582,9 @@ export function AdminChannelReportPage() {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>通道/版本</span>
|
<span>通道名称 / 编号 / 版本</span>
|
||||||
<strong>
|
<strong>
|
||||||
{material.channel.name} · V{material.materialVersion}
|
{material.channel.name} · {material.channel.code} · V{material.materialVersion}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -550,20 +592,23 @@ export function AdminChannelReportPage() {
|
|||||||
{material.fields.map((field) => (
|
{material.fields.map((field) => (
|
||||||
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
|
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
|
||||||
<span>
|
<span>
|
||||||
{field.exportName || field.name}
|
{field.name}({field.code})
|
||||||
|
{field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}”` : ''}
|
||||||
{field.required ? ' *' : ''}
|
{field.required ? ' *' : ''}
|
||||||
</span>
|
</span>
|
||||||
<strong>
|
<strong>
|
||||||
{typeof field.value === 'object'
|
<MaterialFieldValue value={field.value} />
|
||||||
? String((field.value as Record<string, unknown>)?.fileName ?? '-')
|
|
||||||
: String(field.value ?? '-')}
|
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{material.historicalFields.map((field) => (
|
{material.historicalFields.map((field) => (
|
||||||
<div key={field.code}>
|
<div key={field.code}>
|
||||||
<span>{field.name}(历史字段)</span>
|
<span>
|
||||||
<strong>{String(field.value ?? '-')}</strong>
|
{field.name}({field.code},历史字段)
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
<MaterialFieldValue value={field.value} />
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -608,12 +653,20 @@ export function AdminChannelReportPage() {
|
|||||||
{configType ? (
|
{configType ? (
|
||||||
<ReportFieldMappingModal
|
<ReportFieldMappingModal
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
commonFields={commonFields}
|
||||||
libraryFields={libraryFields}
|
libraryFields={libraryFields}
|
||||||
onClose={() => setConfigType(undefined)}
|
onClose={() => setConfigType(undefined)}
|
||||||
onSave={saveFieldMapping}
|
onSave={saveFieldMapping}
|
||||||
reportType={configType}
|
reportType={configType}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{exportTask ? (
|
||||||
|
<ReportExportFormatModal
|
||||||
|
busy={exportBusy}
|
||||||
|
onClose={() => setExportTask(undefined)}
|
||||||
|
onConfirm={(format) => void exportMaterial(exportTask, format)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,46 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
|
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
|
||||||
|
|
||||||
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } }));
|
const { adminApi } = vi.hoisted(() => ({
|
||||||
|
adminApi: {
|
||||||
|
listDrainageFields: vi.fn(),
|
||||||
|
listCommonReportFields: vi.fn(),
|
||||||
|
reorderCommonReportFields: vi.fn(),
|
||||||
|
updateCommonReportField: vi.fn(),
|
||||||
|
updateDrainageField: vi.fn(),
|
||||||
|
createDrainageField: vi.fn(),
|
||||||
|
deleteDrainageField: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('@/api/adminApi', () => ({ adminApi }));
|
vi.mock('@/api/adminApi', () => ({ adminApi }));
|
||||||
|
|
||||||
describe('common reporting configuration', () => {
|
describe('common reporting configuration', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.values(adminApi).forEach((method) => method.mockReset());
|
Object.values(adminApi).forEach((method) => method.mockReset());
|
||||||
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
|
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
|
||||||
adminApi.listDrainageFields.mockResolvedValue([field]);
|
const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' };
|
||||||
adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]);
|
adminApi.listDrainageFields.mockResolvedValue([field, secondField]);
|
||||||
|
adminApi.listCommonReportFields.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'common-1',
|
||||||
|
drainageFieldId: 'field-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
required: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
drainageField: field,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'common-2',
|
||||||
|
drainageFieldId: 'field-2',
|
||||||
|
reportType: 'signature',
|
||||||
|
required: false,
|
||||||
|
sortOrder: 20,
|
||||||
|
drainageField: secondField,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
adminApi.reorderCommonReportFields.mockResolvedValue([]);
|
||||||
adminApi.updateCommonReportField.mockResolvedValue({});
|
adminApi.updateCommonReportField.mockResolvedValue({});
|
||||||
|
adminApi.updateDrainageField.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
it('opens existing values and saves the edited requirement with PUT API', async () => {
|
it('opens existing values and saves the edited requirement with PUT API', async () => {
|
||||||
render(<AdminDrainageFieldsPage />);
|
render(<AdminDrainageFieldsPage />);
|
||||||
@@ -20,8 +50,67 @@ describe('common reporting configuration', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '是否必填' }));
|
fireEvent.click(screen.getByRole('button', { name: '是否必填' }));
|
||||||
fireEvent.click(screen.getByRole('option', { name: '必填' }));
|
fireEvent.click(screen.getByRole('option', { name: '必填' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||||
await waitFor(() => expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { drainageFieldId: 'field-1', reportType: 'signature', required: true }));
|
await waitFor(() =>
|
||||||
|
expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', {
|
||||||
|
drainageFieldId: 'field-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
|
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves a common field within its own material type through the reorder API', async () => {
|
||||||
|
render(<AdminDrainageFieldsPage />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({
|
||||||
|
reportType: 'signature',
|
||||||
|
ids: ['common-2', 'common-1'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits a field definition and locks mapping keys for a referenced field', async () => {
|
||||||
|
adminApi.listDrainageFields.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'field-1',
|
||||||
|
code: 'license',
|
||||||
|
name: '主体证明',
|
||||||
|
fieldType: 'file',
|
||||||
|
status: 'active',
|
||||||
|
usageCount: 1,
|
||||||
|
description: '旧说明',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<AdminDrainageFieldsPage />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' }));
|
||||||
|
expect(screen.getByRole('dialog')).toHaveTextContent('编辑报备字段');
|
||||||
|
expect(screen.getByLabelText('字段代码')).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '字段类型' })).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', {
|
||||||
|
code: 'license',
|
||||||
|
name: '企业主体证明',
|
||||||
|
fieldType: 'file',
|
||||||
|
description: '新说明',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the add-field action inside the field definition section', async () => {
|
||||||
|
render(<AdminDrainageFieldsPage />);
|
||||||
|
const heading = await screen.findByRole('heading', { name: '字段定义' });
|
||||||
|
expect(heading.closest('.admin-drainage-section__heading')).toContainElement(
|
||||||
|
screen.getByRole('button', { name: '添加字段' }),
|
||||||
|
);
|
||||||
|
expect(document.querySelector('.page-heading')).not.toContainElement(
|
||||||
|
screen.getByRole('button', { name: '添加字段' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
||||||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ export function AdminDrainageFieldsPage() {
|
|||||||
const [type, setType] = useState('all');
|
const [type, setType] = useState('all');
|
||||||
const [appliedType, setAppliedType] = useState('all');
|
const [appliedType, setAppliedType] = useState('all');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editingField, setEditingField] = useState<DrainageField | null>(null);
|
||||||
|
const [fieldSaving, setFieldSaving] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
||||||
@@ -45,6 +47,7 @@ export function AdminDrainageFieldsPage() {
|
|||||||
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
||||||
const [commonRequired, setCommonRequired] = useState(false);
|
const [commonRequired, setCommonRequired] = useState(false);
|
||||||
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
|
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
|
||||||
|
const [commonOrderingId, setCommonOrderingId] = useState<string>();
|
||||||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
@@ -62,30 +65,57 @@ export function AdminDrainageFieldsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredFields = useMemo(
|
const filteredFields = useMemo(
|
||||||
() => fields.filter((field) => {
|
() =>
|
||||||
const matchesKeyword = !appliedKeyword || [field.code, field.name, field.fieldType, field.description].some((value) => String(value ?? '').includes(appliedKeyword));
|
fields.filter((field) => {
|
||||||
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
|
const matchesKeyword =
|
||||||
return matchesKeyword && matchesType;
|
!appliedKeyword ||
|
||||||
}),
|
[field.code, field.name, field.fieldType, field.description].some((value) =>
|
||||||
|
String(value ?? '').includes(appliedKeyword),
|
||||||
|
);
|
||||||
|
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
|
||||||
|
return matchesKeyword && matchesType;
|
||||||
|
}),
|
||||||
[appliedKeyword, appliedType, fields],
|
[appliedKeyword, appliedType, fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
function createField() {
|
function closeFieldModal() {
|
||||||
adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' })
|
setCreating(false);
|
||||||
|
setEditingField(null);
|
||||||
|
setCode('');
|
||||||
|
setName('');
|
||||||
|
setFieldType('string');
|
||||||
|
setDescription('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFieldModal(field?: DrainageField) {
|
||||||
|
setEditingField(field ?? null);
|
||||||
|
setCode(field?.code ?? '');
|
||||||
|
setName(field?.name ?? '');
|
||||||
|
setFieldType((field?.fieldType as ReportFieldType | undefined) ?? 'string');
|
||||||
|
setDescription(field?.description ?? '');
|
||||||
|
setError('');
|
||||||
|
setCreating(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveField() {
|
||||||
|
if (fieldSaving) return;
|
||||||
|
setFieldSaving(true);
|
||||||
|
const request = editingField
|
||||||
|
? adminApi.updateDrainageField(editingField.id, { code, name, fieldType, description })
|
||||||
|
: adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' });
|
||||||
|
request
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCode('');
|
closeFieldModal();
|
||||||
setName('');
|
|
||||||
setFieldType('string');
|
|
||||||
setDescription('');
|
|
||||||
setCreating(false);
|
|
||||||
loadData();
|
loadData();
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
|
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'))
|
||||||
|
.finally(() => setFieldSaving(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteField() {
|
function deleteField() {
|
||||||
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return;
|
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return;
|
||||||
adminApi.deleteDrainageField(deleteTarget.id)
|
adminApi
|
||||||
|
.deleteDrainageField(deleteTarget.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadData();
|
loadData();
|
||||||
@@ -98,7 +128,9 @@ export function AdminDrainageFieldsPage() {
|
|||||||
setCommonSaving(true);
|
setCommonSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
|
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
|
||||||
const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body);
|
const request = editingCommonId
|
||||||
|
? adminApi.updateCommonReportField(editingCommonId, body)
|
||||||
|
: adminApi.createCommonReportField(body);
|
||||||
request
|
request
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCommonFieldId('');
|
setCommonFieldId('');
|
||||||
@@ -122,7 +154,8 @@ export function AdminDrainageFieldsPage() {
|
|||||||
|
|
||||||
function deleteCommonField() {
|
function deleteCommonField() {
|
||||||
if (!commonDeleteTarget) return;
|
if (!commonDeleteTarget) return;
|
||||||
adminApi.deleteCommonReportField(commonDeleteTarget.id)
|
adminApi
|
||||||
|
.deleteCommonReportField(commonDeleteTarget.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setCommonDeleteTarget(null);
|
setCommonDeleteTarget(null);
|
||||||
loadData();
|
loadData();
|
||||||
@@ -130,9 +163,31 @@ export function AdminDrainageFieldsPage() {
|
|||||||
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveCommonField(field: CommonReportField, direction: -1 | 1) {
|
||||||
|
if (commonOrderingId) return;
|
||||||
|
const group = commonFields.filter((item) => item.reportType === field.reportType);
|
||||||
|
const currentIndex = group.findIndex((item) => item.id === field.id);
|
||||||
|
const targetIndex = currentIndex + direction;
|
||||||
|
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= group.length) return;
|
||||||
|
const ids = group.map((item) => item.id);
|
||||||
|
[ids[currentIndex], ids[targetIndex]] = [ids[targetIndex], ids[currentIndex]];
|
||||||
|
setCommonOrderingId(field.id);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await adminApi.reorderCommonReportFields({ reportType: field.reportType, ids });
|
||||||
|
loadData();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '通用字段顺序调整失败');
|
||||||
|
} finally {
|
||||||
|
setCommonOrderingId(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
|
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
|
||||||
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
|
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
|
||||||
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
|
const referencedCount = fields.filter(
|
||||||
|
(field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-system-page admin-drainage-page">
|
<section className="page-stack admin-system-page admin-drainage-page">
|
||||||
@@ -142,22 +197,68 @@ export function AdminDrainageFieldsPage() {
|
|||||||
<h1>报备字段库</h1>
|
<h1>报备字段库</h1>
|
||||||
<p>统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。</p>
|
<p>统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)} size="sm">添加字段</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="admin-drainage-summary">
|
<div className="admin-drainage-summary">
|
||||||
<article><span><Database size={18} /></span><div><strong>{fields.length}</strong><p>字段总数</p></div></article>
|
<article>
|
||||||
<article><span><FileCheck2 size={18} /></span><div><strong>{commonFields.length}</strong><p>通用字段配置</p></div></article>
|
<span>
|
||||||
<article><span><Link2 size={18} /></span><div><strong>{referencedCount}</strong><p>已被引用字段</p></div></article>
|
<Database size={18} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{fields.length}</strong>
|
||||||
|
<p>字段总数</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>
|
||||||
|
<FileCheck2 size={18} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{commonFields.length}</strong>
|
||||||
|
<p>通用字段配置</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>
|
||||||
|
<Link2 size={18} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{referencedCount}</strong>
|
||||||
|
<p>已被引用字段</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface admin-drainage-toolbar">
|
<div className="surface admin-drainage-toolbar">
|
||||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
|
<Input
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索字段名称、代码或描述..."
|
||||||
|
prefix={<Search size={16} />}
|
||||||
|
value={keyword}
|
||||||
|
/>
|
||||||
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
|
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
|
||||||
<div className="admin-system-toolbar__actions">
|
<div className="admin-system-toolbar__actions">
|
||||||
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}>查询</Button>
|
<Button
|
||||||
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost">重置</Button>
|
icon={<Search size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setAppliedKeyword(keyword.trim());
|
||||||
|
setAppliedType(type);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setType('all');
|
||||||
|
setAppliedKeyword('');
|
||||||
|
setAppliedType('all');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,61 +268,185 @@ export function AdminDrainageFieldsPage() {
|
|||||||
<h2>通用字段配置</h2>
|
<h2>通用字段配置</h2>
|
||||||
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">配置通用字段</Button>
|
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">
|
||||||
|
配置通用字段
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-drainage-common-grid">
|
<div className="admin-drainage-common-grid">
|
||||||
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="info" />
|
<CommonFieldGroup
|
||||||
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="warning" />
|
fields={signatureCommon}
|
||||||
|
label="签名报备资料"
|
||||||
|
onEdit={openCommonField}
|
||||||
|
onDelete={setCommonDeleteTarget}
|
||||||
|
onMove={moveCommonField}
|
||||||
|
orderingId={commonOrderingId}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
<CommonFieldGroup
|
||||||
|
fields={drainageCommon}
|
||||||
|
label="引流信息报备资料"
|
||||||
|
onEdit={openCommonField}
|
||||||
|
onDelete={setCommonDeleteTarget}
|
||||||
|
onMove={moveCommonField}
|
||||||
|
orderingId={commonOrderingId}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface admin-drainage-section">
|
<div className="surface admin-drainage-section">
|
||||||
<div className="admin-drainage-section__heading"><div><h2>字段定义</h2><p>共 {filteredFields.length} 个结果。已被通道或通用配置引用的字段不能删除。</p></div></div>
|
<div className="admin-drainage-section__heading">
|
||||||
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
|
<div>
|
||||||
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
|
<h2>字段定义</h2>
|
||||||
return <article className="admin-drainage-field-card" key={field.id}>
|
<p>共 {filteredFields.length} 个结果。已被通道或通用配置引用的字段不能删除。</p>
|
||||||
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost">删除</Button></div>
|
</div>
|
||||||
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
|
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm">
|
||||||
<div className="admin-drainage-field-card__meta"><span>通道引用 <strong>{field.usageCount ?? 0}</strong></span><span>通用配置 <strong>{field.commonUsageCount ?? 0}</strong></span></div>
|
添加字段
|
||||||
</article>;
|
</Button>
|
||||||
})}</div> : <div className="admin-drainage-empty">没有符合筛选条件的字段</div>}
|
</div>
|
||||||
|
{filteredFields.length ? (
|
||||||
|
<div className="admin-drainage-field-grid">
|
||||||
|
{filteredFields.map((field) => {
|
||||||
|
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
|
||||||
|
return (
|
||||||
|
<article className="admin-drainage-field-card" key={field.id}>
|
||||||
|
<div className="admin-drainage-field-card__top">
|
||||||
|
<span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span>
|
||||||
|
<div className="admin-drainage-field-card__actions">
|
||||||
|
<Button
|
||||||
|
aria-label={`编辑${field.name}`}
|
||||||
|
icon={<Edit3 size={14} />}
|
||||||
|
iconOnly
|
||||||
|
onClick={() => openFieldModal(field)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={`删除${field.name}`}
|
||||||
|
disabled={locked}
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
iconOnly
|
||||||
|
onClick={() => setDeleteTarget(field)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>{field.name ?? '-'}</h3>
|
||||||
|
<code>{field.code}</code>
|
||||||
|
<p>{field.description || '暂无字段说明'}</p>
|
||||||
|
<div className="admin-drainage-field-card__meta">
|
||||||
|
<span>
|
||||||
|
通道引用 <strong>{field.usageCount ?? 0}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
通用配置 <strong>{field.commonUsageCount ?? 0}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-drainage-empty">没有符合筛选条件的字段</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost">取消</Button><Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>{commonSaving ? '保存中...' : '保存'}</Button></>}
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>
|
||||||
|
{commonSaving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
onClose={() => setConfiguringCommon(false)}
|
onClose={() => setConfiguringCommon(false)}
|
||||||
open={configuringCommon}
|
open={configuringCommon}
|
||||||
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
|
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}(${field.code})`, value: field.id }))]} value={commonFieldId} />
|
<Select
|
||||||
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
|
label="报备字段"
|
||||||
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
|
onChange={(event) => setCommonFieldId(event.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: '请选择字段', value: '' },
|
||||||
|
...fields
|
||||||
|
.filter((field) => field.status !== 'deleted')
|
||||||
|
.map((field) => ({ label: `${field.name}(${field.code})`, value: field.id })),
|
||||||
|
]}
|
||||||
|
value={commonFieldId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="资料用途"
|
||||||
|
onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')}
|
||||||
|
options={[
|
||||||
|
{ label: '签名报备资料', value: 'signature' },
|
||||||
|
{ label: '引流信息报备资料', value: 'drainage' },
|
||||||
|
]}
|
||||||
|
value={commonReportType}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="是否必填"
|
||||||
|
onChange={(event) => setCommonRequired(event.target.value === 'true')}
|
||||||
|
options={[
|
||||||
|
{ label: '选填', value: 'false' },
|
||||||
|
{ label: '必填', value: 'true' },
|
||||||
|
]}
|
||||||
|
value={String(commonRequired)}
|
||||||
|
/>
|
||||||
<p>修改后用于后续新增、编辑时的资料要求;历史报备资料和要求快照保持不变。</p>
|
<p>修改后用于后续新增、编辑时的资料要求;历史报备资料和要求快照保持不变。</p>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setCreating(false)} variant="ghost">取消</Button>
|
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost">
|
||||||
<Button disabled={!code || !name || Boolean(codeError)} onClick={createField}>保存</Button>
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>
|
||||||
|
{fieldSaving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
onClose={() => setCreating(false)}
|
onClose={closeFieldModal}
|
||||||
open={creating}
|
open={creating}
|
||||||
title="添加报备字段"
|
title={editingField ? '编辑报备字段' : '添加报备字段'}
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Input error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
|
<Input
|
||||||
|
disabled={Boolean(
|
||||||
|
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
|
||||||
|
)}
|
||||||
|
error={codeError}
|
||||||
|
label="字段代码"
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
placeholder="仅允许数字和英文字母"
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||||
<Select
|
<Select
|
||||||
|
disabled={Boolean(
|
||||||
|
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
|
||||||
|
)}
|
||||||
label="字段类型"
|
label="字段类型"
|
||||||
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
|
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
|
||||||
options={typeOptions.filter((option) => option.value !== 'all')}
|
options={typeOptions.filter((option) => option.value !== 'all')}
|
||||||
value={fieldType}
|
value={fieldType}
|
||||||
/>
|
/>
|
||||||
|
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? (
|
||||||
|
<p className="admin-system-modal-form__wide">
|
||||||
|
该字段已被引用,为保护现有通道映射和历史资料,只能修改字段名称和描述。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Textarea
|
<Textarea
|
||||||
className="admin-system-modal-form__wide"
|
className="admin-system-modal-form__wide"
|
||||||
label="描述"
|
label="描述"
|
||||||
@@ -233,7 +458,16 @@ export function AdminDrainageFieldsPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
{deleteTarget ? (
|
{deleteTarget ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteField} variant="danger">确认删除</Button></>}
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={deleteField} variant="danger">
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
open
|
open
|
||||||
title="删除报备字段"
|
title="删除报备字段"
|
||||||
@@ -243,18 +477,114 @@ export function AdminDrainageFieldsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{commonDeleteTarget ? (
|
{commonDeleteTarget ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => setCommonDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteCommonField} variant="danger">确认删除</Button></>}
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setCommonDeleteTarget(null)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={deleteCommonField} variant="danger">
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
onClose={() => setCommonDeleteTarget(null)}
|
onClose={() => setCommonDeleteTarget(null)}
|
||||||
open
|
open
|
||||||
title="删除通用字段配置"
|
title="删除通用字段配置"
|
||||||
>
|
>
|
||||||
<p>确认删除“{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}”的{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}通用配置吗?字段库定义和历史报备资料不会删除。</p>
|
<p>
|
||||||
|
确认删除“{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}”的
|
||||||
|
{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}
|
||||||
|
通用配置吗?字段库定义和历史报备资料不会删除。
|
||||||
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommonFieldGroup({ fields, label, onEdit, onDelete, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
|
function CommonFieldGroup({
|
||||||
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost">修改</Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
fields,
|
||||||
|
label,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onMove,
|
||||||
|
orderingId,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
fields: CommonReportField[];
|
||||||
|
label: string;
|
||||||
|
onEdit: (field: CommonReportField) => void;
|
||||||
|
onDelete: (field: CommonReportField) => void;
|
||||||
|
onMove: (field: CommonReportField, direction: -1 | 1) => void;
|
||||||
|
orderingId?: string;
|
||||||
|
tone: 'info' | 'warning';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="admin-drainage-common-group">
|
||||||
|
<div className="admin-drainage-common-group__title">
|
||||||
|
<Tag tone={tone}>{label}</Tag>
|
||||||
|
<span>{fields.length} 项</span>
|
||||||
|
</div>
|
||||||
|
{fields.length ? (
|
||||||
|
<div className="admin-drainage-common-list">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong>
|
||||||
|
<span>
|
||||||
|
{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag>
|
||||||
|
<div className="admin-drainage-common-order">
|
||||||
|
<Button
|
||||||
|
aria-label={`上移通用字段${field.drainageField.name}`}
|
||||||
|
disabled={index === 0 || Boolean(orderingId)}
|
||||||
|
icon={<ArrowUp size={14} />}
|
||||||
|
iconOnly
|
||||||
|
onClick={() => onMove(field, -1)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
上移
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={`下移通用字段${field.drainageField.name}`}
|
||||||
|
disabled={index === fields.length - 1 || Boolean(orderingId)}
|
||||||
|
icon={<ArrowDown size={14} />}
|
||||||
|
iconOnly
|
||||||
|
onClick={() => onMove(field, 1)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
下移
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
aria-label={`修改通用字段${field.drainageField.name}`}
|
||||||
|
icon={<Edit3 size={14} />}
|
||||||
|
onClick={() => onEdit(field)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="删除通用字段"
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
iconOnly
|
||||||
|
onClick={() => onDelete(field)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="admin-drainage-common-empty">暂未配置字段</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-101
@@ -1,20 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
|
||||||
BarChart3,
|
|
||||||
DollarSign,
|
|
||||||
FileCheck2,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
Breadcrumb,
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
MoneyText,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
type TableColumn,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { Chart } from '@/components/ui/Chart';
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
||||||
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||||
@@ -70,7 +57,11 @@ export function AdminHome() {
|
|||||||
enterprise: account.tenantName,
|
enterprise: account.tenantName,
|
||||||
todaySpend,
|
todaySpend,
|
||||||
availableBalance,
|
availableBalance,
|
||||||
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
|
balanceStatus: (availableBalance <= 0
|
||||||
|
? '欠费'
|
||||||
|
: availableBalance < 100
|
||||||
|
? '紧张'
|
||||||
|
: '充足') as EnterpriseSpendRank['balanceStatus'],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [dashboard]);
|
}, [dashboard]);
|
||||||
@@ -78,35 +69,48 @@ export function AdminHome() {
|
|||||||
const totalSend = dashboard?.today.sent ?? 0;
|
const totalSend = dashboard?.today.sent ?? 0;
|
||||||
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||||
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
||||||
|
const todayReturned = moneyUnitsToYuan(dashboard?.today.returnedCents);
|
||||||
|
const todayBilled = moneyUnitsToYuan(dashboard?.today.billedCents);
|
||||||
|
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
|
||||||
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
|
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
|
||||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
|
const pendingAudits = dashboard?.pendingAudits ?? {
|
||||||
|
enterpriseCertifications: 0,
|
||||||
|
smsAudits: 0,
|
||||||
|
templates: 0,
|
||||||
|
signatures: 0,
|
||||||
|
drainageInfos: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() =>
|
||||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
createLineOption({
|
||||||
series: [
|
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||||
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
series: [
|
||||||
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||||
],
|
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||||
}),
|
],
|
||||||
|
}),
|
||||||
[dashboard],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const auditSpeedOption = useMemo(
|
const auditSpeedOption = useMemo(
|
||||||
() => createDualAxisBarLineOption({
|
() =>
|
||||||
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
|
createDualAxisBarLineOption({
|
||||||
bar: {
|
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
|
||||||
name: '审核数量',
|
bar: {
|
||||||
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
|
name: '审核数量',
|
||||||
},
|
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
|
||||||
line: {
|
},
|
||||||
name: '平均处理时长(分钟)',
|
line: {
|
||||||
data: dashboard?.auditProcessingSpeed.map((item) => (
|
name: '平均处理时长(分钟)',
|
||||||
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1))
|
data:
|
||||||
)) ?? [],
|
dashboard?.auditProcessingSpeed.map((item) =>
|
||||||
},
|
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1)),
|
||||||
}),
|
) ?? [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
[dashboard],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,9 +126,23 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
|
{
|
||||||
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
|
key: 'todaySpend',
|
||||||
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
|
title: '今日消费(元)',
|
||||||
|
align: 'right',
|
||||||
|
render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'availableBalance',
|
||||||
|
title: '可用余额',
|
||||||
|
align: 'right',
|
||||||
|
render: (record) => formatCount(record.availableBalance),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'balanceStatus',
|
||||||
|
title: '余额状态',
|
||||||
|
render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -148,9 +166,7 @@ export function AdminHome() {
|
|||||||
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||||
处理审核
|
处理审核
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate('/admin/monitor')}>
|
<Button onClick={() => navigate('/admin/monitor')}>查看发送监控</Button>
|
||||||
查看发送监控
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,20 +176,54 @@ export function AdminHome() {
|
|||||||
<strong>{formatCount(totalSend)} 条</strong>
|
<strong>{formatCount(totalSend)} 条</strong>
|
||||||
<small>来自真实短信记录聚合</small>
|
<small>来自真实短信记录聚合</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日消息分片数</span>
|
||||||
|
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} 片</strong>
|
||||||
|
<small>来自真实分片审计记录</small>
|
||||||
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>总体成功率</span>
|
<span>总体成功率</span>
|
||||||
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
||||||
<small>delivered / 今日总量</small>
|
<small>delivered / 今日总量</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日到达率</span>
|
||||||
|
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
|
||||||
|
<small>到达分片 / 发送总分片</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日活跃签名</span>
|
||||||
|
<strong>{activeSignatureCount}</strong>
|
||||||
|
<small>当天有真实发送记录的签名</small>
|
||||||
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日消费金额</span>
|
<span>今日消费金额</span>
|
||||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
<strong>¥{formatCurrency(todaySpend)}</strong>
|
||||||
<small>来自今日消息金额聚合</small>
|
<small>来自今日消息金额聚合</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日活跃签名</span>
|
<span>今日返还金额</span>
|
||||||
<strong>{activeSignatureCount}</strong>
|
<strong>¥{formatCurrency(todayReturned)}</strong>
|
||||||
<small>当天有真实发送记录的签名</small>
|
<small>来自今日返还流水</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日计收金额</span>
|
||||||
|
<strong>¥{formatCurrency(todayBilled)}</strong>
|
||||||
|
<small>成功短信计费条数 × 客户价</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日利润</span>
|
||||||
|
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>
|
||||||
|
¥{formatCurrency(todayProfit)}
|
||||||
|
</strong>
|
||||||
|
<small>计收金额 - 成功分片通道成本</small>
|
||||||
|
</div>
|
||||||
|
<div className="surface metric-card">
|
||||||
|
<span>今日利润率</span>
|
||||||
|
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>
|
||||||
|
{(dashboard?.today.profitRate ?? 0).toFixed(1)}%
|
||||||
|
</strong>
|
||||||
|
<small>今日利润 / 今日计收金额</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||||
@@ -181,12 +231,12 @@ export function AdminHome() {
|
|||||||
<div className="chart-grid">
|
<div className="chart-grid">
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>今日发送趋势</h2>
|
<h2>今日发送趋势</h2>
|
||||||
<p className="muted">按上海时区逐小时展示业务短信提交总条数和最终成功条数。</p>
|
<p className="muted">按上海时区逐小时展示业务短信提交总条数和最终成功条数。</p>
|
||||||
<Chart height={300} option={sendTrendOption} />
|
<Chart height={300} option={sendTrendOption} />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>审核处理速度</h2>
|
<h2>审核处理速度</h2>
|
||||||
<p className="muted">展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。</p>
|
<p className="muted">展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。</p>
|
||||||
<Chart height={300} option={auditSpeedOption} />
|
<Chart height={300} option={auditSpeedOption} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,73 +255,75 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface section-stack">
|
<div className="surface section-stack">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>运营状态</h2>
|
||||||
|
<p className="muted">当日关键流程状态汇总。</p>
|
||||||
|
</div>
|
||||||
|
<BarChart3 size={20} className="status-info" />
|
||||||
|
</div>
|
||||||
|
<div className="overview-grid overview-grid--three">
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>企业认证待审</span>
|
||||||
|
<strong>{pendingAudits.enterpriseCertifications} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>短信审核待审</span>
|
||||||
|
<strong>{pendingAudits.smsAudits} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>模板待审</span>
|
||||||
|
<strong>{pendingAudits.templates} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>签名待审</span>
|
||||||
|
<strong>{pendingAudits.signatures} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>引流信息待审</span>
|
||||||
|
<strong>{pendingAudits.drainageInfos} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<div className="mini-status-card">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
<h2>运营状态</h2>
|
<span>平均等待</span>
|
||||||
<p className="muted">当日关键流程状态汇总。</p>
|
<strong>{dashboard?.taskCount ?? 0} 任务</strong>
|
||||||
</div>
|
<small>真实批量任务总数。</small>
|
||||||
<BarChart3 size={20} className="status-info" />
|
|
||||||
</div>
|
|
||||||
<div className="overview-grid overview-grid--three">
|
|
||||||
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
|
|
||||||
<FileCheck2 size={22} />
|
|
||||||
<span>企业认证待审</span>
|
|
||||||
<strong>{pendingAudits.enterpriseCertifications} 条</strong>
|
|
||||||
</Button>
|
|
||||||
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
|
|
||||||
<FileCheck2 size={22} />
|
|
||||||
<span>短信审核待审</span>
|
|
||||||
<strong>{pendingAudits.smsAudits} 条</strong>
|
|
||||||
</Button>
|
|
||||||
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
|
|
||||||
<FileCheck2 size={22} />
|
|
||||||
<span>模板待审</span>
|
|
||||||
<strong>{pendingAudits.templates} 条</strong>
|
|
||||||
</Button>
|
|
||||||
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
|
|
||||||
<FileCheck2 size={22} />
|
|
||||||
<span>签名待审</span>
|
|
||||||
<strong>{pendingAudits.signatures} 条</strong>
|
|
||||||
</Button>
|
|
||||||
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
|
|
||||||
<FileCheck2 size={22} />
|
|
||||||
<span>引流信息待审</span>
|
|
||||||
<strong>{pendingAudits.drainageInfos} 条</strong>
|
|
||||||
</Button>
|
|
||||||
<div className="mini-status-card">
|
|
||||||
<ShieldCheck size={22} />
|
|
||||||
<div>
|
|
||||||
<span>平均等待</span>
|
|
||||||
<strong>{dashboard?.taskCount ?? 0} 任务</strong>
|
|
||||||
<small>真实批量任务总数。</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mini-status-card">
|
|
||||||
<ShieldCheck size={22} />
|
|
||||||
<div>
|
|
||||||
<span>下游投递告警</span>
|
|
||||||
<strong>{downstreamAlertCount} 条</strong>
|
|
||||||
<small>积压过久或近期失败。</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mini-status-card">
|
||||||
|
<ShieldCheck size={22} />
|
||||||
|
<div>
|
||||||
|
<span>下游投递告警</span>
|
||||||
|
<strong>{downstreamAlertCount} 条</strong>
|
||||||
|
<small>积压过久或近期失败。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">关闭</Button>
|
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate('/admin/recharge-records')}>查看充值记录</Button>
|
<Button onClick={() => navigate('/admin/recharge-records')}>查看充值记录</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
onClose={() => setSelectedEnterprise(null)}
|
onClose={() => setSelectedEnterprise(null)}
|
||||||
open={Boolean(selectedEnterprise)}
|
open={Boolean(selectedEnterprise)}
|
||||||
title={(
|
title={
|
||||||
<div className="ui-detail-title">
|
<div className="ui-detail-title">
|
||||||
<h2>企业消费详情</h2>
|
<h2>企业消费详情</h2>
|
||||||
<p>{selectedEnterprise?.id}</p>
|
<p>{selectedEnterprise?.id}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{selectedEnterprise ? (
|
{selectedEnterprise ? (
|
||||||
<div className="ui-detail-info-grid">
|
<div className="ui-detail-info-grid">
|
||||||
@@ -291,7 +343,9 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ui-detail-info-grid__item">
|
<div className="ui-detail-info-grid__item">
|
||||||
<span>今日消费</span>
|
<span>今日消费</span>
|
||||||
<strong><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
|
<strong>
|
||||||
|
<MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText>
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-detail-info-grid__item">
|
<div className="ui-detail-info-grid__item">
|
||||||
<span>可用余额</span>
|
<span>可用余额</span>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function AdminReportBatchesPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [copiedChannelId, setCopiedChannelId] = useState('');
|
const [copiedChannelId, setCopiedChannelId] = useState('');
|
||||||
const [downloadBusy, setDownloadBusy] = useState('');
|
const [downloadBusy, setDownloadBusy] = useState('');
|
||||||
|
const [outputFormat, setOutputFormat] = useState<'excel_drawing' | 'wps_cell_image'>('excel_drawing');
|
||||||
const [statusBusy, setStatusBusy] = useState(false);
|
const [statusBusy, setStatusBusy] = useState(false);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function AdminReportBatchesPage() {
|
|||||||
async function openExports(batch: ReportMaterialBatch) {
|
async function openExports(batch: ReportMaterialBatch) {
|
||||||
try {
|
try {
|
||||||
setExportDetail(await adminApi.getReportMaterialBatch(batch.id));
|
setExportDetail(await adminApi.getReportMaterialBatch(batch.id));
|
||||||
|
setOutputFormat('excel_drawing');
|
||||||
setCopiedChannelId('');
|
setCopiedChannelId('');
|
||||||
setError('');
|
setError('');
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
@@ -166,7 +168,7 @@ export function AdminReportBatchesPage() {
|
|||||||
async function downloadChannelFile(batch: ReportMaterialBatch, fileId: string, channelName: string) {
|
async function downloadChannelFile(batch: ReportMaterialBatch, fileId: string, channelName: string) {
|
||||||
try {
|
try {
|
||||||
setDownloadBusy(fileId);
|
setDownloadBusy(fileId);
|
||||||
const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId);
|
const blob = await adminApi.downloadReportMaterialBatchFile(batch.id, fileId, outputFormat);
|
||||||
downloadBlob(
|
downloadBlob(
|
||||||
blob,
|
blob,
|
||||||
`${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`,
|
`${batchDate(batch)}_${safeDownloadName(channelName)}_${safeDownloadName(batch.batchNo)}.xlsx`,
|
||||||
@@ -180,7 +182,7 @@ export function AdminReportBatchesPage() {
|
|||||||
async function downloadAll(batch: ReportMaterialBatch) {
|
async function downloadAll(batch: ReportMaterialBatch) {
|
||||||
try {
|
try {
|
||||||
setDownloadBusy('all');
|
setDownloadBusy('all');
|
||||||
const blob = await adminApi.downloadReportMaterialBatch(batch.id);
|
const blob = await adminApi.downloadReportMaterialBatch(batch.id, outputFormat);
|
||||||
downloadBlob(blob, `${batchDate(batch)}_${safeDownloadName(batch.batchNo)}_报备文件.zip`);
|
downloadBlob(blob, `${batchDate(batch)}_${safeDownloadName(batch.batchNo)}_报备文件.zip`);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '批次报备文件下载失败');
|
setError(failure instanceof Error ? failure.message : '批次报备文件下载失败');
|
||||||
@@ -480,7 +482,16 @@ export function AdminReportBatchesPage() {
|
|||||||
title={`报备文件导出 · ${exportDetail.batchNo}`}
|
title={`报备文件导出 · ${exportDetail.batchNo}`}
|
||||||
>
|
>
|
||||||
<section className="report-batch-briefs" aria-label="通道报备简报与文件">
|
<section className="report-batch-briefs" aria-label="通道报备简报与文件">
|
||||||
<p className="muted">每个通道一份报备表格和简报;全部下载为ZIP压缩包,内含对应XLSX和TXT。</p>
|
<Select
|
||||||
|
label="导出格式"
|
||||||
|
onChange={(event) => setOutputFormat(event.target.value as 'excel_drawing' | 'wps_cell_image')}
|
||||||
|
options={[
|
||||||
|
{ label: '系统 Excel 文件(标准 Drawing 图片)', value: 'excel_drawing' },
|
||||||
|
{ label: 'WPS 单元格图片文件(DISPIMG)', value: 'wps_cell_image' },
|
||||||
|
]}
|
||||||
|
value={outputFormat}
|
||||||
|
/>
|
||||||
|
<p className="muted">每个通道一份报备表格和简报;全部下载为ZIP压缩包,内含所选格式的XLSX和TXT。</p>
|
||||||
{exportDetail.briefs?.length ? (
|
{exportDetail.briefs?.length ? (
|
||||||
exportDetail.briefs.map((brief) => {
|
exportDetail.briefs.map((brief) => {
|
||||||
const fileAvailable = exportDetail.exportFiles.some(
|
const fileAvailable = exportDetail.exportFiles.some(
|
||||||
|
|||||||
@@ -2,8 +2,41 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Download, Eye, Search } from 'lucide-react';
|
import { Download, Eye, Search } from 'lucide-react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
|
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
Button,
|
||||||
|
CarrierTag,
|
||||||
|
DateRangeInput,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Textarea,
|
||||||
|
type DateRangeValue,
|
||||||
|
type TableColumn,
|
||||||
|
} from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
import { ReportExportFormatModal, type ReportWorkbookFormat } from './ReportExportFormatModal';
|
||||||
|
|
||||||
|
function materialValue(value: unknown) {
|
||||||
|
const file = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||||
|
const fileObjectId = String(file.fileObjectId ?? '');
|
||||||
|
const fileName = String(file.fileName ?? fileObjectId ?? '-');
|
||||||
|
if (
|
||||||
|
fileObjectId &&
|
||||||
|
(String(file.contentType ?? '').startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(fileName))
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="report-material-image-value">
|
||||||
|
<img alt={fileName} src={fileDownloadUrl(fileObjectId, 'inline')} />
|
||||||
|
<a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (fileObjectId) return <a href={fileDownloadUrl(fileObjectId)}>{fileName}</a>;
|
||||||
|
return String(value ?? '-');
|
||||||
|
}
|
||||||
|
|
||||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||||
pending: { label: '未报备', tone: 'neutral' },
|
pending: { label: '未报备', tone: 'neutral' },
|
||||||
@@ -119,7 +152,8 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
|
|||||||
<div>
|
<div>
|
||||||
<span>状态变化</span>
|
<span>状态变化</span>
|
||||||
<strong>
|
<strong>
|
||||||
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} → {statusMeta[record.statusAfter]?.label ?? record.statusAfter}
|
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} →{' '}
|
||||||
|
{statusMeta[record.statusAfter]?.label ?? record.statusAfter}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -155,9 +189,17 @@ export function AdminReportTasksPage() {
|
|||||||
const [statusReason, setStatusReason] = useState('');
|
const [statusReason, setStatusReason] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
|
||||||
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: initialStatus, carrier: 'all' });
|
const [appliedFilters, setAppliedFilters] = useState({
|
||||||
|
keyword: '',
|
||||||
|
dateRange: {} as DateRangeValue,
|
||||||
|
reportType: 'all',
|
||||||
|
status: initialStatus,
|
||||||
|
carrier: 'all',
|
||||||
|
});
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
function loadData(targetPage = page, filters = appliedFilters) {
|
function loadData(targetPage = page, filters = appliedFilters) {
|
||||||
@@ -231,8 +273,9 @@ export function AdminReportTasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportMaterial(task: ReportTask) {
|
async function exportMaterial(task: ReportTask, outputFormat: ReportWorkbookFormat) {
|
||||||
try {
|
try {
|
||||||
|
setExportBusy(true);
|
||||||
const blob = await adminApi.exportSingleReportMaterial({
|
const blob = await adminApi.exportSingleReportMaterial({
|
||||||
reportType: task.reportType,
|
reportType: task.reportType,
|
||||||
signatureId: task.signatureId,
|
signatureId: task.signatureId,
|
||||||
@@ -240,6 +283,7 @@ export function AdminReportTasksPage() {
|
|||||||
carrier: task.carrier ?? undefined,
|
carrier: task.carrier ?? undefined,
|
||||||
drainageItemId: task.drainageItemId ?? undefined,
|
drainageItemId: task.drainageItemId ?? undefined,
|
||||||
batchItemId: task.exportItems?.[0]?.batchItem.id,
|
batchItemId: task.exportItems?.[0]?.batchItem.id,
|
||||||
|
outputFormat,
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
@@ -247,8 +291,11 @@ export function AdminReportTasksPage() {
|
|||||||
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
|
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
|
||||||
anchor.click();
|
anchor.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
setExportTask(null);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
|
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +327,8 @@ export function AdminReportTasksPage() {
|
|||||||
<div>
|
<div>
|
||||||
<strong>{taskTargetLabel(record)}</strong>
|
<strong>{taskTargetLabel(record)}</strong>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
|
{record.reportType === 'drainage' ? '引流信息' : '签名'} ·{' '}
|
||||||
|
{record.signature?.tenant?.name ?? record.tenantId}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -292,7 +340,11 @@ export function AdminReportTasksPage() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div>
|
<div>
|
||||||
<strong>{record.channel?.name ?? record.channelId}</strong>
|
<strong>{record.channel?.name ?? record.channelId}</strong>
|
||||||
{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}
|
{record.reportType !== 'drainage' ? (
|
||||||
|
<div className="muted">
|
||||||
|
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -316,7 +368,11 @@ export function AdminReportTasksPage() {
|
|||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag>,
|
render: (record) => (
|
||||||
|
<Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>
|
||||||
|
{(statusMeta[record.status] ?? { label: record.status }).label}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
|
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
|
||||||
{
|
{
|
||||||
@@ -329,7 +385,7 @@ export function AdminReportTasksPage() {
|
|||||||
查看报备资料
|
查看报备资料
|
||||||
</Button>
|
</Button>
|
||||||
{record.reportType !== 'drainage' ? (
|
{record.reportType !== 'drainage' ? (
|
||||||
<Button icon={<Download size={14} />} onClick={() => void exportMaterial(record)} size="sm" variant="ghost">
|
<Button icon={<Download size={14} />} onClick={() => setExportTask(record)} size="sm" variant="ghost">
|
||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -362,9 +418,7 @@ export function AdminReportTasksPage() {
|
|||||||
<div className="page-heading__actions">
|
<div className="page-heading__actions">
|
||||||
<Button
|
<Button
|
||||||
disabled={!tasks.length}
|
disabled={!tasks.length}
|
||||||
onClick={() =>
|
onClick={() => setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))}
|
||||||
setSelected(allCurrentPageSelected ? new Set() : new Set(tasks.map((task) => task.id)))
|
|
||||||
}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{allCurrentPageSelected ? '取消全选' : '全选当页'}
|
{allCurrentPageSelected ? '取消全选' : '全选当页'}
|
||||||
@@ -385,7 +439,12 @@ export function AdminReportTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
<div className="surface admin-task-filter">
|
<div className="surface admin-task-filter">
|
||||||
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
|
<Input
|
||||||
|
label="企业/应用/通道/报备对象"
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索报备明细"
|
||||||
|
value={keyword}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="报备类型"
|
label="报备类型"
|
||||||
onChange={(event) => setReportType(event.target.value)}
|
onChange={(event) => setReportType(event.target.value)}
|
||||||
@@ -407,7 +466,15 @@ export function AdminReportTasksPage() {
|
|||||||
]}
|
]}
|
||||||
value={carrier}
|
value={carrier}
|
||||||
/>
|
/>
|
||||||
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
|
<Select
|
||||||
|
label="报备状态"
|
||||||
|
onChange={(event) => setStatus(event.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: '全部状态', value: 'all' },
|
||||||
|
...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value })),
|
||||||
|
]}
|
||||||
|
value={status}
|
||||||
|
/>
|
||||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||||
<div className="admin-task-filter__actions">
|
<div className="admin-task-filter__actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -428,7 +495,13 @@ export function AdminReportTasksPage() {
|
|||||||
setReportType('all');
|
setReportType('all');
|
||||||
setCarrier('all');
|
setCarrier('all');
|
||||||
setStatus('all');
|
setStatus('all');
|
||||||
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: 'all', carrier: 'all' };
|
const filters = {
|
||||||
|
keyword: '',
|
||||||
|
dateRange: {} as DateRangeValue,
|
||||||
|
reportType: 'all',
|
||||||
|
status: 'all',
|
||||||
|
carrier: 'all',
|
||||||
|
};
|
||||||
setAppliedFilters(filters);
|
setAppliedFilters(filters);
|
||||||
if (page !== 1) setPage(1);
|
if (page !== 1) setPage(1);
|
||||||
else loadData(1, filters);
|
else loadData(1, filters);
|
||||||
@@ -442,10 +515,25 @@ export function AdminReportTasksPage() {
|
|||||||
<div className="surface report-task-table-card">
|
<div className="surface report-task-table-card">
|
||||||
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
|
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
|
<Pagination
|
||||||
|
nextDisabled={page * pageSize >= total}
|
||||||
|
onNext={() => setPage((current) => current + 1)}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
page={page}
|
||||||
|
previousDisabled={page <= 1}
|
||||||
|
total={total}
|
||||||
|
totalPages={Math.max(1, Math.ceil(total / pageSize))}
|
||||||
|
/>
|
||||||
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
||||||
{material ? (
|
{material ? (
|
||||||
<Modal footer={<Button onClick={() => setMaterial(null)}>关闭</Button>} onClose={() => setMaterial(null)} open size="xl" title="查看报备资料">
|
<Modal
|
||||||
|
footer={<Button onClick={() => setMaterial(null)}>关闭</Button>}
|
||||||
|
onClose={() => setMaterial(null)}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title="查看报备资料"
|
||||||
|
>
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
<div>
|
<div>
|
||||||
@@ -459,9 +547,9 @@ export function AdminReportTasksPage() {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>通道/版本</span>
|
<span>通道名称 / 编号 / 版本</span>
|
||||||
<strong>
|
<strong>
|
||||||
{material.channel.name} · V{material.materialVersion}
|
{material.channel.name} · {material.channel.code} · V{material.materialVersion}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,16 +557,19 @@ export function AdminReportTasksPage() {
|
|||||||
{material.fields.map((field) => (
|
{material.fields.map((field) => (
|
||||||
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
|
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
|
||||||
<span>
|
<span>
|
||||||
{field.exportName || field.name}
|
{field.name}({field.code})
|
||||||
|
{field.exportName && field.exportName !== field.name ? ` · 导出为“${field.exportName}”` : ''}
|
||||||
{field.required ? ' *' : ''}
|
{field.required ? ' *' : ''}
|
||||||
</span>
|
</span>
|
||||||
<strong>{typeof field.value === 'object' ? String((field.value as Record<string, unknown>)?.fileName ?? '-') : String(field.value ?? '-')}</strong>
|
<strong>{materialValue(field.value)}</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{material.historicalFields.map((field) => (
|
{material.historicalFields.map((field) => (
|
||||||
<div key={field.code}>
|
<div key={field.code}>
|
||||||
<span>{field.name}(历史字段)</span>
|
<span>
|
||||||
<strong>{String(field.value ?? '-')}</strong>
|
{field.name}({field.code},历史字段)
|
||||||
|
</span>
|
||||||
|
<strong>{materialValue(field.value)}</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -529,10 +620,23 @@ export function AdminReportTasksPage() {
|
|||||||
]}
|
]}
|
||||||
value={nextStatus}
|
value={nextStatus}
|
||||||
/>
|
/>
|
||||||
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
|
<Textarea
|
||||||
|
label="修改原因(选填)"
|
||||||
|
onChange={(event) => setStatusReason(event.target.value)}
|
||||||
|
placeholder="可填写供应商反馈或人工处理说明"
|
||||||
|
rows={3}
|
||||||
|
value={statusReason}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{exportTask ? (
|
||||||
|
<ReportExportFormatModal
|
||||||
|
busy={exportBusy}
|
||||||
|
onClose={() => setExportTask(null)}
|
||||||
|
onConfirm={(format) => void exportMaterial(exportTask, format)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal } from '@/components/ui';
|
||||||
|
|
||||||
|
export type ReportWorkbookFormat = 'excel_drawing' | 'wps_cell_image';
|
||||||
|
|
||||||
|
export function ReportExportFormatModal({
|
||||||
|
busy = false,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = '选择报备文件格式',
|
||||||
|
}: {
|
||||||
|
busy?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (format: ReportWorkbookFormat) => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const [format, setFormat] = useState<ReportWorkbookFormat>('excel_drawing');
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={busy} onClick={onClose} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onClick={() => onConfirm(format)}>
|
||||||
|
{busy ? '生成中…' : '确认导出'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<div className="report-export-format-options" role="radiogroup" aria-label="报备文件格式">
|
||||||
|
<button
|
||||||
|
aria-checked={format === 'excel_drawing'}
|
||||||
|
onClick={() => setFormat('excel_drawing')}
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>系统 Excel 文件</strong>
|
||||||
|
<span>标准 Drawing 图片,兼容 Microsoft Excel 及多数办公软件。</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-checked={format === 'wps_cell_image'}
|
||||||
|
onClick={() => setFormat('wps_cell_image')}
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>WPS 单元格图片文件</strong>
|
||||||
|
<span>使用 DISPIMG 和 cellimages.xml,适配业务常用的 WPS 报备资料格式。</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
||||||
|
|
||||||
|
describe('ReportFieldMappingModal', () => {
|
||||||
|
it('includes common report fields by default and uses a quiet normal-width remove action', () => {
|
||||||
|
const library = { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'image', status: 'active' };
|
||||||
|
render(
|
||||||
|
<ReportFieldMappingModal
|
||||||
|
fields={[]}
|
||||||
|
commonFields={[
|
||||||
|
{
|
||||||
|
id: 'common-1',
|
||||||
|
drainageFieldId: 'field-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
required: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
drainageField: library,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
libraryFields={[library]}
|
||||||
|
reportType="signature"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('营业执照')).toBeVisible();
|
||||||
|
expect(screen.getByText('1 列')).toBeVisible();
|
||||||
|
const remove = screen.getByRole('button', { name: '移除字段' });
|
||||||
|
expect(remove).toHaveClass('channel-remove-field-button', 'ui-button--ghost');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
|
import { type ChannelReportField, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||||
|
|
||||||
type ReportType = 'signature' | 'drainage';
|
type ReportType = 'signature' | 'drainage';
|
||||||
@@ -30,8 +30,12 @@ const transformOptions = [
|
|||||||
{ label: '转小写', value: 'lowercase' },
|
{ label: '转小写', value: 'lowercase' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function initialDraft(fields: ChannelReportField[], reportType: ReportType): DraftField[] {
|
function initialDraft(
|
||||||
return fields
|
fields: ChannelReportField[],
|
||||||
|
commonFields: CommonReportField[],
|
||||||
|
reportType: ReportType,
|
||||||
|
): DraftField[] {
|
||||||
|
const configured = fields
|
||||||
.filter((field) => field.reportType === reportType || field.reportType === 'both')
|
.filter((field) => field.reportType === reportType || field.reportType === 'both')
|
||||||
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
|
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
|
||||||
.map((field, index) => ({
|
.map((field, index) => ({
|
||||||
@@ -50,43 +54,90 @@ function initialDraft(fields: ChannelReportField[], reportType: ReportType): Dra
|
|||||||
transform: String(field.transform ?? ''),
|
transform: String(field.transform ?? ''),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}));
|
}));
|
||||||
}
|
const configuredIds = new Set(configured.map((field) => field.drainageFieldId));
|
||||||
|
const defaults = commonFields
|
||||||
export function ReportFieldMappingModal({ fields, libraryFields, reportType, onClose, onSave }: {
|
.filter(
|
||||||
fields: ChannelReportField[];
|
(field) =>
|
||||||
libraryFields: DictionaryItem[];
|
field.reportType === reportType && field.status !== 'deleted' && !configuredIds.has(field.drainageFieldId),
|
||||||
reportType: ReportType;
|
)
|
||||||
onClose: () => void;
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
onSave: (fields: DraftField[]) => Promise<void>;
|
.map((field, index) => ({
|
||||||
}) {
|
drainageFieldId: field.drainageFieldId,
|
||||||
const [draft, setDraft] = useState(() => initialDraft(fields, reportType));
|
code: String(field.drainageField.code ?? field.drainageFieldId),
|
||||||
const [search, setSearch] = useState('');
|
name: String(field.drainageField.name ?? field.drainageField.code ?? '未命名字段'),
|
||||||
const [saving, setSaving] = useState(false);
|
fieldType: String(field.drainageField.fieldType ?? 'string'),
|
||||||
const [error, setError] = useState('');
|
exportName: String(field.drainageField.name ?? field.drainageField.code ?? ''),
|
||||||
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
|
required: field.required,
|
||||||
const available = useMemo(() => libraryFields.filter((field) => !selectedIds.has(String(field.id)) && [field.name, field.code].some((value) => String(value ?? '').toLowerCase().includes(search.trim().toLowerCase()))), [libraryFields, search, selectedIds]);
|
description: String(field.drainageField.description ?? ''),
|
||||||
|
sortOrder: (configured.length + index + 1) * 10,
|
||||||
function addField(field: DictionaryItem) {
|
|
||||||
setDraft((current) => [...current, {
|
|
||||||
drainageFieldId: String(field.id),
|
|
||||||
code: String(field.code ?? field.id),
|
|
||||||
name: String(field.name ?? field.code ?? '未命名字段'),
|
|
||||||
fieldType: String(field.fieldType ?? 'string'),
|
|
||||||
exportName: String(field.name ?? field.code ?? ''),
|
|
||||||
required: Boolean(field.required),
|
|
||||||
description: String(field.description ?? ''),
|
|
||||||
sortOrder: (current.length + 1) * 10,
|
|
||||||
columnWidth: 18,
|
columnWidth: 18,
|
||||||
imageWidth: 120,
|
imageWidth: 120,
|
||||||
imageHeight: 80,
|
imageHeight: 80,
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
transform: '',
|
transform: '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}]);
|
}));
|
||||||
|
return [...configured, ...defaults].map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportFieldMappingModal({
|
||||||
|
fields,
|
||||||
|
commonFields,
|
||||||
|
libraryFields,
|
||||||
|
reportType,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
fields: ChannelReportField[];
|
||||||
|
commonFields: CommonReportField[];
|
||||||
|
libraryFields: DictionaryItem[];
|
||||||
|
reportType: ReportType;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (fields: DraftField[]) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(() => initialDraft(fields, commonFields, reportType));
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
|
||||||
|
const available = useMemo(
|
||||||
|
() =>
|
||||||
|
libraryFields.filter(
|
||||||
|
(field) =>
|
||||||
|
!selectedIds.has(String(field.id)) &&
|
||||||
|
[field.name, field.code].some((value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.trim().toLowerCase()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[libraryFields, search, selectedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
function addField(field: DictionaryItem) {
|
||||||
|
setDraft((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
drainageFieldId: String(field.id),
|
||||||
|
code: String(field.code ?? field.id),
|
||||||
|
name: String(field.name ?? field.code ?? '未命名字段'),
|
||||||
|
fieldType: String(field.fieldType ?? 'string'),
|
||||||
|
exportName: String(field.name ?? field.code ?? ''),
|
||||||
|
required: Boolean(field.required),
|
||||||
|
description: String(field.description ?? ''),
|
||||||
|
sortOrder: (current.length + 1) * 10,
|
||||||
|
columnWidth: 18,
|
||||||
|
imageWidth: 120,
|
||||||
|
imageHeight: 80,
|
||||||
|
defaultValue: '',
|
||||||
|
transform: '',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchField(index: number, patch: Partial<DraftField>) {
|
function patchField(index: number, patch: Partial<DraftField>) {
|
||||||
setDraft((current) => current.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field));
|
setDraft((current) => current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function move(index: number, offset: number) {
|
function move(index: number, offset: number) {
|
||||||
@@ -100,49 +151,177 @@ export function ReportFieldMappingModal({ fields, libraryFields, reportType, onC
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (draft.some((field) => !field.exportName.trim())) { setError('导出表头名称不能为空'); return; }
|
if (draft.some((field) => !field.exportName.trim())) {
|
||||||
setSaving(true); setError('');
|
setError('导出表头名称不能为空');
|
||||||
try { await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }))); onClose(); }
|
return;
|
||||||
catch (failure) { setError(failure instanceof Error ? failure.message : '字段配置保存失败'); }
|
}
|
||||||
finally { setSaving(false); }
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 })));
|
||||||
|
onClose();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '字段配置保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Modal
|
return (
|
||||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中...' : '保存配置'}</Button></>}
|
<Modal
|
||||||
onClose={onClose}
|
footer={
|
||||||
open
|
<>
|
||||||
size="xl"
|
<Button onClick={onClose} variant="ghost">
|
||||||
title={<div className="channel-field-config-title"><h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2><p>配置系统字段到通道Excel表头的映射、顺序和图片布局。</p></div>}
|
取消
|
||||||
>
|
</Button>
|
||||||
<div className="channel-field-config">
|
<Button disabled={saving} onClick={() => void save()}>
|
||||||
<section className="channel-field-pool">
|
{saving ? '保存中...' : '保存配置'}
|
||||||
<div className="channel-field-section-head"><h3>字段池</h3><Tag tone="neutral">{available.length} 个可选</Tag></div>
|
</Button>
|
||||||
<Input onChange={(event) => setSearch(event.target.value)} placeholder="搜索标准字段" prefix={<Search size={16} />} value={search} />
|
</>
|
||||||
<div className="channel-field-pool-list">
|
}
|
||||||
{available.map((field) => <button key={String(field.id)} onClick={() => addField(field)} type="button"><span><strong>{String(field.name ?? field.code)}</strong><Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag></span><span>添加 <Plus size={15} /></span></button>)}
|
onClose={onClose}
|
||||||
{available.length === 0 ? <p>没有可添加的字段</p> : null}
|
open
|
||||||
|
size="xl"
|
||||||
|
title={
|
||||||
|
<div className="channel-field-config-title">
|
||||||
|
<h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2>
|
||||||
|
<p>配置系统字段到通道Excel表头的映射、顺序和图片布局。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
}
|
||||||
<section className="channel-selected-fields">
|
>
|
||||||
<div className="channel-field-section-head"><div><h3>导出字段</h3><p>从上到下对应Excel从左到右的列顺序</p></div><Tag tone="info">{draft.length} 列</Tag></div>
|
<div className="channel-field-config">
|
||||||
<div className="channel-export-preview">{draft.map((field, index) => <span key={field.drainageFieldId}>{String.fromCharCode(65 + index)} · {field.exportName || field.name}</span>)}</div>
|
<section className="channel-field-pool">
|
||||||
<div className="channel-selected-field-list">
|
<div className="channel-field-section-head">
|
||||||
{draft.map((field, index) => <article key={field.drainageFieldId}>
|
<h3>字段池</h3>
|
||||||
<div className="channel-selected-field-head"><span className="channel-selected-field-index">{index + 1}</span><strong>{field.name}</strong><Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag><div className="channel-selected-field-order"><button disabled={index === 0} onClick={() => move(index, -1)} type="button"><ChevronUp size={16} /></button><button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button"><ChevronDown size={16} /></button></div></div>
|
<Tag tone="neutral">{available.length} 个可选</Tag>
|
||||||
<div className="channel-field-mapping-grid">
|
</div>
|
||||||
<Input label="通道导出表头" onChange={(event) => patchField(index, { exportName: event.target.value })} value={field.exportName} />
|
<Input
|
||||||
<Select label="是否必填" onChange={(event) => patchField(index, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(field.required)} />
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
<Input label="列宽" min="6" onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })} type="number" value={String(field.columnWidth)} />
|
placeholder="搜索标准字段"
|
||||||
<Select label="文本转换" onChange={(event) => patchField(index, { transform: event.target.value })} options={transformOptions} value={field.transform} />
|
prefix={<Search size={16} />}
|
||||||
{field.fieldType !== 'string' ? <><Input label="图片宽度(px)" min="24" onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })} type="number" value={String(field.imageWidth)} /><Input label="图片高度(px)" min="24" onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })} type="number" value={String(field.imageHeight)} /></> : <Input label="缺省值" onChange={(event) => patchField(index, { defaultValue: event.target.value })} value={field.defaultValue} />}
|
value={search}
|
||||||
|
/>
|
||||||
|
<div className="channel-field-pool-list">
|
||||||
|
{available.map((field) => (
|
||||||
|
<button key={String(field.id)} onClick={() => addField(field)} type="button">
|
||||||
|
<span>
|
||||||
|
<strong>{String(field.name ?? field.code)}</strong>
|
||||||
|
<Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
添加 <Plus size={15} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{available.length === 0 ? <p>没有可添加的字段</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="channel-selected-fields">
|
||||||
|
<div className="channel-field-section-head">
|
||||||
|
<div>
|
||||||
|
<h3>导出字段</h3>
|
||||||
|
<p>从上到下对应Excel从左到右的列顺序</p>
|
||||||
</div>
|
</div>
|
||||||
<Textarea label="通道说明" onChange={(event) => patchField(index, { description: event.target.value })} rows={2} value={field.description} />
|
<Tag tone="info">{draft.length} 列</Tag>
|
||||||
<Button icon={<Trash2 size={15} />} onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))} size="sm" variant="danger">移除字段</Button>
|
</div>
|
||||||
</article>)}
|
<div className="channel-export-preview">
|
||||||
{draft.length === 0 ? <div className="channel-report-empty">请从左侧添加报备字段</div> : null}
|
{draft.map((field, index) => (
|
||||||
</div>
|
<span key={field.drainageFieldId}>
|
||||||
</section>
|
{String.fromCharCode(65 + index)} · {field.exportName || field.name}
|
||||||
</div>
|
</span>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
))}
|
||||||
</Modal>;
|
</div>
|
||||||
|
<div className="channel-selected-field-list">
|
||||||
|
{draft.map((field, index) => (
|
||||||
|
<article key={field.drainageFieldId}>
|
||||||
|
<div className="channel-selected-field-head">
|
||||||
|
<span className="channel-selected-field-index">{index + 1}</span>
|
||||||
|
<strong>{field.name}</strong>
|
||||||
|
<Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag>
|
||||||
|
<div className="channel-selected-field-order">
|
||||||
|
<button disabled={index === 0} onClick={() => move(index, -1)} type="button">
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="channel-field-mapping-grid">
|
||||||
|
<Input
|
||||||
|
label="通道导出表头"
|
||||||
|
onChange={(event) => patchField(index, { exportName: event.target.value })}
|
||||||
|
value={field.exportName}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="是否必填"
|
||||||
|
onChange={(event) => patchField(index, { required: event.target.value === 'true' })}
|
||||||
|
options={[
|
||||||
|
{ label: '选填', value: 'false' },
|
||||||
|
{ label: '必填', value: 'true' },
|
||||||
|
]}
|
||||||
|
value={String(field.required)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="列宽"
|
||||||
|
min="6"
|
||||||
|
onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })}
|
||||||
|
type="number"
|
||||||
|
value={String(field.columnWidth)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="文本转换"
|
||||||
|
onChange={(event) => patchField(index, { transform: event.target.value })}
|
||||||
|
options={transformOptions}
|
||||||
|
value={field.transform}
|
||||||
|
/>
|
||||||
|
{field.fieldType !== 'string' ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="图片宽度(px)"
|
||||||
|
min="24"
|
||||||
|
onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })}
|
||||||
|
type="number"
|
||||||
|
value={String(field.imageWidth)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="图片高度(px)"
|
||||||
|
min="24"
|
||||||
|
onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })}
|
||||||
|
type="number"
|
||||||
|
value={String(field.imageHeight)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
label="缺省值"
|
||||||
|
onChange={(event) => patchField(index, { defaultValue: event.target.value })}
|
||||||
|
value={field.defaultValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
label="通道说明"
|
||||||
|
onChange={(event) => patchField(index, { description: event.target.value })}
|
||||||
|
rows={2}
|
||||||
|
value={field.description}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="channel-remove-field-button"
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
|
onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
移除字段
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{draft.length === 0 ? <div className="channel-report-empty">请从左侧添加报备字段</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ vi.mock('@/api/adminApi', () => ({ adminApi }));
|
|||||||
describe('ReportMaterialImportModal mapping profile action', () => {
|
describe('ReportMaterialImportModal mapping profile action', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.values(adminApi).forEach((method) => method.mockReset());
|
Object.values(adminApi).forEach((method) => method.mockReset());
|
||||||
adminApi.listTenantOptions.mockResolvedValue([{ id: 'tenant-1', name: '测试企业', code: 'T001', status: 'active' }]);
|
adminApi.listTenantOptions.mockResolvedValue([
|
||||||
adminApi.listEnterpriseApplicationOptions.mockResolvedValue([]);
|
{ id: 'tenant-1', name: '测试企业', code: 'T001', status: 'active' },
|
||||||
|
]);
|
||||||
|
adminApi.listEnterpriseApplicationOptions.mockResolvedValue([
|
||||||
|
{ id: 'app-1', tenantId: 'tenant-1', name: '测试应用', status: 'active' },
|
||||||
|
]);
|
||||||
adminApi.listDrainageFields.mockResolvedValue([]);
|
adminApi.listDrainageFields.mockResolvedValue([]);
|
||||||
adminApi.listReportImportProfiles.mockResolvedValue([]);
|
adminApi.listReportImportProfiles.mockResolvedValue([]);
|
||||||
adminApi.analyzeReportMaterialImport.mockResolvedValue({
|
adminApi.analyzeReportMaterialImport.mockResolvedValue({
|
||||||
id: 'analysis-1',
|
id: 'analysis-1',
|
||||||
columns: [{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 }],
|
columns: [
|
||||||
|
{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 },
|
||||||
|
],
|
||||||
rows: [],
|
rows: [],
|
||||||
suggestedMappings: [],
|
suggestedMappings: [],
|
||||||
});
|
});
|
||||||
@@ -37,6 +43,9 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
|||||||
expect(tenantSelect).not.toBeNull();
|
expect(tenantSelect).not.toBeNull();
|
||||||
await user.click(tenantSelect!);
|
await user.click(tenantSelect!);
|
||||||
await user.click(screen.getByRole('option', { name: /测试企业/ }));
|
await user.click(screen.getByRole('option', { name: /测试企业/ }));
|
||||||
|
const applicationSelect = screen.getByText('企业应用(必选)').closest('label')?.querySelector('button');
|
||||||
|
await user.click(applicationSelect!);
|
||||||
|
await user.click(screen.getByRole('option', { name: '测试应用' }));
|
||||||
const fileInput = document.querySelector('input[type="file"]');
|
const fileInput = document.querySelector('input[type="file"]');
|
||||||
expect(fileInput).not.toBeNull();
|
expect(fileInput).not.toBeNull();
|
||||||
fireEvent.change(fileInput!, { target: { files: [new File(['xlsx'], 'mapping.xlsx')] } });
|
fireEvent.change(fileInput!, { target: { files: [new File(['xlsx'], 'mapping.xlsx')] } });
|
||||||
@@ -46,7 +55,9 @@ describe('ReportMaterialImportModal mapping profile action', () => {
|
|||||||
expect(toggle).toHaveClass('ui-button', 'report-import-profile__toggle');
|
expect(toggle).toHaveClass('ui-button', 'report-import-profile__toggle');
|
||||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'));
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'),
|
||||||
|
);
|
||||||
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
|
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, FileSpreadsheet, Plus } from 'lucide-react';
|
import { CheckCircle2, FileSpreadsheet, Plus } from 'lucide-react';
|
||||||
import { adminApi, type DictionaryItem, type EnterpriseApplication, type ReportImportMapping, type ReportImportProfile, type TenantOption } from '@/api/adminApi';
|
import {
|
||||||
|
adminApi,
|
||||||
|
type DictionaryItem,
|
||||||
|
type EnterpriseApplication,
|
||||||
|
type ReportImportMapping,
|
||||||
|
type ReportImportProfile,
|
||||||
|
type TenantOption,
|
||||||
|
} from '@/api/adminApi';
|
||||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
|
|
||||||
type ReportType = 'signature' | 'drainage';
|
type ReportType = 'signature' | 'drainage';
|
||||||
@@ -8,17 +15,36 @@ type AnalyzeResult = {
|
|||||||
id: string;
|
id: string;
|
||||||
sheetName?: string;
|
sheetName?: string;
|
||||||
sheets?: string[];
|
sheets?: string[];
|
||||||
columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>;
|
columns: Array<{
|
||||||
|
sourceColumnIndex: number;
|
||||||
|
columnLetter: string;
|
||||||
|
sourceHeader: string;
|
||||||
|
sourceHeaderPath: string;
|
||||||
|
imageCount: number;
|
||||||
|
}>;
|
||||||
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
|
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
|
||||||
suggestedMappings: ReportImportMapping[];
|
suggestedMappings: ReportImportMapping[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾空格', value: 'trim' }, { label: '仅保留数字', value: 'digits' }, { label: '转大写', value: 'uppercase' }, { label: '转小写', value: 'lowercase' }];
|
const transforms = [
|
||||||
|
{ label: '保持原值', value: '' },
|
||||||
|
{ label: '去除首尾空格', value: 'trim' },
|
||||||
|
{ label: '仅保留数字', value: 'digits' },
|
||||||
|
{ label: '转大写', value: 'uppercase' },
|
||||||
|
{ label: '转小写', value: 'lowercase' },
|
||||||
|
];
|
||||||
|
|
||||||
function coreTargets(reportType: ReportType) {
|
function coreTargets(reportType: ReportType) {
|
||||||
return reportType === 'signature'
|
return reportType === 'signature'
|
||||||
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
|
? [
|
||||||
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '引流 URL 或号码', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
|
{ label: '短信签名', value: 'signatureName:signature_name:string' },
|
||||||
|
{ label: '签名用途/依据', value: 'purpose:purpose:string' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: '所属短信签名', value: 'signatureName:signature_name:string' },
|
||||||
|
{ label: '引流 URL 或号码', value: 'url:url:string' },
|
||||||
|
{ label: '备注', value: 'remark:remark:string' },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
||||||
@@ -41,95 +67,346 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listDrainageFields()])
|
Promise.all([
|
||||||
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
|
adminApi.listTenantOptions(),
|
||||||
|
adminApi.listEnterpriseApplicationOptions(),
|
||||||
|
adminApi.listDrainageFields(),
|
||||||
|
])
|
||||||
|
.then(([tenantItems, applicationItems, fieldItems]) => {
|
||||||
|
setTenants(tenantItems);
|
||||||
|
setApplications(applicationItems);
|
||||||
|
setLibraryFields(fieldItems.filter((item) => item.status === 'active'));
|
||||||
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
|
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.listReportImportProfiles(reportType).then(setProfiles).catch(() => setProfiles([]));
|
adminApi
|
||||||
setProfileId(''); setAnalysis(undefined); setMappings([]);
|
.listReportImportProfiles(reportType)
|
||||||
|
.then(setProfiles)
|
||||||
|
.catch(() => setProfiles([]));
|
||||||
}, [reportType]);
|
}, [reportType]);
|
||||||
|
|
||||||
const availableApplications = useMemo(() => applications.filter((item) => !tenantId || item.tenantId === tenantId), [applications, tenantId]);
|
function changeReportType(next: ReportType) {
|
||||||
|
setReportType(next);
|
||||||
|
setProfileId('');
|
||||||
|
setAnalysis(undefined);
|
||||||
|
setMappings([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableApplications = useMemo(
|
||||||
|
() => applications.filter((item) => !tenantId || item.tenantId === tenantId),
|
||||||
|
[applications, tenantId],
|
||||||
|
);
|
||||||
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
|
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
|
||||||
|
|
||||||
async function analyze() {
|
async function analyze() {
|
||||||
if (!tenantId || !file) { setError('请选择企业和 XLSX 文件'); return; }
|
if (!tenantId || !applicationId || !file) {
|
||||||
setBusy(true); setError('');
|
setError('请选择企业、企业应用和 XLSX 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.analyzeReportMaterialImport(file, { tenantId, applicationId: applicationId || undefined, reportType, headerRowCount, dataStartRow, profileId: profileId || undefined }) as AnalyzeResult;
|
const result = (await adminApi.analyzeReportMaterialImport(file, {
|
||||||
setAnalysis(result); setMappings(result.suggestedMappings ?? []);
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
reportType,
|
||||||
|
headerRowCount,
|
||||||
|
dataStartRow,
|
||||||
|
profileId: profileId || undefined,
|
||||||
|
})) as AnalyzeResult;
|
||||||
|
setAnalysis(result);
|
||||||
|
setMappings(result.suggestedMappings ?? []);
|
||||||
const selectedProfile = profiles.find((item) => item.id === profileId);
|
const selectedProfile = profiles.find((item) => item.id === profileId);
|
||||||
if (selectedProfile) setProfileName(selectedProfile.name);
|
if (selectedProfile) setProfileName(selectedProfile.name);
|
||||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件解析失败'); }
|
} catch (failure) {
|
||||||
finally { setBusy(false); }
|
setError(failure instanceof Error ? failure.message : '文件解析失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
|
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
|
||||||
setMappings((current) => {
|
setMappings((current) => {
|
||||||
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
|
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
|
||||||
if (!encoded) return remaining;
|
if (!encoded) return remaining;
|
||||||
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [ReportImportMapping['targetKind'], string, ReportImportMapping['fieldType']];
|
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [
|
||||||
return [...remaining, { sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetKind, targetFieldCode, fieldType, required: false, sortOrder: (column.sourceColumnIndex + 1) * 10 }].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
|
ReportImportMapping['targetKind'],
|
||||||
|
string,
|
||||||
|
ReportImportMapping['fieldType'],
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
...remaining,
|
||||||
|
{
|
||||||
|
sourceHeader: column.sourceHeader,
|
||||||
|
sourceHeaderPath: column.sourceHeaderPath,
|
||||||
|
sourceColumnIndex: column.sourceColumnIndex,
|
||||||
|
targetKind,
|
||||||
|
targetFieldCode,
|
||||||
|
fieldType,
|
||||||
|
required: false,
|
||||||
|
sortOrder: (column.sourceColumnIndex + 1) * 10,
|
||||||
|
},
|
||||||
|
].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
|
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
|
||||||
setMappings((current) => current.map((item) => item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item));
|
setMappings((current) =>
|
||||||
|
current.map((item) => (item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
if (!analysis || mappings.length === 0) { setError('请至少配置一个导入字段映射'); return; }
|
if (!analysis || mappings.length === 0) {
|
||||||
if (saveProfile && !profileName.trim()) { setError('请输入映射方案名称'); return; }
|
setError('请至少配置一个导入字段映射');
|
||||||
setBusy(true); setError('');
|
return;
|
||||||
|
}
|
||||||
|
if (saveProfile && !profileName.trim()) {
|
||||||
|
setError('请输入映射方案名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
await adminApi.commitReportMaterialImport(analysis.id, {
|
await adminApi.commitReportMaterialImport(analysis.id, {
|
||||||
mappings,
|
mappings,
|
||||||
profile: saveProfile ? { id: profileId || undefined, name: profileName, reportType, tenantId, applicationId: applicationId || null, sheetName: analysis.sheetName, headerRowCount, dataStartRow, columns: mappings } : undefined,
|
profile: saveProfile
|
||||||
|
? {
|
||||||
|
id: profileId || undefined,
|
||||||
|
name: profileName,
|
||||||
|
reportType,
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
sheetName: analysis.sheetName,
|
||||||
|
headerRowCount,
|
||||||
|
dataStartRow,
|
||||||
|
columns: mappings,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
onCompleted(); onClose();
|
onCompleted();
|
||||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '导入失败'); }
|
onClose();
|
||||||
finally { setBusy(false); }
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '导入失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetOptions = [
|
const targetOptions = [
|
||||||
{ label: '不导入此列', value: '' },
|
{ label: '不导入此列', value: '' },
|
||||||
...coreTargets(reportType),
|
...coreTargets(reportType),
|
||||||
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
|
...libraryFields.map((field) => ({
|
||||||
|
label: `报备字段 · ${String(field.name ?? field.code)}`,
|
||||||
|
value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}`,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '提交中...' : '提交导入审核'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2>批量导入签名与引流资料</h2><p>支持 WPS 另存的 XLSX 及单元格内嵌图片;解析后的新增和修改项进入审核中心,审核通过前不会影响现有业务资料。</p></div>}>
|
return (
|
||||||
<div className="report-import-basic-grid">
|
<Modal
|
||||||
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
|
footer={
|
||||||
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))]} value={tenantId} />
|
<>
|
||||||
<Select label="企业应用(可选)" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '不限定应用', value: '' }, ...availableApplications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} />
|
<Button onClick={onClose} variant="ghost">
|
||||||
<Select label="复用导入映射(可选)" onChange={(event) => { const id = event.target.value; setProfileId(id); const profile = profiles.find((item) => item.id === id); if (profile) { setHeaderRowCount(profile.headerRowCount); setDataStartRow(profile.dataStartRow); } }} options={[{ label: '新建映射', value: '' }, ...profiles.map((item) => ({ label: item.name, value: item.id }))]} value={profileId} />
|
取消
|
||||||
<Input label="表头行数" max="5" min="1" onChange={(event) => setHeaderRowCount(Number(event.target.value))} type="number" value={String(headerRowCount)} />
|
</Button>
|
||||||
<Input label="数据起始行" min="2" onChange={(event) => setDataStartRow(Number(event.target.value))} type="number" value={String(dataStartRow)} />
|
{analysis ? (
|
||||||
</div>
|
<Button disabled={busy} onClick={() => void commit()}>
|
||||||
<label className="report-import-file"><span><FileSpreadsheet size={22} /><strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong></span><input accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setFile(event.target.files?.[0]); setAnalysis(undefined); }} type="file" /></label>
|
{busy ? '提交中...' : '提交导入审核'}
|
||||||
{analysis ? <div className="report-import-mapping">
|
</Button>
|
||||||
<div className="channel-field-section-head"><div><h3>导入字段映射</h3><p>源列顺序不受限制,每一列明确映射到系统标准字段。</p></div><Tag tone="info">检测到 {analysis.columns.length} 列</Tag></div>
|
) : (
|
||||||
<div className="report-import-mapping-table"><div className="report-import-mapping-head"><span>源列/图片</span><span>目标字段</span><span>数据类型</span><span>必填</span><span>转换</span></div>{analysis.columns.map((column) => {
|
<Button disabled={busy || !file || !tenantId || !applicationId} onClick={() => void analyze()}>
|
||||||
const mapping = mappingByColumn.get(column.sourceColumnIndex);
|
{busy ? '解析中...' : '解析文件并配置映射'}
|
||||||
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
|
</Button>
|
||||||
return <div className="report-import-mapping-row" key={column.sourceColumnIndex}><span><strong>{column.columnLetter} · {column.sourceHeader}</strong><small>{column.sourceHeaderPath}</small>{column.imageCount ? <Tag tone="warning">{column.imageCount} 张图片</Tag> : null}</span><Select onChange={(event) => setTarget(column, event.target.value)} options={targetOptions} value={encoded} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { fieldType: event.target.value as ReportImportMapping['fieldType'] })} options={[{ label: '文本', value: 'string' }, { label: '图片', value: 'image' }, { label: '文件', value: 'file' }]} value={mapping?.fieldType ?? 'string'} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(mapping?.required ?? false)} /><Select disabled={!mapping || mapping.fieldType !== 'string'} onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })} options={transforms} value={mapping?.transform ?? ''} /></div>;
|
)}
|
||||||
})}</div>
|
</>
|
||||||
<div className="report-import-profile">
|
}
|
||||||
<Button
|
onClose={onClose}
|
||||||
aria-pressed={saveProfile}
|
open
|
||||||
className="report-import-profile__toggle"
|
size="xl"
|
||||||
icon={saveProfile ? <CheckCircle2 size={16} /> : <Plus size={16} />}
|
title={
|
||||||
onClick={() => setSaveProfile((value) => !value)}
|
<div className="channel-field-config-title">
|
||||||
variant={saveProfile ? 'secondary' : 'ghost'}
|
<h2>批量导入签名与引流资料</h2>
|
||||||
>
|
<p>支持不超过100MB的 Excel Drawing 或 WPS DISPIMG 单元格图片 XLSX;解析后的新增和修改项进入审核中心。</p>
|
||||||
{saveProfile ? '本次将保存/更新映射方案' : '保存为可复用映射方案'}
|
</div>
|
||||||
</Button>
|
}
|
||||||
{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}
|
>
|
||||||
|
<div className="report-import-basic-grid">
|
||||||
|
<Select
|
||||||
|
label="资料类型"
|
||||||
|
onChange={(event) => changeReportType(event.target.value as ReportType)}
|
||||||
|
options={[
|
||||||
|
{ label: '签名资料', value: 'signature' },
|
||||||
|
{ label: '引流信息资料', value: 'drainage' },
|
||||||
|
]}
|
||||||
|
value={reportType}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="所属企业"
|
||||||
|
onChange={(event) => {
|
||||||
|
setTenantId(event.target.value);
|
||||||
|
setApplicationId('');
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: '请选择企业', value: '' },
|
||||||
|
...tenants.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id })),
|
||||||
|
]}
|
||||||
|
value={tenantId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="企业应用(必选)"
|
||||||
|
onChange={(event) => setApplicationId(event.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: '请选择企业应用', value: '' },
|
||||||
|
...availableApplications.map((item) => ({ label: item.name, value: item.id })),
|
||||||
|
]}
|
||||||
|
value={applicationId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="复用导入映射(可选)"
|
||||||
|
onChange={(event) => {
|
||||||
|
const id = event.target.value;
|
||||||
|
setProfileId(id);
|
||||||
|
const profile = profiles.find((item) => item.id === id);
|
||||||
|
if (profile) {
|
||||||
|
setHeaderRowCount(profile.headerRowCount);
|
||||||
|
setDataStartRow(profile.dataStartRow);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: '新建映射', value: '' },
|
||||||
|
...profiles.map((item) => ({ label: item.name, value: item.id })),
|
||||||
|
]}
|
||||||
|
value={profileId}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="表头行数"
|
||||||
|
max="5"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) => setHeaderRowCount(Number(event.target.value))}
|
||||||
|
type="number"
|
||||||
|
value={String(headerRowCount)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="数据起始行"
|
||||||
|
min="2"
|
||||||
|
onChange={(event) => setDataStartRow(Number(event.target.value))}
|
||||||
|
type="number"
|
||||||
|
value={String(dataStartRow)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{analysis.rows.length ? <details className="report-import-preview"><summary>查看前 {analysis.rows.length} 行解析预览</summary><pre>{JSON.stringify(analysis.rows, null, 2)}</pre></details> : null}
|
<label className="report-import-file">
|
||||||
</div> : null}
|
<span>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
<FileSpreadsheet size={22} />
|
||||||
</Modal>;
|
<strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
onChange={(event) => {
|
||||||
|
setFile(event.target.files?.[0]);
|
||||||
|
setAnalysis(undefined);
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{analysis ? (
|
||||||
|
<div className="report-import-mapping">
|
||||||
|
<div className="channel-field-section-head">
|
||||||
|
<div>
|
||||||
|
<h3>导入字段映射</h3>
|
||||||
|
<p>源列顺序不受限制,每一列明确映射到系统标准字段。</p>
|
||||||
|
</div>
|
||||||
|
<Tag tone="info">检测到 {analysis.columns.length} 列</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="report-import-mapping-table">
|
||||||
|
<div className="report-import-mapping-head">
|
||||||
|
<span>源列/图片</span>
|
||||||
|
<span>目标字段</span>
|
||||||
|
<span>数据类型</span>
|
||||||
|
<span>必填</span>
|
||||||
|
<span>转换</span>
|
||||||
|
</div>
|
||||||
|
{analysis.columns.map((column) => {
|
||||||
|
const mapping = mappingByColumn.get(column.sourceColumnIndex);
|
||||||
|
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
|
||||||
|
return (
|
||||||
|
<div className="report-import-mapping-row" key={column.sourceColumnIndex}>
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
{column.columnLetter} · {column.sourceHeader}
|
||||||
|
</strong>
|
||||||
|
<small>{column.sourceHeaderPath}</small>
|
||||||
|
{column.imageCount ? <Tag tone="warning">{column.imageCount} 张图片</Tag> : null}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
onChange={(event) => setTarget(column, event.target.value)}
|
||||||
|
options={targetOptions}
|
||||||
|
value={encoded}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
disabled={!mapping}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchMapping(column.sourceColumnIndex, {
|
||||||
|
fieldType: event.target.value as ReportImportMapping['fieldType'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ label: '文本', value: 'string' },
|
||||||
|
{ label: '图片', value: 'image' },
|
||||||
|
{ label: '文件', value: 'file' },
|
||||||
|
]}
|
||||||
|
value={mapping?.fieldType ?? 'string'}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
disabled={!mapping}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ label: '选填', value: 'false' },
|
||||||
|
{ label: '必填', value: 'true' },
|
||||||
|
]}
|
||||||
|
value={String(mapping?.required ?? false)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
disabled={!mapping || mapping.fieldType !== 'string'}
|
||||||
|
onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })}
|
||||||
|
options={transforms}
|
||||||
|
value={mapping?.transform ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="report-import-profile">
|
||||||
|
<Button
|
||||||
|
aria-pressed={saveProfile}
|
||||||
|
className="report-import-profile__toggle"
|
||||||
|
icon={saveProfile ? <CheckCircle2 size={16} /> : <Plus size={16} />}
|
||||||
|
onClick={() => setSaveProfile((value) => !value)}
|
||||||
|
variant={saveProfile ? 'secondary' : 'ghost'}
|
||||||
|
>
|
||||||
|
{saveProfile ? '本次将保存/更新映射方案' : '保存为可复用映射方案'}
|
||||||
|
</Button>
|
||||||
|
{saveProfile ? (
|
||||||
|
<Input
|
||||||
|
label="映射方案名称"
|
||||||
|
onChange={(event) => setProfileName(event.target.value)}
|
||||||
|
placeholder="例如:海南移动签名资料模板"
|
||||||
|
value={profileName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{analysis.rows.length ? (
|
||||||
|
<details className="report-import-preview">
|
||||||
|
<summary>查看前 {analysis.rows.length} 行解析预览</summary>
|
||||||
|
<pre>{JSON.stringify(analysis.rows, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,18 +141,43 @@ describe('report workbench pages', () => {
|
|||||||
application: { id: 'app-1', name: '测试应用' },
|
application: { id: 'app-1', name: '测试应用' },
|
||||||
}));
|
}));
|
||||||
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
|
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
|
||||||
adminApi.listPendingReportMaterials.mockResolvedValue({ items: materials, total: materials.length, page: 1, pageSize: 20 });
|
adminApi.listPendingReportMaterials.mockResolvedValue({
|
||||||
|
items: materials,
|
||||||
|
total: materials.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
adminApi.preflightReportMaterialBatch.mockResolvedValue({
|
adminApi.preflightReportMaterialBatch.mockResolvedValue({
|
||||||
eligible: true,
|
eligible: true,
|
||||||
eligibleTargetCount: 2,
|
eligibleTargetCount: 2,
|
||||||
skippedTargetCount: 5,
|
skippedTargetCount: 5,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
|
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
|
||||||
{ id: 'signature:partial', eligible: true, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(true), target(false, ['缺少必填字段:营业执照'])] },
|
{
|
||||||
{ id: 'signature:incomplete', eligible: false, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(false, ['缺少必填字段:营业执照'])] },
|
id: 'signature:partial',
|
||||||
{ id: 'signature:abandoned', eligible: false, blockedReasons: ['该通道报备明细已放弃报备'], targets: [target(false, ['该通道报备明细已放弃报备'])] },
|
eligible: true,
|
||||||
|
blockedReasons: ['缺少必填字段:营业执照'],
|
||||||
|
targets: [target(true), target(false, ['缺少必填字段:营业执照'])],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signature:incomplete',
|
||||||
|
eligible: false,
|
||||||
|
blockedReasons: ['缺少必填字段:营业执照'],
|
||||||
|
targets: [target(false, ['缺少必填字段:营业执照'])],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signature:abandoned',
|
||||||
|
eligible: false,
|
||||||
|
blockedReasons: ['该通道报备明细已放弃报备'],
|
||||||
|
targets: [target(false, ['该通道报备明细已放弃报备'])],
|
||||||
|
},
|
||||||
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
|
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
|
||||||
{ id: 'signature:generated', eligible: false, blockedReasons: ['同一资料版本已在批次 RB-1 生成'], targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])] },
|
{
|
||||||
|
id: 'signature:generated',
|
||||||
|
eligible: false,
|
||||||
|
blockedReasons: ['同一资料版本已在批次 RB-1 生成'],
|
||||||
|
targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,27 +193,27 @@ describe('report workbench pages', () => {
|
|||||||
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
|
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
|
||||||
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
|
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
|
||||||
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
|
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
|
||||||
screen.getAllByText('缺少必填字段:营业执照').forEach((message) =>
|
screen.getAllByText('缺少必填字段:营业执照').forEach((message) => expect(message).toHaveClass('status-danger'));
|
||||||
expect(message).toHaveClass('status-danger'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the report record list compact while retaining full details in the dialog', async () => {
|
it('keeps the report record list compact while retaining full details in the dialog', async () => {
|
||||||
adminApi.listReportRecordsPage.mockResolvedValue({
|
adminApi.listReportRecordsPage.mockResolvedValue({
|
||||||
items: [{
|
items: [
|
||||||
id: 'record-1',
|
{
|
||||||
taskId: 'report-task-with-a-long-identifier-1',
|
id: 'record-1',
|
||||||
channelId: 'channel-1',
|
taskId: 'report-task-with-a-long-identifier-1',
|
||||||
action: 'manual_status_change',
|
channelId: 'channel-1',
|
||||||
statusBefore: 'pending',
|
action: 'manual_status_change',
|
||||||
statusAfter: 'approved',
|
statusBefore: 'pending',
|
||||||
reason: '通道已确认报备通过',
|
statusAfter: 'approved',
|
||||||
sourceEntry: 'report_task',
|
reason: '通道已确认报备通过',
|
||||||
createdAt: '2026-09-03 15:30:00',
|
sourceEntry: 'report_task',
|
||||||
channel: { id: 'channel-1', name: '测试通道' },
|
createdAt: '2026-09-03 15:30:00',
|
||||||
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
|
channel: { id: 'channel-1', name: '测试通道' },
|
||||||
task: task('record-1'),
|
operator: { id: 'operator-1', username: 'operator', displayName: '运营一' },
|
||||||
}],
|
task: task('record-1'),
|
||||||
|
},
|
||||||
|
],
|
||||||
total: 1,
|
total: 1,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
@@ -258,6 +283,7 @@ describe('report workbench pages', () => {
|
|||||||
});
|
});
|
||||||
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.getByRole('button', { name: '导出格式' })).toHaveTextContent('系统 Excel 文件');
|
||||||
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
|
expect(screen.getByText(/1\.短信签名:/)).toBeVisible();
|
||||||
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: '全部下载' })).toBeEnabled();
|
||||||
await user.click(screen.getByRole('button', { name: '复制简报' }));
|
await user.click(screen.getByRole('button', { name: '复制简报' }));
|
||||||
|
|||||||
@@ -91,4 +91,10 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
|
|||||||
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
|
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the complete signature context in the signature hover text', () => {
|
||||||
|
renderTable({ expandedSignatureId: '' });
|
||||||
|
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('企业:深圳市聆界科技有限公司'));
|
||||||
|
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('应用:营销通知应用'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ export function EnterpriseSignaturesTable({
|
|||||||
: payload.links;
|
: payload.links;
|
||||||
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
||||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||||
|
const signatureTooltip = [
|
||||||
|
`签名:${formatSignatureName(signature.name)}`,
|
||||||
|
`企业:${signature.tenant?.name ?? signature.tenantId}`,
|
||||||
|
`应用:${signature.application?.name ?? '未绑定应用'}`,
|
||||||
|
`用途:${signature.purpose || '未填写'}`,
|
||||||
|
`审核状态:${signature.auditStatus}`,
|
||||||
|
`创建时间:${signature.createdAt ? new Date(signature.createdAt).toLocaleString('zh-CN') : '未知'}`,
|
||||||
|
`更新时间:${signature.updatedAt ? new Date(signature.updatedAt).toLocaleString('zh-CN') : '未知'}`,
|
||||||
|
].join('\n');
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
aria-label={`签名总体状态:${cardVisual.label}`}
|
aria-label={`签名总体状态:${cardVisual.label}`}
|
||||||
@@ -86,7 +95,7 @@ export function EnterpriseSignaturesTable({
|
|||||||
>
|
>
|
||||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<div className="enterprise-signature-table__signature" data-label="签名">
|
<div aria-label={signatureTooltip} className="enterprise-signature-table__signature" data-label="签名" title={signatureTooltip}>
|
||||||
<strong>{formatSignatureName(signature.name)}</strong>
|
<strong>{formatSignatureName(signature.name)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div data-label="企业">
|
<div data-label="企业">
|
||||||
|
|||||||
+92
-7
@@ -4302,7 +4302,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-metric-grid {
|
.admin-metric-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__value--danger {
|
||||||
|
color: var(--color-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1380px) {
|
||||||
|
.admin-metric-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-workload-grid {
|
.admin-workload-grid {
|
||||||
@@ -6178,6 +6188,54 @@
|
|||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
min-height: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-remove-field-button {
|
||||||
|
justify-self: start;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-material-image-value {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-material-image-value img {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-height: 180px;
|
||||||
|
max-width: min(100%, 280px);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-format-options {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-format-options button {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-format-options button[aria-checked="true"] {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
border-color: var(--color-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-format-options span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-field-pool-list > button:hover {
|
.channel-field-pool-list > button:hover {
|
||||||
@@ -7273,8 +7331,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix {
|
.signature-quality-matrix {
|
||||||
border: 1px solid var(--color-border);
|
background: var(--color-surface-muted);
|
||||||
border-radius: var(--radius-md);
|
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-primary));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -7290,7 +7349,7 @@
|
|||||||
.signature-quality-matrix td {
|
.signature-quality-matrix td {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
padding: var(--space-3);
|
padding: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
@@ -7306,19 +7365,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix thead th {
|
.signature-quality-matrix thead th {
|
||||||
background: var(--color-surface-muted);
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix tbody th {
|
.signature-quality-matrix tbody th {
|
||||||
|
background: var(--color-surface);
|
||||||
color: var(--color-text-strong);
|
color: var(--color-text-strong);
|
||||||
min-width: 210px;
|
min-width: 210px;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix__metric {
|
.signature-quality-matrix__metric {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 7px;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-quality-matrix__metric--success { border-left-color: var(--color-success); }
|
||||||
|
.signature-quality-matrix__metric--warning { border-left-color: var(--color-warning); }
|
||||||
|
.signature-quality-matrix__metric--danger { border-left-color: var(--color-danger); }
|
||||||
|
|
||||||
|
.signature-quality-matrix__metric > div {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix__metric strong {
|
.signature-quality-matrix__metric strong {
|
||||||
@@ -7327,6 +7409,7 @@
|
|||||||
|
|
||||||
.signature-quality-matrix__metric small {
|
.signature-quality-matrix__metric small {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-quality-matrix__metric em {
|
.signature-quality-matrix__metric em {
|
||||||
@@ -8741,12 +8824,14 @@
|
|||||||
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||||
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
|
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
|
||||||
.admin-drainage-common-list { display: grid; gap: 8px; }
|
.admin-drainage-common-list { display: grid; gap: 8px; }
|
||||||
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto; padding: 11px 12px; }
|
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto auto; padding: 11px 12px; }
|
||||||
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
|
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
|
||||||
|
.admin-drainage-common-order { display: inline-flex; gap: 2px; }
|
||||||
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||||
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||||||
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
|
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
|
||||||
|
.admin-drainage-field-card__actions { display: inline-flex; gap: 4px; }
|
||||||
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
|
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
|
||||||
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
|
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
|
||||||
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
|
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-4);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: var(--space-6) var(--space-4);
|
padding: var(--space-4) var(--space-4);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: var(--space-5);
|
gap: var(--space-3);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
margin-right: calc(var(--space-4) * -1);
|
margin-right: calc(var(--space-4) * -1);
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-1);
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +145,8 @@
|
|||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
min-height: 42px;
|
min-height: 36px;
|
||||||
padding: 10px var(--space-3);
|
padding: 7px var(--space-3);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
padding: 10px var(--space-3);
|
padding: 7px var(--space-3);
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ export function isImageUpload(file: Pick<File, 'name' | 'type'>) {
|
|||||||
return file.type.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.name);
|
return file.type.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertUploadFileSize(file: Pick<File, 'name' | 'size' | 'type'>) {
|
export function assertUploadFileSize(
|
||||||
|
file: Pick<File, 'name' | 'size' | 'type'>,
|
||||||
|
customLimit?: { bytes: number; message: string },
|
||||||
|
) {
|
||||||
|
if (customLimit) {
|
||||||
|
if (file.size > customLimit.bytes) throw new Error(customLimit.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const image = isImageUpload(file);
|
const image = isImageUpload(file);
|
||||||
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
|
const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES;
|
||||||
if (file.size > limit) {
|
if (file.size > limit) {
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root ${APP_DIR}/dist;
|
root ${APP_DIR}/dist;
|
||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 50m;
|
client_max_body_size 110m;
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
|
|||||||
Reference in New Issue
Block a user