feat: complete signature and drainage reporting workflows
This commit is contained in:
@@ -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", '')
|
||||||
|
);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 中的静态三网状态代替任务状态。
|
||||||
- 发送前必须校验签名在目标通道报备通过。
|
- 发送前必须校验签名在目标通道报备通过。
|
||||||
- 报备任务导出、导入具备幂等和历史记录。
|
- 报备任务导出、导入具备幂等和历史记录。
|
||||||
|
|
||||||
|
|||||||
@@ -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 保存,并按来源通道分别写入规范化报备材料表。
|
||||||
|
|||||||
@@ -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 条新 migration;API 全量 13 suites、139 项通过,Gateway 全量 Go 测试、Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由标题正确、DOM 非空、无框架错误覆盖且 console 无 error/warn;真实图形验证码阻止进入受保护页,未代解验证码、未绕过认证、未注入 mock。
|
||||||
|
|||||||
+14
-5
@@ -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) }),
|
||||||
|
|||||||
@@ -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}”吗?删除后会写入真实后台。`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user