From b8560372cc79eb405d49ef97fd51db60067b169a Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 28 Jul 2026 23:20:13 +0800 Subject: [PATCH] fix: paginate operational list pages --- .../migration.sql | 9 ++ api/prisma/schema.prisma | 9 ++ api/src/billing/billing.controller.ts | 20 ++- api/src/billing/billing.service.ts | 41 +++++ api/src/channels/channels.controller.ts | 18 ++- api/src/channels/channels.service.ts | 122 ++++++++++++++ .../operations/admin-operations.controller.ts | 41 ++++- .../client-operations.controller.ts | 25 ++- api/src/operations/operations.service.spec.ts | 24 ++- api/src/operations/operations.service.ts | 140 +++++++++++++++- .../send-chain/admin-send-chain.controller.ts | 6 +- .../client-send-chain.controller.ts | 6 +- api/src/send-chain/send-chain.service.ts | 59 +++++++ .../sms-config/admin-sms-config.controller.ts | 25 ++- .../client-sms-config.controller.ts | 26 ++- api/src/sms-config/sms-config.service.spec.ts | 5 + api/src/sms-config/sms-config.service.ts | 153 +++++++++++++++++- .../first-version-development-requirements.md | 9 ++ docs/system-functional-test-cases.md | 22 +++ docs/testing-progress.md | 7 + src/api/adminApi.ts | 58 ++++++- src/apps/admin/AdminChannelsPage.tsx | 43 +++-- .../admin/AdminEnterpriseApplicationsPage.tsx | 29 ++-- .../admin/AdminEnterpriseSignaturesPage.tsx | 47 +++--- .../admin/AdminEnterpriseTemplatesPage.tsx | 48 +++--- src/apps/admin/AdminRechargeRecordsPage.tsx | 50 +++--- src/apps/admin/AdminReportRecordsPage.tsx | 50 +++--- src/apps/admin/AdminReportTasksPage.tsx | 40 +++-- src/apps/admin/AdminSmsRecordsPage.tsx | 84 ++++++---- src/apps/admin/AdminSmsTaskProgressPage.tsx | 68 ++++---- src/apps/admin/AdminSmsUplinkRecordsPage.tsx | 51 +++--- src/apps/client/ClientApplicationsPage.tsx | 24 ++- src/apps/client/ClientBatchTasksPage.tsx | 74 +++++---- src/apps/client/ClientBillingPage.tsx | 18 ++- src/apps/client/ClientSendDetailPage.tsx | 52 +++--- src/apps/client/ClientSignaturesPage.tsx | 48 +++--- src/apps/client/ClientTemplatesPage.tsx | 35 ++-- src/apps/client/ClientUplinkMessagesPage.tsx | 29 ++-- tools/deploy/production-bootstrap.sh | 5 + tools/deploy/production-deploy.sh | 10 ++ 40 files changed, 1208 insertions(+), 422 deletions(-) create mode 100644 api/prisma/migrations/20260728223000_optimize_list_pagination/migration.sql diff --git a/api/prisma/migrations/20260728223000_optimize_list_pagination/migration.sql b/api/prisma/migrations/20260728223000_optimize_list_pagination/migration.sql new file mode 100644 index 0000000..979c36b --- /dev/null +++ b/api/prisma/migrations/20260728223000_optimize_list_pagination/migration.sql @@ -0,0 +1,9 @@ +CREATE INDEX "RechargeOrder_payMethod_createdAt_idx" ON "RechargeOrder"("payMethod", "createdAt"); +CREATE INDEX "SmsApplication_status_createdAt_idx" ON "SmsApplication"("status", "createdAt"); +CREATE INDEX "SmsSignature_tenantId_updatedAt_idx" ON "SmsSignature"("tenantId", "updatedAt"); +CREATE INDEX "SmsTemplate_tenantId_auditStatus_createdAt_idx" ON "SmsTemplate"("tenantId", "auditStatus", "createdAt"); +CREATE INDEX "SmsChannel_status_createdAt_idx" ON "SmsChannel"("status", "createdAt"); +CREATE INDEX "ChannelSignatureReportTask_status_createdAt_idx" ON "ChannelSignatureReportTask"("status", "createdAt"); +CREATE INDEX "ChannelSignatureReportRecord_createdAt_idx" ON "ChannelSignatureReportRecord"("createdAt"); +CREATE INDEX "SmsMessageRecord_queuedAt_idx" ON "SmsMessageRecord"("queuedAt"); +CREATE INDEX "SmsUplinkMessage_receivedAt_idx" ON "SmsUplinkMessage"("receivedAt"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 02f43fe..af15682 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -359,6 +359,7 @@ model RechargeOrder { tenant Tenant @relation(fields: [tenantId], references: [id]) @@index([tenantId, createdAt]) + @@index([payMethod, createdAt]) } model SmsBillingRecord { @@ -441,6 +442,7 @@ model SmsApplication { riskRules RiskRule[] @@index([tenantId, status]) + @@index([status, createdAt]) @@index([status, autoDisableAt]) } @@ -666,6 +668,7 @@ model SmsSignature { reportBatchItems ReportMaterialBatchItem[] @@index([tenantId, auditStatus]) + @@index([tenantId, updatedAt]) @@index([tenantId, reportStatus]) } @@ -734,6 +737,7 @@ model SmsTemplate { batchTasks SmsBatchTask[] messageRecords SmsMessageRecord[] + @@index([tenantId, auditStatus, createdAt]) @@index([tenantId, auditStatus]) @@index([applicationId]) } @@ -808,6 +812,7 @@ model SmsChannel { gatewaySubmitDeadLetters GatewaySubmitDeadLetter[] @@index([status]) + @@index([status, createdAt]) } model CmppConnectionState { @@ -1043,6 +1048,7 @@ model ChannelSignatureReportTask { exportItems ReportExportFileItem[] @@index([tenantId, status]) + @@index([status, createdAt]) @@index([signatureId, channelId]) @@index([signatureId, drainageItemId, channelId]) @@index([reportType, status]) @@ -1065,6 +1071,7 @@ model ChannelSignatureReportRecord { @@index([taskId, createdAt]) @@index([channelId, createdAt]) + @@index([createdAt]) } model ReportExportFile { @@ -1447,6 +1454,7 @@ model SmsMessageRecord { @@unique([applicationId, clientMessageId]) @@index([tenantId, status, queuedAt]) + @@index([queuedAt]) @@index([batchTaskId, status]) @@index([reviewTaskId, status]) @@index([phoneNumber]) @@ -1726,6 +1734,7 @@ model SmsUplinkMessage { matchCandidates SmsUplinkMatchCandidate[] @@index([tenantId, createdAt]) + @@index([receivedAt]) @@index([applicationId, receivedAt]) @@index([channelId, receivedAt]) @@index([messageRecordId]) diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index 1f96b68..fa47cbb 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; @@ -37,13 +37,17 @@ export class BillingController { } @Get('recharges') - listRechargeOrders(@TenantId() tenantId?: string) { - return this.billing.listRechargeOrders(tenantId); + listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) }) + : this.billing.listRechargeOrders(tenantId); } @Get('manual-recharges') - listManualRechargeRecords(@TenantId() tenantId?: string) { - return this.billing.listManualRechargeRecords(tenantId); + listManualRechargeRecords(@TenantId() tenantId?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.billing.listManualRechargeRecordsPage({ tenantId, enterpriseKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) + : this.billing.listManualRechargeRecords(tenantId); } @Post('manual-recharges') @@ -121,8 +125,10 @@ export class ClientBillingController { constructor(private readonly billing: BillingService) {} @Get('orders') - listRechargeOrders(@TenantId() tenantId?: string) { - return this.billing.listRechargeOrders(tenantId); + listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) }) + : this.billing.listRechargeOrders(tenantId); } @Post('estimate') diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index d276fa4..1857b08 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -139,6 +139,17 @@ export class BillingService { }); } + async listRechargeOrdersPage(query: { tenantId?: string; page?: number; pageSize?: number }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.RechargeOrderWhereInput = query.tenantId ? { tenantId: query.tenantId } : {}; + const [items, total] = await Promise.all([ + this.prisma.rechargeOrder.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), + this.prisma.rechargeOrder.count({ where }), + ]); + return { items, total, page, pageSize }; + } + async listManualRechargeRecords(tenantId?: string) { const orders = await this.prisma.rechargeOrder.findMany({ where: { @@ -167,6 +178,36 @@ export class BillingService { })); } + async listManualRechargeRecordsPage(query: { tenantId?: string; enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.RechargeOrderWhereInput = { + tenantId: query.tenantId, + payMethod: 'manual_topup', + tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + }; + const [orders, total] = await Promise.all([ + this.prisma.rechargeOrder.findMany({ where, include: { tenant: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), + this.prisma.rechargeOrder.count({ where }), + ]); + const orderIds = orders.map((order) => order.id); + const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({ + where: { relatedType: 'recharge_order', relatedId: { in: orderIds } }, + select: { relatedId: true, balanceAfter: true }, + }) : []; + const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)])); + return { + items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })), + total, + page, + pageSize, + }; + } + async createRechargeOrder(data: CreateRechargeOrderDto) { const amountCents = data.amountCents; assertMoneyUnits(amountCents, '充值金额', { allowNegative: true, allowZero: false }); diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index eb19bb2..ade38a7 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -30,8 +30,10 @@ export class ChannelsController { constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {} @Get('channels') - listChannels() { - return this.channels.listChannels(); + listChannels(@Query('keyword') keyword?: string, @Query('carrier') carrier?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.channels.listChannelsPage({ keyword, carrier, status, page: Number(page), pageSize: Number(pageSize) }) + : this.channels.listChannels(); } @Post('channels') @@ -167,8 +169,10 @@ export class ChannelsController { } @Get('report-tasks') - listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string) { - return this.channels.listReportTasks(tenantId, status, channelId, reportType); + listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string, @Query('keyword') keyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize || keyword || createdAtFrom || createdAtTo + ? this.channels.listReportTasksPage({ tenantId, status, channelId, reportType, keyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) + : this.channels.listReportTasks(tenantId, status, channelId, reportType); } @Post('report-tasks/generate') @@ -193,7 +197,9 @@ export class ChannelsController { } @Get('report-records') - listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string) { - return this.channels.listReportRecords(taskId, channelId); + listReportRecords(@Query('taskId') taskId?: string, @Query('channelId') channelId?: string, @Query('keyword') keyword?: string, @Query('reportType') reportType?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize || keyword || reportType || createdAtFrom || createdAtTo + ? this.channels.listReportRecordsPage({ taskId, channelId, keyword, reportType, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) + : this.channels.listReportRecords(taskId, channelId); } } diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 5581397..c50a9b3 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -252,6 +252,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); } + async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsChannelWhereInput = { + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined, + name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.smsChannel.findMany({ + where, + include: { connectionStates: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsChannel.count({ where }), + ]); + return { items, total, page, pageSize }; + } + async createChannel(data: CreateChannelDto) { assertMoneyUnits(data.unitPrice ?? 0, '通道单价'); const missingFields = ['code', 'name', 'gatewayHost', 'account', 'passwordCipher', 'srcId'].filter((field) => { @@ -1281,6 +1302,63 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); } + async listReportTasksPage(query: { + tenantId?: string; + status?: string; + channelId?: string; + reportType?: string; + keyword?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const keyword = query.keyword?.trim(); + const where: Prisma.ChannelSignatureReportTaskWhereInput = { + tenantId: query.tenantId, + status: query.status, + channelId: query.channelId, + reportType: query.reportType, + signature: { auditStatus: { not: 'deleted' } }, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + OR: keyword ? [ + { id: { contains: keyword } }, + { channel: { name: { contains: keyword } } }, + { signature: { name: { contains: keyword } } }, + { signature: { tenant: { name: { contains: keyword } } } }, + { signature: { application: { name: { contains: keyword } } } }, + { drainageInfo: { siteName: { contains: keyword } } }, + { drainageInfo: { url: { contains: keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.channelSignatureReportTask.findMany({ + where, + include: { + signature: { include: { tenant: true, application: true } }, + channel: true, + drainageInfo: true, + exportItems: { + include: { exportFile: true, batchItem: { include: { batch: true } } }, + orderBy: { id: 'desc' }, + take: 1, + }, + records: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.channelSignatureReportTask.count({ where }), + ]); + return { items, total, page, pageSize }; + } + async createReportTask(data: CreateReportTaskDto) { const reportType = data.reportType ?? 'signature'; if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required'); @@ -1414,6 +1492,50 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { }); } + async listReportRecordsPage(query: { + taskId?: string; + channelId?: string; + keyword?: string; + reportType?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const keyword = query.keyword?.trim(); + const where: Prisma.ChannelSignatureReportRecordWhereInput = { + taskId: query.taskId, + channelId: query.channelId, + task: query.reportType ? { reportType: query.reportType } : undefined, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + OR: keyword ? [ + { taskId: { contains: keyword } }, + { action: { contains: keyword } }, + { reason: { contains: keyword } }, + { channel: { name: { contains: keyword } } }, + { task: { signature: { name: { contains: keyword } } } }, + { task: { drainageInfo: { siteName: { contains: keyword } } } }, + { task: { drainageInfo: { url: { contains: keyword } } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.prisma.channelSignatureReportRecord.findMany({ + where, + include: { channel: true, task: { include: { signature: true, drainageInfo: true } } }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.channelSignatureReportRecord.count({ where }), + ]); + return { items, total, page, pageSize }; + } + private async getReportTaskOrThrow(taskId: string) { const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } }); if (!task) { diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 88376b0..f0c5c26 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -42,8 +42,10 @@ export class AdminOperationsController { @Query('queuedAtFrom') queuedAtFrom?: string, @Query('queuedAtTo') queuedAtTo?: string, @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, ) { - return this.operations.listMessages({ + return this.operations.listMessagesPage({ tenantId, applicationId, channelId, @@ -55,9 +57,40 @@ export class AdminOperationsController { queuedAtFrom, queuedAtTo, status, + page: Number(page), + pageSize: Number(pageSize), }); } + @Get('messages/export') + async exportMessages( + @Query('tenantId') tenantId: string | undefined, + @Query('applicationId') applicationId: string | undefined, + @Query('channelId') channelId: string | undefined, + @Query('phoneNumber') phoneNumber: string | undefined, + @Query('contentKeyword') contentKeyword: string | undefined, + @Query('channelKeyword') channelKeyword: string | undefined, + @Query('queuedAtFrom') queuedAtFrom: string | undefined, + @Query('queuedAtTo') queuedAtTo: string | undefined, + @Query('status') status: string | undefined, + @Res() response: DownloadResponse, + ) { + const exported = await this.operations.exportMessages({ + tenantId, + applicationId, + channelId, + phoneNumber, + contentKeyword, + channelKeyword, + queuedAtFrom, + queuedAtTo, + status, + }); + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); + response.send(`\uFEFF${exported.content}`); + } + @Get('message-segment-audits') messageSegmentAudits( @Query('messageId') messageId?: string, @@ -67,8 +100,10 @@ export class AdminOperationsController { } @Get('uplink-messages') - listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) { - return this.operations.listUplinkMessages({ tenantId, channelId }); + listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.operations.listUplinkMessagesPage({ tenantId, channelId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }) + : this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime }); } @Post('uplink-messages/:id/claim') diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts index 49ebcda..34f2a1e 100644 --- a/api/src/operations/client-operations.controller.ts +++ b/api/src/operations/client-operations.controller.ts @@ -27,13 +27,32 @@ export class ClientOperationsController { @Query('messageId') messageId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('status') status?: string, + @Query('contentKeyword') contentKeyword?: string, + @Query('queuedAtFrom') queuedAtFrom?: string, + @Query('queuedAtTo') queuedAtTo?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, ) { - return this.operations.listClientMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status }); + return this.operations.listClientMessagesPage({ + tenantId, + applicationId, + taskId, + messageId, + phoneNumber, + status, + contentKeyword, + queuedAtFrom, + queuedAtTo, + page: Number(page), + pageSize: Number(pageSize), + }); } @Get('uplink-messages') - listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) { - return this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime }); + listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true) + : this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime }); } @Get('dashboard') diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index f0e1334..b786ab9 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -15,9 +15,10 @@ function createPrismaMock() { }, smsMessageRecord: { findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), + count: jest.fn().mockResolvedValue(51), groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), - }, + }, smsReceiptRecord: { findMany: jest.fn().mockResolvedValue([]), }, @@ -254,6 +255,27 @@ describe('OperationsService', () => { })); }); + it('paginates message records in PostgreSQL and limits heavy relations to the requested page', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.listMessagesPage({ tenantId: 'tenant-1', page: 2, pageSize: 25 })).resolves.toEqual({ + items: [{ messageId: 'MSG-1' }], + total: 51, + page: 2, + pageSize: 25, + }); + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1' }), + skip: 25, + take: 25, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + })); + expect(prisma.smsMessageRecord.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ tenantId: 'tenant-1' }), + }); + }); + it('returns uplink messages with tenant and channel display data', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 3c530f6..7ed3361 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -16,6 +16,8 @@ export interface MessageQuery { status?: string; queuedAtFrom?: string; queuedAtTo?: string; + page?: number; + pageSize?: number; } export interface TraceQuery extends MessageQuery { @@ -122,12 +124,117 @@ export class OperationsService { }); } + async listMessagesPage(query: MessageQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25))); + const where = messageWhere(query); + const [items, total] = await Promise.all([ + this.prisma.smsMessageRecord.findMany({ + where, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, name: true, srcId: true } }, + submitRecords: { + select: { + id: true, + submitId: true, + channelId: true, + channelGroupId: true, + channelGroupName: true, + gatewayMessageId: true, + submitStatus: true, + submittedAt: true, + createdAt: true, + channel: { select: { id: true, name: true } }, + channelGroup: { select: { id: true, name: true } }, + }, + }, + receiptRecords: { + select: { + id: true, + messageId: true, + gatewayMessageId: true, + receiptStatus: true, + rawStatus: true, + errorCode: true, + errorMessage: true, + deliveredAt: true, + createdAt: true, + channelId: true, + channel: { select: { id: true, name: true } }, + }, + }, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsMessageRecord.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async exportMessages(query: MessageQuery) { + const items = await this.prisma.smsMessageRecord.findMany({ + where: messageWhere(query), + select: { + messageId: true, + queuedAt: true, + phoneNumber: true, + province: true, + carrier: true, + billingUnits: true, + amountCents: true, + status: true, + submitStatus: true, + deliveredAt: true, + content: true, + tenant: { select: { name: true } }, + application: { select: { name: true } }, + channel: { select: { name: true } }, + }, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + }); + const rows = [ + ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'], + ...items.map((item) => [ + item.messageId, + item.tenant?.name ?? '', + item.application?.name ?? '', + item.queuedAt.toISOString(), + item.phoneNumber, + item.province ?? '', + item.carrier ?? '', + String(item.billingUnits), + String(moneyToNumber(item.amountCents)), + item.channel?.name ?? '', + item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, + item.deliveredAt?.toISOString() ?? '', + item.content, + ]), + ]; + return { + fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`, + content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), + }; + } + async listClientMessages(query: MessageQuery) { const items = await this.listMessages(query); return items.map(clientMessageView); } - listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) { + async listClientMessagesPage(query: MessageQuery) { + const result = await this.listMessagesPage(query); + return { ...result, items: result.items.map(clientMessageView) }; + } + + listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId: query.tenantId, @@ -152,15 +259,42 @@ export class OperationsService { }, }, orderBy: { receivedAt: 'desc' }, - take: 500, + skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, + take: query.pageSize ?? 500, }); } - async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) { + async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { const items = await this.listUplinkMessages(query); return items.map(clientUplinkView); } + async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsUplinkMessageWhereInput = { + tenantId: query.tenantId, + channelId: query.channelId, + applicationId: query.applicationId, + phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, + content: query.keyword ? { contains: query.keyword } : undefined, + receivedAt: query.startTime || query.endTime ? { + gte: query.startTime ? new Date(query.startTime) : undefined, + lte: query.endTime ? new Date(query.endTime) : undefined, + } : undefined, + }; + const [rawItems, total] = await Promise.all([ + this.listUplinkMessages({ ...query, page, pageSize }), + this.prisma.smsUplinkMessage.count({ where }), + ]); + return { + items: clientView ? rawItems.map(clientUplinkView) : rawItems, + total, + page, + pageSize, + }; + } + async monitor(query: { tenantId?: string; channelId?: string }) { const where = messageWhere(query); const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ diff --git a/api/src/send-chain/admin-send-chain.controller.ts b/api/src/send-chain/admin-send-chain.controller.ts index 421d257..69e9600 100644 --- a/api/src/send-chain/admin-send-chain.controller.ts +++ b/api/src/send-chain/admin-send-chain.controller.ts @@ -8,8 +8,10 @@ export class AdminSendChainController { constructor(private readonly sendChain: SendChainService) {} @Get('batch-tasks') - listBatchTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { - return this.sendChain.listBatchTasks(tenantId, status); + listBatchTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.sendChain.listBatchTasksPage({ tenantId, status, keyword, enterpriseKeyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) + : this.sendChain.listBatchTasks(tenantId, status); } @Get('batch-tasks/:id/messages') diff --git a/api/src/send-chain/client-send-chain.controller.ts b/api/src/send-chain/client-send-chain.controller.ts index 8d2f4b2..55fa60e 100644 --- a/api/src/send-chain/client-send-chain.controller.ts +++ b/api/src/send-chain/client-send-chain.controller.ts @@ -24,8 +24,10 @@ export class ClientSendChainController { } @Get('batch-tasks') - listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) { - return this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client'); + listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.sendChain.listBatchTasksPage({ tenantId: requireTenantId(tenantId), status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) }) + : this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client'); } @Get('batch-tasks/:id') diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 2f57ccb..9f53256 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -599,6 +599,65 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.listMessages({ tenantId, taskId }); } + async listBatchTasksPage(query: { + tenantId?: string; + status?: string; + sourceType?: string; + keyword?: string; + enterpriseKeyword?: string; + applicationKeyword?: string; + createdAtFrom?: string; + createdAtTo?: string; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsBatchTaskWhereInput = { + tenantId: query.tenantId, + status: query.status, + sourceType: query.sourceType ?? 'client', + taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, + tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, + application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined, + createdAt: query.createdAtFrom || query.createdAtTo ? { + gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, + lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, + } : undefined, + }; + const [tasks, total] = await Promise.all([ + this.prisma.smsBatchTask.findMany({ + where, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + template: { select: { id: true, name: true, content: true } }, + apiRequests: true, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.smsBatchTask.count({ where }), + ]); + const taskIds = tasks.map((task) => task.id); + const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ + by: ['batchTaskId', 'carrier', 'province', 'status'], + where: { batchTaskId: { in: taskIds } }, + _count: { _all: true }, + _sum: { billingUnits: true }, + }) : []; + return { + items: tasks.map((task) => ({ + ...task, + messageStats: messageStats.filter((item) => item.batchTaskId === task.id), + })), + total, + page, + pageSize, + }; + } + async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, sourceType: 'client' }, diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index d26dad3..e9c150f 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -12,8 +12,14 @@ export class AdminSmsConfigController { constructor(private readonly smsConfig: SmsConfigService, private readonly reviewGovernance: ReviewGovernanceService, private readonly deletions: DeletionGovernanceService) {} @Get('enterprise-applications') - listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) { - return this.smsConfig.listApplications({ tenantId, keyword, enterpriseKeyword, applicationKeyword, status, includeConnections: true }); + listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + const query = { tenantId, keyword, enterpriseKeyword, applicationKeyword, status, includeConnections: true, page: Number(page), pageSize: Number(pageSize) }; + return page || pageSize ? this.smsConfig.listApplicationsPage(query) : this.smsConfig.listApplications(query); + } + + @Get('enterprise-application-options') + listApplicationOptions(@Query('tenantId') tenantId?: string) { + return this.smsConfig.listApplicationOptions(tenantId); } @Get('enterprise-applications/:id') @@ -65,8 +71,14 @@ export class AdminSmsConfigController { } @Get('enterprise-signatures') - listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string) { - return this.smsConfig.listSignatures({ tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword }); + listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, page: Number(page), pageSize: Number(pageSize) }; + return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query); + } + + @Get('enterprise-signature-options') + listSignatureOptions(@Query('tenantId') tenantId?: string) { + return this.smsConfig.listSignatureOptions(tenantId); } @Post('enterprise-signatures') @@ -113,8 +125,9 @@ export class AdminSmsConfigController { } @Get('enterprise-templates') - listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string) { - return this.smsConfig.listTemplates({ tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword }); + listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, page: Number(page), pageSize: Number(pageSize) }; + return page || pageSize ? this.smsConfig.listTemplatesPage(query) : this.smsConfig.listTemplates(query); } @Post('enterprise-templates') diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index 867d909..662186d 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -23,8 +23,15 @@ export class ClientSmsConfigController { constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {} @Get('applications') - listApplications(@TenantId() tenantId?: string) { - return this.smsConfig.listApplications(tenantId); + listApplications(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) }) + : this.smsConfig.listApplications(tenantId); + } + + @Get('application-options') + listApplicationOptions(@TenantId() tenantId?: string) { + return this.smsConfig.listApplicationOptions(tenantId); } @Post('applications') @@ -64,9 +71,14 @@ export class ClientSmsConfigController { return this.smsConfig.listClientSignatures(tenantId); } + @Get('signature-options') + listSignatureOptions(@TenantId() tenantId?: string) { + return this.smsConfig.listSignatureOptions(tenantId); + } + @Get('signatures-workspace') - getSignatureWorkspace(@TenantId() tenantId?: string) { - return this.smsConfig.getClientSignatureWorkspace(tenantId); + getSignatureWorkspace(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) }); } @Post('signatures') @@ -124,8 +136,10 @@ export class ClientSmsConfigController { } @Get('templates') - listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string) { - return this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true'); + listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return page || pageSize + ? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) }) + : this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true'); } @Post('templates') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 333673e..63a4251 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -69,6 +69,7 @@ function createPrismaMock() { }, smsSignature: { groupBy: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), findMany: jest.fn().mockResolvedValue([{ id: 'sig-1', tenantId: 'tenant-1', @@ -900,6 +901,7 @@ describe('SmsConfigService', () => { it('returns real client signature workspace counts from database grouping', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findMany.mockResolvedValue([]); + prisma.smsSignature.count.mockResolvedValue(8); prisma.smsSignature.groupBy.mockResolvedValue([ { auditStatus: 'pending', _count: { _all: 2 } }, { auditStatus: 'approved', _count: { _all: 5 } }, @@ -910,6 +912,9 @@ describe('SmsConfigService', () => { await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({ items: [], summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 }, + total: 8, + page: 1, + pageSize: 10, }); expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({ where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } }, diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 9df7351..8602e52 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -122,6 +122,8 @@ export interface TemplateListQuery { applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; + page?: number; + pageSize?: number; } export interface ApplicationListQuery { @@ -131,6 +133,8 @@ export interface ApplicationListQuery { applicationKeyword?: string; status?: string; includeConnections?: boolean; + page?: number; + pageSize?: number; } export interface SignatureListQuery { @@ -141,6 +145,8 @@ export interface SignatureListQuery { applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; + page?: number; + pageSize?: number; } export interface GatewayDownstreamConnectionEventDto { @@ -205,7 +211,15 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { ipAllowlist: true, httpConfig: true, }, + omit: { + secretHash: true, + passwordCipher: true, + }, orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), }); if (!query.includeConnections) { return applications; @@ -243,6 +257,34 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { || left.id.localeCompare(right.id)); } + async listApplicationsPage(query: ApplicationListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsApplicationWhereInput = { + tenantId: query.tenantId, + status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listApplications({ ...query, page, pageSize }), + this.prisma.smsApplication.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + listApplicationOptions(tenantId?: string) { + return this.prisma.smsApplication.findMany({ + where: { tenantId, status: { not: 'deleted' } }, + select: { id: true, tenantId: true, name: true, status: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } + async getApplication(applicationId: string, tenantId?: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, @@ -905,6 +947,10 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { reportTasks: { include: { channel: true, drainageInfo: true } }, }, orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), }); const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id)); const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({ @@ -979,9 +1025,60 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { }); } - async listClientSignatures(tenantId?: string, signatureId?: string) { + async listSignaturesPage(query: SignatureListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsSignatureWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined, + drainageItems: query.drainageKeyword ? { + some: { + auditStatus: { not: 'deleted' }, + OR: [ + { siteName: { contains: query.drainageKeyword } }, + { url: { contains: query.drainageKeyword } }, + { remark: { contains: query.drainageKeyword } }, + ], + }, + } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { purpose: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listSignatures({ ...query, page, pageSize }), + this.prisma.smsSignature.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + listSignatureOptions(tenantId?: string) { + return this.prisma.smsSignature.findMany({ + where: { tenantId, auditStatus: { not: 'deleted' } }, + select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }); + } + + async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { const signatures = await this.prisma.smsSignature.findMany({ - where: { id: signatureId, tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, + where: { + id: signatureId, + tenantId, + applicationId: query.applicationId, + auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + OR: query.keyword?.trim() ? [ + { name: { contains: query.keyword.trim() } }, + { purpose: { contains: query.keyword.trim() } }, + { application: { name: { contains: query.keyword.trim() } } }, + ] : undefined, + }, select: { id: true, tenantId: true, @@ -1020,6 +1117,8 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { _count: { select: { reportMaterials: true } }, }, orderBy: { updatedAt: 'desc' }, + skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, + take: query.pageSize, }); return signatures.map((signature) => { const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {}; @@ -1056,9 +1155,22 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { return signature; } - async getClientSignatureWorkspace(tenantId?: string) { - const [items, statusCounts] = await Promise.all([ - this.listClientSignatures(tenantId), + async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const filteredWhere: Prisma.SmsSignatureWhereInput = { + tenantId, + applicationId: query.applicationId, + auditStatus: query.status || { notIn: ['deleted', 'disabled'] }, + OR: query.keyword?.trim() ? [ + { name: { contains: query.keyword.trim() } }, + { purpose: { contains: query.keyword.trim() } }, + { application: { name: { contains: query.keyword.trim() } } }, + ] : undefined, + }; + const [items, total, statusCounts] = await Promise.all([ + this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }), + this.prisma.smsSignature.count({ where: filteredWhere }), this.prisma.smsSignature.groupBy({ by: ['auditStatus'], where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } }, @@ -1073,7 +1185,7 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { summary[item.auditStatus as keyof Omit] = count; } } - return { items, summary }; + return { items, summary, total, page, pageSize }; } async listClientDrainageInfos(tenantId?: string, itemId?: string) { @@ -1464,9 +1576,38 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy { }, include: { variables: true, application: true, tenant: true, signature: true }, orderBy: { createdAt: 'desc' }, + ...(query.page && query.pageSize ? { + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + } : {}), }); } + async listTemplatesPage(query: TemplateListQuery) { + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); + const where: Prisma.SmsTemplateWhereInput = { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, + application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, + name: query.nameKeyword ? { contains: query.nameKeyword } : undefined, + content: query.contentKeyword ? { contains: query.contentKeyword } : undefined, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { content: { contains: query.keyword } }, + { category: { contains: query.keyword } }, + { application: { name: { contains: query.keyword } } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total] = await Promise.all([ + this.listTemplates({ ...query, page, pageSize }), + this.prisma.smsTemplate.count({ where }), + ]); + return { items, total, page, pageSize }; + } + listClientTemplates(tenantId: string | undefined, includeHistory = false) { return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 56e09d1..c111027 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1800,3 +1800,12 @@ - 查看签名明细时使用“通道行 × 运营商列”矩阵,每个有数据的单元格展示提交次数、成功率、平均到达时间及提交失败数量;所选日期无真实提交显示`—`,不据此推断通道不支持该运营商。 - 平均到达时间只统计成功送达的提交尝试,从该通道提交受理时间开始,到该通道成功回执完成为止;长短信以全部成功分片完成时间为准。 - 签名列表提供签名、企业或应用关键字查询和后端分页;查看详情不应依赖前端模拟数据或浏览器本地存储。 +# 列表查询性能与分页约束(2026-07-28) + +- 除通道组外,运营端和客户端业务列表必须使用真实后端、数据库分页;浏览器不得先拉取完整结果再切片分页。 +- 本轮覆盖短信记录、短信任务进度、报备任务、报备记录、企业应用、企业签名及引流、企业模板、短信通道、上行短信、充值记录,以及对应客户端页面。 +- 列表筛选条件必须传入后端并参与总数统计;分页响应统一包含 `items`、`total`、`page`、`pageSize`。页面翻页只读取目标页,筛选和重置回到第一页。 +- 短信记录列表只返回当前页面渲染及详情所需字段;CSV 导出使用独立后端导出接口,不通过浏览器当前页或全量列表拼接。 +- 企业应用和签名等下拉框使用轻量选项接口,不得为了选择器加载连接、资料、报备任务等完整关联对象。 +- 分页排序字段应有数据库索引;Nginx 对 JSON、JavaScript、CSS、CSV、文本和 SVG 响应启用 gzip,降低传输与解析等待。 +- 通道组本轮按用户明确要求排除,保留现状,后续如数据规模扩大再单独改造。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 76ce4f1..15e560b 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3987,3 +3987,25 @@ npm run verify:phase8 | TC-ANALYTICS-SIGNATURE-011 | 点击“查看明细” | 打开右侧详情抽屉,展示运营商概览及通道×运营商矩阵;Esc、关闭按钮和遮罩均可关闭 | | TC-ANALYTICS-SIGNATURE-012 | 所选日期没有已登记签名发送 | 返回真实空状态,不显示演示或历史日期数据 | | TC-ANALYTICS-SIGNATURE-013 | 同一运营商的一条业务短信首通道失败后切换通道并最终送达 | 运营商概览计1条业务短信、最终成功率为100%;通道矩阵仍分别记录两次真实提交 | +# 列表后端分页与响应性能测试(2026-07-28) + +## TC-LIST-PERF-001 短信记录真实后端分页 + +1. 进入运营端短信记录,选择日期并查询。 +2. 验证请求携带 `page/pageSize`,响应为 `items/total/page/pageSize`,`items` 不超过页容量。 +3. 翻到下一页,验证数据库返回目标页且页面未在浏览器缓存全量记录。 +4. 验证关联提交、回执、下游投递仅属于当前页短信;CSV 下载调用独立导出接口。 + +## TC-LIST-PERF-002 其他运营端列表分页 + +逐页验证短信任务、报备任务、报备记录、企业应用、企业签名、企业模板、短信通道、上行短信和充值记录。筛选在后端生效,总数与条件一致,翻页只请求当前页;通道组不在本轮范围。 + +## TC-LIST-PERF-003 客户端列表分页 + +逐页验证短信明细、批量任务、应用、签名与引流、模板、上行短信和充值记录。验证租户边界不变、筛选回到第一页、选项接口仅返回下拉必需字段。 + +## TC-LIST-PERF-004 数据库索引与压缩 + +1. 应用分页索引 migration。 +2. 对短信、任务、报备、应用、签名、模板、通道、上行和充值分页 SQL 执行计划进行检查。 +3. 携带 `Accept-Encoding: gzip` 请求大于 1KB 的 JSON,验证响应 `Content-Encoding: gzip`。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 242dda1..0f12e84 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2671,3 +2671,10 @@ git diff --check - `.deployed-commit=95c052e9ea987f58c34002543331c2b75f36c642`;API、Gateway、Nginx、PostgreSQL、MinIO和Redis均active,`12026/17890/8090/3000/6379/5432/9000`均监听,API/Gateway health与Redis PONG通过。`gateway.submit.commands`消费者1、`pending=0`、`lag=0`,12个通道TPS配置键存在,发布后API/Gateway error级journal为0。 - 公网首页、运营端、客户端和API health均HTTP 200,公网CMPP 17890 TCP可连接。4条active供应商通道中3条为`connected 1/1`;“会员营销-富泷”为`failed 0/1`,数据库记录`authentication / connect response status: auth failed`并保留系统自动重连,未修改账号、密码或启停状态。120秒内活跃下游客户连接为0。 - 部署源码和前端产物均包含`.ui-query-actions`、两个报备筛选布局类及“条业务短信”新口径文案。应用内浏览器仍因无已登录运营会话停留在图形验证码登录页,未绕过验证码或虚报登录后视觉验收;本次未发送、重投或补发真实短信。 +# 2026-07-28 全业务列表后端分页与短信记录性能修复(发布前) + +- 预生产短信记录页面卡顿根因已定位:页面一次读取443条短信及其760条提交、1492条回执和435条下游投递,JSON约4.47MB,再在浏览器切出25条;数据库基础查询不足1ms,主要耗时来自不必要的关联装载、序列化、网络传输和前端解析。 +- 除用户明确排除的“通道组”外,显式浏览器切片分页已全部移除。运营端短信记录、短信任务、报备任务/记录、企业应用/签名/模板、短信通道、上行短信和充值记录,以及客户端短信明细、批量任务、应用、签名/引流、模板、上行短信和充值记录,均改为真实PostgreSQL分页与后端筛选。 +- 短信记录列表改为当前页最小字段与当前页关联数据,新增独立后端CSV导出;企业应用、签名下拉改用轻量选项接口。新增分页排序索引migration `20260728223000_optimize_list_pagination`,部署脚本为JSON及静态文本资源启用gzip并在重启前执行`nginx -t`。 +- Prisma format、validate、generate通过;Node.js v24.14.0下前端与API TypeScript检查通过。API全量26 suites / 367 tests全部通过,新增短信记录数据库分页页码、容量、总数及目标页关联约束;Jest仅保留既有`--forceExit`异步句柄提示。后续构建、Gateway、安全门禁、提交、推送及预生产部署结果在本节继续补记。 +- 既有`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续视为构建/临时产物,不纳入提交。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 2e7902a..d8ddb85 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -574,6 +574,9 @@ export type ClientSmsSignatureView = Pick request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), + listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/billing/manual-recharges', query)), preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), + listEnterpriseApplicationsPage: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-applications', query)), + listEnterpriseApplicationOptions: (query: { tenantId?: string } = {}) => + request(withQuery('/admin/enterprise-application-options', query)), getEnterpriseApplication: (id: string) => request(`/admin/enterprise-applications/${id}`), createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => @@ -1742,6 +1751,8 @@ export const adminApi = { body: JSON.stringify(body), }), listChannels: () => request('/admin/channels'), + listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/channels', query)), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => request>(withQuery('/admin/reports/reconciliation', query)), exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => @@ -1800,6 +1811,10 @@ export const adminApi = { request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-signatures', query)), + listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => + request(withQuery('/admin/enterprise-signature-options', query)), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => @@ -1822,6 +1837,8 @@ export const adminApi = { request(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) => request(withQuery('/admin/enterprise-templates', query)), + listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/enterprise-templates', query)), createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => @@ -1890,6 +1907,8 @@ export const adminApi = { createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), + listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/report-tasks', query)), createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => @@ -1899,8 +1918,12 @@ export const adminApi = { importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), + listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/report-records', query)), listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/send/batch-tasks', query)), + listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/send/batch-tasks', query)), listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => request(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)), terminateAdminBatchTask: (id: string) => @@ -1909,10 +1932,14 @@ export const adminApi = { request(withQuery('/admin/send/messages', query)), listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) => request(withQuery('/admin/operations/message-segment-audits', query)), - listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => - request(withQuery('/admin/operations/messages', query)), + listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/operations/messages', query)), + exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) => + requestBlob(withQuery('/admin/operations/messages/export', query)), listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => request(withQuery('/admin/operations/uplink-messages', query)), + listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) => + request>(withQuery('/admin/operations/uplink-messages', query)), claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) => request(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }), listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), @@ -2058,8 +2085,14 @@ export const clientApi = { request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), + listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/billing/orders', query), { tenantId }), listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/applications', { tenantId }), + listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/applications', query), { tenantId }), + listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/application-options', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api`, { tenantId }), @@ -2077,8 +2110,10 @@ export const clientApi = { request(withQuery('/client/report-fields/common', { reportType }), { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { tenantId }), - getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/signatures-workspace', { tenantId }), + listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/signature-options', { tenantId }), + getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/signatures-workspace', query), { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -2101,6 +2136,13 @@ export const clientApi = { keyword: query.keyword, includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), }), { tenantId }), + listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/templates', { + keyword: query.keyword, + includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), + page: query.page, + pageSize: query.pageSize, + }), { tenantId }), createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -2115,6 +2157,8 @@ export const clientApi = { request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/send/batch-tasks', query), { tenantId }), + listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/send/batch-tasks', query), { tenantId }), cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -2125,10 +2169,12 @@ export const clientApi = { request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), - listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/operations/messages', query), { tenantId }), + listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/operations/messages', query), { tenantId }), listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/uplink-messages', query), { tenantId }), + listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request>(withQuery('/client/operations/uplink-messages', query), { tenantId }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { assertUploadFileSize(file); const form = new FormData(); diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 79d7cd7..d11607b 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; @@ -497,42 +497,35 @@ export function AdminChannelsPage() { const [logState, setLogState] = useState(null); const [logKeyword, setLogKeyword] = useState(''); const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const pageSize = 10; - function loadChannels() { - Promise.all([adminApi.listChannels(), adminApi.getSendQuality()]) - .then(async ([items, quality]) => { - const visibleChannels = items.filter((item) => item.status !== 'deleted'); + function loadChannels(targetPage = page, filters = { keyword, carrier, status }) { + Promise.all([ + adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }), + adminApi.getSendQuality(), + ]) + .then(async ([result, quality]) => { + const visibleChannels = result.items; const connections = await Promise.all(visibleChannels.map((channel) => adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]), )); const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item])); setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index], qualityByChannel.get(item.id)))); + setTotal(result.total); setError(''); }) .catch((failure: Error) => setError(failure.message || '通道列表加载失败')); } useEffect(() => { - loadChannels(); - }, []); + loadChannels(page); + }, [page]); - const filteredChannels = useMemo( - () => channels.filter((channel) => { - const matchesKeyword = !keyword || channel.name.includes(keyword); - const matchesCarrier = carrier === 'all' || channel.carrier === carrier; - const matchesStatus = status === 'all' || channel.status === status; - return matchesKeyword && matchesCarrier && matchesStatus; - }), - [carrier, channels, keyword, status], - ); - const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize)); + const filteredChannels = channels; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [carrier, channels.length, keyword, status]); + const visibleChannels = channels; async function upsertChannel(nextChannel: SmsChannel) { try { @@ -617,8 +610,8 @@ export function AdminChannelsPage() { setStatus(event.target.value)} options={statusOptions} value={status} />
- - + +
@@ -677,7 +670,7 @@ export function AdminChannelsPage() { totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} - total={filteredChannels.length} + total={total} /> diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index 2fbef49..c6b58db 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { formatAmount, moneyUnitsToYuan } from '@/utils/currency'; @@ -316,6 +316,9 @@ function CmppConnectionModal({ export function AdminEnterpriseApplicationsPage() { const navigate = useNavigate(); const [smsApps, setSmsApps] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const pageSize = 10; const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState(''); const [applicationKeyword, setApplicationKeyword] = useState(''); @@ -339,10 +342,11 @@ export function AdminEnterpriseApplicationsPage() { >(null); const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null); - async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) { + async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }, targetPage = page) { try { - const applications = await adminApi.listEnterpriseApplications(filters); - setSmsApps(applications.map(mapApplication)); + const result = await adminApi.listEnterpriseApplicationsPage({ ...filters, page: targetPage, pageSize }); + setSmsApps(result.items.map(mapApplication)); + setTotal(result.total); setError(''); } catch (err) { setSmsApps([]); @@ -351,8 +355,8 @@ export function AdminEnterpriseApplicationsPage() { } useEffect(() => { - void loadSmsApps(); - }, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus]); + void loadSmsApps(undefined, page); + }, [page]); async function openAddModal() { setAddModalOpen(true); @@ -441,12 +445,7 @@ export function AdminEnterpriseApplicationsPage() { } } - const filteredSmsApps = useMemo( - () => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)) - && (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword)) - && (appliedStatus === 'all' || item.status === appliedStatus)), - [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps], - ); + const filteredSmsApps = smsApps; const smsColumns = useMemo>>(() => [ { key: 'name', title: '应用名称', width: '180px', render: (record) => {record.name} }, @@ -524,8 +523,8 @@ export function AdminEnterpriseApplicationsPage() { value={status} />
- - + +
@@ -534,7 +533,7 @@ export function AdminEnterpriseApplicationsPage() {
}, + { label: '短信应用', value: 'sms', content: <> setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} /> }, { label: '彩信应用', value: 'mms', pending: true, content:
彩信应用待后端能力确认,本页不展示演示数据。
}, ]} /> diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 7f92af9..ca13ce0 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ChevronDown, ChevronRight, Edit3, FileSpreadsheet, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react'; import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi'; import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui'; @@ -614,19 +614,23 @@ export function AdminEnterpriseSignaturesPage() { const [signatureReport, setSignatureReport] = useState(null); const [reportStatusTarget, setReportStatusTarget] = useState(null); const [signatures, setSignatures] = useState([]); + const [total, setTotal] = useState(0); const [tenants, setTenants] = useState([]); const [page, setPage] = useState(1); const [importOpen, setImportOpen] = useState(false); const [message, setMessage] = useState(''); - async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) { + const pageSize = 10; + + async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page) { try { - const [signatureItems, tenantItems, applicationItems] = await Promise.all([ - adminApi.listEnterpriseSignatures(filters), + const [signatureResult, tenantItems, applicationItems] = await Promise.all([ + adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize }), adminApi.listTenants(), - adminApi.listEnterpriseApplications(), + adminApi.listEnterpriseApplicationOptions(), ]); - setSignatures(signatureItems); + setSignatures(signatureResult.items); + setTotal(signatureResult.total); setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted')); setApplications(applicationItems); setError(''); @@ -636,26 +640,13 @@ export function AdminEnterpriseSignaturesPage() { } useEffect(() => { - void loadData(); - }, []); + void loadData(undefined, page); + }, [page]); - const filteredSignatures = useMemo(() => signatures.filter((item) => { - const enterprise = item.tenant?.name ?? item.tenantId; - const application = item.application?.name ?? ''; - const drainageItems = readDrainagePayload(item).links; - return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword)) - && (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword)) - && (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword)) - && (!appliedDrainageKeyword || drainageItems.some((drainage) => `${drainage.siteName} ${drainage.url} ${drainage.remark}`.includes(appliedDrainageKeyword))); - }), [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize)); + const filteredSignatures = signatures; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]); + const visibleSignatures = signatures; async function saveSignature(state: SignatureFormState) { const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null; @@ -792,7 +783,7 @@ export function AdminEnterpriseSignaturesPage() { totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} - total={filteredSignatures.length} + total={total} /> {filteredSignatures.length === 0 ?
暂无企业签名
: null} @@ -823,7 +814,8 @@ export function AdminEnterpriseSignaturesPage() { setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedSignatureKeyword(filters.signatureKeyword); setAppliedDrainageKeyword(filters.drainageKeyword); - void loadData(filters); + setPage(1); + void loadData(filters, 1); }}>查询 diff --git a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx index e7b9d64..c531bfa 100644 --- a/src/apps/admin/AdminEnterpriseTemplatesPage.tsx +++ b/src/apps/admin/AdminEnterpriseTemplatesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react'; import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; @@ -289,6 +289,7 @@ export function AdminEnterpriseTemplatesPage() { const [templateModal, setTemplateModal] = useState(null); const [templatePreview, setTemplatePreview] = useState(null); const [templates, setTemplates] = useState([]); + const [total, setTotal] = useState(0); const [templateNameKeyword, setTemplateNameKeyword] = useState(''); const [appliedTemplateNameKeyword, setAppliedTemplateNameKeyword] = useState(''); const [templateContentKeyword, setTemplateContentKeyword] = useState(''); @@ -296,15 +297,18 @@ export function AdminEnterpriseTemplatesPage() { const [tenants, setTenants] = useState([]); const [page, setPage] = useState(1); - async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }) { + const pageSize = 10; + + async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) { try { - const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([ - adminApi.listEnterpriseTemplates(filters), + const [templateResult, tenantItems, applicationItems, signatureList] = await Promise.all([ + adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize }), adminApi.listTenants(), - adminApi.listEnterpriseApplications(), - adminApi.listEnterpriseSignatures(), + adminApi.listEnterpriseApplicationOptions(), + adminApi.listEnterpriseSignatureOptions(), ]); - setTemplates(templateItems); + setTemplates(templateResult.items); + setTotal(templateResult.total); setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted')); setApplications(applicationItems); setSignatureItems(signatureList); @@ -315,25 +319,13 @@ export function AdminEnterpriseTemplatesPage() { } useEffect(() => { - void loadData(); - }, []); + void loadData(undefined, page); + }, [page]); - const filteredTemplates = useMemo(() => templates.filter((item) => { - const enterprise = item.tenant?.name ?? item.tenantId; - const application = item.application?.name ?? ''; - return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword)) - && (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword)) - && (!appliedTemplateNameKeyword || item.name.includes(appliedTemplateNameKeyword)) - && (!appliedTemplateContentKeyword || item.content.includes(appliedTemplateContentKeyword)); - }), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword, templates]); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize)); + const filteredTemplates = templates; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword]); + const visibleTemplates = templates; async function saveTemplate(state: TemplateFormState) { const existing = templateModal && templateModal !== 'new' ? templateModal : null; @@ -387,7 +379,8 @@ export function AdminEnterpriseTemplatesPage() { setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedTemplateNameKeyword(filters.nameKeyword); setAppliedTemplateContentKeyword(filters.contentKeyword); - void loadData(filters); + setPage(1); + void loadData(filters, 1); }}>查询 @@ -447,7 +441,7 @@ export function AdminEnterpriseTemplatesPage() { onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} - total={filteredTemplates.length} + total={total} totalPages={totalPages} /> diff --git a/src/apps/admin/AdminRechargeRecordsPage.tsx b/src/apps/admin/AdminRechargeRecordsPage.tsx index d848aea..be3bf11 100644 --- a/src/apps/admin/AdminRechargeRecordsPage.tsx +++ b/src/apps/admin/AdminRechargeRecordsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Plus, ReceiptText, Search } from 'lucide-react'; import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui'; import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi'; @@ -28,19 +28,28 @@ export function AdminRechargeRecordsPage() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [total, setTotal] = useState(0); + const pageSize = 10; - async function loadData() { + async function loadData(targetPage = page, filters = { enterpriseKeyword, dateRange }) { setLoading(true); setError(''); try { - const [nextTenants, nextAccounts, nextRecords] = await Promise.all([ + const [nextTenants, nextAccounts, result] = await Promise.all([ adminApi.listTenants(), adminApi.listAccounts(), - adminApi.listManualRecharges(), + adminApi.listManualRechargesPage({ + enterpriseKeyword: filters.enterpriseKeyword.trim() || undefined, + createdAtFrom: filters.dateRange.start, + createdAtTo: filters.dateRange.end, + page: targetPage, + pageSize, + }), ]); setTenants(nextTenants); setAccounts(nextAccounts); - setRecords(nextRecords); + setRecords(result.items); + setTotal(result.total); } catch (err) { setError(err instanceof Error ? err.message : '充值记录加载失败'); setRecords([]); @@ -50,35 +59,22 @@ export function AdminRechargeRecordsPage() { } useEffect(() => { - void loadData(); - }, []); + void loadData(page); + }, [page]); - const filteredRows = useMemo( - () => records.filter((item) => { - const rechargeDate = getDate(item.paidAt ?? item.createdAt); - const tenantName = item.tenant?.name ?? tenants.find((tenant) => tenant.id === item.tenantId)?.name ?? item.tenantId; - const matchesEnterprise = !enterpriseKeyword || tenantName.includes(enterpriseKeyword); - const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start; - const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end; - return matchesEnterprise && matchesStartDate && matchesEndDate; - }), - [dateRange.end, dateRange.start, enterpriseKeyword, records, tenants], - ); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const filteredRows = records; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const visibleRows = records; const receiptTenant = receiptRecord ? receiptRecord.tenant ?? tenants.find((tenant) => tenant.id === receiptRecord.tenantId) : undefined; - useEffect(() => { - setPage(1); - }, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]); - function resetFilters() { setEnterpriseKeyword(''); setDateRange({}); + setPage(1); + void loadData(1, { enterpriseKeyword: '', dateRange: {} }); } return ( @@ -95,7 +91,7 @@ export function AdminRechargeRecordsPage() { setEnterpriseKeyword(event.target.value)} value={enterpriseKeyword} />
- +
@@ -156,7 +152,7 @@ export function AdminRechargeRecordsPage() { totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} - total={filteredRows.length} + total={total} /> diff --git a/src/apps/admin/AdminReportRecordsPage.tsx b/src/apps/admin/AdminReportRecordsPage.tsx index bce9d7f..aacdf39 100644 --- a/src/apps/admin/AdminReportRecordsPage.tsx +++ b/src/apps/admin/AdminReportRecordsPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Clock3, Eye, Search } from 'lucide-react'; import { adminApi, type ReportRecord } from '@/api/adminApi'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui'; const statusTone: Record = { pending: 'neutral', @@ -71,28 +71,30 @@ export function AdminReportRecordsPage() { const [reportType, setReportType] = useState('all'); const [detail, setDetail] = useState(null); const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 10; - function loadData() { - adminApi.listReportRecords() - .then((items) => { - setRecords(items); + function loadData(targetPage = page) { + adminApi.listReportRecordsPage({ + keyword: keyword || undefined, + reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage', + createdAtFrom: dateRange.start || undefined, + createdAtTo: dateRange.end || undefined, + page: targetPage, + pageSize, + }) + .then((result) => { + setRecords(result.items); + setTotal(result.total); setError(''); }) .catch((failure: Error) => setError(failure.message || '报备记录加载失败')); } useEffect(() => { - loadData(); - }, []); - - const filteredRecords = useMemo(() => records.filter((record) => { - const text = `${record.taskId}${record.channel?.name ?? ''}${record.action}${actionLabel[record.action] ?? ''}${recordSource(record)}${record.reason ?? ''}${record.task?.signature?.name ?? ''}${record.task?.signature?.purpose ?? ''}${record.task?.drainageInfo?.siteName ?? ''}${record.task?.drainageInfo?.url ?? ''}${record.task?.drainageInfo?.remark ?? ''}`; - const date = record.createdAt?.slice(0, 10) ?? ''; - return (!keyword || text.includes(keyword)) - && (!dateRange.start || date >= dateRange.start) - && (!dateRange.end || date <= dateRange.end) - && (reportType === 'all' || record.task?.reportType === reportType); - }), [dateRange.end, dateRange.start, keyword, records, reportType]); + loadData(page); + }, [page]); const columns: Array> = [ { key: 'task', title: '报备任务号', width: '190px', render: (record) => {record.taskId} }, @@ -122,14 +124,24 @@ export function AdminReportRecordsPage() {
+
+ = total} + onNext={() => setPage((current) => current + 1)} + onPageChange={setPage} + onPrevious={() => setPage((current) => Math.max(1, current - 1))} + page={page} + previousDisabled={page <= 1} + total={total} + totalPages={Math.max(1, Math.ceil(total / pageSize))} + /> {detail ? setDetail(null)} record={detail} /> : null} diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx index 926dc0b..733ffc0 100644 --- a/src/apps/admin/AdminReportTasksPage.tsx +++ b/src/apps/admin/AdminReportTasksPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Eye, Search } from 'lucide-react'; import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi'; -import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; const statusMeta: Record = { @@ -74,25 +74,30 @@ export function AdminReportTasksPage() { const [statusReason, setStatusReason] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 10; - function loadData() { - adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' }) - .then((items) => { - setTasks(items); + function loadData(targetPage = page) { + adminApi.listReportTasksPage({ + reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage', + keyword: keyword || undefined, + createdAtFrom: dateRange.start || undefined, + createdAtTo: dateRange.end || undefined, + page: targetPage, + pageSize, + }) + .then((result) => { + setTasks(result.items); + setTotal(result.total); setError(''); }) .catch((failure: Error) => setError(failure.message || '报备明细加载失败')); } - useEffect(loadData, [reportType]); - - const filteredTasks = useMemo(() => tasks.filter((task) => { - const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageInfo?.siteName ?? ''}${task.drainageInfo?.url ?? ''}${task.signature?.tenant?.name ?? ''}${task.signature?.application?.name ?? ''}`; - const date = task.createdAt?.slice(0, 10) ?? ''; - return (!keyword || text.includes(keyword)) - && (!dateRange.start || date >= dateRange.start) - && (!dateRange.end || date <= dateRange.end); - }), [dateRange.end, dateRange.start, keyword, tasks]); + useEffect(() => { + loadData(page); + }, [page]); async function saveTaskStatus() { if (!statusTask) return; @@ -143,13 +148,14 @@ export function AdminReportTasksPage() { setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
+
+ = total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} /> {detailTask ? setDetailTask(null)} task={detailTask} /> : null} } onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态"> {statusTask ?
diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index 969c409..cb3686c 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -419,6 +419,10 @@ export function AdminSmsRecordsPage() { const [segmentLoading, setSegmentLoading] = useState(false); const [error, setError] = useState(''); const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filterTenants, setFilterTenants] = useState>([]); + const [filterApplications, setFilterApplications] = useState>([]); type MessageFilters = { tenantId?: string; @@ -444,19 +448,30 @@ export function AdminSmsRecordsPage() { }; } - function loadData(filters = currentFilters()) { - adminApi.listOperationMessages(filters) - .then((items) => { - setRecords(items); - setSelectedRecord((current) => current ? items.find((item) => item.id === current.id) ?? null : null); - setPage(1); + function loadData(filters = currentFilters(), targetPage = page) { + setLoading(true); + adminApi.listOperationMessages({ ...filters, page: targetPage, pageSize }) + .then((result) => { + setRecords(result.items); + setTotal(result.total); + setSelectedRecord((current) => current ? result.items.find((item) => item.id === current.id) ?? null : null); setError(''); }) - .catch((failure: Error) => setError(failure.message || '短信记录加载失败')); + .catch((failure: Error) => setError(failure.message || '短信记录加载失败')) + .finally(() => setLoading(false)); } useEffect(() => { - loadData(); + loadData(currentFilters(), page); + }, [page]); + + useEffect(() => { + Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()]) + .then(([tenants, applications]) => { + setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name }))); + setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name }))); + }) + .catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败')); }, []); useEffect(() => { @@ -475,31 +490,19 @@ export function AdminSmsRecordsPage() { }, [selectedRecord]); const enterpriseOptions = useMemo(() => { - const tenants = new Map(); - records.forEach((record) => { - if (record.tenantId) { - tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId); - } - }); - return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))]; - }, [records]); + return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))]; + }, [filterTenants]); const applicationOptions = useMemo(() => { - const applications = new Map(); - records - .filter((record) => enterprise === 'all' || record.tenantId === enterprise) - .forEach((record) => { - if (record.applicationId) { - applications.set(record.applicationId, record.application?.name ?? record.applicationId); - } - }); - return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))]; - }, [enterprise, records]); + return [{ label: '全部应用', value: 'all' }, ...filterApplications + .filter((item) => enterprise === 'all' || item.tenantId === enterprise) + .map((item) => ({ label: item.name, value: item.id }))]; + }, [enterprise, filterApplications]); const filteredRows = records; - const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const visibleRows = filteredRows; function resetFilters() { const defaultDateRange = defaultSmsRecordDateRange(); @@ -510,7 +513,22 @@ export function AdminSmsRecordsPage() { setContentKeyword(''); setChannelKeyword(''); setStatus('all'); - loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }); + if (page !== 1) setPage(1); + else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1); + } + + async function exportRecords() { + try { + const blob = await adminApi.exportOperationMessages(currentFilters()); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `sms-records-${new Date().toISOString().slice(0, 10)}.csv`; + anchor.click(); + URL.revokeObjectURL(url); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '短信记录导出失败'); + } } return ( @@ -551,17 +569,17 @@ export function AdminSmsRecordsPage() { value={status} />
- +
- +
- {filteredRows.length === 0 ?
暂无短信记录
: visibleRows.map((record) => ( + {loading ?
正在加载真实短信记录...
: filteredRows.length === 0 ?
暂无短信记录
: visibleRows.map((record) => (
@@ -588,7 +606,7 @@ export function AdminSmsRecordsPage() { onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={currentPage} previousDisabled={currentPage <= 1} - total={filteredRows.length} + total={total} totalPages={totalPages} />
diff --git a/src/apps/admin/AdminSmsTaskProgressPage.tsx b/src/apps/admin/AdminSmsTaskProgressPage.tsx index a11cf5e..0b6d739 100644 --- a/src/apps/admin/AdminSmsTaskProgressPage.tsx +++ b/src/apps/admin/AdminSmsTaskProgressPage.tsx @@ -382,12 +382,25 @@ export function AdminSmsTaskProgressPage() { const [terminateTarget, setTerminateTarget] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [total, setTotal] = useState(0); + const [filterTenants, setFilterTenants] = useState([]); + const [filterApplications, setFilterApplications] = useState>([]); + const pageSize = 10; - function loadTasks() { + function loadTasks(targetPage = page) { setLoading(true); - adminApi.listAdminBatchTasks() - .then((items) => { - setTasks(items.map(mapTask)); + adminApi.listAdminBatchTasksPage({ + keyword: keyword || undefined, + enterpriseKeyword: enterprise === 'all' ? undefined : enterprise, + applicationKeyword: application === 'all' ? undefined : application, + createdAtFrom: submittedDateRange.start || undefined, + createdAtTo: submittedDateRange.end || undefined, + page: targetPage, + pageSize, + }) + .then((result) => { + setTasks(result.items.map(mapTask)); + setTotal(result.total); setError(''); }) .catch((reason: Error) => setError(reason.message || '短信任务进度加载失败')) @@ -395,7 +408,17 @@ export function AdminSmsTaskProgressPage() { } useEffect(() => { - loadTasks(); + loadTasks(page); + }, [page]); + + useEffect(() => { + Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()]) + .then(([tenants, applications]) => { + const tenantNameById = new Map(tenants.map((item) => [item.id, item.name])); + setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => item.name)); + setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name }))); + }) + .catch((failure: Error) => setError(failure.message || '任务筛选项加载失败')); }, []); function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) { @@ -410,35 +433,18 @@ export function AdminSmsTaskProgressPage() { }, [phoneTarget, phonePage, phonePageSize]); const enterpriseOptions = useMemo(() => { - const names = Array.from(new Set(tasks.map((item) => item.enterprise))); - return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))]; - }, [tasks]); + return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))]; + }, [filterTenants]); const applicationOptions = useMemo(() => { - const names = Array.from(new Set(tasks.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application))); + const names = Array.from(new Set(filterApplications.filter((item) => enterprise === 'all' || item.tenantName === enterprise).map((item) => item.name))); return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))]; - }, [enterprise, tasks]); + }, [enterprise, filterApplications]); - const filteredTasks = useMemo( - () => tasks.filter((item) => { - const submittedDate = item.submittedAt.slice(0, 10); - const matchesKeyword = !keyword || item.id.includes(keyword); - const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise; - const matchesApplication = application === 'all' || item.application === application; - const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start; - const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end; - return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate; - }), - [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks], - ); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize)); + const filteredTasks = tasks; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]); + const visibleTasks = filteredTasks; function resetFilters() { setKeyword(''); @@ -480,7 +486,7 @@ export function AdminSmsTaskProgressPage() { setPhoneKeyword(event.target.value)} prefix={} value={phoneKeyword} /> setContentKeyword(event.target.value)} value={contentKeyword} />
- +
@@ -324,7 +326,8 @@ export function AdminSmsUplinkRecordsPage() { {error ?

{error}

: null}
-
+
+ setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} /> {selectedMessage ? ( diff --git a/src/apps/client/ClientApplicationsPage.tsx b/src/apps/client/ClientApplicationsPage.tsx index 8cea979..90c0352 100644 --- a/src/apps/client/ClientApplicationsPage.tsx +++ b/src/apps/client/ClientApplicationsPage.tsx @@ -69,12 +69,15 @@ export function ClientApplicationsPage() { const [paramsError, setParamsError] = useState(''); const [copied, setCopied] = useState(false); const [copyError, setCopyError] = useState(''); + const [total, setTotal] = useState(0); + const pageSize = 10; function loadApplications() { setLoading(true); - clientApi.listApplications() - .then((items) => { - setApplications(items.filter((item) => item.status !== 'deleted')); + clientApi.listApplicationsPage({ page, pageSize }) + .then((result) => { + setApplications(result.items.filter((item) => item.status !== 'deleted')); + setTotal(result.total); setError(''); }) .catch((reason: Error) => setError(reason.message || '短信应用加载失败')) @@ -83,7 +86,7 @@ export function ClientApplicationsPage() { useEffect(() => { loadApplications(); - }, []); + }, [page]); function openParams(application: ClientSmsApplication) { if (application.interfaceEnabled === false) return; @@ -102,14 +105,9 @@ export function ClientApplicationsPage() { } const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(applications.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleApplications = applications.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [applications.length]); + const visibleApplications = applications; function copyParams() { if (selectedRows.length === 0) { @@ -128,7 +126,7 @@ export function ClientApplicationsPage() {

短信应用列表

- 共 {applications.length} 个应用 + 共 {total} 个应用 {loading ?

正在加载短信应用...

: null} @@ -180,7 +178,7 @@ export function ClientApplicationsPage() { totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} - total={applications.length} + total={total} /> ([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [total, setTotal] = useState(0); + const [applications, setApplications] = useState>([]); const [keyword, setKeyword] = useState(''); const [application, setApplication] = useState('all'); const [submittedDateRange, setSubmittedDateRange] = useState({}); @@ -106,11 +108,21 @@ export function ClientBatchTasksPage() { const [page, setPage] = useState(1); const [selectedTask, setSelectedTask] = useState(null); - function loadTasks() { + const pageSize = 10; + + function loadTasks(targetPage = page) { setLoading(true); - clientApi.listBatchTasks() - .then((items) => { - setTasks(items.map(mapTask)); + clientApi.listBatchTasksPage({ + keyword: keyword.trim() || undefined, + applicationKeyword: application === 'all' ? undefined : application, + createdAtFrom: submittedDateRange.start, + createdAtTo: submittedDateRange.end, + page: targetPage, + pageSize, + }) + .then((result) => { + setTasks(result.items.map(mapTask)); + setTotal(result.total); setError(''); }) .catch((reason: Error) => setError(reason.message || '批量任务加载失败')) @@ -118,39 +130,28 @@ export function ClientBatchTasksPage() { } useEffect(() => { - loadTasks(); - }, []); - - const applicationOptions = useMemo(() => { - const names = Array.from(new Set(tasks.map((item) => item.applicationName))); - return [ - { label: '全部应用', value: 'all' }, - ...names.map((name) => ({ label: name, value: name })), - ]; - }, [tasks]); - - const filteredTasks = tasks.filter((item) => { - const matchesKeyword = !keyword || item.id.includes(keyword); - const matchesApplication = application === 'all' || item.applicationName === application; - const submittedDate = item.submittedAt.slice(0, 10); - const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start; - const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end; - return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate; - }); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize)); - const currentPage = Math.min(page, totalPages); - const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize); + loadTasks(page); + }, [page]); useEffect(() => { - setPage(1); - }, [application, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]); + clientApi.listApplicationOptions() + .then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name })))) + .catch(() => undefined); + }, []); + + const applicationOptions = [ + { label: '全部应用', value: 'all' }, + ...applications.map((item) => ({ label: item.name, value: item.name })), + ]; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, totalPages); + const visibleTasks = tasks; function terminateTask(id: string) { const source = tasks.find((item) => item.id === id); if (!source) return; clientApi.cancelBatchTask(source.backendId) - .then(loadTasks) + .then(() => loadTasks(page)) .catch((reason: Error) => setError(reason.message || '发送批次终止失败')); } @@ -236,17 +237,24 @@ export function ClientBatchTasksPage() { 共找到 {filteredTasks.length} 个发送批次} + summary={<>共找到 {total} 个发送批次} > setKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + setPage(1); + loadTasks(1); + } + }} placeholder="输入发送批次号搜索" prefix={} value={keyword} />
) : null} - = totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={orders.length} /> + = totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={total} /> ); diff --git a/src/apps/client/ClientSendDetailPage.tsx b/src/apps/client/ClientSendDetailPage.tsx index e8ef3c0..99eb223 100644 --- a/src/apps/client/ClientSendDetailPage.tsx +++ b/src/apps/client/ClientSendDetailPage.tsx @@ -60,18 +60,26 @@ export function ClientSendDetailPage() { const [contentKeyword, setContentKeyword] = useState(''); const [phoneKeyword, setPhoneKeyword] = useState(''); const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [applications, setApplications] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - function loadData() { + function loadData(targetPage = page) { setLoading(true); clientApi.listMessages({ applicationId: applicationId === 'all' ? undefined : applicationId, phoneNumber: phoneKeyword || undefined, status: status === 'all' ? undefined : status, + contentKeyword: contentKeyword || undefined, + queuedAtFrom: dateRange.start || undefined, + queuedAtTo: dateRange.end || undefined, + page: targetPage, + pageSize: 10, }) - .then((items) => { - setRecords(items); + .then((result) => { + setRecords(result.items); + setTotal(result.total); setError(''); }) .catch((reason: Error) => setError(reason.message || '短信发送详情加载失败')) @@ -79,37 +87,31 @@ export function ClientSendDetailPage() { } useEffect(() => { - loadData(); - }, [applicationId, phoneKeyword, status]); + loadData(page); + }, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]); + + useEffect(() => { + clientApi.listApplicationOptions() + .then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name })))) + .catch((reason: Error) => setError(reason.message || '应用列表加载失败')); + }, []); const applicationOptions = useMemo(() => { - const applications = new Map(); - records.forEach((item) => { - if (item.applicationId) { - applications.set(item.applicationId, item.application?.name ?? item.applicationId); - } - }); return [ { label: '全部应用', value: 'all' }, - ...Array.from(applications.entries()).map(([value, label]) => ({ label, value })), + ...applications.map((item) => ({ label: item.name, value: item.id })), ]; - }, [records]); + }, [applications]); - const filteredRows = records.filter((item) => { - const sentDate = getDate(item.queuedAt); - const matchesStartDate = !dateRange.start || sentDate >= dateRange.start; - const matchesEndDate = !dateRange.end || sentDate <= dateRange.end; - const matchesContent = !contentKeyword || item.content.includes(contentKeyword); - return matchesStartDate && matchesEndDate && matchesContent; - }); + const filteredRows = records; const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const visibleRows = filteredRows; useEffect(() => { setPage(1); - }, [contentKeyword, dateRange.end, dateRange.start, records.length]); + }, [applicationId, contentKeyword, dateRange.end, dateRange.start, phoneKeyword, status]); return (
@@ -122,7 +124,7 @@ export function ClientSendDetailPage() { 共找到 {filteredRows.length} 条发送记录} + summary={<>共找到 {total} 条发送记录} > setKeyword(event.target.value)} placeholder="搜索签名、用途或应用" prefix={} value={keyword} /> - setStatusFilter(event.target.value)} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} /> + { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名、用途或应用" prefix={} value={keyword} /> + { setStatusFilter(event.target.value); setPage(1); }} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} /> @@ -377,7 +387,7 @@ export function ClientSignaturesPage() { })}
- = totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} total={filteredItems.length} totalPages={totalPages} /> + = totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} total={workspace.total} totalPages={totalPages} /> {signatureModal ? setSignatureModal(undefined)} onSaved={() => { setSignatureModal(undefined); loadData(); }} signature={signatureModal === 'new' ? undefined : signatureModal} /> : null} {drainageModal ? setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null} diff --git a/src/apps/client/ClientTemplatesPage.tsx b/src/apps/client/ClientTemplatesPage.tsx index d832418..0b0660d 100644 --- a/src/apps/client/ClientTemplatesPage.tsx +++ b/src/apps/client/ClientTemplatesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react'; import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi'; @@ -217,13 +217,16 @@ export function ClientTemplatesPage() { const [loading, setLoading] = useState(true); const [modalTemplate, setModalTemplate] = useState(null); const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 10; - function loadData() { + function loadData(targetPage = page) { setLoading(true); - Promise.all([clientApi.listApplications(), clientApi.listTemplates({ includeHistory: true }), clientApi.listSignatures()]) - .then(([applicationItems, templateItems, signatureItems]) => { + Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()]) + .then(([applicationItems, templateResult, signatureItems]) => { setApplications(applicationItems.filter((item) => item.status === 'active')); - setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled')); + setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled')); + setTotal(templateResult.total); setSignatures(signatureItems); setError(''); }) @@ -232,20 +235,14 @@ export function ClientTemplatesPage() { } useEffect(() => { - loadData(); - }, []); + const timer = window.setTimeout(() => loadData(page), 300); + return () => window.clearTimeout(timer); + }, [page, keyword]); - const filteredTemplates = useMemo(() => templates.filter((item) => ( - !keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword) - )), [keyword, templates]); - const pageSize = 10; - const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize)); + const filteredTemplates = templates; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, totalPages); - const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - useEffect(() => { - setPage(1); - }, [filteredTemplates.length, keyword]); + const visibleTemplates = templates; async function saveTemplate(state: TemplateFormState) { const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null; @@ -282,7 +279,7 @@ export function ClientTemplatesPage() {
setKeyword(event.target.value)} + onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索模板名称、应用、签名或内容" prefix={} value={keyword} @@ -327,7 +324,7 @@ export function ClientTemplatesPage() { totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} - total={filteredTemplates.length} + total={total} /> {!loading && !error && filteredTemplates.length === 0 ?

暂无短信模板。

: null} diff --git a/src/apps/client/ClientUplinkMessagesPage.tsx b/src/apps/client/ClientUplinkMessagesPage.tsx index 7986ce5..4f9c567 100644 --- a/src/apps/client/ClientUplinkMessagesPage.tsx +++ b/src/apps/client/ClientUplinkMessagesPage.tsx @@ -8,6 +8,7 @@ import { DetailSection, Input, Modal, + Pagination, QueryPanel, Table, type DateRangeValue, @@ -33,12 +34,16 @@ export function ClientUplinkMessagesPage() { const [matching, setMatching] = useState(false); const [error, setError] = useState(''); const [detailError, setDetailError] = useState(''); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const pageSize = 10; - function loadData() { + function loadData(targetPage = page) { setLoading(true); - clientApi.listUplinkMessages({ phoneNumber: phoneKeyword || undefined, keyword: contentKeyword || undefined, startTime: dateRange.start ? `${dateRange.start}T00:00:00+08:00` : undefined, endTime: dateRange.end ? `${dateRange.end}T23:59:59+08:00` : undefined }) - .then((items) => { - setMessages(items); + clientApi.listUplinkMessagesPage({ phoneNumber: phoneKeyword || undefined, keyword: contentKeyword || undefined, startTime: dateRange.start ? `${dateRange.start}T00:00:00+08:00` : undefined, endTime: dateRange.end ? `${dateRange.end}T23:59:59+08:00` : undefined, page: targetPage, pageSize }) + .then((result) => { + setMessages(result.items); + setTotal(result.total); setError(''); }) .catch((reason: Error) => setError(reason.message || '上行短信加载失败')) @@ -55,17 +60,22 @@ export function ClientUplinkMessagesPage() { } setMatching(true); - clientApi.listMessages({ messageId: message.messageId }) - .then((items) => setMatchedRecords(items)) + clientApi.listMessages({ messageId: message.messageId, page: 1, pageSize: 10 }) + .then((result) => setMatchedRecords(result.items)) .catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败')) .finally(() => setMatching(false)); } useEffect(() => { - const timer = window.setTimeout(loadData, 300); + setPage(1); + const timer = window.setTimeout(() => loadData(1), 300); return () => window.clearTimeout(timer); }, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]); + useEffect(() => { + if (page > 1) loadData(page); + }, [page]); + const columns = useMemo>>(() => [ { key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => {record.phoneNumber} }, { key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => {getTime(record.receivedAt)} }, @@ -92,7 +102,7 @@ export function ClientUplinkMessagesPage() {

查看上行短信

- 共找到 {messages.length} 条上行记录}> + 共找到 {total} 条上行记录}> setPhoneKeyword(event.target.value)} @@ -113,7 +123,8 @@ export function ClientUplinkMessagesPage() { {error ?

{error}

: null}
- +
+ setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} /> /etc/nginx/conf.d/cmpp-compression.conf <<'EOF' +gzip on; +gzip_vary on; +gzip_min_length 1024; +gzip_comp_level 5; +gzip_types application/json application/javascript text/css text/plain text/csv image/svg+xml; +EOF +nginx -t + echo "[deploy] Restarting services" systemctl daemon-reload if [[ "${OBJECT_STORAGE_DRIVER:-minio}" == "local" ]]; then