fix: paginate operational list pages
This commit is contained in:
@@ -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");
|
||||
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'] } },
|
||||
|
||||
@@ -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<typeof summary, 'total'>] = 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' });
|
||||
}
|
||||
|
||||
@@ -1800,3 +1800,12 @@
|
||||
- 查看签名明细时使用“通道行 × 运营商列”矩阵,每个有数据的单元格展示提交次数、成功率、平均到达时间及提交失败数量;所选日期无真实提交显示`—`,不据此推断通道不支持该运营商。
|
||||
- 平均到达时间只统计成功送达的提交尝试,从该通道提交受理时间开始,到该通道成功回执完成为止;长短信以全部成功分片完成时间为准。
|
||||
- 签名列表提供签名、企业或应用关键字查询和后端分页;查看详情不应依赖前端模拟数据或浏览器本地存储。
|
||||
# 列表查询性能与分页约束(2026-07-28)
|
||||
|
||||
- 除通道组外,运营端和客户端业务列表必须使用真实后端、数据库分页;浏览器不得先拉取完整结果再切片分页。
|
||||
- 本轮覆盖短信记录、短信任务进度、报备任务、报备记录、企业应用、企业签名及引流、企业模板、短信通道、上行短信、充值记录,以及对应客户端页面。
|
||||
- 列表筛选条件必须传入后端并参与总数统计;分页响应统一包含 `items`、`total`、`page`、`pageSize`。页面翻页只读取目标页,筛选和重置回到第一页。
|
||||
- 短信记录列表只返回当前页面渲染及详情所需字段;CSV 导出使用独立后端导出接口,不通过浏览器当前页或全量列表拼接。
|
||||
- 企业应用和签名等下拉框使用轻量选项接口,不得为了选择器加载连接、资料、报备任务等完整关联对象。
|
||||
- 分页排序字段应有数据库索引;Nginx 对 JSON、JavaScript、CSS、CSV、文本和 SVG 响应启用 gzip,降低传输与解析等待。
|
||||
- 通道组本轮按用户明确要求排除,保留现状,后续如数据规模扩大再单独改造。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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/`和空文件`=`继续视为构建/临时产物,不纳入提交。
|
||||
|
||||
+52
-6
@@ -574,6 +574,9 @@ export type ClientSmsSignatureView = Pick<ClientSmsSignature,
|
||||
export type ClientSignatureWorkspace = {
|
||||
items: ClientSmsSignatureView[];
|
||||
summary: { total: number; pending: number; approved: number; rejected: number; draft: number };
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type SmsDrainageInfo = {
|
||||
@@ -1702,12 +1705,18 @@ export const adminApi = {
|
||||
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||
listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)),
|
||||
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
|
||||
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
|
||||
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
listEnterpriseApplicationsPage: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<EnterpriseApplication>>(withQuery('/admin/enterprise-applications', query)),
|
||||
listEnterpriseApplicationOptions: (query: { tenantId?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-application-options', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/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<AdminChannel[]>('/admin/channels'),
|
||||
listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<AdminChannel>>(withQuery('/admin/channels', query)),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
|
||||
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
|
||||
@@ -1800,6 +1811,10 @@ export const adminApi = {
|
||||
request<ReviewDecisionResult>(`/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<ClientSmsSignature[]>(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<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
|
||||
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
@@ -1822,6 +1837,8 @@ export const adminApi = {
|
||||
request<SmsDrainageInfo>(`/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<ClientSmsTemplate[]>(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<PagedResult<ClientSmsTemplate>>(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<ClientSmsTemplate>('/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<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(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<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
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<string, unknown> }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(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<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)),
|
||||
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
||||
request<SmsBatchTask[]>(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<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
|
||||
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
|
||||
terminateAdminBatchTask: (id: string) =>
|
||||
@@ -1909,10 +1932,14 @@ export const adminApi = {
|
||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
||||
request<SmsMessageSegmentAudit[]>(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<SmsMessageRecord[]>(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<PagedResult<SmsMessageRecord>>(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<SmsUplinkMessage[]>(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<PagedResult<SmsUplinkMessage>>(withQuery('/admin/operations/uplink-messages', query)),
|
||||
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
|
||||
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
|
||||
@@ -2058,8 +2085,14 @@ export const clientApi = {
|
||||
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }),
|
||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }),
|
||||
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsApplication[]>('/client/application-options', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
|
||||
@@ -2077,8 +2110,10 @@ export const clientApi = {
|
||||
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
|
||||
getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSignatureWorkspace>('/client/signatures-workspace', { tenantId }),
|
||||
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }),
|
||||
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, 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<PagedResult<ClientSmsTemplate>>(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<ClientSmsTemplate>('/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<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask[]>(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<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
||||
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask>(`/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -2125,10 +2169,12 @@ export const clientApi = {
|
||||
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsMessageRecord[]>(`/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<SmsMessageRecord[]>(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<PagedResult<SmsMessageRecord>>(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<SmsUplinkMessage[]>(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<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
|
||||
assertUploadFileSize(file);
|
||||
const form = new FormData();
|
||||
|
||||
@@ -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<ChannelLogState | null>(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() {
|
||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => void loadChannels()}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadChannels(1); }}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); setPage(1); void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,7 +670,7 @@ export function AdminChannelsPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredChannels.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<SmsApp[]>([]);
|
||||
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<Array<TableColumn<SmsApp>>>(() => [
|
||||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
@@ -524,8 +523,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); void loadSmsApps(filters); }}>查询</Button>
|
||||
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); void loadSmsApps(filters); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); setPage(1); void loadSmsApps(filters, 1); }}>查询</Button>
|
||||
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); setPage(1); void loadSmsApps(filters, 1); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,7 +533,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
|
||||
{ label: '短信应用', value: 'sms', content: <><Table columns={smsColumns} data={filteredSmsApps} pagination={false} rowKey="id" /><Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => 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: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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<ClientSmsSignature | null>(null);
|
||||
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
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 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
@@ -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);
|
||||
}}>查询</Button>
|
||||
<Button onClick={() => {
|
||||
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
|
||||
@@ -835,7 +827,8 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
setAppliedApplicationKeyword('');
|
||||
setAppliedSignatureKeyword('');
|
||||
setAppliedDrainageKeyword('');
|
||||
void loadData(filters);
|
||||
setPage(1);
|
||||
void loadData(filters, 1);
|
||||
}} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<ClientSmsTemplate | 'new' | null>(null);
|
||||
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
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<TenantOption[]>([]);
|
||||
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);
|
||||
}}>查询</Button>
|
||||
<Button onClick={() => {
|
||||
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
|
||||
@@ -399,7 +392,8 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
setAppliedApplicationKeyword('');
|
||||
setAppliedTemplateNameKeyword('');
|
||||
setAppliedTemplateContentKeyword('');
|
||||
void loadData(filters);
|
||||
setPage(1);
|
||||
void loadData(filters, 1);
|
||||
}} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} value={enterpriseKeyword} />
|
||||
<DateRangeInput label="充值日期" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-recharge-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadData(1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +152,7 @@ export function AdminRechargeRecordsPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredRows.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'neutral',
|
||||
@@ -71,28 +71,30 @@ export function AdminReportRecordsPage() {
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [detail, setDetail] = useState<ReportRecord | null>(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<TableColumn<ReportRecord>> = [
|
||||
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
|
||||
@@ -122,14 +124,24 @@ export function AdminReportRecordsPage() {
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<Table columns={columns} data={filteredRecords} emptyText="暂无报备记录" rowKey="id" />
|
||||
<Table columns={columns} data={records} emptyText="暂无报备记录" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={total}
|
||||
totalPages={Math.max(1, Math.ceil(total / pageSize))}
|
||||
/>
|
||||
|
||||
{detail ? <RecordDetailModal onClose={() => setDetail(null)} record={detail} /> : null}
|
||||
</section>
|
||||
|
||||
@@ -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<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||
@@ -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() {
|
||||
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}>查询</Button><Button onClick={() => {
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}>查询</Button><Button onClick={() => {
|
||||
setKeyword('');
|
||||
setDateRange({});
|
||||
setReportType('all');
|
||||
}} variant="ghost">重置</Button></div>
|
||||
</div>
|
||||
<div className="surface report-task-table-card"><Table columns={columns} data={filteredTasks} emptyText="暂无报备明细" rowKey="id" /></div>
|
||||
<div className="surface report-task-table-card"><Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" /></div>
|
||||
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
|
||||
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
|
||||
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost">取消</Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
|
||||
{statusTask ? <div className="page-stack">
|
||||
|
||||
@@ -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<Array<{ id: string; name: string }>>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<Array<{ id: string; tenantId: string; name: string }>>([]);
|
||||
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => loadData()}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(currentFilters(), 1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} variant="ghost">导出CSV</Button>
|
||||
<Button icon={<Download size={16} />} onClick={() => void exportRecords()} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="admin-sms-record-list">
|
||||
{filteredRows.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : visibleRows.map((record) => (
|
||||
{loading ? <div className="ui-table__empty">正在加载真实短信记录...</div> : filteredRows.length === 0 ? <div className="ui-table__empty">暂无短信记录</div> : visibleRows.map((record) => (
|
||||
<article className="admin-sms-record-card" key={record.id}>
|
||||
<header>
|
||||
<div className="admin-sms-record-sender">
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -382,12 +382,25 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filterTenants, setFilterTenants] = useState<string[]>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<Array<{ tenantName: string; name: string }>>([]);
|
||||
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() {
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadTasks}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadTasks(1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,7 +600,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredTasks.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
@@ -210,12 +211,23 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [claimError, setClaimError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
function loadData() {
|
||||
function loadData(targetPage = page, filters = { phoneKeyword, contentKeyword, dateRange }) {
|
||||
setLoading(true);
|
||||
adminApi.listAdminUplinkMessages()
|
||||
.then((items) => {
|
||||
setMessages(items);
|
||||
adminApi.listAdminUplinkMessagesPage({
|
||||
phoneNumber: filters.phoneKeyword.trim() || undefined,
|
||||
keyword: filters.contentKeyword.trim() || undefined,
|
||||
startTime: filters.dateRange.start ? `${filters.dateRange.start}T00:00:00+08:00` : undefined,
|
||||
endTime: filters.dateRange.end ? `${filters.dateRange.end}T23:59:59.999+08:00` : undefined,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
})
|
||||
.then((result) => {
|
||||
setMessages(result.items);
|
||||
setTotal(result.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
|
||||
@@ -233,32 +245,22 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
}
|
||||
|
||||
setMatching(true);
|
||||
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId })
|
||||
.then((items) => setMatchedRecords(items))
|
||||
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId, page: 1, pageSize: 10 })
|
||||
.then((result) => setMatchedRecords(result.items))
|
||||
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
|
||||
.finally(() => setMatching(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() => messages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
|
||||
}),
|
||||
[contentKeyword, dateRange.end, dateRange.start, messages, phoneKeyword],
|
||||
);
|
||||
loadData(page);
|
||||
}, [page]);
|
||||
|
||||
function resetFilters() {
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
setPage(1);
|
||||
loadData(1, { phoneKeyword: '', contentKeyword: '', dateRange: {} });
|
||||
}
|
||||
|
||||
function handleClaim(candidate: SmsUplinkMatchCandidate) {
|
||||
@@ -271,7 +273,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
.then((updated) => {
|
||||
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
|
||||
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
|
||||
loadData();
|
||||
loadData(page);
|
||||
})
|
||||
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
|
||||
.finally(() => setClaimingId(''));
|
||||
@@ -316,7 +318,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<div className="admin-uplink-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadData(1); }}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,7 +326,8 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-uplink-table-card">
|
||||
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
|
||||
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} pagination={false} rowKey="id" />
|
||||
<Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => 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))} />
|
||||
</div>
|
||||
|
||||
{selectedMessage ? (
|
||||
|
||||
@@ -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() {
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<h1>短信应用列表</h1>
|
||||
<span className="muted">共 {applications.length} 个应用</span>
|
||||
<span className="muted">共 {total} 个应用</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="muted">正在加载短信应用...</p> : null}
|
||||
@@ -180,7 +178,7 @@ export function ClientApplicationsPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={applications.length}
|
||||
total={total}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -99,6 +99,8 @@ export function ClientBatchTasksPage() {
|
||||
const [tasks, setTasks] = useState<BatchTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
@@ -106,11 +108,21 @@ export function ClientBatchTasksPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(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() {
|
||||
|
||||
<QueryPanel
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredTasks.length}</strong> 个发送批次</>}
|
||||
summary={<>共找到 <strong>{total}</strong> 个发送批次</>}
|
||||
>
|
||||
<Input
|
||||
label="发送批次号"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
setPage(1);
|
||||
loadTasks(1);
|
||||
}
|
||||
}}
|
||||
placeholder="输入发送批次号搜索"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<Button onClick={() => { setPage(1); loadTasks(1); }} variant="primary">查询</Button>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface batch-table-card">
|
||||
@@ -301,7 +309,7 @@ export function ClientBatchTasksPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredTasks.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,23 +12,25 @@ export function ClientBillingPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(orders.length / pageSize));
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleOrders = orders.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const visibleOrders = orders;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.getDashboard(), clientApi.listOrders()])
|
||||
.then(([dashboard, nextOrders]) => {
|
||||
Promise.all([clientApi.getDashboard(), clientApi.listOrdersPage({ page, pageSize })])
|
||||
.then(([dashboard, result]) => {
|
||||
setBalanceCents(dashboard.accounts[0]?.balanceCents ?? 0);
|
||||
setCreditCents(dashboard.accounts[0]?.creditCents ?? 0);
|
||||
setOrders(nextOrders);
|
||||
setOrders(result.items);
|
||||
setTotal(result.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '账户信息加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -46,7 +48,7 @@ export function ClientBillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading"><div><h2>充值记录</h2><p className="muted">如需充值,请联系平台运营人员。</p></div><Tag tone="info">{orders.length} 条</Tag></div>
|
||||
<div className="section-heading"><div><h2>充值记录</h2><p className="muted">如需充值,请联系平台运营人员。</p></div><Tag tone="info">{total} 条</Tag></div>
|
||||
{loading ? <p className="muted">正在加载账户信息...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{!loading && !error ? (
|
||||
@@ -67,7 +69,7 @@ export function ClientBillingPage() {
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
<Pagination nextDisabled={currentPage >= 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} />
|
||||
<Pagination nextDisabled={currentPage >= 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} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<Array<{ id: string; name: string }>>([]);
|
||||
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<string, string>();
|
||||
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 (
|
||||
<section className="page-stack">
|
||||
@@ -122,7 +124,7 @@ export function ClientSendDetailPage() {
|
||||
|
||||
<QueryPanel
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}
|
||||
summary={<>共找到 <strong>{total}</strong> 条发送记录</>}
|
||||
>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
|
||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||
@@ -232,7 +234,7 @@ export function ClientSendDetailPage() {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredRows.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import {
|
||||
@@ -14,6 +14,9 @@ import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCo
|
||||
const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
|
||||
items: [],
|
||||
summary: { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 },
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
@@ -269,9 +272,20 @@ export function ClientSignaturesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
const pageSize = 10;
|
||||
|
||||
function loadData(targetPage = page) {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.getSignatureWorkspace()])
|
||||
Promise.all([
|
||||
clientApi.listApplicationOptions(),
|
||||
clientApi.getSignatureWorkspace({
|
||||
keyword: keyword.trim() || undefined,
|
||||
applicationId: applicationFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
}),
|
||||
])
|
||||
.then(([applicationItems, signatureWorkspace]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setWorkspace(signatureWorkspace);
|
||||
@@ -281,19 +295,15 @@ export function ClientSignaturesPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(loadData, []);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => loadData(page), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [applicationFilter, keyword, page, statusFilter]);
|
||||
|
||||
const filteredItems = useMemo(() => workspace.items.filter((item) => {
|
||||
const matchesKeyword = !keyword.trim() || [item.name, item.purpose, item.application?.name].join(' ').toLowerCase().includes(keyword.trim().toLowerCase());
|
||||
return matchesKeyword && (!applicationFilter || item.applicationId === applicationFilter) && (!statusFilter || item.auditStatus === statusFilter);
|
||||
}), [applicationFilter, keyword, statusFilter, workspace.items]);
|
||||
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize));
|
||||
const filteredItems = workspace.items;
|
||||
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => setPage(1), [applicationFilter, keyword, statusFilter]);
|
||||
const visibleItems = workspace.items;
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((current) => {
|
||||
@@ -314,7 +324,7 @@ export function ClientSignaturesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); };
|
||||
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); setPage(1); };
|
||||
return <section className="page-stack client-signature-page">
|
||||
<header className="client-signature-heading">
|
||||
<div className="client-signature-title">
|
||||
@@ -332,9 +342,9 @@ export function ClientSignaturesPage() {
|
||||
</section>
|
||||
|
||||
<div className="client-signature-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名、用途或应用" prefix={<Search size={17} />} value={keyword} />
|
||||
<Select onChange={(event) => setApplicationFilter(event.target.value)} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
|
||||
<Select onChange={(event) => setStatusFilter(event.target.value)} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
|
||||
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名、用途或应用" prefix={<Search size={17} />} value={keyword} />
|
||||
<Select onChange={(event) => { setApplicationFilter(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
|
||||
<Select onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
|
||||
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
@@ -377,7 +387,7 @@ export function ClientSignaturesPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<Pagination nextDisabled={currentPage >= 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} />
|
||||
<Pagination nextDisabled={currentPage >= 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 ? <SignatureModal applications={applications} onClose={() => setSignatureModal(undefined)} onSaved={() => { setSignatureModal(undefined); loadData(); }} signature={signatureModal === 'new' ? undefined : signatureModal} /> : null}
|
||||
{drainageModal ? <DrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
|
||||
|
||||
@@ -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<ClientSmsTemplate | 'new' | null>(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() {
|
||||
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索模板名称、应用、签名或内容"
|
||||
prefix={<Search size={17} />}
|
||||
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 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
|
||||
@@ -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<Array<TableColumn<SmsUplinkMessage>>>(() => [
|
||||
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{getTime(record.receivedAt)}</span> },
|
||||
@@ -92,7 +102,7 @@ export function ClientUplinkMessagesPage() {
|
||||
<h1>查看上行短信</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{messages.length}</strong> 条上行记录</>}>
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{total}</strong> 条上行记录</>}>
|
||||
<Input
|
||||
label="手机号码"
|
||||
onChange={(event) => setPhoneKeyword(event.target.value)}
|
||||
@@ -113,7 +123,8 @@ export function ClientUplinkMessagesPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface uplink-table-card">
|
||||
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
|
||||
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} pagination={false} rowKey="id" />
|
||||
<Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => 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))} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -277,6 +277,11 @@ server {
|
||||
root ${APP_DIR}/dist;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
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;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:${API_PORT}/api/;
|
||||
|
||||
@@ -64,6 +64,16 @@ chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
|
||||
echo "[deploy] Ensuring runtime log directories"
|
||||
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/gateway"
|
||||
|
||||
echo "[deploy] Ensuring HTTP response compression"
|
||||
cat >/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
|
||||
|
||||
Reference in New Issue
Block a user