feat: complete signature and drainage reporting workflows

This commit is contained in:
hectorzhao
2026-07-13 17:40:37 +08:00
parent 567e4da0c9
commit 551b99cbcd
21 changed files with 452 additions and 71 deletions
@@ -0,0 +1,9 @@
ALTER TABLE "ChannelSignatureReportTask"
ADD COLUMN "reportType" TEXT NOT NULL DEFAULT 'signature',
ADD COLUMN "drainageItemId" TEXT;
CREATE INDEX "ChannelSignatureReportTask_signatureId_drainageItemId_channelId_idx"
ON "ChannelSignatureReportTask"("signatureId", "drainageItemId", "channelId");
CREATE INDEX "ChannelSignatureReportTask_reportType_status_idx"
ON "ChannelSignatureReportTask"("reportType", "status");
@@ -0,0 +1,45 @@
INSERT INTO "ChannelSignatureReportTask" (
"id", "tenantId", "signatureId", "channelId", "reportType", "drainageItemId", "status", "createdAt", "updatedAt"
)
SELECT
'drainage_' || md5(random()::text || clock_timestamp()::text || material."id"),
signature."tenantId",
material."signatureId",
material."channelId",
'drainage',
material."drainageItemId",
'pending',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM (
SELECT DISTINCT ON ("signatureId", "drainageItemId", "channelId")
"id", "signatureId", "drainageItemId", "channelId"
FROM "DrainageReportMaterial"
ORDER BY "signatureId", "drainageItemId", "channelId", "createdAt"
) material
JOIN "SmsSignature" signature ON signature."id" = material."signatureId"
WHERE NOT EXISTS (
SELECT 1
FROM "ChannelSignatureReportTask" task
WHERE task."signatureId" = material."signatureId"
AND task."channelId" = material."channelId"
AND task."reportType" = 'drainage'
AND task."drainageItemId" = material."drainageItemId"
);
INSERT INTO "ChannelSignatureReportRecord" (
"id", "taskId", "channelId", "action", "statusAfter", "reason", "createdAt"
)
SELECT
'drainage_record_' || md5(random()::text || clock_timestamp()::text || task."id"),
task."id",
task."channelId",
'migration_backfill',
task."status",
'从已有引流报备材料回填任务',
CURRENT_TIMESTAMP
FROM "ChannelSignatureReportTask" task
WHERE task."reportType" = 'drainage'
AND NOT EXISTS (
SELECT 1 FROM "ChannelSignatureReportRecord" record WHERE record."taskId" = task."id"
);
@@ -0,0 +1,7 @@
CREATE UNIQUE INDEX "ChannelSignatureReportTask_target_key"
ON "ChannelSignatureReportTask"(
"signatureId",
"channelId",
"reportType",
COALESCE("drainageItemId", '')
);
+4
View File
@@ -720,6 +720,8 @@ model ChannelSignatureReportTask {
tenantId String tenantId String
signatureId String signatureId String
channelId String channelId String
reportType String @default("signature")
drainageItemId String?
status String @default("pending") status String @default("pending")
reason String? reason String?
createdById String? createdById String?
@@ -734,6 +736,8 @@ model ChannelSignatureReportTask {
@@index([tenantId, status]) @@index([tenantId, status])
@@index([signatureId, channelId]) @@index([signatureId, channelId])
@@index([signatureId, drainageItemId, channelId])
@@index([reportType, status])
} }
model ChannelSignatureReportRecord { model ChannelSignatureReportRecord {
+27
View File
@@ -216,6 +216,33 @@ describe('ChannelsService', () => {
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
}); });
it('changes a drainage report task without overwriting the signature report summary', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn(),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
create: jest.fn(),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({
items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }],
reason: '引流信息已报备',
})).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]);
expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.smsSignature.update).not.toHaveBeenCalled();
});
beforeEach(() => { beforeEach(() => {
mockQueueAdd.mockClear(); mockQueueAdd.mockClear();
mockQueueClose.mockClear(); mockQueueClose.mockClear();
+18 -6
View File
@@ -98,11 +98,13 @@ export interface CreateReportTaskDto {
tenantId: string; tenantId: string;
signatureId: string; signatureId: string;
channelId: string; channelId: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
createdById?: string; createdById?: string;
} }
export interface ChangeReportTaskStatusesDto { export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string }>; items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
reason?: string; reason?: string;
operatorId?: string; operatorId?: string;
} }
@@ -973,11 +975,15 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} }
async createReportTask(data: CreateReportTaskDto) { async createReportTask(data: CreateReportTaskDto) {
const reportType = data.reportType ?? 'signature';
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
const task = await this.prisma.channelSignatureReportTask.create({ const task = await this.prisma.channelSignatureReportTask.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
signatureId: data.signatureId, signatureId: data.signatureId,
channelId: data.channelId, channelId: data.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined,
createdById: data.createdById, createdById: data.createdById,
status: 'pending', status: 'pending',
}, },
@@ -993,20 +999,24 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status'); if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
} }
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.map((item) => item.signatureId))]; const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
for (const item of data.items) { for (const item of data.items) {
const reportType = item.reportType ?? 'signature';
if (reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException('drainageItemId is required');
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } }); const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } }); const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found'); if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId } }); const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
const task = existing const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } }) ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, status: item.status, reason: data.reason, createdById: data.operatorId } }); : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } }); await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
} }
const summaries = []; const summaries = [];
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId)); for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
return summaries; return [...summaries, ...drainageResults];
}); });
} }
@@ -1018,7 +1028,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
include: { group: { include: { items: { include: { channel: true } } } } }, include: { group: { include: { items: { include: { channel: true } } } } },
}) : []; }) : [];
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId }, include: { channel: true } }); const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } });
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel); const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
@@ -1067,7 +1077,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}, },
}); });
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
if ((task.reportType ?? 'signature') === 'signature') {
await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId); await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
}
return imported; return imported;
} }
@@ -739,6 +739,7 @@ describe('SendChainService', () => {
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id })); await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(expect.objectContaining({ submitted: true, channelId: backup.id }));
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ reportType: 'signature' }) }));
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) })); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ channelId: backup.id }) }));
}); });
+2 -2
View File
@@ -1876,7 +1876,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const signatureId = await this.resolveMessageSignatureId(message); const signatureId = await this.resolveMessageSignatureId(message);
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId, status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
select: { channelId: true }, select: { channelId: true },
}); });
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
@@ -2247,7 +2247,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('短信签名未配置,不能提交到通道'); throw new BadRequestException('短信签名未配置,不能提交到通道');
} }
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId, status: 'approved' }, where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
select: { id: true }, select: { id: true },
}); });
if (!reportTask) { if (!reportTask) {
@@ -54,7 +54,7 @@ export class AdminSmsConfigController {
@Post('enterprise-signatures') @Post('enterprise-signatures')
createSignature(@Body() body: CreateSmsSignatureDto) { createSignature(@Body() body: CreateSmsSignatureDto) {
return this.smsConfig.createSignature(body); return this.smsConfig.createSignature(body, { initialAuditStatus: 'approved' });
} }
@Put('enterprise-signatures/:id') @Put('enterprise-signatures/:id')
@@ -66,6 +66,7 @@ function createPrismaMock() {
materials: [], materials: [],
}]), }]),
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
}, },
signatureReportMaterial: { signatureReportMaterial: {
@@ -75,6 +76,14 @@ function createPrismaMock() {
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }), upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
}, },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportRecord: {
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
},
smsTemplate: { smsTemplate: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([{
id: 'tpl-1', id: 'tpl-1',
@@ -502,6 +511,24 @@ describe('SmsConfigService', () => {
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({ expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } }, where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
}); });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }),
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }),
});
});
it('creates admin signatures with an approved initial audit status', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.createSignature({ tenantId: 'tenant-1', name: '运营新建签名' }, { initialAuditStatus: 'approved' });
expect(prisma.smsSignature.create).toHaveBeenCalledWith({
data: expect.objectContaining({ auditStatus: 'approved', name: '运营新建签名' }),
});
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
}); });
it('updates enterprise signature drainage info through the admin API path', async () => { it('updates enterprise signature drainage info through the admin API path', async () => {
+70 -5
View File
@@ -44,6 +44,10 @@ export interface CreateSmsSignatureDto {
drainageInfo?: Record<string, unknown>; drainageInfo?: Record<string, unknown>;
} }
export interface CreateSmsSignatureOptions {
initialAuditStatus?: string;
}
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & { export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
auditStatus?: string; auditStatus?: string;
}; };
@@ -589,19 +593,44 @@ export class SmsConfigService {
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' }, where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } }, include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : []; }) : [];
return signatures.map((signature) => ({ return signatures.map((signature) => ({
...signature, ...signature,
reportTargets: (() => { reportTargets: (() => {
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task])); const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id })); return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(), })(),
drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))];
})),
drainageCarrierReportSummary: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
}))];
})),
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all')); const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task])); const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length; const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
@@ -610,7 +639,7 @@ export class SmsConfigService {
})); }));
} }
async createSignature(data: CreateSmsSignatureDto) { async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo); await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo); const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
const signature = await this.prisma.smsSignature.create({ const signature = await this.prisma.smsSignature.create({
@@ -619,10 +648,21 @@ export class SmsConfigService {
applicationId: data.applicationId, applicationId: data.applicationId,
name: data.name, name: data.name,
purpose: data.purpose, purpose: data.purpose,
auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
}, },
}); });
await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo); await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
if (options.initialAuditStatus) {
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signature.id,
action: 'admin_create_approved',
statusAfter: options.initialAuditStatus,
reason: '运营端新建签名自动审核通过',
});
}
return signature; return signature;
} }
@@ -684,6 +724,13 @@ export class SmsConfigService {
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}), ...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
}, },
}); });
await this.prisma.channelSignatureReportTask.deleteMany({
where: {
signatureId,
reportType: 'drainage',
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) { for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
const value = reportValueParts(signatureValues[field.code]); const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) { for (const channel of field.channels) {
@@ -694,11 +741,29 @@ export class SmsConfigService {
}); });
} }
} }
const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel]));
const signatureOwner = links.length > 0
? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } })
: null;
for (const link of links) { for (const link of links) {
const drainageItemId = String(link.id ?? ''); const drainageItemId = String(link.id ?? '');
const values = isRecord(link.reportValues) ? link.reportValues : {}; const values = isRecord(link.reportValues) ? link.reportValues : {};
if (!drainageItemId) continue; if (!drainageItemId) continue;
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'))) { for (const channel of drainageChannels.values()) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId },
});
if (!existingTask && signatureOwner) {
const task = await this.prisma.channelSignatureReportTask.create({
data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' },
});
await this.prisma.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' },
});
}
}
for (const field of drainageFields) {
const value = reportValueParts(values[field.code]); const value = reportValueParts(values[field.code]);
for (const channel of field.channels) { for (const channel of field.channels) {
await this.prisma.drainageReportMaterial.upsert({ await this.prisma.drainageReportMaterial.upsert({
@@ -1182,10 +1182,14 @@
8. 回执导入。 8. 回执导入。
9. 报备记录。 9. 报备记录。
10. 签名报备状态同步。 10. 签名报备状态同步。
11. 引流信息按通道报备任务、报备记录和回执。
12. 企业签名、通道报备详情、报备任务三个入口共用同一报备状态事实源。
验收标准: 验收标准:
- 签名审核状态和通道报备状态分层。 - 签名审核状态和通道报备状态分层。
- 运营端提供真实签名审核列表、资料详情和通过/驳回操作;运营端直接新建的签名自动审核通过并写审核记录。
- 引流信息按“报备字段库 → 通道配置引流字段 → 企业签名提交资料 → 通道报备任务”流转,不使用 JSON 中的静态三网状态代替任务状态。
- 发送前必须校验签名在目标通道报备通过。 - 发送前必须校验签名在目标通道报备通过。
- 报备任务导出、导入具备幂等和历史记录。 - 报备任务导出、导入具备幂等和历史记录。
+26
View File
@@ -233,6 +233,18 @@
- 驳回原因保存并返回客户端。 - 驳回原因保存并返回客户端。
- 被驳回模板不能用于发送。 - 被驳回模板不能用于发送。
### TC-ADMIN-001A 运营端新建签名自动通过
- 优先级:P0
- 前置条件:运营管理员已登录,目标企业和应用存在。
- 步骤:
1. 在运营端企业签名页新建签名并保存。
2. 查询签名列表、签名审核页和审核记录。
- 预期结果:
- `SmsSignature.auditStatus=approved`,不停留在 draft。
- 生成 `admin_create_approved` 审核记录。
- 客户端自行新建签名仍按 draft → pending → approved/rejected 流转。
### TC-ADMIN-003 通道创建与启停 ### TC-ADMIN-003 通道创建与启停
- 优先级:P0 - 优先级:P0
@@ -308,6 +320,20 @@
5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。
- 预期结果: - 预期结果:
- 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。
### TC-ADMIN-005B 引流信息按通道报备及三入口同步
- 优先级:P0
- 前置条件:企业应用已绑定通道组,至少一个目标通道配置 `drainage``both` 报备字段。
- 步骤:
1. 在企业签名中新增引流信息,填写动态字段并保存。
2. 查询 `DrainageReportMaterial``ChannelSignatureReportTask(reportType=drainage)`
3. 分别从企业签名、通道报备详情、报备任务页修改同一引流项在同一通道的状态。
4. 查看报备记录,导出任务并导入回执。
- 预期结果:
- 每个“签名 + 引流项 + 通道”对应独立真实任务,初始为 pending。
- 三个入口的状态和通过数/总数一致,修改写入 `ChannelSignatureReportRecord`
- 引流任务不参与 `SmsSignature.reportStatus` 聚合,也不能被短信发送选路误当为签名报备通过。
- 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。 - 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。
- 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。 - 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。
- 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。 - 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。
+9
View File
@@ -1668,3 +1668,12 @@ git diff --check
- 本地验证:API 全量 13 suites、137 项通过,API build、前端 build、`git diff --check` 通过;前端仅有既有 chunk size warning。Chrome 当前无已登录生产标签,部署前未绕过图形验证码或注入 mock 数据。 - 本地验证:API 全量 13 suites、137 项通过,API build、前端 build、`git diff --check` 通过;前端仅有既有 chunk size warning。Chrome 当前无已登录生产标签,部署前未绕过图形验证码或注入 mock 数据。
- 已将功能提交 `19d47b45` push 到 `origin/main` 并部署生产。部署前备份 PostgreSQL 为 `/opt/cmpp-platform/backups/cmpp-20260713-103205.sql`(约 61MB),备份运行源码为 `/opt/cmpp-platform/backups/source-20260713-103205.tar.gz`(约 63MB);发布快照本地/服务器 SHA-256 一致。生产无待执行 migration,`.deployed-commit=19d47b45``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200。生产 `index.html` 已引用新资源 `index-HtuLnrXL.js/index-C5Tf8YiT.css`,源码已确认包含企业模板响应式行和签名操作区两列布局。 - 已将功能提交 `19d47b45` push 到 `origin/main` 并部署生产。部署前备份 PostgreSQL 为 `/opt/cmpp-platform/backups/cmpp-20260713-103205.sql`(约 61MB),备份运行源码为 `/opt/cmpp-platform/backups/source-20260713-103205.tar.gz`(约 63MB);发布快照本地/服务器 SHA-256 一致。生产无待执行 migration,`.deployed-commit=19d47b45``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200。生产 `index.html` 已引用新资源 `index-HtuLnrXL.js/index-C5Tf8YiT.css`,源码已确认包含企业模板响应式行和签名操作区两列布局。
- 生产浏览器确认路由可加载、标题为“聆界短信管理平台”、DOM 非空、无 console error/warn 和框架错误覆盖。由于浏览器没有已登录运营端会话,访问受保护页面按预期落到登录界面;未经用户确认不代解图形验证码,因此登录后真实数据截图和点击交互留待用户刷新生产页面验收。 - 生产浏览器确认路由可加载、标题为“聆界短信管理平台”、DOM 非空、无 console error/warn 和框架错误覆盖。由于浏览器没有已登录运营端会话,访问受保护页面按预期落到登录界面;未经用户确认不代解图形验证码,因此登录后真实数据截图和点击交互留待用户刷新生产页面验收。
## 2026-07-13 签名审核与引流信息通道报备闭环
- 生产只读核查确认:运营端新建的【安徽航天信息】仍为 `auditStatus=draft`;引流资料保存在 `SmsSignature.drainageInfo.links`,动态字段可写 `DrainageReportMaterial`,但缺少引流项维度的真实通道报备任务和记录。
- 将 `/admin/signatures` 从占位页替换为真实签名审核页,支持状态查询、资质/动态资料详情、通过和带原因驳回,调用现有 NestJS 审核 API 并写 `AuditRecord`。运营端新建签名直接写 `auditStatus=approved`,同时写 `admin_create_approved` 审核记录;客户端提交审核流不变。
- `ChannelSignatureReportTask` 增加 `reportType=signature/drainage``drainageItemId`。保存引流资料时,根据企业应用的真实路由通道及其 `drainage/both` 字段自动生成 pending 任务和 create 记录;删除引流项时同步清理对应材料和任务。
- 企业签名引流列表、通道报备详情、报备任务页均按引流项和通道读取/修改同一任务,共享报备记录、导出和回执链路。企业签名三网状态改为引流任务真实汇总,不再使用 `drainageInfo.links.mobile/unicom/telecom` 静态值。
- 短信发送选路和最终通道二次校验明确增加 `reportType=signature`,引流通过任务不会误放行未报备签名。Prisma migrations`20260713153000_add_drainage_report_tasks``20260713154000_backfill_drainage_report_tasks``20260713155000_unique_report_task_scope`;后两者分别回填已有引流材料的 pending 任务/记录,并保证同一报备对象和通道仅一个任务。
- 本地真实 PostgreSQL 已成功应用 3 条新 migrationAPI 全量 13 suites、139 项通过,Gateway 全量 Go 测试、Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由标题正确、DOM 非空、无框架错误覆盖且 console 无 error/warn;真实图形验证码阻止进入受保护页,未代解验证码、未绕过认证、未注入 mock。
+14 -5
View File
@@ -285,6 +285,8 @@ export type ClientSmsSignature = {
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>; reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>;
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>;
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
}; };
export type ClientSmsTemplate = { export type ClientSmsTemplate = {
@@ -301,7 +303,7 @@ export type ClientSmsTemplate = {
updatedAt: string; updatedAt: string;
variables?: Array<{ name: string; example?: string | null; required?: boolean }>; variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
application?: { id: string; name: string }; application?: { id: string; name: string };
signature?: { id: string; name: string }; signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
tenant?: TenantOption; tenant?: TenantOption;
}; };
@@ -539,8 +541,10 @@ export type ReportTask = DictionaryItem & {
tenantId: string; tenantId: string;
signatureId: string; signatureId: string;
channelId: string; channelId: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string | null;
status: string; status: string;
signature?: { id: string; name: string }; signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
channel?: { id: string; name: string; code: string }; channel?: { id: string; name: string; code: string };
}; };
@@ -930,7 +934,12 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}), }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) => approveSignature: (id: string) => request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectSignature: (id: string, reason = '运营审核驳回') => request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) => createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
@@ -981,9 +990,9 @@ export const adminApi = {
createChannelReportField: (body: Record<string, unknown>) => createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) => createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string }>; reason?: string; operatorId?: string }) => changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
+13 -29
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, Search } from 'lucide-react'; import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
@@ -16,6 +16,8 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
pending: { label: '未报备', tone: 'neutral' }, pending: { label: '未报备', tone: 'neutral' },
waiting_material: { label: '资料待补充', tone: 'warning' }, waiting_material: { label: '资料待补充', tone: 'warning' },
exporting: { label: '报备中', tone: 'warning' }, exporting: { label: '报备中', tone: 'warning' },
reporting: { label: '报备中', tone: 'warning' },
abandoned: { label: '已放弃', tone: 'neutral' },
partial_success: { label: '部分成功', tone: 'warning' }, partial_success: { label: '部分成功', tone: 'warning' },
filing: { label: '报备中', tone: 'warning' }, filing: { label: '报备中', tone: 'warning' },
}; };
@@ -72,7 +74,6 @@ export function AdminChannelReportPage() {
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]); const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]); const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all'); const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>(); const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
@@ -109,7 +110,8 @@ export function AdminChannelReportPage() {
const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]); const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
const visibleTasks = useMemo(() => tasks.filter((task) => { const visibleTasks = useMemo(() => tasks.filter((task) => {
const signature = signatureMap.get(task.signatureId); const signature = signatureMap.get(task.signatureId);
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name].some((value) => String(value ?? '').includes(keyword.trim())); const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId);
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim()));
return matchesKeyword && (status === 'all' || task.status === status); return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]); }), [keyword, signatureMap, status, tasks]);
@@ -124,17 +126,9 @@ export function AdminChannelReportPage() {
.catch((failure: Error) => setError(failure.message || '报备字段保存失败')); .catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
} }
function toggle(signatureId: string) {
setExpanded((current) => {
const next = new Set(current);
next.has(signatureId) ? next.delete(signatureId) : next.add(signatureId);
return next;
});
}
function saveTaskStatus() { function saveTaskStatus() {
if (!statusTask) return; if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason }) adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); }) .then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败')); .catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
} }
@@ -159,37 +153,27 @@ export function AdminChannelReportPage() {
<div className="channel-report-filter-grid"> <div className="channel-report-filter-grid">
<Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} /> <Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} /> <Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<div><strong></strong><p className="muted"></p></div> <div><strong></strong><p className="muted"></p></div>
</div> </div>
<div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div> <div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div>
</div> </div>
<div className="surface channel-report-table"> <div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div> <div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => { {visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId); const signature = signatureMap.get(task.signatureId);
const links = drainageItems(signature); const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const isExpanded = expanded.has(task.signatureId);
const exported = lastRecord(task.id, 'export'); const exported = lastRecord(task.id, 'export');
const imported = lastRecord(task.id, 'receipt_import'); const imported = lastRecord(task.id, 'receipt_import');
return <div key={task.id}> return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<div className="channel-report-row channel-report-row--signature">
<span /> <span />
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div> <div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
<ReportStatus value={task.status} /> <ReportStatus value={task.status} />
<DateTime value={task.createdAt} /> <DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} /> <DateTime value={imported?.createdAt ?? exported?.createdAt} />
<DateTime value={task.updatedAt} /> <DateTime value={task.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div> <div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div> <div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>
{isExpanded ? links.map((link, index) => <div className="channel-report-row channel-report-row--drainage" key={String(link.id ?? index)}>
<span />
<div className="channel-report-name channel-report-name--flow"><i /><span><strong>{String(link.siteName || link.url || `引流信息 ${index + 1}`)}</strong><small>{String(link.url ?? '')}</small></span></div>
<ReportStatus value={task.status} /><DateTime value={link.submittedAt ?? signature?.updatedAt} /><DateTime value={imported?.createdAt} /><DateTime value={signature?.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage: link })} type="button"><Eye size={16} /></button></div>
</div>) : null}
</div>; </div>;
})} })}
</div> </div>
@@ -595,22 +595,47 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
</Modal>; </Modal>;
} }
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) { function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
const summary = signature.drainageCarrierReportSummary?.[item.id];
const targets = signature.drainageReportTargets?.[item.id] ?? [];
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid"> <div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div> <div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div> <div><span></span><strong>{item.url}</strong></div>
<div><span></span><StatusTag status={item.mobile} /></div> <div><span></span><CarrierReportTag summary={summary?.mobile} /></div>
<div><span></span><StatusTag status={item.unicom} /></div> <div><span></span><CarrierReportTag summary={summary?.unicom} /></div>
<div><span></span><StatusTag status={item.telecom} /></div> <div><span></span><CarrierReportTag summary={summary?.telecom} /></div>
<div><span></span><strong>{item.submittedAt}</strong></div> <div><span></span><strong>{item.submittedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div> <div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div> </div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{target.channel.carrier}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</Modal> </Modal>
); );
} }
function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
</div>
</Modal>;
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) { function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return ( return (
<Modal <Modal
@@ -634,7 +659,8 @@ export function AdminEnterpriseSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]); const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null); const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null); const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState(''); const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -793,21 +819,24 @@ export function AdminEnterpriseSignaturesPage() {
<span></span> <span></span>
<span></span> <span></span>
</div> </div>
{payload.links.map((item) => ( {payload.links.map((item) => {
const summary = signature.drainageCarrierReportSummary?.[item.id];
return (
<div className="drainage-table__row" key={item.id}> <div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong> <strong>{item.siteName}</strong>
<span className="drainage-table__url" title={item.url}>{item.url}</span> <span className="drainage-table__url" title={item.url}>{item.url}</span>
<StatusTag status={item.mobile} /> <CarrierReportTag summary={summary?.mobile} />
<StatusTag status={item.unicom} /> <CarrierReportTag summary={summary?.unicom} />
<StatusTag status={item.telecom} /> <CarrierReportTag summary={summary?.telecom} />
<span className="muted">{item.submittedAt}</span> <span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions"> <span className="drainage-row-actions">
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost"></Button> <Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button> <Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button> <Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button>
</span> </span>
</div> </div>
))} );})}
</div> </div>
) : ( ) : (
<p className="muted"></p> <p className="muted"></p>
@@ -897,7 +926,8 @@ export function AdminEnterpriseSignaturesPage() {
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }} onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
/> />
) : null} ) : null}
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null} {drainageReport ? <DrainageReportModal item={drainageReport.item} onClose={() => setDrainageReport(null)} signature={drainageReport.signature} /> : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? ( {deleteTarget ? (
<ConfirmModal <ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`} message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
+19 -7
View File
@@ -10,6 +10,10 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
reporting: { label: '报备中', tone: 'warning' }, reporting: { label: '报备中', tone: 'warning' },
partial: { label: '部分完成', tone: 'info' }, partial: { label: '部分完成', tone: 'info' },
completed: { label: '已完成', tone: 'success' }, completed: { label: '已完成', tone: 'success' },
approved: { label: '报备通过', tone: 'success' },
waiting_material: { label: '资料待补充', tone: 'warning' },
exporting: { label: '报备中', tone: 'warning' },
abandoned: { label: '已放弃', tone: 'neutral' },
failed: { label: '有失败', tone: 'danger' }, failed: { label: '有失败', tone: 'danger' },
}; };
@@ -21,6 +25,14 @@ type ReceiptImportPayload = {
remark: string; remark: string;
}; };
function taskTargetLabel(task: ReportTask) {
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
const payload = task.signature?.drainageInfo;
const links = payload && Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
const item = links.find((link) => String(link.id ?? '') === task.drainageItemId);
return String(item?.siteName ?? item?.url ?? task.drainageItemId ?? '引流信息');
}
function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) { function ReceiptImportModal({ onClose, onSubmit, task }: { onClose: () => void; onSubmit: (payload: ReceiptImportPayload) => void; task: ReportTask }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [fileObject, setFileObject] = useState<FileObject | null>(null); const [fileObject, setFileObject] = useState<FileObject | null>(null);
@@ -97,7 +109,7 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{task.id}</p></div>}> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{task.id}</p></div>}>
<div className="admin-task-detail report-task-detail"> <div className="admin-task-detail report-task-detail">
<div className="admin-task-metrics"> <div className="admin-task-metrics">
<div className="admin-task-metric"><span></span><strong>{task.signature?.name ?? task.signatureId}</strong></div> <div className="admin-task-metric"><span>{task.reportType === 'drainage' ? '引流信息' : '签名'}</span><strong>{taskTargetLabel(task)}</strong></div>
<div className="admin-task-metric admin-task-metric--primary"><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div> <div className="admin-task-metric admin-task-metric--primary"><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
<div className="admin-task-metric admin-task-metric--success"><span></span><strong>{status.label}</strong></div> <div className="admin-task-metric admin-task-metric--success"><span></span><strong>{status.label}</strong></div>
</div> </div>
@@ -131,7 +143,7 @@ export function AdminReportTasksPage() {
}, []); }, []);
const filteredTasks = useMemo(() => tasks.filter((task) => { const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}`; const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageItemId ?? ''}`;
const date = task.createdAt?.slice(0, 10) ?? ''; const date = task.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword)) return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start) && (!dateRange.start || date >= dateRange.start)
@@ -162,14 +174,14 @@ export function AdminReportTasksPage() {
function saveTaskStatus() { function saveTaskStatus() {
if (!statusTask) return; if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason }) adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason })
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); }) .then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败')); .catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
} }
const columns: Array<TableColumn<ReportTask>> = [ const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> }, { key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> }, { key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> }, { key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' }, { key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ {
@@ -192,14 +204,14 @@ export function AdminReportTasksPage() {
<section className="page-stack admin-sms-task-page report-task-page"> <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading"> <div className="page-heading">
<div> <div>
<Breadcrumb items={['报备任务', '签名通道报备']} /> <Breadcrumb items={['报备任务']} />
<h1></h1> <h1></h1>
</div> </div>
</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} />
<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 icon={<Search size={16} />} onClick={loadData}></Button> <Button icon={<Search size={16} />} onClick={loadData}></Button>
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
draft: { label: '草稿', tone: 'neutral' },
pending: { label: '待审核', tone: 'info' },
approved: { label: '已通过', tone: 'success' },
rejected: { label: '已驳回', tone: 'danger' },
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function fileRef(value: unknown): FileRef | null {
const item = asRecord(value);
return item.fileObjectId && item.fileName ? {
fileObjectId: String(item.fileObjectId),
fileName: String(item.fileName),
contentType: item.contentType ? String(item.contentType) : undefined,
} : null;
}
function SignatureDetail({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
const payload = asRecord(item.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const values = asRecord(payload.signatureReportValues);
const profileFiles = ['credentialFile', 'legalFrontFile', 'legalBackFile', 'responsibleFrontFile', 'responsibleBackFile']
.map((key) => fileRef(profile[key]))
.filter((value): value is FileRef => Boolean(value));
const materialFiles: FileRef[] = (item.materials ?? []).flatMap((value) => {
const material = asRecord(value);
return material.fileObjectId ? [{
fileObjectId: String(material.fileObjectId),
fileName: String(material.title ?? material.fileName ?? material.fileObjectId),
contentType: material.contentType ? String(material.contentType) : undefined,
}] : [];
});
const files = [...new Map([...profileFiles, ...materialFiles].map((file) => [file.fileObjectId, file])).values()];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="签名审核详情">
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.name}</strong></div>
<div><span></span><Tag tone={(statusMeta[item.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[item.auditStatus] ?? statusMeta.draft).label}</Tag></div>
<div><span></span><strong>{String(profile.basis ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.companyName ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.creditCode ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.legalPersonName ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.responsibleName ?? '-')}</strong></div>
<div><span></span><strong>{String(profile.responsiblePhone ?? '-')}</strong></div>
{item.rejectReason ? <div className="detail-grid__wide"><span></span><strong>{item.rejectReason}</strong></div> : null}
</div>
<div className="surface" style={{ padding: 16 }}><strong></strong><div className="table-actions" style={{ marginTop: 12 }}>{files.length ? files.map((file) => <FileActions file={file} key={file.fileObjectId} />) : <span className="muted"></span>}</div></div>
<div className="surface" style={{ padding: 16 }}><strong></strong><div className="detail-grid" style={{ marginTop: 12 }}>{Object.entries(values).length ? Object.entries(values).map(([key, value]) => <div key={key}><span>{key}</span><strong>{fileRef(value) ? <FileActions file={fileRef(value)} /> : String(value ?? '-')}</strong></div>) : <span className="muted"></span>}</div></div>
</div>
</Modal>;
}
export function AdminSignatureAuditPage() {
const [items, setItems] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [detail, setDetail] = useState<ClientSmsSignature>();
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
const [reason, setReason] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listEnterpriseSignatures({ keyword, status: status === 'all' ? undefined : status })
.then((records) => { setItems(records); setError(''); })
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
}
useEffect(loadData, [keyword, status]);
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
async function approve(item: ClientSmsSignature) {
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
}
async function reject() {
if (!rejectTarget || !reason.trim()) return;
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
}
const columns = useMemo<Array<TableColumn<ClientSmsSignature>>>(() => [
{ key: 'name', title: '签名', render: (record) => <strong>{record.name}</strong> },
{ key: 'tenant', title: '企业', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button><Button disabled={record.auditStatus !== 'pending'} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success"></Button><Button disabled={record.auditStatus !== 'pending'} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button></div> },
], []);
return <section className="page-stack admin-template-audit-page">
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div>
<div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div>
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost"></Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger"></Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回签名审核"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
</section>;
}
+1 -1
View File
@@ -111,7 +111,7 @@ export function AdminLayout() {
{ label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck }, { label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck },
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare }, { label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 }, { label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
{ label: '彩信模板审核', to: '/admin/signatures', icon: ImageIcon, pending: true }, { label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
], ],
}, },
{ {
+2 -1
View File
@@ -27,6 +27,7 @@ import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFor
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage'; import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage'; import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage'; import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage'; import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage'; import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage'; import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
@@ -95,7 +96,7 @@ export function AppRoutes() {
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} /> <Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} /> <Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
<Route path="templates" element={<AdminTemplateAuditPage />} /> <Route path="templates" element={<AdminTemplateAuditPage />} />
<Route path="signatures" element={<PagePlaceholder />} /> <Route path="signatures" element={<AdminSignatureAuditPage />} />
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} /> <Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
<Route path="sms-audit" element={<AdminSmsAuditPage />} /> <Route path="sms-audit" element={<AdminSmsAuditPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} /> <Route path="report-tasks" element={<AdminReportTasksPage />} />