fix: polish channel groups and add production deployment

This commit is contained in:
hectorzhao
2026-07-07 11:16:08 +08:00
parent b5132d7f4e
commit 72f2c010ce
44 changed files with 1265 additions and 489 deletions
+1
View File
@@ -11,6 +11,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
logs/ logs/
dump.rdb
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -0,0 +1,4 @@
ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryTimeLimitMinutes" INTEGER NOT NULL DEFAULT 720;
UPDATE "SmsChannelGroup"
SET "retryTimeLimitMinutes" = LEAST(GREATEST("retryTimeLimitHours", 1), 72) * 60;
+1
View File
@@ -536,6 +536,7 @@ model SmsChannelGroup {
status String @default("active") status String @default("active")
retryEnabled Boolean @default(true) retryEnabled Boolean @default(true)
retryTimeLimitHours Int @default(72) retryTimeLimitHours Int @default(72)
retryTimeLimitMinutes Int @default(720)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
-16
View File
@@ -6,7 +6,6 @@ import {
BillingActionDto, BillingActionDto,
CreateManualRechargeDto, CreateManualRechargeDto,
CreateRechargeOrderDto, CreateRechargeOrderDto,
CreateAccountTransactionDto,
CreateBillingPlanDto, CreateBillingPlanDto,
CreateBillingRuleDto, CreateBillingRuleDto,
CreateSmsBillingRecordDto, CreateSmsBillingRecordDto,
@@ -39,16 +38,6 @@ export class BillingController {
return this.billing.createAccount(body); return this.billing.createAccount(body);
} }
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('transactions')
createTransaction(@Body() body: CreateAccountTransactionDto) {
return this.billing.createTransaction(body);
}
@Get('recharges') @Get('recharges')
listRechargeOrders(@TenantId() tenantId?: string) { listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId); return this.billing.listRechargeOrders(tenantId);
@@ -135,11 +124,6 @@ export class ClientBillingController {
return this.billing.listPlans(); return this.billing.listPlans();
} }
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('orders') @Post('orders')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) { createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body); return this.billing.createRechargeOrder(body);
-22
View File
@@ -124,28 +124,6 @@ export class BillingService {
return this.prisma.tenantAccount.create({ data: createData }); return this.prisma.tenantAccount.create({ data: createData });
} }
listTransactions(tenantId?: string) {
return this.prisma.accountTransaction.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createTransaction(data: CreateAccountTransactionDto) {
const createData: Prisma.AccountTransactionUncheckedCreateInput = {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: data.balanceAfter ?? 0,
relatedType: data.relatedType,
relatedId: data.relatedId,
remark: data.remark,
};
return this.prisma.accountTransaction.create({ data: createData });
}
listRechargeOrders(tenantId?: string) { listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({ return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined, where: tenantId ? { tenantId } : undefined,
+18 -4
View File
@@ -67,7 +67,7 @@ function createPrismaMock() {
channelHealthMetric: { findMany: jest.fn() }, channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: { smsChannelGroup: {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }), findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }), delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
}, },
@@ -156,7 +156,7 @@ describe('ChannelsService', () => {
passwordCipher: 'secret', passwordCipher: 'secret',
srcId: '10690000', srcId: '10690000',
}); });
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }); await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
expect(prisma.smsChannel.create).toHaveBeenCalledWith({ expect(prisma.smsChannel.create).toHaveBeenCalledWith({
@@ -188,7 +188,7 @@ describe('ChannelsService', () => {
body: expect.stringContaining('"messageType":"ConnectChannel"'), body: expect.stringContaining('"messageType":"ConnectChannel"'),
})); }));
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }), data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
}); });
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
@@ -331,7 +331,7 @@ describe('ChannelsService', () => {
name: '移动组更新', name: '移动组更新',
carrier: 'mobile', carrier: 'mobile',
retryEnabled: true, retryEnabled: true,
retryTimeLimitHours: 24, retryTimeLimitMinutes: 750,
items: [ items: [
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 }, { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 }, { channelId: 'channel-national', carrier: 'mobile', priority: 1 },
@@ -340,6 +340,20 @@ describe('ChannelsService', () => {
expect(prisma.$transaction).toHaveBeenCalled(); expect(prisma.$transaction).toHaveBeenCalled();
const transactionCallback = prisma.$transaction.mock.calls[0][0];
const tx = {
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
smsChannelGroup: {
update: jest.fn(),
findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }),
},
};
await transactionCallback(tx);
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
where: { id: 'group-1' },
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
});
await expect(service.updateGroup('group-1', { await expect(service.updateGroup('group-1', {
carrier: 'mobile', carrier: 'mobile',
items: [ items: [
+26 -12
View File
@@ -33,6 +33,7 @@ export interface CreateChannelGroupDto {
status?: string; status?: string;
retryEnabled?: boolean; retryEnabled?: boolean;
retryTimeLimitHours?: number; retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
} }
export interface CreateChannelGroupItemDto { export interface CreateChannelGroupItemDto {
@@ -54,6 +55,7 @@ export interface UpdateChannelGroupDto {
status?: string; status?: string;
retryEnabled?: boolean; retryEnabled?: boolean;
retryTimeLimitHours?: number; retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>; items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
} }
@@ -175,7 +177,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
} }
listChannels() { listChannels() {
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }); return this.prisma.smsChannel.findMany({
include: { connectionStates: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
} }
async createChannel(data: CreateChannelDto) { async createChannel(data: CreateChannelDto) {
@@ -574,17 +580,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
listGroups() { listGroups() {
return this.prisma.smsChannelGroup.findMany({ return this.prisma.smsChannelGroup.findMany({
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100, take: 100,
}); });
} }
createGroup(data: CreateChannelGroupDto) { createGroup(data: CreateChannelGroupDto) {
const retryTimeLimitHours = data.retryTimeLimitHours ?? 72; const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
}
const carrier = normalizeBusinessCarrier(data.carrier); const carrier = normalizeBusinessCarrier(data.carrier);
return this.prisma.smsChannelGroup.create({ return this.prisma.smsChannelGroup.create({
data: { data: {
@@ -594,7 +597,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
description: data.description, description: data.description,
status: data.status ?? 'active', status: data.status ?? 'active',
retryEnabled: data.retryEnabled ?? true, retryEnabled: data.retryEnabled ?? true,
retryTimeLimitHours, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
}, },
}); });
} }
@@ -659,10 +663,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
if (!current) { if (!current) {
throw new NotFoundException('Channel group not found'); throw new NotFoundException('Channel group not found');
} }
const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours; const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { data.retryTimeLimitMinutes,
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); data.retryTimeLimitHours,
} current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
);
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
const items = data.items ?? []; const items = data.items ?? [];
const channelIds = [...new Set(items.map((item) => item.channelId))]; const channelIds = [...new Set(items.map((item) => item.channelId))];
@@ -681,7 +686,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
description: data.description, description: data.description,
status: data.status ?? current.status, status: data.status ?? current.status,
retryEnabled: data.retryEnabled ?? current.retryEnabled, retryEnabled: data.retryEnabled ?? current.retryEnabled,
retryTimeLimitHours, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
}, },
}); });
if (items.length > 0) { if (items.length > 0) {
@@ -1220,6 +1226,14 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
return 'completed'; return 'completed';
} }
function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
}
return value;
}
function normalizeBusinessCarrier(carrier?: string | null) { function normalizeBusinessCarrier(carrier?: string | null) {
const normalized = normalizeChannelCarrier(carrier); const normalized = normalizeChannelCarrier(carrier);
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
+50 -1
View File
@@ -49,6 +49,7 @@ function createPrismaMock() {
status: 'active', status: 'active',
retryEnabled: true, retryEnabled: true,
retryTimeLimitHours: 72, retryTimeLimitHours: 72,
retryTimeLimitMinutes: 4320,
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }], items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
}, },
}; };
@@ -379,7 +380,7 @@ describe('SendChainService', () => {
applicationId: 'app-1', applicationId: 'app-1',
groupId: 'group-1', groupId: 'group-1',
carrier: 'mobile', carrier: 'mobile',
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] }, group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] },
}); });
await service.handleSubmitResult({ await service.handleSubmitResult({
@@ -400,6 +401,53 @@ describe('SendChainService', () => {
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
}); });
it('stops failed receipt retry after the configured minute limit', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
prisma.smsMessageRecord.findFirst.mockResolvedValue({
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
templateId: 'tpl-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
content: 'hello',
channelId: 'channel-1',
gatewayMessageId: 'GW-1',
status: 'submitted',
amountCents: 3,
billingUnits: 1,
unitPrice: 3,
queuedAt: new Date(Date.now() - 90 * 60_000),
});
prisma.channelRouteRule.findFirst.mockResolvedValue({
id: 'route-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
carrier: 'mobile',
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] },
});
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
gatewayMessageId: 'GW-1',
receiptStatus: 'undelivered',
rawStatus: 'UNDELIV',
});
expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled();
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
where: { id: 'record-1' },
data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }),
});
});
it('does not let stale failed receipts overwrite a later delivered message', async () => { it('does not let stale failed receipts overwrite a later delivered message', async () => {
const { service, prisma, billing } = createService(); const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findFirst.mockResolvedValue({ prisma.smsMessageRecord.findFirst.mockResolvedValue({
@@ -467,6 +515,7 @@ describe('SendChainService', () => {
status: 'active', status: 'active',
retryEnabled: true, retryEnabled: true,
retryTimeLimitHours: 72, retryTimeLimitHours: 72,
retryTimeLimitMinutes: 4320,
items: [ items: [
{ {
id: 'wrong-item', id: 'wrong-item',
+4 -3
View File
@@ -751,12 +751,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
take: 200, take: 200,
}); });
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId); const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000; const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
if (ageHours >= 72) { if (ageMinutes >= 72 * 60) {
return null; return null;
} }
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber)); const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) { const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
return null; return null;
} }
try { try {
@@ -4,7 +4,7 @@
本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。 本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。
当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、账单流水进入第一版开发范围。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、充值记录进入第一版开发范围,账单流水页面和公开交易查询 API 暂不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
## 1. 项目目标 ## 1. 项目目标
@@ -17,7 +17,7 @@
- 支持通道级限速、失败重试、回执同步和发送记录追踪。 - 支持通道级限速、失败重试、回执同步和发送记录追踪。
- 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。 - 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。
- 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。 - 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。
- 账户计费、充值套餐、账单流水进入第一版范围,并与发送记录形成可对账闭环。 - 账户计费、充值套餐、充值记录进入第一版范围,并与发送记录形成可对账闭环;账单流水页面暂不验收
## 2. 角色与权限 ## 2. 角色与权限
@@ -154,7 +154,7 @@
19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整。 19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整。
20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。 20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。
21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。
22. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 22. 通道组补发时间上限由运营端配置,交互为“小时 + 分钟”,默认 12 小时 0 分钟,最小 1 分钟,最大不得超过 72 小时;真实发送链路按分钟级上限判断是否继续补发。本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。
23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。 23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。
### 4.7 通道签名报备 ### 4.7 通道签名报备
+92
View File
@@ -0,0 +1,92 @@
# CMPP 平台生产部署手册
## 本次生产端口
- 运营端、客户端页面:`http://8.160.169.106:12026`
- API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。
- Redis:仅本机 `127.0.0.1:6379`
- PostgreSQL:仅本机 `127.0.0.1:5432`
- MinIO:仅本机 `127.0.0.1:9000/9001`,页面上传下载通过 API 转发。
- Gateway 控制服务:仅本机 `127.0.0.1:8090`
- CMPP 入站端口:`17890` 已作为部署变量保留;当前 Go Gateway 代码尚未实现完整入站 CMPP Server,不要把 HTTP 控制服务误当作 CMPP 监听。
## 首次部署
1. 本机提交并推送 `main`
2. 使用 root 登录服务器,或先放置免密 SSH key。
3. 在服务器执行:
```bash
curl -fsSL http://175.27.255.91:3000/hectorzhao/lislgosms/raw/branch/main/tools/deploy/production-bootstrap.sh -o /root/production-bootstrap.sh
bash /root/production-bootstrap.sh
```
可覆盖的环境变量:
```bash
APP_DIR=/opt/cmpp-platform
REPO_URL=http://175.27.255.91:3000/hectorzhao/lislgosms.git
BRANCH=main
PUBLIC_HTTP_PORT=12026
API_PORT=3000
GATEWAY_CMPP_ADDR=0.0.0.0:17890
PROD_ADMIN_EMAIL=admin@example.com
PROD_ADMIN_USERNAME=prod_admin
PROD_ADMIN_PASSWORD='change-me'
```
脚本会安装 Node.js、Go、PostgreSQL、Redis、MinIO、Nginx,创建 systemd 服务,执行 Prisma migrate,构建前端/API/Gateway,并创建平台管理员。
## 后续发布
```bash
cd /opt/cmpp-platform
git fetch origin main
git reset --hard origin/main
bash tools/deploy/production-deploy.sh
```
## 账号和密钥
- 生产管理员账号写入 `/root/cmpp-platform-admin.txt`
- 首次部署汇总凭据写入 `/root/cmpp-platform-credentials.txt`
- 这两个文件权限为 `600`,不要提交到 Git。
- root 密码后续可修改;SSH 私钥放入 `/root/.ssh/authorized_keys` 后即可免密登录。
## 日志
```bash
journalctl -u cmpp-api -f
journalctl -u cmpp-gateway -f
journalctl -u cmpp-minio -f
tail -f /opt/cmpp-platform/logs/api/stderr.log
tail -f /opt/cmpp-platform/logs/gateway/stderr.log
```
## 健康检查
```bash
curl http://127.0.0.1:3000/api/health
curl http://127.0.0.1:8090/health
curl http://127.0.0.1:12026/
redis-cli -h 127.0.0.1 -p 6379 ping
pg_isready -d "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)"
```
## 回滚
1. 数据库备份:
```bash
pg_dump "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)" > /opt/cmpp-platform/backups/cmpp-$(date +%F-%H%M%S).sql
```
2. 回滚代码:
```bash
cd /opt/cmpp-platform
git reset --hard <上一版提交>
bash tools/deploy/production-deploy.sh
```
3. 如迁移造成不可兼容故障,先停服务,再恢复数据库备份。
+10 -7
View File
@@ -42,7 +42,7 @@
| CMPP 连接闭环 | 通道连接状态、登录认证、active test、断线重连、连接异常对路由/发送影响、状态监控和日志告警。 | | CMPP 连接闭环 | 通道连接状态、登录认证、active test、断线重连、连接异常对路由/发送影响、状态监控和日志告警。 |
| Dashboard 闭环 | 客户端/运营端指标口径、数据范围、时间筛选、状态统计、账务统计、通道连接指标和明细跳转一致。 | | Dashboard 闭环 | 客户端/运营端指标口径、数据范围、时间筛选、状态统计、账务统计、通道连接指标和明细跳转一致。 |
| 内容校验闭环 | 非法字符展示、敏感词、控制字符、内容清洗或拒绝、计费不被非法字符干扰、错误原因可见。 | | 内容校验闭环 | 非法字符展示、敏感词、控制字符、内容清洗或拒绝、计费不被非法字符干扰、错误原因可见。 |
| 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、账单流水、对账。 | | 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、充值记录、对账。账单流水页面和公开交易查询 API 暂不纳入第一版验收。 |
| 系统日志闭环 | 登录、创建、修改、删除、审核、导入导出、密钥重置、发送、报备、账务动作均可查询和定位操作者。 | | 系统日志闭环 | 登录、创建、修改、删除、审核、导入导出、密钥重置、发送、报备、账务动作均可查询和定位操作者。 |
## 4. 客户端功能用例 ## 4. 客户端功能用例
@@ -50,10 +50,10 @@
### TC-CLIENT-001 登录与租户隔离 ### TC-CLIENT-001 登录与租户隔离
- 优先级:P0 - 优先级:P0
- 前置条件:存在 `tenant-a``tenant-b`,两个租户各有发送任务和账单流水 - 前置条件:存在 `tenant-a``tenant-b`,两个租户各有发送任务和充值/计费记录
- 步骤: - 步骤:
1. 使用 `tenant-a` 企业管理员登录客户端。 1. 使用 `tenant-a` 企业管理员登录客户端。
2. 打开工作台、批量任务、发送详情、上行短信、账单流水 2. 打开工作台、批量任务、发送详情、上行短信、充值套餐
3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。 3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。
- 预期结果: - 预期结果:
- 登录成功,返回当前租户上下文。 - 登录成功,返回当前租户上下文。
@@ -257,14 +257,17 @@
5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。 5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。
6. 在同一通道组中为山东省重复添加第二个省网通道。 6. 在同一通道组中为山东省重复添加第二个省网通道。
7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。 7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。
8. 创建租户/应用维度路由规则 8. 配置补发时间上限为 12 小时 30 分钟并保存,再重新打开编辑页
9. 创建发送任务触发送链路 9. 创建租户/应用维度路由规则
10. 创建发送任务触发送链路。
- 预期结果: - 预期结果:
- 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。 - 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。
- 通道组明细 carrier 必须等于通道组运营商。 - 通道组明细 carrier 必须等于通道组运营商。
- 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。 - 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。
- 省网 item 的省份必须与通道发送地区一致。 - 省网 item 的省份必须与通道发送地区一致。
- 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。 - 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。
- 省网通道和全国通道在添加/编辑页以表格展示,通道状态文案为“链接正常/通道停用”,且“链接正常”来自真实 CMPP 连接状态。
- 补发时间上限按分钟级真实保存,12 小时 30 分钟回填为 12 小时 30 分钟。
- 路由优先命中租户/应用规则。 - 路由优先命中租户/应用规则。
- 主通道 active 时选择主通道。 - 主通道 active 时选择主通道。
- 主通道 disabled 时选择备用 active 通道。 - 主通道 disabled 时选择备用 active 通道。
@@ -809,13 +812,13 @@
1. 模拟普通 failed 回执,确认触发补发。 1. 模拟普通 failed 回执,确认触发补发。
2. 模拟 unknown 状态,执行 72 小时超时补偿。 2. 模拟 unknown 状态,执行 72 小时超时补偿。
3. 将消息提交时间调整为超过 72 小时。 3. 将消息提交时间调整为超过 72 小时。
4. 将消息提交时间调整为超过通道组补发时间上限。 4. 将消息提交时间调整为超过通道组分钟级补发时间上限。
5. 关闭通道组失败补发后再次模拟 failed。 5. 关闭通道组失败补发后再次模拟 failed。
- 预期结果: - 预期结果:
- 普通 failed 在限制内触发补发。 - 普通 failed 在限制内触发补发。
- unknown 不触发补发。 - unknown 不触发补发。
- 超过 72 小时不触发补发。 - 超过 72 小时不触发补发。
- 超过通道组补发时间上限不触发补发。 - 超过通道组分钟级补发时间上限不触发补发。
- 通道组关闭失败补发时不触发补发。 - 通道组关闭失败补发时不触发补发。
### TC-SEND-019 迟到旧通道回执不覆盖最终成功 ### TC-SEND-019 迟到旧通道回执不覆盖最终成功
+73
View File
@@ -704,3 +704,76 @@ git diff --check
- 前端 build 通过,仍存在既有 Vite chunk size warning。 - 前端 build 通过,仍存在既有 Vite chunk size warning。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 - `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
## 2026-07-07 手机号段 Tab 和通道组补发上限
### 本轮修复
- 运营端手机号段库页面移除自定义卡片式 Tab,改用通用 `Tabs` 控件,与企业应用管理页面“短信应用/彩信应用”交互一致。
- 通道组添加/编辑页面新增“补发时间上限(小时)”输入控件,编辑时回填 `retryTimeLimitHours`,保存时写入真实通道组接口。
- 补发时间上限按后端现有校验限制为 1 到 72 小时。
### 已执行命令
```bash
npm run build
git diff --check
```
### 当前结果
- 前端 build 通过,仍存在既有 Vite chunk size warning。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
## 2026-07-07 账单流水页面移除和列表分页
### 本轮修复
- 删除客户端账单流水页面和运营端账单流水页面,移除对应路由、菜单、占位映射和首页跳转入口。
- 移除公开交易查询/创建接口:`GET/POST /api/admin/billing/transactions``GET /api/client/billing/transactions`
- 保留内部 `AccountTransaction` 写入能力,人工充值、扣费、释放、退款等真实计费动作仍可写入内部账务记录;本期不作为独立账单流水页面验收。
- 通用 `Table` 组件新增内置分页,默认每页 10 条;服务端分页页面关闭内置分页,避免双分页。
- 补齐手写列表和卡片列表分页:通道管理、通道组、充值记录、客户端应用、客户端充值套餐、客户端签名、客户端模板、客户端批量任务、客户端发送详情、运营端短信任务进度、运营端企业签名。
### 已执行命令
```bash
npm run build
npm --prefix api run build
npm --prefix api test -- billing.service.spec.ts --runInBand
git diff --check
```
### 当前结果
- 前端 build 通过,仍存在既有 Vite chunk size warning。
- API build 通过。
- BillingService 单测通过:1 个 test suite、6 个测试通过。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
## 2026-07-07 通道组表格和分钟级补发上限
### 本轮修复
- 通道组添加/编辑页的省网分流、全国通道配置从卡片改为通用表格展示,行内保留编辑、删除操作。
- 通道状态文案改为设计锚点口径“链接正常/通道停用”;“链接正常”必须来自真实 CMPP 连接状态 connected 且当前连接数大于 0,新建但未连接的 active 通道不再显示为链接正常。
- 通道组补发时间上限从整小时升级为分钟级配置,页面交互为“小时 + 分钟”,默认 12 小时 0 分钟;后端新增 `retryTimeLimitMinutes` 持久化字段,并保留 `retryTimeLimitHours` 兼容旧调用。
- 发送链路按分钟级上限判断是否继续补发,超过配置分钟数、超过 72 小时或关闭补发时均不再补发。
### 已执行命令
```bash
npm --prefix api run prisma:generate
npm --prefix api test -- channels.service.spec.ts send-chain.service.spec.ts --runInBand
npm --prefix api run build
npm run build
git diff --check
```
### 当前结果
- Prisma Client 已根据新 schema 生成。
- ChannelsService 和 SendChainService 定向单测通过:2 个 test suites、35 个测试通过。
- API build 通过。
- 前端 build 通过,仍存在既有 Vite chunk size warning。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
+3 -18
View File
@@ -171,19 +171,6 @@ export type RechargeOrder = {
tenant?: TenantOption; tenant?: TenantOption;
}; };
export type AccountTransaction = {
id: string;
tenantId: string;
transactionType: string;
amountCents: number;
smsUnits: number;
balanceAfter: number;
relatedType?: string | null;
relatedId?: string | null;
remark?: string | null;
createdAt: string;
};
export type BillingPlan = { export type BillingPlan = {
id: string; id: string;
name: string; name: string;
@@ -339,6 +326,7 @@ export type ChannelGroup = DictionaryItem & {
description?: string | null; description?: string | null;
retryEnabled?: boolean; retryEnabled?: boolean;
retryTimeLimitHours?: number; retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
items?: ChannelGroupItem[]; items?: ChannelGroupItem[];
}; };
@@ -555,7 +543,6 @@ export const adminApi = {
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)), request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'), listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
listTransactions: (tenantId?: string) => request<AccountTransaction[]>(withQuery('/admin/billing/transactions', { tenantId })),
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })), listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) => createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
@@ -644,9 +631,9 @@ export const adminApi = {
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}), }),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'), listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) => createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) =>
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array<Record<string, unknown>> }) => updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteChannelGroup: (id: string) => deleteChannelGroup: (id: string) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }), request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
@@ -758,8 +745,6 @@ export const clientApi = {
}), }),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }), request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }), request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
-48
View File
@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react';
import { Breadcrumb, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type TenantAccount } from '@/api/adminApi';
const columns: Array<TableColumn<TenantAccount>> = [
{ key: 'id', title: '账户编号', render: (record) => record.id },
{ key: 'name', title: '客户名称', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'balance', title: '现金余额', render: (record) => `¥${(record.balanceCents / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', render: (record) => `${record.smsUnits.toLocaleString('zh-CN')}` },
{ key: 'creditCents', title: '授信额度', render: (record) => `¥${(record.creditCents / 100).toLocaleString('zh-CN')}` },
{
key: 'status',
title: '账户状态',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
{record.status === 'active' ? '正常' : '已停用'}
</Tag>
),
},
];
export function AdminBillingPage() {
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [error, setError] = useState('');
useEffect(() => {
adminApi.listAccounts()
.then((items) => {
setAccounts(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '账务账户加载失败'));
}, []);
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['账单流水']} />
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface">
<Table columns={columns} data={accounts} emptyText="暂无账户数据" rowKey="id" />
</div>
</section>
);
}
+107 -67
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom'; type Carrier = 'mobile' | 'unicom' | 'telecom';
type ChannelStatus = 'normal' | 'stopped'; type ChannelStatus = 'normal' | 'stopped';
@@ -49,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
}; };
const statusLabels: Record<ChannelStatus, string> = { const statusLabels: Record<ChannelStatus, string> = {
normal: '通道启用', normal: '链接正常',
stopped: '通道停用', stopped: '通道停用',
}; };
@@ -67,45 +67,18 @@ function isCarrierCompatible(channelCarrier: string | null | undefined, carrier:
} }
function getChannelStatus(channel?: AdminChannel): ChannelStatus { function getChannelStatus(channel?: AdminChannel): ChannelStatus {
return channel?.status === 'active' ? 'normal' : 'stopped'; if (channel?.status !== 'active') return 'stopped';
return (channel.connectionStates ?? []).some((connection) =>
connection.status === 'connected'
&& connection.desiredConnections > 0
&& connection.currentConnections > 0
) ? 'normal' : 'stopped';
} }
function StatusTag({ status }: { status: ChannelStatus }) { function StatusTag({ status }: { status: ChannelStatus }) {
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>; return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
} }
function RouteCard({
title,
subtitle,
channel,
status,
onEdit,
onDelete,
}: {
title: string;
subtitle: string;
channel?: AdminChannel;
status: ChannelStatus;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<article className="channel-route-card">
<div>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
<p>{channel?.name ?? '未命名通道'}</p>
<small>{channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'}</small>
<StatusTag status={status} />
<footer>
<button onClick={onEdit} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={15} /></button>
</footer>
</article>
);
}
function RouteConfigModal({ function RouteConfigModal({
channels, channels,
carrier, carrier,
@@ -135,7 +108,7 @@ function RouteConfigModal({
const channelOptions = [ const channelOptions = [
{ label: '请选择', value: '' }, { label: '请选择', value: '' },
...selectableChannels.map((channel) => ({ ...selectableChannels.map((channel) => ({
label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`, label: `${channel.name}${channel.code} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
value: channel.id, value: channel.id,
})), })),
]; ];
@@ -173,6 +146,7 @@ function RouteConfigModal({
)} )}
onClose={onClose} onClose={onClose}
open open
size="xl"
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'} title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
> >
<div className="channel-route-modal"> <div className="channel-route-modal">
@@ -187,7 +161,7 @@ function RouteConfigModal({
</div> </div>
</> </>
)} )}
<Select label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} /> <Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
</div> </div>
</Modal> </Modal>
); );
@@ -201,6 +175,8 @@ export function AdminChannelGroupFormPage() {
const [groupName, setGroupName] = useState(''); const [groupName, setGroupName] = useState('');
const [carrier, setCarrier] = useState<Carrier>('mobile'); const [carrier, setCarrier] = useState<Carrier>('mobile');
const [retryEnabled, setRetryEnabled] = useState(true); const [retryEnabled, setRetryEnabled] = useState(true);
const [retryLimitHours, setRetryLimitHours] = useState('12');
const [retryLimitMinutes, setRetryLimitMinutes] = useState('0');
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]); const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]); const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
const [modal, setModal] = useState<RouteModalState | null>(null); const [modal, setModal] = useState<RouteModalState | null>(null);
@@ -209,11 +185,66 @@ export function AdminChannelGroupFormPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]); const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
{ key: 'province', title: '省份', width: '160px', render: (route) => route.province },
{
key: 'channel',
title: '通道名称',
width: '280px',
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'province', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
{ key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority },
{
key: 'channel',
title: '通道名称',
width: '280px',
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'national', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
function applyGroup(group: ChannelGroup) { function applyGroup(group: ChannelGroup) {
setGroupName(group.name); setGroupName(group.name);
setCarrier(group.carrier); setCarrier(group.carrier);
setRetryEnabled(group.retryEnabled ?? true); setRetryEnabled(group.retryEnabled ?? true);
const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
setRetryLimitHours(String(Math.floor(retryMinutes / 60)));
setRetryLimitMinutes(String(retryMinutes % 60));
setProvinceRoutes((group.items ?? []) setProvinceRoutes((group.items ?? [])
.filter((item) => item.province) .filter((item) => item.province)
.map((item) => ({ .map((item) => ({
@@ -294,12 +325,24 @@ export function AdminChannelGroupFormPage() {
setError('请输入通道组名称'); setError('请输入通道组名称');
return; return;
} }
const retryHours = Number(retryLimitHours);
const retryMinutes = Number(retryLimitMinutes);
if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) {
setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数');
return;
}
const retryTimeLimitMinutes = retryHours * 60 + retryMinutes;
if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) {
setError('补发时间上限需大于 0 分钟且不超过 72 小时');
return;
}
const payload = { const payload = {
name: groupName.trim(), name: groupName.trim(),
carrier, carrier,
status: 'active', status: 'active',
retryEnabled, retryEnabled,
retryTimeLimitHours: 72, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
items: buildItems(), items: buildItems(),
}; };
setSaving(true); setSaving(true);
@@ -312,7 +355,8 @@ export function AdminChannelGroupFormPage() {
carrier, carrier,
status: 'active', status: 'active',
retryEnabled, retryEnabled,
retryTimeLimitHours: 72, retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
}).then((group) => adminApi.updateChannelGroup(group.id, payload)); }).then((group) => adminApi.updateChannelGroup(group.id, payload));
request request
@@ -351,25 +395,34 @@ export function AdminChannelGroupFormPage() {
<i /> <i />
</button> </button>
</div> </div>
<div className="channel-group-retry-limit">
<span></span>
<Input
disabled={!retryEnabled}
max="72"
min="0"
onChange={(event) => setRetryLimitHours(event.target.value)}
suffix="小时"
type="number"
value={retryLimitHours}
/>
<Input
disabled={!retryEnabled}
max="59"
min="0"
onChange={(event) => setRetryLimitMinutes(event.target.value)}
suffix="分钟"
type="number"
value={retryLimitMinutes}
/>
<small> 1 72 12 0 </small>
</div>
</div> </div>
</section> </section>
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<div className="channel-route-card-grid"> <Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" pagination={false} rowKey="id" />
{provinceRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))}
onEdit={() => setModal({ type: 'province', mode: 'edit', route })}
status={route.status}
subtitle="省网优先路由"
title={route.province}
/>
))}
{provinceRoutes.length === 0 ? <p className="channel-route-empty"></p> : null}
</div>
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost"> <Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
</Button> </Button>
@@ -377,20 +430,7 @@ export function AdminChannelGroupFormPage() {
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<div className="channel-route-card-grid"> <Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" pagination={false} rowKey="id" />
{nationalRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))}
onEdit={() => setModal({ type: 'national', mode: 'edit', route })}
status={route.status}
subtitle="全国补发路由"
title={`优先级 ${route.priority}`}
/>
))}
{nationalRoutes.length === 0 ? <p className="channel-route-empty"></p> : null}
</div>
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost"> <Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
</Button> </Button>
+86 -21
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react'; import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi'; import { adminApi, type ChannelGroup } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
@@ -12,12 +12,32 @@ const carrierLabels: Record<GroupCarrier, string> = {
telecom: '电信', telecom: '电信',
}; };
function formatRetryLimit(group: ChannelGroup) {
if (group.retryEnabled === false) return '已关闭';
const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
return `${Math.floor(totalMinutes / 60)}小时${totalMinutes % 60}分钟`;
}
function getGroupSummary(group: ChannelGroup) {
const items = group.items ?? [];
const provinceCount = items.filter((item) => item.province).length;
const nationalCount = items.length - provinceCount;
const connectedCount = items.filter((item) => (item.channel?.connectionStates ?? []).some((connection) =>
connection.status === 'connected'
&& connection.desiredConnections > 0
&& connection.currentConnections > 0
)).length;
return { connectedCount, nationalCount, provinceCount, totalCount: items.length };
}
export function AdminChannelGroupsPage() { export function AdminChannelGroupsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [groupName, setGroupName] = useState(''); const [groupName, setGroupName] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]); const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null); const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
const [page, setPage] = useState(1);
const [error, setError] = useState(''); const [error, setError] = useState('');
const pageSize = 10;
function loadData() { function loadData() {
adminApi.listChannelGroups() adminApi.listChannelGroups()
@@ -33,6 +53,13 @@ export function AdminChannelGroupsPage() {
}, []); }, []);
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleGroups = filteredGroups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [groupName, groups.length]);
function deleteGroup() { function deleteGroup() {
if (!deleteTarget) return; if (!deleteTarget) return;
@@ -65,36 +92,74 @@ export function AdminChannelGroupsPage() {
</section> </section>
<section className="surface channel-group-list"> <section className="surface channel-group-list">
<div className="channel-group-grid"> <div className="channel-group-config-list">
{filteredGroups.map((group) => ( {visibleGroups.map((group) => {
<article className="channel-group-card" key={group.id}> const summary = getGroupSummary(group);
<header> const previewItems = (group.items ?? []).slice(0, 4);
return (
<article className="channel-group-config-item" key={group.id}>
<div className="channel-group-config-item__identity">
<span className="channel-group-config-item__icon"><Layers3 size={18} /></span>
<div> <div>
<Layers3 size={18} />
<strong>{group.name}</strong> <strong>{group.name}</strong>
<span>{carrierLabels[group.carrier] ?? group.carrier}</span>
</div> </div>
<span title="运营商">{carrierLabels[group.carrier] ?? group.carrier}</span>
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
</header>
<div className="channel-group-card__body">
{(group.items ?? []).slice(0, 5).map((item, index) => {
const channel = item.channel as { name?: string } | undefined;
return <p key={`${group.id}-${index}`}>{channel?.name ?? '未命名通道'}</p>;
})}
{(group.items?.length ?? 0) === 0 ? <p className="muted"></p> : null}
</div> </div>
<footer>
<div className="channel-group-config-item__metrics" aria-label="通道组配置摘要">
<div>
<span></span>
<strong>{summary.provinceCount}</strong>
</div>
<div>
<span></span>
<strong>{summary.nationalCount}</strong>
</div>
<div>
<span></span>
<strong>{summary.connectedCount}/{summary.totalCount}</strong>
</div>
</div>
<div className="channel-group-config-item__policy">
<Tag tone={group.retryEnabled === false ? 'neutral' : 'success'}>
{group.retryEnabled === false ? '补发关闭' : '补发开启'}
</Tag>
<span><Clock3 size={15} />{formatRetryLimit(group)}</span>
<span><RadioTower size={15} />{summary.totalCount ? `${summary.totalCount} 个通道` : '暂无通道'}</span>
</div>
<div className="channel-group-config-item__channels">
{previewItems.map((item) => (
<span key={item.id}>
{item.province ? `${item.province} / ` : `P${item.priority} / `}
{item.channel?.name ?? '未命名通道'}
</span>
))}
{summary.totalCount > previewItems.length ? <span>+{summary.totalCount - previewItems.length}</span> : null}
{summary.totalCount === 0 ? <span></span> : null}
</div>
<div className="channel-group-config-item__actions">
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button"> <button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} /> <Pencil size={15} />
</button> </button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button"> <button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<Trash2 size={15} /> <Trash2 size={15} />
</button> </button>
</footer>
</article>
))}
</div> </div>
<Pagination nextDisabled={false} page={1} total={filteredGroups.length} /> </article>
);
})}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredGroups.length}
/>
</section> </section>
<Modal <Modal
+19 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
@@ -362,6 +362,8 @@ export function AdminChannelsPage() {
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null); const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null); const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
const [logState, setLogState] = useState<ChannelLogState | null>(null); const [logState, setLogState] = useState<ChannelLogState | null>(null);
const [page, setPage] = useState(1);
const pageSize = 10;
function loadChannels() { function loadChannels() {
adminApi.listChannels() adminApi.listChannels()
@@ -389,6 +391,13 @@ export function AdminChannelsPage() {
}), }),
[carrier, channels, keyword, status], [carrier, channels, keyword, status],
); );
const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [carrier, channels.length, keyword, status]);
async function upsertChannel(nextChannel: SmsChannel) { async function upsertChannel(nextChannel: SmsChannel) {
try { try {
@@ -495,7 +504,7 @@ export function AdminChannelsPage() {
<span></span> <span></span>
<span></span> <span></span>
</div> </div>
{filteredChannels.map((channel) => ( {visibleChannels.map((channel) => (
<article className="sms-channel-table__row" key={channel.id}> <article className="sms-channel-table__row" key={channel.id}>
<div className="sms-channel-identity"> <div className="sms-channel-identity">
<strong>{channel.name}</strong> <strong>{channel.name}</strong>
@@ -531,13 +540,14 @@ export function AdminChannelsPage() {
</div> </div>
</article> </article>
))} ))}
<div className="sms-channel-pagination"> <Pagination
<Select options={[{ label: '10 条/页', value: '10' }, { label: '20 条/页', value: '20' }]} value="10" /> nextDisabled={currentPage >= totalPages}
<Button disabled size="sm" variant="ghost"></Button> onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
<Button size="sm" variant="secondary">1</Button> onPrevious={() => setPage((value) => Math.max(1, value - 1))}
<Button size="sm" variant="ghost">2</Button> page={currentPage}
<Button size="sm" variant="ghost"></Button> previousDisabled={currentPage <= 1}
</div> total={filteredChannels.length}
/>
</div> </div>
{modal ? ( {modal ? (
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react'; import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi'; import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing'; type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -510,6 +510,7 @@ export function AdminEnterpriseSignaturesPage() {
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null); const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData() { async function loadData() {
try { try {
@@ -537,6 +538,14 @@ export function AdminEnterpriseSignaturesPage() {
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword)) return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword)); && (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
}), [enterpriseKeyword, signatureKeyword, signatures]); }), [enterpriseKeyword, signatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [enterpriseKeyword, filteredSignatures.length, signatureKeyword]);
async function saveSignature(state: SignatureFormState) { async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null; const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -609,7 +618,7 @@ export function AdminEnterpriseSignaturesPage() {
const smsSignatureContent = ( const smsSignatureContent = (
<div className="signature-list admin-enterprise-signature-list"> <div className="signature-list admin-enterprise-signature-list">
{filteredSignatures.map((signature) => { {visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
const expanded = expandedSignatureId === signature.id; const expanded = expandedSignatureId === signature.id;
return ( return (
@@ -672,6 +681,14 @@ export function AdminEnterpriseSignaturesPage() {
</article> </article>
); );
})} })}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
{filteredSignatures.length === 0 ? <div className="ui-table__empty"></div> : null} {filteredSignatures.length === 0 ? <div className="ui-table__empty"></div> : null}
</div> </div>
); );
+1 -1
View File
@@ -261,7 +261,7 @@ export function AdminHome() {
footer={( footer={(
<> <>
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost"></Button> <Button onClick={() => setSelectedEnterprise(null)} variant="ghost"></Button>
<Button onClick={() => navigate('/admin/billing')}></Button> <Button onClick={() => navigate('/admin/recharge-records')}></Button>
</> </>
)} )}
onClose={() => setSelectedEnterprise(null)} onClose={() => setSelectedEnterprise(null)}
+9 -23
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react'; import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
type PhoneSegment = DictionaryItem & { type PhoneSegment = DictionaryItem & {
@@ -123,25 +123,6 @@ export function AdminPhoneSegmentsPage() {
</section> </section>
</div> </div>
<div className="surface phone-segment-tabs">
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">
<Smartphone size={18} />
<span>
<strong></strong>
<small> 7 </small>
</span>
<em>{segments.length}</em>
</button>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">
<RadioTower size={18} />
<span>
<strong></strong>
<small></small>
</span>
<em>{rules.length}</em>
</button>
</div>
<div className="surface admin-system-toolbar phone-segment-toolbar"> <div className="surface admin-system-toolbar phone-segment-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} /> <Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}> <Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
@@ -150,9 +131,14 @@ export function AdminPhoneSegmentsPage() {
</div> </div>
<div className="surface admin-system-table-card"> <div className="surface admin-system-table-card">
{activeTab === 'segments' <Tabs
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" />} value={activeTab}
items={[
{ label: `手机号段 ${segments.length}`, value: 'segments', content: <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> },
{ label: `运营商区分规则 ${rules.length}`, value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
]}
/>
</div> </div>
<Modal <Modal
+23 -22
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi'; import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
type ManualRechargeForm = { type ManualRechargeForm = {
tenantId: string; tenantId: string;
@@ -37,12 +37,12 @@ function RemarkCell({ value }: { value?: string }) {
export function AdminRechargeRecordsPage() { export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState<RechargeOrder[]>([]); const [records, setRecords] = useState<RechargeOrder[]>([]);
const [accounts, setAccounts] = useState<TenantAccount[]>([]); const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false); const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -50,16 +50,14 @@ export function AdminRechargeRecordsPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([ const [nextTenants, nextRecords, nextAccounts] = await Promise.all([
adminApi.listTenants(), adminApi.listTenants(),
adminApi.listManualRecharges(), adminApi.listManualRecharges(),
adminApi.listAccounts(), adminApi.listAccounts(),
adminApi.listTransactions(),
]); ]);
setTenants(nextTenants); setTenants(nextTenants);
setRecords(nextRecords); setRecords(nextRecords);
setAccounts(nextAccounts); setAccounts(nextAccounts);
setTransactions(nextTransactions);
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' })); setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败'); setError(err instanceof Error ? err.message : '充值记录加载失败');
@@ -84,6 +82,14 @@ export function AdminRechargeRecordsPage() {
}), }),
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants], [dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
); );
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]);
function resetFilters() { function resetFilters() {
setEnterpriseKeyword(''); setEnterpriseKeyword('');
@@ -151,16 +157,15 @@ export function AdminRechargeRecordsPage() {
<tr><td className="ui-table__empty" colSpan={7}>...</td></tr> <tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
) : filteredRows.length === 0 ? ( ) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={7}></td></tr> <tr><td className="ui-table__empty" colSpan={7}></td></tr>
) : filteredRows.map((record) => { ) : visibleRows.map((record) => {
const account = accounts.find((item) => item.tenantId === record.tenantId); const account = accounts.find((item) => item.tenantId === record.tenantId);
const transaction = transactions.find((item) => item.relatedId === record.id);
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId; const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return ( return (
<tr key={record.id}> <tr key={record.id}>
<td><strong>{tenantName}</strong></td> <td><strong>{tenantName}</strong></td>
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td> <td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
<td>{formatAmount(record.amountCents / 100)}</td> <td>{formatAmount(record.amountCents / 100)}</td>
<td>{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}</td> <td>{formatAmount((account?.balanceCents ?? 0) / 100)}</td>
<td><Tag tone="warning"></Tag></td> <td><Tag tone="warning"></Tag></td>
<td>{record.operatorId || '运营'}</td> <td>{record.operatorId || '运营'}</td>
<td><RemarkCell value={record.remark ?? undefined} /></td> <td><RemarkCell value={record.remark ?? undefined} /></td>
@@ -171,18 +176,14 @@ export function AdminRechargeRecordsPage() {
</table> </table>
</div> </div>
<div className="admin-recharge-pagination"> <Pagination
<Select options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }]} value="10" /> nextDisabled={currentPage >= totalPages}
<div> onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
<Button icon={<ChevronLeft size={16} />} iconOnly variant="ghost"></Button> onPrevious={() => setPage((value) => Math.max(1, value - 1))}
<Button size="sm" variant="ghost">24</Button> page={currentPage}
<Button size="sm">25</Button> previousDisabled={currentPage <= 1}
<Button size="sm" variant="ghost">26</Button> total={filteredRows.length}
<span>...</span> />
<Button size="sm" variant="ghost">63</Button>
<Button icon={<ChevronRight size={16} />} iconOnly variant="ghost"></Button>
</div>
</div>
</div> </div>
{manualOpen ? ( {manualOpen ? (
+1 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react'; import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi'; import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
delivered: '发送成功', delivered: '发送成功',
@@ -104,7 +104,6 @@ export function AdminSmsRecordsPage() {
<div className="surface"> <div className="surface">
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" /> <Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
<Pagination total={filteredRows.length} />
</div> </div>
<Modal <Modal
+18 -2
View File
@@ -329,6 +329,7 @@ export function AdminSmsTaskProgressPage() {
const [application, setApplication] = useState('all'); const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({}); const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null); const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null); const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null); const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -371,6 +372,14 @@ export function AdminSmsTaskProgressPage() {
}), }),
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks], [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
); );
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);
useEffect(() => {
setPage(1);
}, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
function resetFilters() { function resetFilters() {
setKeyword(''); setKeyword('');
@@ -439,7 +448,7 @@ export function AdminSmsTaskProgressPage() {
<tr><td className="ui-table__empty" colSpan={8}>...</td></tr> <tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
) : filteredTasks.length === 0 ? ( ) : filteredTasks.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={8}></td></tr> <tr><td className="ui-table__empty" colSpan={8}></td></tr>
) : filteredTasks.map((record) => { ) : visibleTasks.map((record) => {
const progress = getProgress(record); const progress = getProgress(record);
const { signature, content } = splitSignature(record.templateContent); const { signature, content } = splitSignature(record.templateContent);
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : ''; const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
@@ -518,7 +527,14 @@ export function AdminSmsTaskProgressPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination total={filteredTasks.length} /> <Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
</div> </div>
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null} {selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
@@ -7,7 +7,6 @@ import {
DateRangeInput, DateRangeInput,
Input, Input,
Modal, Modal,
Pagination,
Table, Table,
type DateRangeValue, type DateRangeValue,
type TableColumn, type TableColumn,
@@ -225,7 +224,6 @@ export function AdminSmsUplinkRecordsPage() {
<div className="surface admin-uplink-table-card"> <div className="surface admin-uplink-table-card">
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" /> <Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
<Pagination total={filteredMessages.length} />
</div> </div>
{selectedMessage ? ( {selectedMessage ? (
+1 -1
View File
@@ -127,7 +127,7 @@ export function AdminSystemLogsPage() {
</div> </div>
<div className="surface system-table-card"> <div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" /> <Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
<Pagination <Pagination
nextDisabled={currentPage >= totalPages} nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
+19 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ClipboardCopy, FileText } from 'lucide-react'; import { ClipboardCopy, FileText } from 'lucide-react';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi'; import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { Button, Modal, Tag } from '@/components/ui'; import { Button, Modal, Pagination, Tag } from '@/components/ui';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive'; type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
@@ -61,6 +61,7 @@ export function ClientApplicationsPage() {
const [params, setParams] = useState<ApplicationCmppParams | null>(null); const [params, setParams] = useState<ApplicationCmppParams | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [paramsLoading, setParamsLoading] = useState(false); const [paramsLoading, setParamsLoading] = useState(false);
const [page, setPage] = useState(1);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [paramsError, setParamsError] = useState(''); const [paramsError, setParamsError] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -96,6 +97,14 @@ export function ClientApplicationsPage() {
} }
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]); const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(applications.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleApplications = applications.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [applications.length]);
function copyParams() { function copyParams() {
if (selectedRows.length === 0) { if (selectedRows.length === 0) {
@@ -123,7 +132,7 @@ export function ClientApplicationsPage() {
) : null} ) : null}
<div className="sms-app-grid"> <div className="sms-app-grid">
{applications.map((application) => { {visibleApplications.map((application) => {
const linkStatus = normalizeStatus(application); const linkStatus = normalizeStatus(application);
return ( return (
<article className="sms-app-card" key={application.id}> <article className="sms-app-card" key={application.id}>
@@ -155,6 +164,14 @@ export function ClientApplicationsPage() {
); );
})} })}
</div> </div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={applications.length}
/>
<Modal <Modal
footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>} footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>}
+18 -2
View File
@@ -103,6 +103,7 @@ export function ClientBatchTasksPage() {
const [application, setApplication] = useState('all'); const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({}); const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null); const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null); const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
function loadTasks() { function loadTasks() {
@@ -136,6 +137,14 @@ export function ClientBatchTasksPage() {
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end; const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate; 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);
useEffect(() => {
setPage(1);
}, [application, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
function terminateTask(id: string) { function terminateTask(id: string) {
const source = tasks.find((item) => item.id === id); const source = tasks.find((item) => item.id === id);
@@ -253,7 +262,7 @@ export function ClientBatchTasksPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredTasks.map((record, index) => { {visibleTasks.map((record, index) => {
const { signature, content } = splitSignature(record.templateContent); const { signature, content } = splitSignature(record.templateContent);
return ( return (
@@ -284,7 +293,14 @@ export function ClientBatchTasksPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination total={filteredTasks.length} /> <Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
</div> </div>
<Modal <Modal
+19 -2
View File
@@ -1,12 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CreditCard } from 'lucide-react'; import { CreditCard } from 'lucide-react';
import { Button, Tag } from '@/components/ui'; import { Button, Pagination, Tag } from '@/components/ui';
import { clientApi, type BillingPlan } from '@/api/adminApi'; import { clientApi, type BillingPlan } from '@/api/adminApi';
export function ClientBillingPage() { export function ClientBillingPage() {
const [plans, setPlans] = useState<BillingPlan[]>([]); const [plans, setPlans] = useState<BillingPlan[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(plans.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visiblePlans = plans.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -19,6 +24,10 @@ export function ClientBillingPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
setPage(1);
}, [plans.length]);
function createOrder(plan: BillingPlan) { function createOrder(plan: BillingPlan) {
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' }) clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
.catch((reason: Error) => setError(reason.message || '充值订单创建失败')); .catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
@@ -36,7 +45,7 @@ export function ClientBillingPage() {
{loading ? <p className="muted">...</p> : null} {loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="plan-grid"> <div className="plan-grid">
{plans.map((plan) => ( {visiblePlans.map((plan) => (
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}> <article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
<div className="section-heading"> <div className="section-heading">
<h2>{plan.name}</h2> <h2>{plan.name}</h2>
@@ -50,6 +59,14 @@ export function ClientBillingPage() {
</article> </article>
))} ))}
</div> </div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={plans.length}
/>
{!loading && !error && plans.length === 0 ? <p className="muted"></p> : null} {!loading && !error && plans.length === 0 ? <p className="muted"></p> : null}
</div> </div>
</section> </section>
-87
View File
@@ -1,87 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Table, Tag, type TableColumn } from '@/components/ui';
import { clientApi, type AccountTransaction, type RechargeOrder } from '@/api/adminApi';
type Invoice = {
id: string;
title: string;
messages: number;
amount: number;
createdAt: string;
status: 'paid' | 'pending' | 'failed';
};
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
paid: 'success',
pending: 'info',
failed: 'danger',
};
const statusLabelMap: Record<Invoice['status'], string> = {
paid: '已支付',
pending: '处理中',
failed: '支付失败',
};
const columns: Array<TableColumn<Invoice>> = [
{ key: 'id', title: '流水号', render: (record) => record.id },
{ key: 'title', title: '项目', render: (record) => record.title },
{ key: 'messages', title: '短信条数', render: (record) => `${record.messages.toLocaleString('zh-CN')}` },
{ key: 'amount', title: '金额', render: (record) => `¥${record.amount.toLocaleString('zh-CN')}` },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
];
export function ClientInvoicesPage() {
const [orders, setOrders] = useState<RechargeOrder[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([clientApi.listOrders(), clientApi.listTransactions()])
.then(([orderItems, transactionItems]) => {
setOrders(orderItems);
setTransactions(transactionItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '账单流水加载失败'))
.finally(() => setLoading(false));
}, []);
const rows = useMemo<Invoice[]>(() => [
...orders.map((item) => ({
id: item.orderNo,
title: item.payMethod === 'manual_topup' ? '人工充值' : '充值订单',
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: item.status === 'paid' ? 'paid' as const : item.status === 'failed' ? 'failed' as const : 'pending' as const,
})),
...transactions.map((item) => ({
id: item.id,
title: item.remark ?? item.transactionType,
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: 'paid' as const,
})),
].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), [orders, transactions]);
return (
<section className="page-stack">
<div className="page-heading">
<div>
<p className="eyebrow"></p>
<h1></h1>
</div>
</div>
<div className="surface">
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<Table columns={columns} data={rows} emptyText="暂无账单流水" rowKey="id" />
</div>
</section>
);
}
+19 -1
View File
@@ -4,6 +4,7 @@ import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import { import {
DateRangeInput, DateRangeInput,
Input, Input,
Pagination,
QueryPanel, QueryPanel,
Select, Select,
Tag, Tag,
@@ -58,6 +59,7 @@ export function ClientSendDetailPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [contentKeyword, setContentKeyword] = useState(''); const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState(''); const [phoneKeyword, setPhoneKeyword] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -100,6 +102,14 @@ export function ClientSendDetailPage() {
const matchesContent = !contentKeyword || item.content.includes(contentKeyword); const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesContent; return matchesStartDate && matchesEndDate && matchesContent;
}); });
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [contentKeyword, dateRange.end, dateRange.start, records.length]);
return ( return (
<section className="page-stack"> <section className="page-stack">
@@ -166,7 +176,7 @@ export function ClientSendDetailPage() {
<tr><td className="ui-table__empty" colSpan={9}>...</td></tr> <tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
) : filteredRows.length === 0 ? ( ) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={9}></td></tr> <tr><td className="ui-table__empty" colSpan={9}></td></tr>
) : filteredRows.map((record) => { ) : visibleRows.map((record) => {
const receipt = getReceipt(record); const receipt = getReceipt(record);
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-'; const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
const region = record.channel?.sendRegion ?? '-'; const region = record.channel?.sendRegion ?? '-';
@@ -214,6 +224,14 @@ export function ClientSendDetailPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
/>
</div> </div>
</section> </section>
); );
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react'; import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, FileActions, Input, Modal, Select, Tag } from '@/components/ui'; import { Button, FileActions, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi'; import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = { const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
@@ -37,6 +37,7 @@ export function ClientSignaturesPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [purpose, setPurpose] = useState(''); const [purpose, setPurpose] = useState('');
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [page, setPage] = useState(1);
function loadData() { function loadData() {
setLoading(true); setLoading(true);
@@ -57,6 +58,14 @@ export function ClientSignaturesPage() {
const filteredSignatures = useMemo(() => signatures.filter((item) => ( const filteredSignatures = useMemo(() => signatures.filter((item) => (
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword) !keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
)), [keyword, signatures]); )), [keyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredSignatures.length, keyword]);
async function createSignature() { async function createSignature() {
try { try {
@@ -114,7 +123,7 @@ export function ClientSignaturesPage() {
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="signature-list"> <div className="signature-list">
{filteredSignatures.map((signature) => ( {visibleSignatures.map((signature) => (
<article className="signature-card signature-card--green" key={signature.id}> <article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary"> <div className="signature-summary">
<div> <div>
@@ -143,6 +152,14 @@ export function ClientSignaturesPage() {
</article> </article>
))} ))}
</div> </div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null} {!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null}
<Modal <Modal
+1 -1
View File
@@ -121,7 +121,7 @@ export function ClientSystemLogsPage() {
</div> </div>
<div className="surface system-table-card"> <div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" /> <Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
<Pagination <Pagination
nextDisabled={currentPage >= totalPages} nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react'; import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi'; import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
type TemplateVariable = { type TemplateVariable = {
@@ -180,6 +180,7 @@ export function ClientTemplatesPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null); const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
const [page, setPage] = useState(1);
function loadData() { function loadData() {
setLoading(true); setLoading(true);
@@ -201,6 +202,14 @@ export function ClientTemplatesPage() {
const filteredTemplates = useMemo(() => templates.filter((item) => ( const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword) !keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
)), [keyword, templates]); )), [keyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredTemplates.length, keyword]);
async function saveTemplate(state: TemplateFormState) { async function saveTemplate(state: TemplateFormState) {
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null; const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
@@ -254,7 +263,7 @@ export function ClientTemplatesPage() {
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid"> <div className="template-card-grid">
{filteredTemplates.map((template) => { {visibleTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name); const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
return ( return (
<article className="template-card template-card--green" key={template.id}> <article className="template-card template-card--green" key={template.id}>
@@ -283,6 +292,14 @@ export function ClientTemplatesPage() {
); );
})} })}
</div> </div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
/>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null} {!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null}
{modalTemplate ? ( {modalTemplate ? (
+1 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react'; import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi'; import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
import { readSession } from '@/api/session'; import { readSession } from '@/api/session';
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type UserForm = { type UserForm = {
displayName: string; displayName: string;
@@ -154,7 +154,6 @@ export function ClientUsersPage() {
{error ? <div className="surface empty-state">{error}</div> : null} {error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface system-table-card"> <div className="surface system-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" /> <Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Pagination total={filteredUsers.length} page={1} />
</div> </div>
{(creating || editingUser) ? ( {(creating || editingUser) ? (
-2
View File
@@ -5,7 +5,6 @@ const pageTitleMap: Record<string, string> = {
'/client/templates': '模板管理', '/client/templates': '模板管理',
'/client/signatures': '签名与引流信息', '/client/signatures': '签名与引流信息',
'/client/billing': '充值套餐', '/client/billing': '充值套餐',
'/client/invoices': '账单流水',
'/client/settings': '账号设置', '/client/settings': '账号设置',
'/admin/monitor': '发送监控', '/admin/monitor': '发送监控',
'/admin/analytics': '数据统计', '/admin/analytics': '数据统计',
@@ -13,7 +12,6 @@ const pageTitleMap: Record<string, string> = {
'/admin/templates': '模板审核', '/admin/templates': '模板审核',
'/admin/signatures': '签名审核', '/admin/signatures': '签名审核',
'/admin/channels': '通道管理', '/admin/channels': '通道管理',
'/admin/billing': '账单流水',
'/admin/settings': '系统配置', '/admin/settings': '系统配置',
}; };
+28 -3
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Pagination } from './PagePrimitives';
export type TableColumn<T> = { export type TableColumn<T> = {
key: string; key: string;
@@ -13,13 +15,26 @@ type TableProps<T> = {
data: T[]; data: T[];
rowKey: keyof T | ((record: T) => string); rowKey: keyof T | ((record: T) => string);
emptyText?: string; emptyText?: string;
pagination?: boolean;
pageSize?: number;
}; };
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) { export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pageSize = 10, pagination = true }: TableProps<T>) {
const [page, setPage] = useState(1);
const minimumTableWidth = columns.reduce((sum, column) => { const minimumTableWidth = columns.reduce((sum, column) => {
const match = column.width?.match(/^(\d+)px$/); const match = column.width?.match(/^(\d+)px$/);
return sum + (match ? Number(match[1]) : 0); return sum + (match ? Number(match[1]) : 0);
}, 0); }, 0);
const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
const activePage = Math.min(page, totalPages);
const visibleData = useMemo(
() => pagination ? data.slice((activePage - 1) * pageSize, activePage * pageSize) : data,
[activePage, data, pageSize, pagination],
);
useEffect(() => {
setPage(1);
}, [data, pageSize]);
function getRowKey(record: T) { function getRowKey(record: T) {
if (typeof rowKey === 'function') { if (typeof rowKey === 'function') {
@@ -50,14 +65,14 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.length === 0 ? ( {visibleData.length === 0 ? (
<tr> <tr>
<td className="ui-table__empty" colSpan={columns.length}> <td className="ui-table__empty" colSpan={columns.length}>
{emptyText} {emptyText}
</td> </td>
</tr> </tr>
) : ( ) : (
data.map((record, index) => ( visibleData.map((record, index) => (
<tr key={getRowKey(record)}> <tr key={getRowKey(record)}>
{columns.map((column) => ( {columns.map((column) => (
<td <td
@@ -72,6 +87,16 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
)} )}
</tbody> </tbody>
</table> </table>
{pagination && data.length > pageSize ? (
<Pagination
nextDisabled={activePage >= totalPages}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={activePage}
previousDisabled={activePage <= 1}
total={data.length}
/>
) : null}
</div> </div>
); );
} }
-1
View File
@@ -135,7 +135,6 @@ export function AdminLayout() {
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true }, { label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true },
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare }, { label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText }, { label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
{ label: '账单流水', to: '/admin/billing', icon: ReceiptText },
], ],
}, },
{ {
-1
View File
@@ -65,7 +65,6 @@ export function ClientLayout() {
title: '账户', title: '账户',
items: [ items: [
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign }, { label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText },
], ],
}, },
{ {
-4
View File
@@ -1,6 +1,5 @@
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage'; import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage';
import { AdminBillingPage } from '@/apps/admin/AdminBillingPage';
import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage'; import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage';
import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage'; import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage';
import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage'; import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage';
@@ -35,7 +34,6 @@ import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
import { ClientBillingPage } from '@/apps/client/ClientBillingPage'; import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage'; import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
import { ClientHome } from '@/apps/client/ClientHome'; import { ClientHome } from '@/apps/client/ClientHome';
import { ClientInvoicesPage } from '@/apps/client/ClientInvoicesPage';
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage'; import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
import { ClientSendPage } from '@/apps/client/ClientSendPage'; import { ClientSendPage } from '@/apps/client/ClientSendPage';
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage'; import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
@@ -70,7 +68,6 @@ export function AppRoutes() {
<Route path="mms-send-detail" element={<PagePlaceholder />} /> <Route path="mms-send-detail" element={<PagePlaceholder />} />
<Route path="mms-uplink-messages" element={<PagePlaceholder />} /> <Route path="mms-uplink-messages" element={<PagePlaceholder />} />
<Route path="billing" element={<ClientBillingPage />} /> <Route path="billing" element={<ClientBillingPage />} />
<Route path="invoices" element={<ClientInvoicesPage />} />
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} /> <Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
<Route path="users" element={<ClientUsersPage />} /> <Route path="users" element={<ClientUsersPage />} />
<Route path="system-logs" element={<ClientSystemLogsPage />} /> <Route path="system-logs" element={<ClientSystemLogsPage />} />
@@ -120,7 +117,6 @@ export function AppRoutes() {
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} /> <Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} /> <Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} /> <Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="billing" element={<AdminBillingPage />} />
<Route path="*" element={<PagePlaceholder />} /> <Route path="*" element={<PagePlaceholder />} />
</Route> </Route>
</Routes> </Routes>
+158 -82
View File
@@ -6828,105 +6828,141 @@ h3 {
gap: var(--space-5); gap: var(--space-5);
} }
.channel-group-grid { .channel-group-config-list {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.channel-group-card { .channel-group-config-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: grid;
grid-template-rows: auto minmax(210px, 1fr) auto;
min-width: 0;
overflow: hidden;
}
.channel-group-card header {
align-items: center;
background: #e8f2ff;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
padding: var(--space-4);
}
.channel-group-card header > div,
.channel-group-card header > span {
align-items: center;
display: inline-flex;
gap: var(--space-2);
min-width: 0;
}
.channel-group-card header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-card header > span {
color: var(--color-text-muted);
flex: 0 0 auto;
font-weight: var(--font-weight-semibold);
}
.channel-group-card__body {
color: var(--color-text-muted);
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.channel-group-card__body p {
line-height: 1.55;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-card__body small {
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
}
.channel-group-card footer {
align-items: center;
background: var(--color-surface-subtle);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-4);
justify-content: flex-end;
padding: var(--space-3) var(--space-4);
}
.channel-group-card footer button {
align-items: center; align-items: center;
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1.15fr) minmax(210px, 0.9fr) minmax(200px, 0.95fr) minmax(220px, 1.2fr) minmax(82px, auto);
min-width: 0;
padding: var(--space-4);
}
.channel-group-config-item__identity {
align-items: center;
display: flex;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__icon {
align-items: center;
background: #eef5ff;
border: 1px solid #cfe2ff;
border-radius: var(--radius-sm);
color: var(--color-selected);
display: inline-flex;
flex: 0 0 auto;
height: 38px;
justify-content: center;
width: 38px;
}
.channel-group-config-item__identity div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.channel-group-config-item__identity strong {
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__identity span:last-child,
.channel-group-config-item__metrics span,
.channel-group-config-item__policy span,
.channel-group-config-item__channels span {
color: var(--color-text-muted);
}
.channel-group-config-item__metrics {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.channel-group-config-item__metrics div {
border-left: 1px solid var(--color-border);
display: grid;
gap: var(--space-1);
padding-left: var(--space-3);
}
.channel-group-config-item__metrics strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.channel-group-config-item__policy {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__policy span {
align-items: center;
display: inline-flex;
gap: var(--space-1);
white-space: nowrap;
}
.channel-group-config-item__channels {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
min-width: 0;
}
.channel-group-config-item__channels span {
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
max-width: 160px;
overflow: hidden;
padding: var(--space-1) var(--space-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: flex-end;
justify-self: end;
min-width: 82px;
}
.channel-group-config-item__actions button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected); color: var(--color-selected);
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
gap: var(--space-1); gap: var(--space-1);
min-height: 32px; padding: 0;
padding: 0 var(--space-3);
} }
.channel-group-card footer button.is-danger { .channel-group-config-item__actions button.is-danger {
color: var(--color-danger); color: var(--color-danger);
} }
.channel-group-card footer button:hover {
background: var(--color-accent-soft);
border-color: currentColor;
}
.channel-group-form-page { .channel-group-form-page {
min-width: 1040px; min-width: 1040px;
} }
@@ -6950,6 +6986,7 @@ h3 {
} }
.channel-group-base-form > .ui-field, .channel-group-base-form > .ui-field,
.channel-group-retry-limit,
.channel-group-radio-row, .channel-group-radio-row,
.channel-group-switch-row { .channel-group-switch-row {
grid-column: 2; grid-column: 2;
@@ -7010,11 +7047,14 @@ h3 {
} }
.channel-group-row-actions button { .channel-group-row-actions button {
align-items: center;
background: transparent; background: transparent;
border: 0; border: 0;
color: var(--color-selected); color: var(--color-selected);
cursor: pointer; cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0; padding: 0;
} }
@@ -7022,6 +7062,23 @@ h3 {
color: var(--color-danger); color: var(--color-danger);
} }
.channel-group-retry-limit {
display: grid;
gap: var(--space-3);
grid-template-columns: 150px 150px minmax(220px, 1fr);
}
.channel-group-retry-limit > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
grid-column: 1 / -1;
}
.channel-group-retry-limit small {
align-self: center;
color: var(--color-text-muted);
}
.channel-route-card-grid { .channel-route-card-grid {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
@@ -7104,6 +7161,25 @@ h3 {
.channel-route-modal { .channel-route-modal {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
.channel-route-modal__channel-select {
grid-column: 1 / -1;
}
.channel-route-modal .ui-select__dropdown {
max-height: min(360px, calc(100vh - 360px));
}
.channel-route-modal .ui-select__dropdown button {
align-items: flex-start;
height: auto;
line-height: 1.45;
min-height: 42px;
padding: var(--space-2) var(--space-3);
white-space: normal;
} }
.channel-route-modal__note { .channel-route-modal__note {
+75
View File
@@ -0,0 +1,75 @@
import { createHash, randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
const prisma = new PrismaClient({ adapter: new PrismaPg(databaseUrl) });
const username = process.env.PROD_ADMIN_USERNAME || 'prod_admin';
const email = process.env.PROD_ADMIN_EMAIL || 'admin@example.com';
const password = process.env.PROD_ADMIN_PASSWORD || randomBytes(18).toString('base64url');
const credentialFile = process.env.PROD_ADMIN_CREDENTIAL_FILE;
function hashPassword(value) {
return createHash('sha256').update(value).digest('hex');
}
async function main() {
const role = await prisma.role.upsert({
where: { code: 'platform_admin' },
update: { name: '平台管理员', scope: 'platform' },
create: { code: 'platform_admin', name: '平台管理员', scope: 'platform' },
});
const user = await prisma.user.upsert({
where: { username },
update: {
email,
displayName: '生产平台管理员',
passwordHash: hashPassword(password),
status: 'active',
failedLoginCount: 0,
lockedUntil: null,
deletedAt: null,
tenantId: null,
},
create: {
username,
email,
displayName: '生产平台管理员',
passwordHash: hashPassword(password),
status: 'active',
},
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
update: {},
create: { userId: user.id, roleId: role.id },
});
const message = [
'CMPP production admin account',
`username=${username}`,
`email=${email}`,
`password=${password}`,
`generatedAt=${new Date().toISOString()}`,
'',
].join('\n');
if (credentialFile) {
writeFileSync(credentialFile, message, { mode: 0o600 });
}
console.log(`Production admin is ready: ${email}`);
if (!credentialFile) {
console.log(`Temporary password: ${password}`);
}
}
main()
.finally(() => prisma.$disconnect())
.catch((error) => {
console.error(error);
process.exit(1);
});
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="${APP_DIR:-/opt/cmpp-platform}"
REPO_URL="${REPO_URL:-http://175.27.255.91:3000/hectorzhao/lislgosms.git}"
BRANCH="${BRANCH:-main}"
PUBLIC_HTTP_PORT="${PUBLIC_HTTP_PORT:-12026}"
API_PORT="${API_PORT:-3000}"
GATEWAY_CONTROL_ADDR="${GATEWAY_CONTROL_ADDR:-127.0.0.1:8090}"
GATEWAY_CMPP_ADDR="${GATEWAY_CMPP_ADDR:-0.0.0.0:17890}"
DB_NAME="${DB_NAME:-cmpp_platform}"
DB_USER="${DB_USER:-cmpp}"
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 24 | tr -d '\n')}"
MINIO_ROOT_USER="${MINIO_ROOT_USER:-cmpp_minio}"
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-$(openssl rand -base64 32 | tr -d '\n')}"
MINIO_BUCKET="${MINIO_BUCKET:-cmpp-platform}"
PROD_ADMIN_EMAIL="${PROD_ADMIN_EMAIL:-admin@example.com}"
PROD_ADMIN_USERNAME="${PROD_ADMIN_USERNAME:-prod_admin}"
PROD_ADMIN_PASSWORD="${PROD_ADMIN_PASSWORD:-$(openssl rand -base64 18 | tr -d '\n')}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root." >&2
exit 1
fi
log() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*"; }
install_packages() {
log "Installing OS packages"
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg git nginx redis-server postgresql postgresql-contrib build-essential tar gzip openssl
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y ca-certificates curl git nginx redis postgresql-server postgresql-contrib gcc gcc-c++ make tar gzip openssl
if [[ ! -d /var/lib/pgsql/data/base ]]; then
postgresql-setup --initdb
fi
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
dnf install -y nodejs
fi
elif command -v yum >/dev/null 2>&1; then
yum install -y ca-certificates curl git nginx redis postgresql-server postgresql-contrib gcc gcc-c++ make tar gzip openssl
if [[ ! -d /var/lib/pgsql/data/base ]]; then
postgresql-setup initdb
fi
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/^v//' | cut -d. -f1)" -lt 22 ]]; then
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
yum install -y nodejs
fi
else
echo "Unsupported Linux distribution: apt-get/dnf/yum not found." >&2
exit 1
fi
}
install_go() {
local version="${GO_VERSION:-1.26.0}"
if command -v go >/dev/null 2>&1 && [[ "$(go version | awk '{print $3}' | sed 's/go//')" == "$version" ]]; then
return
fi
log "Installing Go ${version}"
curl -fL "https://go.dev/dl/go${version}.linux-amd64.tar.gz" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
ln -sf /usr/local/go/bin/go /usr/local/bin/go
}
install_minio() {
log "Installing MinIO"
curl -fL https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio
chmod +x /usr/local/bin/minio
useradd --system --home /var/lib/minio --shell /usr/sbin/nologin minio 2>/dev/null || true
mkdir -p /var/lib/minio
chown -R minio:minio /var/lib/minio
}
start_infra() {
log "Starting PostgreSQL and Redis"
systemctl enable --now postgresql || systemctl enable --now postgresql.service
systemctl enable --now redis-server 2>/dev/null || systemctl enable --now redis
runuser -u postgres -- psql <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USER}') THEN
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
ELSE
ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\\gexec
ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER};
SQL
}
write_env() {
log "Writing production environment"
mkdir -p /etc/cmpp-platform "$APP_DIR" "$APP_DIR/logs/api" "$APP_DIR/logs/gateway" "$APP_DIR/backups"
cat >/etc/cmpp-platform/cmpp-platform.env <<EOF
NODE_ENV=production
API_PORT=${API_PORT}
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@127.0.0.1:5432/${DB_NAME}?schema=public
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
MINIO_ENDPOINT=127.0.0.1:9000
MINIO_ACCESS_KEY=${MINIO_ROOT_USER}
MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD}
MINIO_BUCKET=${MINIO_BUCKET}
OBJECT_STORAGE_DRIVER=minio
GATEWAY_CONTROL_URL=http://127.0.0.1:8090
GATEWAY_HEALTH_ADDR=${GATEWAY_CONTROL_ADDR}
GATEWAY_CMPP_ADDR=${GATEWAY_CMPP_ADDR}
API_BASE_URL=http://127.0.0.1:${API_PORT}/api
EOF
chmod 600 /etc/cmpp-platform/cmpp-platform.env
cat >/etc/cmpp-platform/minio.env <<EOF
MINIO_ROOT_USER=${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
EOF
chmod 600 /etc/cmpp-platform/minio.env
}
write_services() {
log "Writing systemd and nginx configuration"
cat >/etc/systemd/system/cmpp-minio.service <<'EOF'
[Unit]
Description=CMPP MinIO object storage
After=network.target
[Service]
User=minio
Group=minio
EnvironmentFile=/etc/cmpp-platform/minio.env
ExecStart=/usr/local/bin/minio server /var/lib/minio --address 127.0.0.1:9000 --console-address 127.0.0.1:9001
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
cat >/etc/systemd/system/cmpp-api.service <<EOF
[Unit]
Description=CMPP Platform API
After=network.target postgresql.service redis.service cmpp-minio.service
[Service]
WorkingDirectory=${APP_DIR}/api
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
ExecStart=/usr/bin/node dist/main.js
Restart=always
RestartSec=5
StandardOutput=append:${APP_DIR}/logs/api/stdout.log
StandardError=append:${APP_DIR}/logs/api/stderr.log
[Install]
WantedBy=multi-user.target
EOF
cat >/etc/systemd/system/cmpp-gateway.service <<EOF
[Unit]
Description=CMPP Gateway control service
After=network.target cmpp-api.service
[Service]
WorkingDirectory=${APP_DIR}
EnvironmentFile=/etc/cmpp-platform/cmpp-platform.env
ExecStart=${APP_DIR}/dist/cmpp-gateway
Restart=always
RestartSec=5
StandardOutput=append:${APP_DIR}/logs/gateway/stdout.log
StandardError=append:${APP_DIR}/logs/gateway/stderr.log
[Install]
WantedBy=multi-user.target
EOF
cat >/etc/nginx/conf.d/cmpp-platform.conf <<EOF
server {
listen ${PUBLIC_HTTP_PORT};
server_name _;
root ${APP_DIR}/dist;
index index.html;
client_max_body_size 50m;
location /api/ {
proxy_pass http://127.0.0.1:${API_PORT}/api/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
nginx -t
systemctl enable nginx
}
checkout_code() {
log "Checking out application code"
if [[ -d "$APP_DIR/.git" ]]; then
git -C "$APP_DIR" fetch origin "$BRANCH"
git -C "$APP_DIR" checkout "$BRANCH"
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
else
rm -rf "$APP_DIR"
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
fi
}
run_deploy() {
log "Building and deploying application"
PROD_ADMIN_EMAIL="$PROD_ADMIN_EMAIL" \
PROD_ADMIN_USERNAME="$PROD_ADMIN_USERNAME" \
PROD_ADMIN_PASSWORD="$PROD_ADMIN_PASSWORD" \
bash "$APP_DIR/tools/deploy/production-deploy.sh"
}
install_packages
install_go
install_minio
start_infra
write_env
write_services
checkout_code
run_deploy
cat >/root/cmpp-platform-credentials.txt <<EOF
CMPP production credentials
admin_url=http://$(hostname -I | awk '{print $1}'):${PUBLIC_HTTP_PORT}/admin/login
admin_username=${PROD_ADMIN_USERNAME}
admin_email=${PROD_ADMIN_EMAIL}
admin_password=${PROD_ADMIN_PASSWORD}
database=postgresql://${DB_USER}:***@127.0.0.1:5432/${DB_NAME}
minio_user=${MINIO_ROOT_USER}
minio_password=${MINIO_ROOT_PASSWORD}
EOF
chmod 600 /root/cmpp-platform-credentials.txt
log "Production bootstrap finished"
echo "Credentials saved to /root/cmpp-platform-credentials.txt"
echo "Frontend: http://<server-ip>:${PUBLIC_HTTP_PORT}"
echo "Gateway control health: http://127.0.0.1:8090/health"
echo "CMPP inbound port variable reserved: ${GATEWAY_CMPP_ADDR}"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="${APP_DIR:-/opt/cmpp-platform}"
ENV_FILE="${ENV_FILE:-/etc/cmpp-platform/cmpp-platform.env}"
ADMIN_CREDENTIAL_FILE="${ADMIN_CREDENTIAL_FILE:-/root/cmpp-platform-admin.txt}"
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root." >&2
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing environment file: $ENV_FILE" >&2
exit 1
fi
set -a
source "$ENV_FILE"
set +a
cd "$APP_DIR"
echo "[deploy] Installing dependencies"
npm ci
npm --prefix api ci
echo "[deploy] Generating Prisma client and applying migrations"
npm --prefix api run prisma:generate
npm --prefix api run prisma:migrate:deploy
echo "[deploy] Building frontend, API and gateway"
npm run build
npm --prefix api run build
(cd gateway && /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-gateway" ./cmd/gateway)
echo "[deploy] Ensuring production admin"
PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-production-admin.mjs
chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
echo "[deploy] Restarting services"
systemctl daemon-reload
systemctl enable --now cmpp-minio cmpp-api cmpp-gateway nginx
systemctl restart cmpp-minio
systemctl restart cmpp-api
systemctl restart cmpp-gateway
systemctl restart nginx
echo "[deploy] Health checks"
sleep 3
curl -fsS "http://127.0.0.1:${API_PORT:-3000}/api/health" >/dev/null
curl -fsS "http://127.0.0.1:8090/health" >/dev/null
redis-cli -h "${REDIS_HOST:-127.0.0.1}" -p "${REDIS_PORT:-6379}" ping >/dev/null
pg_isready -d "$DATABASE_URL" >/dev/null
echo "[deploy] Done"