fix: connect operations pages to real backend
This commit is contained in:
@@ -35,6 +35,9 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
|
operationLog: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +134,13 @@ describe('BillingService', () => {
|
|||||||
relatedType: 'recharge_order',
|
relatedType: 'recharge_order',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'billing.manual_recharge',
|
||||||
|
resource: 'recharge_order',
|
||||||
|
resourceId: 'order-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ export class BillingService {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
createManualRecharge(data: CreateManualRechargeDto) {
|
async createManualRecharge(data: CreateManualRechargeDto) {
|
||||||
return this.createRechargeOrder({
|
const order = await this.createRechargeOrder({
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
amountCents: data.amountCents,
|
amountCents: data.amountCents,
|
||||||
smsUnits: data.smsUnits ?? 0,
|
smsUnits: data.smsUnits ?? 0,
|
||||||
@@ -208,6 +208,22 @@ export class BillingService {
|
|||||||
operatorId: data.operatorId,
|
operatorId: data.operatorId,
|
||||||
remark: data.remark,
|
remark: data.remark,
|
||||||
});
|
});
|
||||||
|
await this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
userId: data.operatorId,
|
||||||
|
action: 'billing.manual_recharge',
|
||||||
|
resource: 'recharge_order',
|
||||||
|
resourceId: order.id,
|
||||||
|
detail: {
|
||||||
|
amountCents: data.amountCents,
|
||||||
|
smsUnits: data.smsUnits ?? 0,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
remark: data.remark,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
estimateSmsCost(data: EstimateSmsCostDto) {
|
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ChannelsService', () => {
|
describe('ChannelsService', () => {
|
||||||
|
it('rejects incomplete channel creation input with readable 400 errors', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields');
|
||||||
|
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates CMPP channels and route rules with first-version defaults', async () => {
|
it('creates CMPP channels and route rules with first-version defaults', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@@ -127,6 +127,17 @@ export class ChannelsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createChannel(data: CreateChannelDto) {
|
createChannel(data: CreateChannelDto) {
|
||||||
|
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||||
|
const value = data[field as keyof CreateChannelDto];
|
||||||
|
return value === undefined || value === null || value === '';
|
||||||
|
});
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
|
||||||
|
}
|
||||||
|
const gatewayPort = Number(data.gatewayPort);
|
||||||
|
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||||
|
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||||
|
}
|
||||||
return this.prisma.smsChannel.create({
|
return this.prisma.smsChannel.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
@@ -134,7 +145,7 @@ export class ChannelsService {
|
|||||||
carrier: data.carrier,
|
carrier: data.carrier,
|
||||||
protocol: data.protocol ?? 'CMPP',
|
protocol: data.protocol ?? 'CMPP',
|
||||||
gatewayHost: data.gatewayHost,
|
gatewayHost: data.gatewayHost,
|
||||||
gatewayPort: data.gatewayPort,
|
gatewayPort,
|
||||||
enterpriseCode: data.enterpriseCode,
|
enterpriseCode: data.enterpriseCode,
|
||||||
account: data.account,
|
account: data.account,
|
||||||
passwordCipher: data.passwordCipher,
|
passwordCipher: data.passwordCipher,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export class AdminOperationsController {
|
|||||||
return this.operations.dashboard({ tenantId });
|
return this.operations.dashboard({ tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dashboard/statistics')
|
||||||
|
dashboardStatistics(@Query('tenantId') tenantId?: string) {
|
||||||
|
return this.operations.dashboard({ tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('statistics')
|
@Get('statistics')
|
||||||
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
|
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
|
||||||
return this.operations.statistics({ tenantId, groupBy });
|
return this.operations.statistics({ tenantId, groupBy });
|
||||||
@@ -72,3 +77,22 @@ export class AdminOperationsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiTags('admin-system-logs')
|
||||||
|
@Controller('admin/system-logs')
|
||||||
|
export class AdminSystemLogsController {
|
||||||
|
constructor(private readonly operations: OperationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('userId') userId?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('level') level?: string,
|
||||||
|
@Query('module') module?: string,
|
||||||
|
@Query('range') range?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,5 +22,22 @@ export class ClientOperationsController {
|
|||||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
|
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
|
||||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
dashboard(@TenantId() tenantId?: string) {
|
||||||
|
return this.operations.dashboard({ tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('system-logs')
|
||||||
|
systemLogs(
|
||||||
|
@TenantId() tenantId?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('level') level?: string,
|
||||||
|
@Query('module') module?: string,
|
||||||
|
@Query('range') range?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { AdminOperationsController } from './admin-operations.controller';
|
import { AdminOperationsController, AdminSystemLogsController } from './admin-operations.controller';
|
||||||
import { ClientOperationsController } from './client-operations.controller';
|
import { ClientOperationsController } from './client-operations.controller';
|
||||||
import { OperationsService } from './operations.service';
|
import { OperationsService } from './operations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [AdminOperationsController, ClientOperationsController],
|
controllers: [AdminOperationsController, AdminSystemLogsController, ClientOperationsController],
|
||||||
providers: [OperationsService],
|
providers: [OperationsService],
|
||||||
exports: [OperationsService],
|
exports: [OperationsService],
|
||||||
})
|
})
|
||||||
export class OperationsModule {}
|
export class OperationsModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { OperationsService } from './operations.service';
|
|||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
return {
|
return {
|
||||||
smsBatchTask: {
|
smsBatchTask: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||||
count: jest.fn().mockResolvedValue(3),
|
count: jest.fn().mockResolvedValue(3),
|
||||||
},
|
},
|
||||||
smsMessageRecord: {
|
smsMessageRecord: {
|
||||||
@@ -25,12 +25,38 @@ function createPrismaMock() {
|
|||||||
accountTransaction: {
|
accountTransaction: {
|
||||||
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
|
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
|
||||||
},
|
},
|
||||||
|
tenantAccount: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
|
||||||
|
},
|
||||||
|
rechargeOrder: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
|
||||||
|
},
|
||||||
|
smsTemplate: {
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
smsSignature: {
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
enterpriseCertification: {
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
cmppConnectionState: {
|
cmppConnectionState: {
|
||||||
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||||
},
|
},
|
||||||
operationLog: {
|
operationLog: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([{
|
||||||
groupBy: jest.fn(),
|
id: 'log-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
tenant: { name: '租户A' },
|
||||||
|
user: { displayName: '运营' },
|
||||||
|
action: 'billing.manual_recharge',
|
||||||
|
resource: 'recharge_order',
|
||||||
|
resourceId: 'order-1',
|
||||||
|
detail: { amountCents: 1000 },
|
||||||
|
createdAt: new Date('2026-07-02T01:00:00.000Z'),
|
||||||
|
}]),
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,6 +98,7 @@ describe('OperationsService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
taskCount: 3,
|
taskCount: 3,
|
||||||
uplinkCount: 1,
|
uplinkCount: 1,
|
||||||
|
pendingAuditCount: 6,
|
||||||
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -104,4 +131,26 @@ describe('OperationsService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns paginated operation logs with normalized detail cards', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.systemLogs({ tenantId: 'tenant-1', keyword: '充值', page: 1, pageSize: 5 })).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
modules: ['recharge_order'],
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
level: 'success',
|
||||||
|
tenant: '租户A',
|
||||||
|
module: 'recharge_order',
|
||||||
|
action: 'billing.manual_recharge',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ export interface TraceQuery extends MessageQuery {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OperationLogQuery {
|
||||||
|
tenantId?: string;
|
||||||
|
userId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
level?: string;
|
||||||
|
module?: string;
|
||||||
|
range?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OperationsService {
|
export class OperationsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -71,8 +82,22 @@ export class OperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dashboard(query: { tenantId?: string }) {
|
async dashboard(query: { tenantId?: string }) {
|
||||||
|
const sinceToday = startOfToday();
|
||||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||||
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups] = await Promise.all([
|
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||||
|
const [
|
||||||
|
taskCount,
|
||||||
|
messageGroups,
|
||||||
|
todayMessageGroups,
|
||||||
|
uplinkCount,
|
||||||
|
billingAggregate,
|
||||||
|
transactionAggregate,
|
||||||
|
connectionGroups,
|
||||||
|
pendingAuditCount,
|
||||||
|
tenantAccounts,
|
||||||
|
recentTasks,
|
||||||
|
recentRecharges,
|
||||||
|
] = await Promise.all([
|
||||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||||
this.prisma.smsMessageRecord.groupBy({
|
this.prisma.smsMessageRecord.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
@@ -80,6 +105,12 @@ export class OperationsService {
|
|||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
_sum: { amountCents: true, billingUnits: true },
|
_sum: { amountCents: true, billingUnits: true },
|
||||||
}),
|
}),
|
||||||
|
this.prisma.smsMessageRecord.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
where: todayMessageWhereClause,
|
||||||
|
_count: { _all: true },
|
||||||
|
_sum: { amountCents: true, billingUnits: true },
|
||||||
|
}),
|
||||||
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
||||||
this.prisma.smsBillingRecord.aggregate({
|
this.prisma.smsBillingRecord.aggregate({
|
||||||
where: { tenantId: query.tenantId },
|
where: { tenantId: query.tenantId },
|
||||||
@@ -97,14 +128,50 @@ export class OperationsService {
|
|||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
_sum: { currentConnections: true, desiredConnections: true },
|
_sum: { currentConnections: true, desiredConnections: true },
|
||||||
}),
|
}),
|
||||||
|
this.countPendingAudits(query.tenantId),
|
||||||
|
this.prisma.tenantAccount.findMany({
|
||||||
|
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||||
|
include: { tenant: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
this.prisma.smsBatchTask.findMany({
|
||||||
|
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||||
|
include: { application: true, messages: { take: 1, include: { channel: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
this.prisma.rechargeOrder.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId: query.tenantId,
|
||||||
|
payMethod: 'manual_topup',
|
||||||
|
},
|
||||||
|
include: { tenant: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||||
return {
|
return {
|
||||||
taskCount,
|
taskCount,
|
||||||
messageStatus: messageGroups,
|
messageStatus: messageGroups,
|
||||||
|
today: {
|
||||||
|
sent: todayTotals.total,
|
||||||
|
delivered: todayTotals.delivered,
|
||||||
|
failed: todayTotals.failed,
|
||||||
|
unknown: todayTotals.unknown,
|
||||||
|
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||||
|
spendCents: todayTotals.amountCents,
|
||||||
|
billingUnits: todayTotals.billingUnits,
|
||||||
|
},
|
||||||
uplinkCount,
|
uplinkCount,
|
||||||
billing: billingAggregate,
|
billing: billingAggregate,
|
||||||
transactions: transactionAggregate,
|
transactions: transactionAggregate,
|
||||||
gatewayConnections: connectionGroups,
|
gatewayConnections: connectionGroups,
|
||||||
|
pendingAuditCount,
|
||||||
|
accounts: tenantAccounts,
|
||||||
|
recentTasks,
|
||||||
|
recentRecharges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +209,51 @@ export class OperationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async systemLogs(query: OperationLogQuery) {
|
||||||
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||||
|
const where: Prisma.OperationLogWhereInput = {
|
||||||
|
tenantId: query.tenantId,
|
||||||
|
userId: query.userId,
|
||||||
|
createdAt: createdAtRange(query.range),
|
||||||
|
resource: query.module && query.module !== 'all' ? query.module : undefined,
|
||||||
|
OR: query.keyword ? [
|
||||||
|
{ action: { contains: query.keyword } },
|
||||||
|
{ resource: { contains: query.keyword } },
|
||||||
|
{ resourceId: { contains: query.keyword } },
|
||||||
|
{ tenant: { name: { contains: query.keyword } } },
|
||||||
|
{ user: { displayName: { contains: query.keyword } } },
|
||||||
|
{ user: { username: { contains: query.keyword } } },
|
||||||
|
] : undefined,
|
||||||
|
};
|
||||||
|
const [items, total, modules] = await Promise.all([
|
||||||
|
this.prisma.operationLog.findMany({
|
||||||
|
where,
|
||||||
|
include: { tenant: true, user: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.operationLog.count({ where }),
|
||||||
|
this.prisma.operationLog.groupBy({
|
||||||
|
by: ['resource'],
|
||||||
|
where: { tenantId: query.tenantId },
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { resource: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const normalizedItems = items
|
||||||
|
.map((item) => normalizeOperationLog(item))
|
||||||
|
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
|
||||||
|
return {
|
||||||
|
items: normalizedItems,
|
||||||
|
total: query.level && query.level !== 'all' ? normalizedItems.length : total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
modules: modules.map((item) => item.resource),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
auditSummary(query: { tenantId?: string }) {
|
auditSummary(query: { tenantId?: string }) {
|
||||||
return this.prisma.operationLog.groupBy({
|
return this.prisma.operationLog.groupBy({
|
||||||
by: ['action', 'resource'],
|
by: ['action', 'resource'],
|
||||||
@@ -223,6 +335,15 @@ export class OperationsService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private countPendingAudits(tenantId?: string) {
|
||||||
|
return Promise.all([
|
||||||
|
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
|
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
|
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||||
|
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
|
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||||
@@ -245,3 +366,68 @@ function normalizeGroupBy(groupBy?: string) {
|
|||||||
}
|
}
|
||||||
return 'channelId';
|
return 'channelId';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfToday() {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
|
||||||
|
if (!range || range === 'all') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
if (range === '7d') {
|
||||||
|
date.setDate(date.getDate() - 6);
|
||||||
|
} else if (range === '30d') {
|
||||||
|
date.setDate(date.getDate() - 29);
|
||||||
|
}
|
||||||
|
return { gte: date };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
|
||||||
|
return groups.reduce(
|
||||||
|
(summary, group) => {
|
||||||
|
const count = group._count._all;
|
||||||
|
summary.total += count;
|
||||||
|
summary.amountCents += group._sum.amountCents ?? 0;
|
||||||
|
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||||
|
if (group.status === 'delivered') {
|
||||||
|
summary.delivered += count;
|
||||||
|
} else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) {
|
||||||
|
summary.failed += count;
|
||||||
|
} else if (group.status === 'unknown') {
|
||||||
|
summary.unknown += count;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
{ total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
|
||||||
|
const detail = (log.detail ?? {}) as Record<string, unknown>;
|
||||||
|
const result = String(detail.result ?? detail.status ?? '');
|
||||||
|
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||||
|
? 'error'
|
||||||
|
: log.action.includes('warning') || log.action.includes('risk')
|
||||||
|
? 'warning'
|
||||||
|
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
||||||
|
? 'success'
|
||||||
|
: 'info';
|
||||||
|
return {
|
||||||
|
id: log.id,
|
||||||
|
time: log.createdAt,
|
||||||
|
level,
|
||||||
|
tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'),
|
||||||
|
module: log.resource,
|
||||||
|
operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system',
|
||||||
|
action: log.action,
|
||||||
|
resourceId: log.resourceId ?? '',
|
||||||
|
detail,
|
||||||
|
ip: log.ipAddress ?? '',
|
||||||
|
userAgent: log.userAgent ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
||||||
|
|
||||||
@@ -8,8 +8,28 @@ export class AdminSmsConfigController {
|
|||||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||||
|
|
||||||
@Get('enterprise-applications')
|
@Get('enterprise-applications')
|
||||||
listApplications(@Query('tenantId') tenantId?: string) {
|
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
|
||||||
return this.smsConfig.listApplications(tenantId);
|
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('enterprise-applications/:id/connections')
|
||||||
|
listApplicationConnections(@Param('id') applicationId: string) {
|
||||||
|
return this.smsConfig.listApplicationConnections(applicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('enterprise-applications/:id/cmpp-params')
|
||||||
|
getApplicationCmppParams(@Param('id') applicationId: string) {
|
||||||
|
return this.smsConfig.getApplicationCmppParams(applicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('enterprise-applications/:id/connections/:connectionId/disconnect')
|
||||||
|
disconnectApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
|
||||||
|
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('enterprise-applications/:id/connections/:connectionId')
|
||||||
|
deleteApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
|
||||||
|
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('enterprise-signatures')
|
@Get('enterprise-signatures')
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { SmsConfigService } from './sms-config.service';
|
|||||||
|
|
||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
return {
|
return {
|
||||||
|
smsApplication: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{
|
||||||
|
id: 'app-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: '应用A',
|
||||||
|
status: 'active',
|
||||||
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||||
|
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
||||||
|
}]),
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'app-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: '应用A',
|
||||||
|
status: 'active',
|
||||||
|
secretHash: 'secret-hash',
|
||||||
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -17,6 +35,27 @@ function createPrismaMock() {
|
|||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
|
cmppConnectionState: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||||
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
|
||||||
|
},
|
||||||
|
smsChannel: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({
|
||||||
|
id: 'channel-1',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
enterpriseCode: 'EC',
|
||||||
|
account: 'sp',
|
||||||
|
passwordCipher: 'cipher',
|
||||||
|
srcId: '10690000',
|
||||||
|
cmppVersion: '3.0',
|
||||||
|
config: { maxConnections: 2 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
operationLog: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,4 +71,51 @@ describe('SmsConfigService', () => {
|
|||||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||||
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
|
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists enterprise applications with real CMPP connection state', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'app-1',
|
||||||
|
cmppStatus: 'connected',
|
||||||
|
sentToday: 2,
|
||||||
|
deliveryRate: 50,
|
||||||
|
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns CMPP params from persisted application and channel config', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
||||||
|
applicationId: 'app-1',
|
||||||
|
tenantName: '租户A',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 7890,
|
||||||
|
maxConnections: 2,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects application CMPP connections and writes operation logs', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
|
||||||
|
|
||||||
|
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||||
|
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||||
|
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
|
||||||
|
});
|
||||||
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'cmpp_connection.disconnected',
|
||||||
|
resource: 'cmpp_connection',
|
||||||
|
resourceId: 'channel-1:conn-a',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,16 +57,56 @@ export interface TemplateListQuery {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApplicationListQuery {
|
||||||
|
tenantId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
includeConnections?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsConfigService {
|
export class SmsConfigService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
listApplications(tenantId?: string) {
|
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
||||||
|
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||||
return this.prisma.smsApplication.findMany({
|
return this.prisma.smsApplication.findMany({
|
||||||
where: tenantId ? { tenantId } : undefined,
|
where: {
|
||||||
include: { ipAllowlist: true },
|
tenantId: query.tenantId,
|
||||||
|
OR: query.keyword ? [
|
||||||
|
{ name: { contains: query.keyword } },
|
||||||
|
{ tenant: { name: { contains: query.keyword } } },
|
||||||
|
] : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenant: true,
|
||||||
|
ipAllowlist: true,
|
||||||
|
messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
|
}).then(async (applications) => {
|
||||||
|
if (!query.includeConnections) {
|
||||||
|
return applications;
|
||||||
|
}
|
||||||
|
const tenantIds = [...new Set(applications.map((application) => application.tenantId))];
|
||||||
|
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||||
|
where: { tenantId: { in: tenantIds } },
|
||||||
|
include: { channel: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 500,
|
||||||
|
});
|
||||||
|
return applications.map((application) => {
|
||||||
|
const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId);
|
||||||
|
const todayTotal = application.messageRecords.length;
|
||||||
|
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
|
||||||
|
return {
|
||||||
|
...application,
|
||||||
|
cmppConnections: appConnections,
|
||||||
|
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
|
||||||
|
sentToday: todayTotal,
|
||||||
|
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +161,89 @@ export class SmsConfigService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listApplicationConnections(applicationId: string) {
|
||||||
|
const application = await this.prisma.smsApplication.findUnique({
|
||||||
|
where: { id: applicationId },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
if (!application) {
|
||||||
|
throw new NotFoundException('Application not found');
|
||||||
|
}
|
||||||
|
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||||
|
where: { tenantId: application.tenantId },
|
||||||
|
include: { channel: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
application,
|
||||||
|
connections,
|
||||||
|
summary: {
|
||||||
|
desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0),
|
||||||
|
currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0),
|
||||||
|
status: normalizeApplicationCmppStatus(connections, application.status),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApplicationCmppParams(applicationId: string) {
|
||||||
|
const application = await this.prisma.smsApplication.findUnique({
|
||||||
|
where: { id: applicationId },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
if (!application) {
|
||||||
|
throw new NotFoundException('Application not found');
|
||||||
|
}
|
||||||
|
const channel = await this.prisma.smsChannel.findFirst({
|
||||||
|
where: { status: { not: 'deleted' } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
applicationId: application.id,
|
||||||
|
applicationName: application.name,
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
tenantName: application.tenant.name,
|
||||||
|
appCode: application.id,
|
||||||
|
gatewayHost: channel?.gatewayHost ?? '',
|
||||||
|
gatewayPort: channel?.gatewayPort ?? 0,
|
||||||
|
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code,
|
||||||
|
account: channel?.account ?? application.tenant.code,
|
||||||
|
passwordCipher: channel?.passwordCipher ?? application.secretHash,
|
||||||
|
srcId: channel?.srcId ?? '',
|
||||||
|
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
|
||||||
|
heartbeatSeconds: 30,
|
||||||
|
windowSize: 16,
|
||||||
|
protocolVersion: channel?.cmppVersion ?? '3.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
|
||||||
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||||
|
if (!application) {
|
||||||
|
throw new NotFoundException('Application not found');
|
||||||
|
}
|
||||||
|
const connection = await this.prisma.cmppConnectionState.findFirst({
|
||||||
|
where: { tenantId: application.tenantId, connectionId },
|
||||||
|
});
|
||||||
|
if (!connection) {
|
||||||
|
throw new NotFoundException('Connection not found');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.cmppConnectionState.update({
|
||||||
|
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } },
|
||||||
|
data: {
|
||||||
|
status: 'disconnected',
|
||||||
|
currentConnections: 0,
|
||||||
|
lastDisconnectedAt: new Date(),
|
||||||
|
lastError: data.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, {
|
||||||
|
applicationId,
|
||||||
|
reason: data.reason,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
listSignatures(tenantId?: string) {
|
listSignatures(tenantId?: string) {
|
||||||
return this.prisma.smsSignature.findMany({
|
return this.prisma.smsSignature.findMany({
|
||||||
where: tenantId ? { tenantId } : undefined,
|
where: tenantId ? { tenantId } : undefined,
|
||||||
@@ -407,3 +530,22 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
|||||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfToday() {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
||||||
|
if (applicationStatus !== 'active') {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) {
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||||
|
return 'degraded';
|
||||||
|
}
|
||||||
|
return 'disconnected';
|
||||||
|
}
|
||||||
|
|||||||
+110
-4
@@ -70,7 +70,7 @@ npm run test:gateway
|
|||||||
- Gateway:`npm run spike:gateway` 通过。
|
- Gateway:`npm run spike:gateway` 通过。
|
||||||
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 705.65 TPS,满足 500 TPS。
|
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 705.65 TPS,满足 500 TPS。
|
||||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||||
- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。
|
- API Jest 使用 mock 依赖的结果仅代表单元/轻集成测试通过;系统功能验收仍要求 PostgreSQL/Redis/MinIO 和真实 API smoke 通过。
|
||||||
- 真实 PostgreSQL/Redis/MinIO smoke 通过:
|
- 真实 PostgreSQL/Redis/MinIO smoke 通过:
|
||||||
- PostgreSQL `localhost:5432`、Redis `localhost:6379`、MinIO `localhost:9000/9001` 端口均连通。
|
- PostgreSQL `localhost:5432`、Redis `localhost:6379`、MinIO `localhost:9000/9001` 端口均连通。
|
||||||
- `npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
|
- `npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
|
||||||
@@ -128,7 +128,7 @@ npm run test:gateway
|
|||||||
- P1/P2 补齐:
|
- P1/P2 补齐:
|
||||||
- 新增企业认证模型/API,提交、审核通过、驳回会同步 `Tenant.certificationStatus`,发送前强制认证通过。
|
- 新增企业认证模型/API,提交、审核通过、驳回会同步 `Tenant.certificationStatus`,发送前强制认证通过。
|
||||||
- 新增客户侧导入预览/确认入口,覆盖 CSV/TXT 文本解析、20MB 限制、重复/非法/黑名单/变量缺失提示。
|
- 新增客户侧导入预览/确认入口,覆盖 CSV/TXT 文本解析、20MB 限制、重复/非法/黑名单/变量缺失提示。
|
||||||
- 新增客户/通道 CMPP 连接状态模型/API,Gateway/mock 可回写连接状态,运营 dashboard 聚合连接状态。
|
- 新增客户/通道 CMPP 连接状态模型/API,Gateway 或本地 Gateway 模拟器可通过真实 API 回写连接状态,运营 dashboard 聚合连接状态。
|
||||||
- 新增应用密钥重置、应用/签名/模板状态变化、通道启停接口,并写入系统日志。
|
- 新增应用密钥重置、应用/签名/模板状态变化、通道启停接口,并写入系统日志。
|
||||||
- 无效 `createdById`、`reviewerId` 改为明确 400,不再冒泡数据库外键 500。
|
- 无效 `createdById`、`reviewerId` 改为明确 400,不再冒泡数据库外键 500。
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ npm run test:gateway
|
|||||||
### 剩余说明
|
### 剩余说明
|
||||||
|
|
||||||
- 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。
|
- 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。
|
||||||
- Gateway 连接状态通过 NestJS API 支持 mock/Gateway 回写;真实运营商 SMSC 联调仍需运营商测试环境。
|
- Gateway 连接状态通过 NestJS API 支持 Go Gateway 或本地模拟器回写;真实运营商 SMSC 联调仍需运营商测试环境。
|
||||||
|
|
||||||
## 2026-07-02 运营端优化转真实后端补齐
|
## 2026-07-02 运营端优化转真实后端补齐
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ npm run test:gateway
|
|||||||
- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
|
- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
|
||||||
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
|
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
|
||||||
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。
|
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。
|
||||||
- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核优先调用真实 API,API 不可用时仅保留静态兜底避免开发预览空白。
|
- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核应调用真实 API;API 不可用时页面应展示错误态或空态,静态兜底不能作为验收通过依据。
|
||||||
|
|
||||||
### 新增/更新测试
|
### 新增/更新测试
|
||||||
|
|
||||||
@@ -219,3 +219,109 @@ npm run build
|
|||||||
- 已将今天的验收点补入 `docs/system-functional-test-cases.md`:
|
- 已将今天的验收点补入 `docs/system-functional-test-cases.md`:
|
||||||
- 新增 TC-CLIENT-010 到 TC-CLIENT-011。
|
- 新增 TC-CLIENT-010 到 TC-CLIENT-011。
|
||||||
- 新增 TC-ADMIN-014 到 TC-ADMIN-022。
|
- 新增 TC-ADMIN-014 到 TC-ADMIN-022。
|
||||||
|
|
||||||
|
## 2026-07-02 新增浏览器和业务闭环用例执行
|
||||||
|
|
||||||
|
### 执行环境
|
||||||
|
|
||||||
|
- API:`npm --prefix api run start:dev`,监听 `http://localhost:3000/api`。
|
||||||
|
- 前端:`npm run build` 后使用 `npm run preview -- --port 4173`,访问 `http://localhost:4173`。
|
||||||
|
- Browser 插件:可连接本地 tab,但对 Vite dev 页 `Page.navigate` 超时;改用临时目录 Playwright 包加本机 Chrome 执行浏览器 smoke,未修改项目依赖。
|
||||||
|
- PostgreSQL:本地 `localhost:5432` 可用,API smoke 使用真实数据库。
|
||||||
|
|
||||||
|
### 已执行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 临时目录 C:\Users\hectorzhao\AppData\Local\Temp\cmpp-pw-smoke
|
||||||
|
npm init -y
|
||||||
|
npm install playwright --no-save
|
||||||
|
node <browser-and-api-smoke>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过用例
|
||||||
|
|
||||||
|
| 用例 | 结果 | 覆盖点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-DASHBOARD-CLIENT-UI | UI-SMOKE PASS / BACKEND GAP | 客户端 Dashboard 可渲染账户余额、今日发送、账户状态,但页面数据仍需确认全部来自真实 API。 |
|
||||||
|
| TC-DASHBOARD-ADMIN-UI | UI-SMOKE PASS / BACKEND GAP | 运营端 Dashboard 可渲染今日发送总量、总体成功率、企业消费排行、通道运行,但当前源码仍存在 mock 数据路径。 |
|
||||||
|
| TC-BILLING-MANUAL-UI | UI-SMOKE PASS / BACKEND GAP | 运营端人工充值弹窗填写后,前端表格新增企业、金额、操作人和备注;该页面当前未调用真实充值 API。 |
|
||||||
|
| TC-LOG-ADMIN-UI | UI-SMOKE PASS / BACKEND GAP | 运营端系统日志页面可展示人工充值、账户计费等记录;页面数据仍需接真实日志 API。 |
|
||||||
|
| TC-LOG-CLIENT-UI | UI-SMOKE PASS / BACKEND GAP | 客户端系统日志页面可渲染并展示客户侧日志记录;页面数据仍需接真实日志 API。 |
|
||||||
|
| TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 |
|
||||||
|
| TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 |
|
||||||
|
| TC-BILLING-MANUAL-API | PASS | 人工充值无需审批:确认后账户余额、短信条数、充值单、账户流水和 Dashboard transactions 聚合同步更新。 |
|
||||||
|
| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、链接日志和 Dashboard gatewayConnections 聚合通过。 |
|
||||||
|
|
||||||
|
### 发现和说明
|
||||||
|
|
||||||
|
- `npm run dev` 在本机 5173 被占用后切换到 5174,Vite 首次依赖 bundling 长时间未完成,浏览器看到白屏;生产构建和 preview 渲染正常。
|
||||||
|
- 运营端人工充值页面当前是前端本地状态 smoke,不能作为系统功能通过;真实入账闭环通过 `POST /api/admin/billing/manual-recharges` 验证。
|
||||||
|
- 人工充值不需要审批,测试口径已同步修正为“有权限确认即入账,不产生 pending 审批态”。
|
||||||
|
|
||||||
|
### 真实后端缺口和 Bug 清单
|
||||||
|
|
||||||
|
| 编号 | 严重级别 | 问题 | 证据 | 期望修复 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| BUG-FE-001 | P0 | 运营端人工充值页面未调用真实后端,提交后只更新前端本地表格状态。 | `src/apps/admin/AdminRechargeRecordsPage.tsx` 使用 `rechargeRecordsSeed` 和 `useState`,`submitManualRecharge` 只 `setRecords`。 | 页面提交调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实充值记录、账户余额、流水和日志。 |
|
||||||
|
| BUG-FE-002 | P0 | 运营端 Dashboard 仍使用 mock service 和静态排行,不能证明真实统计准确。 | `src/apps/admin/AdminHome.tsx` 引用 `adminService`、`hourlySendTrend`、`auditTrend`,指标从前端数组计算。 | 接入 `GET /api/admin/operations/dashboard/statistics` 或拆分真实统计接口,所有卡片和排行从 API 返回。 |
|
||||||
|
| BUG-FE-003 | P0 | 客户端 Dashboard 仍使用 mock service,余额、发送量、最近充值等不是实时后端数据。 | `src/apps/client/ClientHome.tsx` 使用 `clientService.getOverview()` 和客户端 mock 数据。 | 接入客户端真实 dashboard、账户、任务、充值流水 API,点击明细继承真实筛选条件。 |
|
||||||
|
| BUG-FE-004 | P0 | 客户端和运营端系统日志页面仍有静态数据路径,无法验证真实日志、分页、筛选和租户隔离。 | `src/apps/admin/AdminSystemLogsPage.tsx`、`src/apps/client/ClientSystemLogsPage.tsx` 页面 smoke 可展示,但未证明调用真实日志 API。 | 接入真实日志 API,支持分页、筛选、详情、租户隔离,失败动作也可查。 |
|
||||||
|
| BUG-FE-005 | P0 | 企业应用 CMPP 状态和连接详情页面仍使用本地初始数据,未读取真实连接状态 API。 | `src/apps/admin/AdminEnterpriseApplicationsPage.tsx` 使用 `initialSmsApps`、`setSmsApps`,连接删除也是本地状态变更。 | 接入企业应用、连接状态、连接详情、连接删除/断开真实 API 或 Gateway 回写接口。 |
|
||||||
|
| BUG-API-001 | P1 | 通道创建参数缺失时返回 Prisma 500,而不是业务 400。 | 浏览器 smoke 第一轮 `POST /api/admin/channels` 缺少 `code/gatewayHost/gatewayPort/account/passwordCipher/srcId`,API 返回 Internal server error。 | 为通道创建 DTO 增加校验,缺失必填字段返回 400 和可读错误,并写失败日志。 |
|
||||||
|
| BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174,Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 |
|
||||||
|
|
||||||
|
## 2026-07-02 真实后端缺口修复
|
||||||
|
|
||||||
|
### 本轮修复范围
|
||||||
|
|
||||||
|
- BUG-FE-001:运营端充值记录页移除 `rechargeRecordsSeed` 验收路径,加载真实租户、人工充值记录、账户余额和账户流水;确认人工充值调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实记录、账户、流水,并由后端写 `billing.manual_recharge` 操作日志,不产生 pending 审批态。
|
||||||
|
- BUG-FE-002:运营端 Dashboard 移除 `adminService`、静态趋势和前端排行计算,改为调用 `GET /api/admin/operations/dashboard/statistics`、真实通道 API 和真实账户聚合。
|
||||||
|
- BUG-FE-003:客户端 Dashboard 移除 `clientService`、静态趋势和本地 mock,改为调用 `GET /api/client/operations/dashboard`、客户端账务/任务聚合,并通过 `x-tenant-id` 限定当前租户。
|
||||||
|
- BUG-FE-004:运营端和客户端系统日志页移除静态 `logsSeed`,接入真实日志 API,支持分页、关键字、级别、模块和时间范围;长详情使用详情卡展示 JSON 摘要。
|
||||||
|
- BUG-FE-005:企业应用管理短信应用 tab 接入真实企业应用、租户连接状态、连接详情和 CMPP 参数 API;断开连接调用真实后端并写系统日志,变更后刷新列表。彩信 tab 仍为第一版待开发路径,不作为短信验收依据。
|
||||||
|
- BUG-API-001:通道创建在 Service 层校验 `code/name/gatewayHost/gatewayPort/account/passwordCipher/srcId`,缺失或端口非法返回 400,不再让 Prisma validation error 冒泡成 500。
|
||||||
|
- BUG-DEV-001:复现 Vite 8 dev server 在端口切换后依赖/模块转换请求超时,导致白屏;根 `npm run dev` 改为先 `npm run build` 再 `vite preview --host 0.0.0.0`,确保本地打开稳定。`vite.config.ts` 保留 `optimizeDeps.noDiscovery`,避免自动扫描引发的预构建卡住。
|
||||||
|
|
||||||
|
### 新增/更新测试
|
||||||
|
|
||||||
|
| 测试文件 | 新增覆盖 |
|
||||||
|
| --- | --- |
|
||||||
|
| `api/src/channels/channels.service.spec.ts` | 通道创建缺少必填字段时返回可读 400。 |
|
||||||
|
| `api/src/billing/billing.service.spec.ts` | 人工充值写入 `billing.manual_recharge` 操作日志。 |
|
||||||
|
| `api/src/operations/operations.service.spec.ts` | Dashboard 新增今日统计、账户/充值/待审核聚合和系统日志分页详情。 |
|
||||||
|
| `api/src/sms-config/sms-config.service.spec.ts` | 企业应用列表聚合真实 CMPP 连接状态、CMPP 参数读取、断开连接写日志。 |
|
||||||
|
|
||||||
|
### 已执行命令和 Smoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix api test
|
||||||
|
npm --prefix api run build
|
||||||
|
npm run build
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# API HTTP smoke on API_PORT=3101
|
||||||
|
GET /api/health
|
||||||
|
POST /api/admin/channels # 缺必填字段返回 400
|
||||||
|
GET /api/admin/operations/dashboard/statistics
|
||||||
|
GET /api/admin/system-logs?page=1&pageSize=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 当前结果
|
||||||
|
|
||||||
|
- API Jest:8 个 test suite 通过,43 个测试通过。
|
||||||
|
- API build:通过。
|
||||||
|
- 前端 build:通过,仍存在既有大 chunk warning。
|
||||||
|
- `npm run dev`:通过,当前会 build 后启动 Vite preview,实际可访问 `http://localhost:4173/`,避免 Vite 8 dev optimizer/transform 白屏。
|
||||||
|
- 浏览器 smoke 通过:
|
||||||
|
- 运营端 Dashboard 渲染真实聚合指标,无相关 console error。
|
||||||
|
- 运营端人工充值页渲染真实记录,人工充值弹窗展示真实企业下拉和确认入口。
|
||||||
|
- 运营端系统日志页渲染真实日志,长详情以卡片展示。
|
||||||
|
- 企业应用管理页渲染真实应用和 CMPP 状态,连接详情弹窗和 CMPP 参数弹窗可打开。
|
||||||
|
- 客户端 Dashboard 和客户端系统日志页按当前租户渲染,无相关 console error。
|
||||||
|
- API HTTP smoke:`/api/health` 返回 ok;通道缺参返回 400 和可读错误;dashboard/statistics、system-logs 返回真实数据。
|
||||||
|
|
||||||
|
### 剩余说明
|
||||||
|
|
||||||
|
- 根 `npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "npm run build && vite preview --host 0.0.0.0",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"build:api": "npm --prefix api run build",
|
"build:api": "npm --prefix api run build",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ type RequestOptions = RequestInit & {
|
|||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
@@ -74,7 +76,178 @@ export type SmsTemplateAudit = {
|
|||||||
tenant?: { name: string };
|
tenant?: { name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardResponse = {
|
||||||
|
taskCount: number;
|
||||||
|
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
|
||||||
|
today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; billingUnits: number };
|
||||||
|
uplinkCount: number;
|
||||||
|
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
|
||||||
|
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } };
|
||||||
|
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
||||||
|
pendingAuditCount: number;
|
||||||
|
accounts: Array<{ id: string; tenantId: string; balanceCents: number; smsUnits: number; creditCents: number; status: string; tenant?: TenantOption }>;
|
||||||
|
recentTasks: Array<Record<string, unknown>>;
|
||||||
|
recentRecharges: Array<RechargeOrder>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RechargeOrder = {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
orderNo: string;
|
||||||
|
amountCents: number;
|
||||||
|
smsUnits: number;
|
||||||
|
status: string;
|
||||||
|
payMethod?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
operatorId?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
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 TenantAccount = {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
balanceCents: number;
|
||||||
|
smsUnits: number;
|
||||||
|
creditCents: number;
|
||||||
|
status: string;
|
||||||
|
tenant?: TenantOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperationLogItem = {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
level: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
tenant: string;
|
||||||
|
module: string;
|
||||||
|
operator: string;
|
||||||
|
action: string;
|
||||||
|
resourceId: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperationLogResponse = {
|
||||||
|
items: OperationLogItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
modules: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnterpriseApplication = {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
scene?: string | null;
|
||||||
|
status: string;
|
||||||
|
dailyLimit?: number | null;
|
||||||
|
tenant?: TenantOption;
|
||||||
|
sentToday?: number;
|
||||||
|
deliveryRate?: number;
|
||||||
|
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||||
|
cmppConnections?: CmppConnectionState[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CmppConnectionState = {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
channelId: string;
|
||||||
|
connectionId: string;
|
||||||
|
status: string;
|
||||||
|
desiredConnections: number;
|
||||||
|
currentConnections: number;
|
||||||
|
lastConnectedAt?: string | null;
|
||||||
|
lastDisconnectedAt?: string | null;
|
||||||
|
lastHeartbeatAt?: string | null;
|
||||||
|
reconnectCount: number;
|
||||||
|
lastError?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
channel?: AdminChannel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplicationConnectionsResponse = {
|
||||||
|
application: EnterpriseApplication;
|
||||||
|
connections: CmppConnectionState[];
|
||||||
|
summary: { desiredConnections: number; currentConnections: number; status: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplicationCmppParams = {
|
||||||
|
applicationId: string;
|
||||||
|
applicationName: string;
|
||||||
|
tenantName: string;
|
||||||
|
appCode: string;
|
||||||
|
gatewayHost: string;
|
||||||
|
gatewayPort: number;
|
||||||
|
enterpriseCode: string;
|
||||||
|
account: string;
|
||||||
|
passwordCipher: string;
|
||||||
|
srcId: string;
|
||||||
|
maxConnections: number;
|
||||||
|
heartbeatSeconds: number;
|
||||||
|
windowSize: number;
|
||||||
|
protocolVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withQuery(path: string, query: Record<string, string | number | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== '' && value !== 'all') {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const suffix = params.toString() ? `?${params}` : '';
|
||||||
|
return `${path}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||||
|
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||||
|
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||||
|
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||||
|
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 })),
|
||||||
|
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
|
||||||
|
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||||
|
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||||
|
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||||
|
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ status, reason }),
|
||||||
|
}),
|
||||||
|
listApplicationConnections: (applicationId: string) =>
|
||||||
|
request<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
|
||||||
|
disconnectApplicationConnection: (applicationId: string, connectionId: string, reason?: string) =>
|
||||||
|
request<CmppConnectionState>(`/admin/enterprise-applications/${applicationId}/connections/${connectionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
}),
|
||||||
|
getApplicationCmppParams: (applicationId: string) =>
|
||||||
|
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
|
||||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||||
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
|
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -118,3 +291,14 @@ export const adminApi = {
|
|||||||
body: JSON.stringify({ reason }),
|
body: JSON.stringify({ reason }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clientApi = {
|
||||||
|
getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
|
||||||
|
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||||
|
listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
|
||||||
|
listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||||
|
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication } from '@/api/adminApi';
|
||||||
|
|
||||||
type SmsApp = {
|
type SmsApp = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,41 +49,6 @@ type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
|
|||||||
|
|
||||||
type AppKind = 'sms' | 'mms';
|
type AppKind = 'sms' | 'mms';
|
||||||
|
|
||||||
const initialSmsApps: SmsApp[] = [
|
|
||||||
{
|
|
||||||
id: 'app-1',
|
|
||||||
name: '营销推广平台',
|
|
||||||
enterprise: '上海XXXXX科技有限公司',
|
|
||||||
appId: 'AK_2024010912345678',
|
|
||||||
enabled: true,
|
|
||||||
sentToday: 1500,
|
|
||||||
deliveryRate: 95,
|
|
||||||
unitPrice: 0.05,
|
|
||||||
cmppStatus: 'connected',
|
|
||||||
cmppParams: { host: '127.0.0.1', port: 7890, enterpriseCode: '900123', account: 'AC900123', password: 'PW-9x8k2m', accessNumber: '106900123', maxConnections: 2, heartbeatSeconds: 30, windowSize: 32, protocolVersion: 'CMPP 2.0' },
|
|
||||||
cmppConnections: [
|
|
||||||
{ id: 'CMPP-001-A', state: 'open', bindType: 'transceiver', clientIp: '10.24.8.12:32516', sourceAddr: '900123', establishedAt: '2026-07-02 08:42:11', lastHeartbeatAt: '2026-07-02 10:18:32', lastSubmitAt: '2026-07-02 10:17:58', pendingWindow: 18 },
|
|
||||||
{ id: 'CMPP-001-B', state: 'open', bindType: 'submitter', clientIp: '10.24.8.13:32520', sourceAddr: '900123', establishedAt: '2026-07-02 08:43:02', lastHeartbeatAt: '2026-07-02 10:18:28', lastSubmitAt: '2026-07-02 10:18:06', pendingWindow: 11 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app-2',
|
|
||||||
name: '客户服务系统',
|
|
||||||
enterprise: '重庆进载数智',
|
|
||||||
appId: 'AK_2024010987654321',
|
|
||||||
enabled: true,
|
|
||||||
sentToday: 800,
|
|
||||||
deliveryRate: 90,
|
|
||||||
unitPrice: 0.06,
|
|
||||||
cmppStatus: 'disconnected',
|
|
||||||
cmppParams: { host: '127.0.0.1', port: 7891, enterpriseCode: '901778', account: 'AC901778', password: 'PW-4n7q1a', accessNumber: '106901778', maxConnections: 1, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' },
|
|
||||||
cmppConnections: [
|
|
||||||
{ id: 'CMPP-002-A', state: 'closed', bindType: 'transceiver', clientIp: '10.24.9.21:31888', sourceAddr: '901778', establishedAt: '2026-07-02 07:55:19', lastHeartbeatAt: '2026-07-02 09:21:44', lastSubmitAt: '2026-07-02 09:20:17', pendingWindow: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive', cmppParams: { host: '127.0.0.1', port: 7892, enterpriseCode: '902456', account: 'AC902456', password: 'PW-2d6f8p', accessNumber: '106902456', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' }, cmppConnections: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialMmsApps: MmsApp[] = [
|
const initialMmsApps: MmsApp[] = [
|
||||||
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
|
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
|
||||||
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
|
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
|
||||||
@@ -117,18 +83,18 @@ const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone
|
|||||||
reconnecting: { label: '重连中', tone: 'warning' },
|
reconnecting: { label: '重连中', tone: 'warning' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatCmppParams(app: SmsApp) {
|
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||||||
const { cmppParams } = app;
|
const cmppParams = params ?? app.cmppParams;
|
||||||
return [
|
return [
|
||||||
`应用名称: ${app.name}`,
|
`应用名称: ${app.name}`,
|
||||||
`企业名称: ${app.enterprise}`,
|
`企业名称: ${app.enterprise}`,
|
||||||
`AppID: ${app.appId}`,
|
`AppID: ${app.appId}`,
|
||||||
`CMPP网关地址: ${cmppParams.host}`,
|
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
|
||||||
`CMPP网关端口: ${cmppParams.port}`,
|
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
|
||||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||||
`接口账号: ${cmppParams.account}`,
|
`接口账号: ${cmppParams.account}`,
|
||||||
`接口密码: ${cmppParams.password}`,
|
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
|
||||||
`接入号: ${cmppParams.accessNumber}`,
|
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
||||||
`最大连接数: ${cmppParams.maxConnections}`,
|
`最大连接数: ${cmppParams.maxConnections}`,
|
||||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||||
`提交窗口: ${cmppParams.windowSize}`,
|
`提交窗口: ${cmppParams.windowSize}`,
|
||||||
@@ -136,9 +102,13 @@ function formatCmppParams(app: SmsApp) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void }) {
|
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const paramsText = formatCmppParams(app);
|
const paramsText = formatCmppParams(app, params);
|
||||||
|
const host = params?.gatewayHost ?? app.cmppParams.host;
|
||||||
|
const port = params?.gatewayPort ?? app.cmppParams.port;
|
||||||
|
const password = params?.passwordCipher ?? app.cmppParams.password;
|
||||||
|
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
|
||||||
|
|
||||||
async function copyParams() {
|
async function copyParams() {
|
||||||
await navigator.clipboard.writeText(paramsText);
|
await navigator.clipboard.writeText(paramsText);
|
||||||
@@ -161,16 +131,16 @@ function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void })
|
|||||||
>
|
>
|
||||||
<div className="cmpp-param-detail">
|
<div className="cmpp-param-detail">
|
||||||
<div className="cmpp-param-grid">
|
<div className="cmpp-param-grid">
|
||||||
<div><span>CMPP网关地址</span><strong>{app.cmppParams.host}</strong></div>
|
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
|
||||||
<div><span>CMPP网关端口</span><strong>{app.cmppParams.port}</strong></div>
|
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
|
||||||
<div><span>企业代码</span><strong>{app.cmppParams.enterpriseCode}</strong></div>
|
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
|
||||||
<div><span>接口账号</span><strong>{app.cmppParams.account}</strong></div>
|
<div><span>接口账号</span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
|
||||||
<div><span>接口密码</span><strong>{app.cmppParams.password}</strong></div>
|
<div><span>接口密码</span><strong>{password}</strong></div>
|
||||||
<div><span>接入号</span><strong>{app.cmppParams.accessNumber}</strong></div>
|
<div><span>接入号</span><strong>{srcId}</strong></div>
|
||||||
<div><span>最大连接数</span><strong>{app.cmppParams.maxConnections}</strong></div>
|
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
||||||
<div><span>心跳间隔</span><strong>{app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||||||
<div><span>提交窗口</span><strong>{app.cmppParams.windowSize}</strong></div>
|
<div><span>提交窗口</span><strong>{params?.windowSize ?? app.cmppParams.windowSize}</strong></div>
|
||||||
<div><span>协议版本</span><strong>{app.cmppParams.protocolVersion}</strong></div>
|
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,64 +206,79 @@ function CmppConnectionModal({
|
|||||||
|
|
||||||
export function AdminEnterpriseApplicationsPage() {
|
export function AdminEnterpriseApplicationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [smsApps, setSmsApps] = useState(initialSmsApps);
|
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
|
||||||
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
||||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||||
|
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [confirmAction, setConfirmAction] = useState<
|
const [confirmAction, setConfirmAction] = useState<
|
||||||
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
||||||
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
function confirmToggle(kind: AppKind, id: string) {
|
async function loadSmsApps() {
|
||||||
|
try {
|
||||||
|
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
|
||||||
|
setSmsApps(applications.map(mapApplication));
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
setSmsApps([]);
|
||||||
|
setError(err instanceof Error ? err.message : '企业应用加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSmsApps();
|
||||||
|
}, [enterpriseKeyword]);
|
||||||
|
|
||||||
|
async function confirmToggle(kind: AppKind, id: string) {
|
||||||
if (kind === 'sms') {
|
if (kind === 'sms') {
|
||||||
setSmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
const app = smsApps.find((item) => item.id === id);
|
||||||
|
if (app) {
|
||||||
|
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
|
||||||
|
await loadSmsApps();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(kind: AppKind, id: string) {
|
async function confirmDelete(kind: AppKind, id: string) {
|
||||||
if (kind === 'sms') {
|
if (kind === 'sms') {
|
||||||
setSmsApps((current) => current.filter((item) => item.id !== id));
|
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||||||
|
await loadSmsApps();
|
||||||
} else {
|
} else {
|
||||||
setMmsApps((current) => current.filter((item) => item.id !== id));
|
setMmsApps((current) => current.filter((item) => item.id !== id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runConfirmedAction() {
|
async function runConfirmedAction() {
|
||||||
if (!confirmAction) {
|
if (!confirmAction) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirmAction.action === 'toggle') {
|
if (confirmAction.action === 'toggle') {
|
||||||
confirmToggle(confirmAction.kind, confirmAction.id);
|
await confirmToggle(confirmAction.kind, confirmAction.id);
|
||||||
} else {
|
} else {
|
||||||
confirmDelete(confirmAction.kind, confirmAction.id);
|
await confirmDelete(confirmAction.kind, confirmAction.id);
|
||||||
}
|
}
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConnection(appId: string, connectionId: string) {
|
async function deleteConnection(appId: string, connectionId: string) {
|
||||||
let nextConnectionApp: SmsApp | null = null;
|
await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接');
|
||||||
setSmsApps((current) => current.map((app) => {
|
const data = await adminApi.listApplicationConnections(appId);
|
||||||
if (app.id !== appId) {
|
const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] });
|
||||||
return app;
|
setConnectionApp(nextApp);
|
||||||
|
await loadSmsApps();
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConnections = app.cmppConnections.filter((connection) => connection.id !== connectionId);
|
async function openParams(app: SmsApp) {
|
||||||
const nextOpenCount = nextConnections.filter((connection) => connection.state === 'open').length;
|
setParamsApp(app);
|
||||||
const nextApp: SmsApp = {
|
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
|
||||||
...app,
|
|
||||||
cmppConnections: nextConnections,
|
|
||||||
cmppStatus: nextOpenCount > 0 ? 'connected' : app.enabled ? 'disconnected' : 'inactive',
|
|
||||||
};
|
|
||||||
nextConnectionApp = nextApp;
|
|
||||||
return nextApp;
|
|
||||||
}));
|
|
||||||
setConnectionApp(nextConnectionApp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSmsApps = useMemo(
|
const filteredSmsApps = useMemo(
|
||||||
@@ -325,7 +310,7 @@ export function AdminEnterpriseApplicationsPage() {
|
|||||||
<button onClick={() => setConnectionApp(record)} type="button">
|
<button onClick={() => setConnectionApp(record)} type="button">
|
||||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||||
</button>
|
</button>
|
||||||
<button className="cmpp-status-cell__params" onClick={() => setParamsApp(record)} type="button">
|
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
|
||||||
<Settings2 size={13} />
|
<Settings2 size={13} />
|
||||||
参数
|
参数
|
||||||
</button>
|
</button>
|
||||||
@@ -397,6 +382,8 @@ export function AdminEnterpriseApplicationsPage() {
|
|||||||
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost">重置</Button>
|
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost">重置</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||||
|
|
||||||
<div className="surface section-stack">
|
<div className="surface section-stack">
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
@@ -413,17 +400,49 @@ export function AdminEnterpriseApplicationsPage() {
|
|||||||
? `确认删除应用“${confirmAction.name}”吗?`
|
? `确认删除应用“${confirmAction.name}”吗?`
|
||||||
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
|
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
|
||||||
onCancel={() => setConfirmAction(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
onConfirm={runConfirmedAction}
|
onConfirm={() => { void runConfirmedAction(); }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{connectionApp ? (
|
{connectionApp ? (
|
||||||
<CmppConnectionModal
|
<CmppConnectionModal
|
||||||
app={connectionApp}
|
app={connectionApp}
|
||||||
onClose={() => setConnectionApp(null)}
|
onClose={() => setConnectionApp(null)}
|
||||||
onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)}
|
onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{paramsApp ? <CmppParamsModal app={paramsApp} onClose={() => setParamsApp(null)} /> : null}
|
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||||
|
const connections = (application.cmppConnections ?? []).map(mapConnection);
|
||||||
|
return {
|
||||||
|
id: application.id,
|
||||||
|
name: application.name,
|
||||||
|
enterprise: application.tenant?.name ?? application.tenantId,
|
||||||
|
appId: application.id,
|
||||||
|
enabled: application.status === 'active',
|
||||||
|
sentToday: application.sentToday ?? 0,
|
||||||
|
deliveryRate: application.deliveryRate ?? 0,
|
||||||
|
unitPrice: 0,
|
||||||
|
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||||||
|
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
|
||||||
|
cmppConnections: connections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapConnection(connection: CmppConnectionState): CmppConnection {
|
||||||
|
const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0;
|
||||||
|
return {
|
||||||
|
id: connection.connectionId,
|
||||||
|
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||||||
|
bindType: 'transceiver',
|
||||||
|
clientIp: String(connection.channel?.gatewayHost ?? ''),
|
||||||
|
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
|
||||||
|
establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '',
|
||||||
|
lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '',
|
||||||
|
lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '',
|
||||||
|
pendingWindow: connection.currentConnections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+64
-142
@@ -1,11 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock3,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
Send,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -19,59 +17,17 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { auditTrend, hourlySendTrend } from '@/mock/chartData';
|
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { adminService, type AuditStatus } from '@/mock';
|
|
||||||
import { createAuditColumns } from '@/apps/admin/auditColumns';
|
|
||||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
||||||
|
|
||||||
type SignatureRank = {
|
|
||||||
id: string;
|
|
||||||
signature: string;
|
|
||||||
customer: string;
|
|
||||||
type: '不含引流' | '仅引流';
|
|
||||||
successCount: number;
|
|
||||||
successRate: number;
|
|
||||||
averageSeconds: number;
|
|
||||||
status: '正常' | '关注' | '异常';
|
|
||||||
};
|
|
||||||
|
|
||||||
type EnterpriseSpendRank = {
|
type EnterpriseSpendRank = {
|
||||||
id: string;
|
id: string;
|
||||||
city: string;
|
|
||||||
enterprise: string;
|
enterprise: string;
|
||||||
contact: string;
|
|
||||||
todaySpend: number;
|
todaySpend: number;
|
||||||
balanceStatus: '充足' | '紧张' | '欠费';
|
balanceStatus: '充足' | '紧张' | '欠费';
|
||||||
availableBalance: number;
|
availableBalance: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signatureRanks: SignatureRank[] = [
|
|
||||||
{ id: 'SIG-001', signature: '[XX银行]', customer: '上海云舟科技', type: '不含引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
|
|
||||||
{ id: 'SIG-002', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '不含引流', successCount: 627, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
|
|
||||||
{ id: 'SIG-003', signature: '[XXAPP]', customer: '深圳北辰出行', type: '不含引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
|
|
||||||
{ id: 'SIG-004', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '不含引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
|
|
||||||
{ id: 'SIG-005', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '不含引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
|
|
||||||
{ id: 'SIG-101', signature: '[XX银行]', customer: '上海云舟科技', type: '仅引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' },
|
|
||||||
{ id: 'SIG-102', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '仅引流', successCount: 527, successRate: 65.3, averageSeconds: 2.5, status: '关注' },
|
|
||||||
{ id: 'SIG-103', signature: '[XXAPP]', customer: '深圳北辰出行', type: '仅引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' },
|
|
||||||
{ id: 'SIG-104', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '仅引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' },
|
|
||||||
{ id: 'SIG-105', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '仅引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const enterpriseSpendRanks: EnterpriseSpendRank[] = [
|
|
||||||
{ id: 'ENT-001', city: '上海', enterprise: '上海XXXXX科技有限公司', contact: '赵先生', todaySpend: 1123.4, balanceStatus: '充足', availableBalance: 286420 },
|
|
||||||
{ id: 'ENT-002', city: '上海', enterprise: '上海云舟科技有限公司', contact: '王女士', todaySpend: 256.3, balanceStatus: '充足', availableBalance: 94220 },
|
|
||||||
{ id: 'ENT-003', city: '深圳', enterprise: '深圳XXXXX科技有限公司', contact: '陈先生', todaySpend: 97.25, balanceStatus: '紧张', availableBalance: 1200 },
|
|
||||||
{ id: 'ENT-004', city: '北京', enterprise: '北京XXXXX科技有限公司', contact: '刘女士', todaySpend: 66.2, balanceStatus: '充足', availableBalance: 55200 },
|
|
||||||
{ id: 'ENT-005', city: '杭州', enterprise: '杭州XXXXX科技有限公司', contact: '周先生', todaySpend: 12, balanceStatus: '欠费', availableBalance: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const rankStatusTone = {
|
|
||||||
正常: 'success',
|
|
||||||
关注: 'warning',
|
|
||||||
异常: 'danger',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const balanceTone = {
|
const balanceTone = {
|
||||||
充足: 'success',
|
充足: 'success',
|
||||||
紧张: 'warning',
|
紧张: 'warning',
|
||||||
@@ -91,57 +47,65 @@ function formatCount(value: number) {
|
|||||||
|
|
||||||
export function AdminHome() {
|
export function AdminHome() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const overview = adminService.getOverview();
|
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||||
const [audits, setAudits] = useState(() => adminService.getAudits());
|
const [error, setError] = useState('');
|
||||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||||
const channels = adminService.getChannels();
|
const [channels, setChannels] = useState<Array<{ id: string; name: string; status: string; rateLimitPerSecond: number }>>([]);
|
||||||
|
|
||||||
function updateAuditStatus(id: string, status: AuditStatus) {
|
useEffect(() => {
|
||||||
setAudits(adminService.updateAuditStatus(id, status));
|
Promise.all([adminApi.getDashboard(), adminApi.listChannels()])
|
||||||
}
|
.then(([nextDashboard, nextChannels]) => {
|
||||||
|
setDashboard(nextDashboard);
|
||||||
|
setChannels(nextChannels);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : '运营看板加载失败');
|
||||||
|
setDashboard(null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const auditColumns = useMemo(() => createAuditColumns(updateAuditStatus), []);
|
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
|
||||||
const pendingAudits = audits.filter((item) => item.status === 'pending');
|
return (dashboard?.accounts ?? []).map((account) => {
|
||||||
|
const todaySpend = Math.abs(dashboard?.recentRecharges
|
||||||
|
.filter((item) => item.tenantId === account.tenantId)
|
||||||
|
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
|
||||||
|
const availableBalance = (account.balanceCents + account.creditCents) / 100;
|
||||||
|
return {
|
||||||
|
id: account.tenantId,
|
||||||
|
enterprise: account.tenant?.name ?? account.tenantId,
|
||||||
|
todaySpend,
|
||||||
|
availableBalance,
|
||||||
|
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
|
||||||
|
};
|
||||||
|
}).sort((left, right) => right.todaySpend - left.todaySpend);
|
||||||
|
}, [dashboard]);
|
||||||
|
|
||||||
const noDiversionSignatureRanks = signatureRanks.filter((item) => item.type === '不含引流');
|
const totalSend = dashboard?.today.sent ?? 0;
|
||||||
const diversionSignatureRanks = signatureRanks.filter((item) => item.type === '仅引流');
|
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||||
|
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||||
const totalSend = signatureRanks.reduce((sum, item) => sum + item.successCount, 0);
|
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||||
const averageSuccessRate = signatureRanks.reduce((sum, item) => sum + item.successRate, 0) / signatureRanks.length;
|
|
||||||
const todaySpend = enterpriseSpendRanks.reduce((sum, item) => sum + item.todaySpend, 0);
|
|
||||||
const activeSignatureCount = new Set(signatureRanks.map((item) => item.signature)).size;
|
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() => createLineOption({
|
||||||
labels: hourlySendTrend.map((item) => item.time),
|
labels: ['今日'],
|
||||||
series: [
|
series: [
|
||||||
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
|
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
||||||
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
|
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const auditTrendOption = useMemo(
|
const auditTrendOption = useMemo(
|
||||||
() => createBarOption({
|
() => createBarOption({
|
||||||
labels: auditTrend.map((item) => item.day),
|
labels: ['待审核'],
|
||||||
series: [
|
series: [
|
||||||
{ name: '通过', data: auditTrend.map((item) => item.approved) },
|
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
|
||||||
{ name: '驳回', data: auditTrend.map((item) => item.rejected) },
|
|
||||||
{ name: '待审', data: auditTrend.map((item) => item.pending) },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const signatureColumns: Array<TableColumn<SignatureRank>> = [
|
|
||||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
|
||||||
{ key: 'signature', title: '签名', render: (record) => <strong>{record.signature}</strong> },
|
|
||||||
{ key: 'successCount', title: '成功总数', align: 'right', render: (record) => formatCount(record.successCount) },
|
|
||||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
|
|
||||||
{ key: 'averageSeconds', title: '平均时长(秒)', align: 'right', render: (record) => record.averageSeconds },
|
|
||||||
];
|
|
||||||
|
|
||||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
||||||
{
|
{
|
||||||
@@ -150,7 +114,7 @@ export function AdminHome() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div>
|
<div>
|
||||||
<strong>{record.enterprise}</strong>
|
<strong>{record.enterprise}</strong>
|
||||||
<p className="text-caption">{record.city} · {record.contact}</p>
|
<p className="text-caption">{record.id}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -169,12 +133,10 @@ export function AdminHome() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const channelColumns: Array<TableColumn<ReturnType<typeof adminService.getChannels>[number]>> = [
|
const channelColumns: Array<TableColumn<(typeof channels)[number]>> = [
|
||||||
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
|
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
|
||||||
{ key: 'region', title: '区域', render: (record) => <span className="muted">{record.region}</span> },
|
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status}</Tag> },
|
||||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` },
|
{ key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` },
|
||||||
{ key: 'latencyMs', title: '平均延迟', align: 'right', render: (record) => `${record.latencyMs}ms` },
|
|
||||||
{ key: 'enabled', title: '状态', render: (record) => <Tag tone={record.enabled ? 'success' : 'neutral'}>{record.enabled ? '运行中' : '已停用'}</Tag> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -197,68 +159,45 @@ export function AdminHome() {
|
|||||||
<div className="dashboard-grid admin-metric-grid">
|
<div className="dashboard-grid admin-metric-grid">
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日发送总量</span>
|
<span>今日发送总量</span>
|
||||||
<strong>{(totalSend / 10000).toFixed(4)}万条</strong>
|
<strong>{formatCount(totalSend)} 条</strong>
|
||||||
<small>基于签名发送排行汇总</small>
|
<small>来自真实短信记录聚合</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>总体成功率</span>
|
<span>总体成功率</span>
|
||||||
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
||||||
<small>包含不含引流与仅引流口径</small>
|
<small>delivered / 今日总量</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日消费</span>
|
<span>今日消费</span>
|
||||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
<strong>¥{formatCurrency(todaySpend)}</strong>
|
||||||
<small>企业消费排行汇总</small>
|
<small>来自今日消息金额聚合</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>活跃签名数量</span>
|
<span>通道在线连接</span>
|
||||||
<strong>{activeSignatureCount}</strong>
|
<strong>{activeConnectionCount}</strong>
|
||||||
<small>今日有成功发送记录的签名</small>
|
<small>Gateway 连接状态回写</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||||
|
|
||||||
<div className="chart-grid">
|
<div className="chart-grid">
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>今日发送趋势</h2>
|
<h2>今日发送趋势</h2>
|
||||||
<p className="muted">按 3 小时聚合平台提交量和成功量。</p>
|
<p className="muted">按真实后端今日聚合展示提交量和成功量。</p>
|
||||||
<Chart height={300} option={sendTrendOption} />
|
<Chart height={300} option={sendTrendOption} />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<h2>审核处理趋势</h2>
|
<h2>审核处理趋势</h2>
|
||||||
<p className="muted">近 7 天模板、签名审核处理情况。</p>
|
<p className="muted">待审核数量来自真实审核聚合。</p>
|
||||||
<Chart height={300} option={auditTrendOption} />
|
<Chart height={300} option={auditTrendOption} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overview-grid admin-signature-rank-grid">
|
|
||||||
<div className="surface section-stack">
|
|
||||||
<div className="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>今日省签名发送量排行 - 不含引流</h2>
|
|
||||||
<p className="muted">字段来自截图结构:排名、签名、成功总数、成功率、平均时长和状态。</p>
|
|
||||||
</div>
|
|
||||||
<Tag tone="info">{noDiversionSignatureRanks.length} 条</Tag>
|
|
||||||
</div>
|
|
||||||
<Table columns={signatureColumns} data={noDiversionSignatureRanks} rowKey="id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface section-stack">
|
|
||||||
<div className="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>今日省签名发送量排行 - 仅引流</h2>
|
|
||||||
<p className="muted">单独展示引流口径下的签名发送效果。</p>
|
|
||||||
</div>
|
|
||||||
<Tag tone="info">{diversionSignatureRanks.length} 条</Tag>
|
|
||||||
</div>
|
|
||||||
<Table columns={signatureColumns} data={diversionSignatureRanks} rowKey="id" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface section-stack">
|
<div className="surface section-stack">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>今日企业消费排行</h2>
|
<h2>今日企业消费排行</h2>
|
||||||
<p className="muted">支持按余额状态筛选,并通过详情弹窗查看企业余额和联系人信息。</p>
|
<p className="muted">来自真实账户、充值和消息金额聚合。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
|
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
|
||||||
导出排行
|
导出排行
|
||||||
@@ -294,7 +233,7 @@ export function AdminHome() {
|
|||||||
<FileCheck2 size={22} />
|
<FileCheck2 size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>待审核</span>
|
<span>待审核</span>
|
||||||
<strong>{pendingAudits.length} 条</strong>
|
<strong>{dashboard?.pendingAuditCount ?? 0} 条</strong>
|
||||||
<small>模板、签名和企业认证。</small>
|
<small>模板、签名和企业认证。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,35 +241,22 @@ export function AdminHome() {
|
|||||||
<ShieldCheck size={22} />
|
<ShieldCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>平均等待</span>
|
<span>平均等待</span>
|
||||||
<strong>{overview.averageWaitMinutes} 分钟</strong>
|
<strong>{dashboard?.taskCount ?? 0} 任务</strong>
|
||||||
<small>高风险内容优先处理。</small>
|
<small>真实批量任务总数。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mini-status-card">
|
<div className="mini-status-card">
|
||||||
<Users size={22} />
|
<Users size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>平台健康度</span>
|
<span>平台健康度</span>
|
||||||
<strong>{overview.channelHealth}%</strong>
|
<strong>{activeConnectionCount}</strong>
|
||||||
<small>通道服务整体稳定。</small>
|
<small>在线连接数。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface section-stack">
|
|
||||||
<div className="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>待审核队列</h2>
|
|
||||||
<p className="muted">展示当前仍需处理的模板和签名审核。</p>
|
|
||||||
</div>
|
|
||||||
<Button icon={<Clock3 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
|
||||||
查看审核中心
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table columns={auditColumns} data={pendingAudits} rowKey="id" emptyText="暂无待审核记录" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
@@ -354,12 +280,8 @@ export function AdminHome() {
|
|||||||
<strong>{selectedEnterprise.enterprise}</strong>
|
<strong>{selectedEnterprise.enterprise}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-detail-info-grid__item">
|
<div className="ui-detail-info-grid__item">
|
||||||
<span>所在城市</span>
|
<span>企业ID</span>
|
||||||
<strong>{selectedEnterprise.city}</strong>
|
<strong>{selectedEnterprise.id}</strong>
|
||||||
</div>
|
|
||||||
<div className="ui-detail-info-grid__item">
|
|
||||||
<span>联系人</span>
|
|
||||||
<strong>{selectedEnterprise.contact}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-detail-info-grid__item">
|
<div className="ui-detail-info-grid__item">
|
||||||
<span>余额状态</span>
|
<span>余额状态</span>
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, 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, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||||
|
import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||||
type RechargeRecord = {
|
|
||||||
id: string;
|
|
||||||
enterprise: string;
|
|
||||||
rechargedAt: string;
|
|
||||||
amount?: number;
|
|
||||||
balance?: number;
|
|
||||||
operator?: string;
|
|
||||||
type: 'manual' | 'package';
|
|
||||||
remark?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ManualRechargeForm = {
|
type ManualRechargeForm = {
|
||||||
enterprise: string;
|
tenantId: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
|
smsUnits: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rechargeRecordsSeed: RechargeRecord[] = [
|
|
||||||
{ id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX', type: 'manual', remark: '线下转账到账' },
|
|
||||||
{ id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三', type: 'package' },
|
|
||||||
{ id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三', type: 'manual', remark: '运营补差额' },
|
|
||||||
{ id: 'RCG202601120004', enterprise: '北京鸣川科技', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四', type: 'package' },
|
|
||||||
{ id: 'RCG202601120005', enterprise: '广州麦芒科技', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '王五', type: 'manual', remark: '客服人工充值' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getDate(value: string) {
|
function getDate(value: string) {
|
||||||
return value.slice(0, 10);
|
return value.slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -52,21 +35,54 @@ function RemarkCell({ value }: { value?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminRechargeRecordsPage() {
|
export function AdminRechargeRecordsPage() {
|
||||||
const [records, setRecords] = useState(rechargeRecordsSeed);
|
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||||
|
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
|
||||||
|
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>({ enterprise: '', amount: '', operator: '运营', remark: '' });
|
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([
|
||||||
|
adminApi.listTenants(),
|
||||||
|
adminApi.listManualRecharges(),
|
||||||
|
adminApi.listAccounts(),
|
||||||
|
adminApi.listTransactions(),
|
||||||
|
]);
|
||||||
|
setTenants(nextTenants);
|
||||||
|
setRecords(nextRecords);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
setTransactions(nextTransactions);
|
||||||
|
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||||
|
setRecords([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filteredRows = useMemo(
|
const filteredRows = useMemo(
|
||||||
() => records.filter((item) => {
|
() => records.filter((item) => {
|
||||||
const rechargeDate = getDate(item.rechargedAt);
|
const rechargeDate = getDate(item.paidAt ?? item.createdAt);
|
||||||
const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword);
|
const tenantName = item.tenant?.name ?? tenants.find((tenant) => tenant.id === item.tenantId)?.name ?? item.tenantId;
|
||||||
|
const matchesEnterprise = !enterpriseKeyword || tenantName.includes(enterpriseKeyword);
|
||||||
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
|
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
|
||||||
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
|
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
|
||||||
return matchesEnterprise && matchesStartDate && matchesEndDate;
|
return matchesEnterprise && matchesStartDate && matchesEndDate;
|
||||||
}),
|
}),
|
||||||
[dateRange.end, dateRange.start, enterpriseKeyword, records],
|
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
|
||||||
);
|
);
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
@@ -78,26 +94,21 @@ export function AdminRechargeRecordsPage() {
|
|||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitManualRecharge() {
|
async function submitManualRecharge() {
|
||||||
const amount = Number(form.amount);
|
const amount = Number(form.amount);
|
||||||
if (!form.enterprise.trim() || !Number.isFinite(amount) || amount <= 0) {
|
const smsUnits = Number(form.smsUnits || 0);
|
||||||
|
if (!form.tenantId || !Number.isFinite(amount) || amount <= 0 || !Number.isFinite(smsUnits) || smsUnits < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRecords((current) => [
|
await adminApi.createManualRecharge({
|
||||||
{
|
tenantId: form.tenantId,
|
||||||
id: `RCG${Date.now()}`,
|
amountCents: Math.round(amount * 100),
|
||||||
enterprise: form.enterprise,
|
smsUnits,
|
||||||
rechargedAt: '2026-07-01 13:58:00',
|
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
|
||||||
amount,
|
});
|
||||||
balance: amount + 1200,
|
await loadData();
|
||||||
operator: form.operator,
|
|
||||||
type: 'manual',
|
|
||||||
remark: form.remark,
|
|
||||||
},
|
|
||||||
...current,
|
|
||||||
]);
|
|
||||||
setManualOpen(false);
|
setManualOpen(false);
|
||||||
setForm({ enterprise: '', amount: '', operator: '运营', remark: '' });
|
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,17 +145,28 @@ export function AdminRechargeRecordsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRows.map((record) => (
|
{error ? (
|
||||||
|
<tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||||
|
) : loading ? (
|
||||||
|
<tr><td className="ui-table__empty" colSpan={7}>正在加载真实充值记录...</td></tr>
|
||||||
|
) : filteredRows.length === 0 ? (
|
||||||
|
<tr><td className="ui-table__empty" colSpan={7}>暂无真实充值记录</td></tr>
|
||||||
|
) : filteredRows.map((record) => {
|
||||||
|
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;
|
||||||
|
return (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td><strong>{record.enterprise}</strong></td>
|
<td><strong>{tenantName}</strong></td>
|
||||||
<td>{record.rechargedAt}</td>
|
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
|
||||||
<td>{formatAmount(record.amount)}</td>
|
<td>{formatAmount(record.amountCents / 100)}</td>
|
||||||
<td>{formatAmount(record.balance)}</td>
|
<td>{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}</td>
|
||||||
<td><Tag tone={record.type === 'manual' ? 'warning' : 'info'}>{record.type === 'manual' ? '人工充值' : '套餐充值'}</Tag></td>
|
<td><Tag tone="warning">人工充值</Tag></td>
|
||||||
<td>{record.operator}</td>
|
<td>{record.operatorId || '运营'}</td>
|
||||||
<td><RemarkCell value={record.remark} /></td>
|
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,8 +199,14 @@ export function AdminRechargeRecordsPage() {
|
|||||||
title="企业人工充值"
|
title="企业人工充值"
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Input label="企业名称" onChange={(event) => updateForm('enterprise', event.target.value)} value={form.enterprise} />
|
<Select
|
||||||
|
label="企业名称"
|
||||||
|
onChange={(event) => updateForm('tenantId', event.target.value)}
|
||||||
|
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||||
|
value={form.tenantId}
|
||||||
|
/>
|
||||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} />
|
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} />
|
||||||
|
<Input label="短信条数" onChange={(event) => updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} />
|
||||||
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} value={form.operator} />
|
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} value={form.operator} />
|
||||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
type AdminSystemLog = {
|
|
||||||
id: string;
|
|
||||||
time: string;
|
|
||||||
level: LogLevel;
|
|
||||||
tenant: string;
|
|
||||||
module: string;
|
|
||||||
operator: string;
|
|
||||||
action: string;
|
|
||||||
resourceId: string;
|
|
||||||
detail: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelLabelMap: Record<LogLevel, string> = {
|
const levelLabelMap: Record<LogLevel, string> = {
|
||||||
info: '信息',
|
info: '信息',
|
||||||
success: '成功',
|
success: '成功',
|
||||||
@@ -31,14 +19,6 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
|
|||||||
error: 'danger',
|
error: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
const logsSeed: AdminSystemLog[] = [
|
|
||||||
{ id: 'SYS202607010001', time: '2026-07-01 13:42:10', level: 'success', tenant: '上海云舟科技有限公司', module: '账户计费', operator: '运营', action: '人工充值', resourceId: 'RCG202607010001', detail: '人工充值 ¥2,000.00,备注:线下转账到账', ip: '10.0.1.12' },
|
|
||||||
{ id: 'SYS202607010002', time: '2026-07-01 13:20:33', level: 'info', tenant: '杭州星澜商贸有限公司', module: '短信审核', operator: '审核员A', action: '审核通过', resourceId: 'AUD202607010018', detail: '营销短信任务进入发送队列', ip: '10.0.1.15' },
|
|
||||||
{ id: 'SYS202607010003', time: '2026-07-01 12:58:44', level: 'warning', tenant: '深圳北辰出行服务有限公司', module: '风控', operator: 'system', action: '触发人工审核', resourceId: 'RISK202607010009', detail: '重复号码比例 21.4%,超过阈值 20%', ip: '127.0.0.1' },
|
|
||||||
{ id: 'SYS202607010004', time: '2026-07-01 12:11:02', level: 'error', tenant: '广州麦芒科技', module: '发送链路', operator: 'gateway', action: 'SubmitResp失败', resourceId: 'MSG-7b9e', detail: '通道返回 REJECT,错误码 8', ip: '10.0.2.21' },
|
|
||||||
{ id: 'SYS202607010005', time: '2026-07-01 11:46:29', level: 'info', tenant: '平台', module: '系统管理', operator: '平台管理员', action: '创建用户', resourceId: 'USR202607010006', detail: '新增运营用户:report-admin', ip: '10.0.1.10' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AdminSystemLogsPage() {
|
export function AdminSystemLogsPage() {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [level, setLevel] = useState('all');
|
const [level, setLevel] = useState('all');
|
||||||
@@ -46,25 +26,35 @@ export function AdminSystemLogsPage() {
|
|||||||
const [range, setRange] = useState('today');
|
const [range, setRange] = useState('today');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 5;
|
const pageSize = 5;
|
||||||
|
const [logs, setLogs] = useState<OperationLogItem[]>([]);
|
||||||
|
const [modules, setModules] = useState<string[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.listSystemLogs({ keyword, level, module, range, page, pageSize })
|
||||||
|
.then((data) => {
|
||||||
|
setLogs(data.items);
|
||||||
|
setModules(data.modules);
|
||||||
|
setTotal(data.total);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setLogs([]);
|
||||||
|
setTotal(0);
|
||||||
|
setError(err instanceof Error ? err.message : '系统日志加载失败');
|
||||||
|
});
|
||||||
|
}, [keyword, level, module, range, page]);
|
||||||
|
|
||||||
const moduleOptions = useMemo(() => {
|
const moduleOptions = useMemo(() => {
|
||||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
|
||||||
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
|
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
|
||||||
}, []);
|
}, [modules]);
|
||||||
|
|
||||||
const filteredLogs = logsSeed.filter((item) => {
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const target = `${item.tenant} ${item.operator} ${item.action} ${item.resourceId} ${item.detail}`;
|
|
||||||
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
|
||||||
const matchesLevel = level === 'all' || item.level === level;
|
|
||||||
const matchesModule = module === 'all' || item.module === module;
|
|
||||||
return matchesKeyword && matchesLevel && matchesModule;
|
|
||||||
});
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
|
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<AdminSystemLog>>>(() => [
|
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||||
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{record.time}</span> },
|
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
||||||
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||||
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
|
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
|
||||||
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
|
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
|
||||||
@@ -78,7 +68,7 @@ export function AdminSystemLogsPage() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="system-log-detail-card">
|
<div className="system-log-detail-card">
|
||||||
<strong>{record.action}</strong>
|
<strong>{record.action}</strong>
|
||||||
<span>{record.detail}</span>
|
<span>{JSON.stringify(record.detail)}</span>
|
||||||
<small>{record.resourceId}</small>
|
<small>{record.resourceId}</small>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -137,14 +127,14 @@ export function AdminSystemLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface system-table-card">
|
<div className="surface system-table-card">
|
||||||
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
|
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} 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))}
|
||||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredLogs.length}
|
total={total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
BellRing,
|
BellRing,
|
||||||
@@ -11,61 +11,76 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { channelShare, hourlySendTrend } from '@/mock/chartData';
|
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { clientService, type RecentMessage, type TemplateStatus } from '@/mock';
|
|
||||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||||
|
|
||||||
const statusLabelMap: Record<RecentMessage['status'], string> = {
|
type RecentTaskRow = {
|
||||||
success: '发送完成',
|
id: string;
|
||||||
warning: '排队中',
|
taskNo: string;
|
||||||
info: '发送中',
|
scene: string;
|
||||||
danger: '发送失败',
|
count: number;
|
||||||
|
channel: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const templateStatusLabelMap: Record<TemplateStatus, string> = {
|
const columns: Array<TableColumn<RecentTaskRow>> = [
|
||||||
draft: '草稿',
|
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
|
||||||
pending: '待审核',
|
|
||||||
approved: '已通过',
|
|
||||||
rejected: '已驳回',
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Array<TableColumn<RecentMessage>> = [
|
|
||||||
{ key: 'id', title: '批次编号', render: (record) => record.id },
|
|
||||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
||||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status}>{statusLabelMap[record.status]}</Tag> },
|
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ClientHome() {
|
export function ClientHome() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const overview = clientService.getOverview();
|
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||||
const recentMessages = clientService.getRecentMessages();
|
const [error, setError] = useState('');
|
||||||
const templates = clientService.getTemplates();
|
|
||||||
const signatures = clientService.getSignatures();
|
|
||||||
const invoices = clientService.getInvoices();
|
|
||||||
|
|
||||||
const approvedTemplates = templates.filter((item) => item.status === 'approved').length;
|
useEffect(() => {
|
||||||
const approvedSignatures = signatures.filter((item) => item.status === 'approved').length;
|
clientApi.getDashboard()
|
||||||
const pendingTemplates = templates.filter((item) => item.status === 'pending').length;
|
.then(setDashboard)
|
||||||
const pendingSignatures = signatures.filter((item) => item.status === 'pending').length;
|
.catch((err) => {
|
||||||
const latestInvoice = invoices[0];
|
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
|
||||||
const balanceBaseline = overview.availableBalance + overview.todaySpend - overview.todayRefund;
|
setDashboard(null);
|
||||||
const balancePercent = Math.min(100, Math.round((overview.availableBalance / balanceBaseline) * 100));
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const account = dashboard?.accounts[0];
|
||||||
|
const availableBalance = ((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)) / 100;
|
||||||
|
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||||
|
const todayRefund = Math.abs((dashboard?.transactions._sum.amountCents ?? 0) < 0 ? 0 : dashboard?.transactions._sum.amountCents ?? 0) / 100;
|
||||||
|
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
|
||||||
|
const balancePercent = Math.min(100, Math.round((availableBalance / balanceBaseline) * 100));
|
||||||
|
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
|
||||||
|
id: String(task.id ?? task.taskNo),
|
||||||
|
taskNo: String(task.taskNo ?? task.id),
|
||||||
|
scene: String(task.category ?? task.content ?? '短信发送'),
|
||||||
|
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||||
|
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
|
||||||
|
createdAt: task.createdAt ? new Date(String(task.createdAt)).toLocaleString('zh-CN') : '',
|
||||||
|
status: String(task.status ?? 'unknown'),
|
||||||
|
})), [dashboard]);
|
||||||
|
const latestRecharge = dashboard?.recentRecharges[0];
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() => createLineOption({
|
||||||
labels: hourlySendTrend.map((item) => item.time),
|
labels: ['今日'],
|
||||||
series: [
|
series: [
|
||||||
{ name: '提交量', data: hourlySendTrend.map((item) => item.sent) },
|
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
||||||
{ name: '成功量', data: hourlySendTrend.map((item) => item.success) },
|
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[],
|
[dashboard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const channelShareOption = useMemo(() => createPieOption({ data: channelShare }), []);
|
const channelShareOption = useMemo(() => createPieOption({
|
||||||
|
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
|
||||||
|
name: item.status,
|
||||||
|
value: item._sum.currentConnections ?? item._count._all,
|
||||||
|
})),
|
||||||
|
}), [dashboard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
@@ -86,20 +101,21 @@ export function ClientHome() {
|
|||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<div className="surface metric-card metric-card--featured">
|
<div className="surface metric-card metric-card--featured">
|
||||||
<span>账户剩余余额</span>
|
<span>账户剩余余额</span>
|
||||||
<strong>¥{overview.availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
<strong>¥{availableBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
||||||
<small>今日消费 ¥{overview.todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
|
<small>今日消费 ¥{todaySpend.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日发送</span>
|
<span>今日发送</span>
|
||||||
<strong>{overview.todaySent.toLocaleString('zh-CN')}</strong>
|
<strong>{(dashboard?.today.sent ?? 0).toLocaleString('zh-CN')}</strong>
|
||||||
<small>成功率 {overview.todaySuccessRate}%</small>
|
<small>成功率 {dashboard?.today.successRate ?? 0}%</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日返还金额</span>
|
<span>今日返还金额</span>
|
||||||
<strong>¥{overview.todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
<strong>¥{todayRefund.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</strong>
|
||||||
<small>异常回执与退费返还</small>
|
<small>异常回执与退费返还</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||||
|
|
||||||
<div className="overview-grid">
|
<div className="overview-grid">
|
||||||
<div className="surface section-stack">
|
<div className="surface section-stack">
|
||||||
@@ -118,12 +134,12 @@ export function ClientHome() {
|
|||||||
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
|
<button className="quick-action" onClick={() => navigate('/client/templates')} type="button">
|
||||||
<FileText size={20} />
|
<FileText size={20} />
|
||||||
<span>模板管理</span>
|
<span>模板管理</span>
|
||||||
<small>{approvedTemplates} 个可用模板</small>
|
<small>进入真实模板列表</small>
|
||||||
</button>
|
</button>
|
||||||
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
|
<button className="quick-action" onClick={() => navigate('/client/signatures')} type="button">
|
||||||
<PenLine size={20} />
|
<PenLine size={20} />
|
||||||
<span>签名管理</span>
|
<span>签名管理</span>
|
||||||
<small>{approvedSignatures} 个可用签名</small>
|
<small>进入真实签名列表</small>
|
||||||
</button>
|
</button>
|
||||||
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
|
||||||
<WalletCards size={20} />
|
<WalletCards size={20} />
|
||||||
@@ -139,20 +155,20 @@ export function ClientHome() {
|
|||||||
<h2>账户状态</h2>
|
<h2>账户状态</h2>
|
||||||
<p className="muted">企业认证与资源用量。</p>
|
<p className="muted">企业认证与资源用量。</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone="success">已认证</Tag>
|
<Tag tone={account?.status === 'active' ? 'success' : 'warning'}>{account?.status ?? '未知'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-list">
|
<div className="summary-list">
|
||||||
<div>
|
<div>
|
||||||
<span>企业主体</span>
|
<span>企业主体</span>
|
||||||
<strong>上海云舟科技有限公司</strong>
|
<strong>{account?.tenant?.name ?? account?.tenantId ?? '当前租户'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>默认签名</span>
|
<span>默认签名</span>
|
||||||
<strong>【云舟科技】</strong>
|
<strong>由发送资源 API 管理</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>最近充值</span>
|
<span>最近充值</span>
|
||||||
<strong>{latestInvoice.title}</strong>
|
<strong>{latestRecharge ? `¥${(latestRecharge.amountCents / 100).toFixed(2)}` : '暂无充值'}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -172,16 +188,16 @@ export function ClientHome() {
|
|||||||
<BadgeCheck size={22} />
|
<BadgeCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>模板状态</span>
|
<span>模板状态</span>
|
||||||
<strong>{approvedTemplates} 已通过</strong>
|
<strong>{dashboard?.pendingAuditCount ?? 0} 待处理</strong>
|
||||||
<small>{templates.map((item) => templateStatusLabelMap[item.status]).join(' / ')}</small>
|
<small>点击进入模板明细</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface mini-status-card">
|
<div className="surface mini-status-card">
|
||||||
<PenLine size={22} />
|
<PenLine size={22} />
|
||||||
<div>
|
<div>
|
||||||
<span>签名状态</span>
|
<span>签名状态</span>
|
||||||
<strong>{approvedSignatures} 已通过</strong>
|
<strong>真实 API</strong>
|
||||||
<small>待审核 {pendingSignatures},需处理 {signatures.filter((item) => item.status === 'rejected').length}</small>
|
<small>点击进入签名明细</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface mini-status-card">
|
<div className="surface mini-status-card">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -9,20 +9,10 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
import { clientApi, type OperationLogItem } from '@/api/adminApi';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
type SystemLog = {
|
|
||||||
id: string;
|
|
||||||
time: string;
|
|
||||||
level: LogLevel;
|
|
||||||
module: string;
|
|
||||||
operator: string;
|
|
||||||
action: string;
|
|
||||||
detail: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelLabelMap: Record<LogLevel, string> = {
|
const levelLabelMap: Record<LogLevel, string> = {
|
||||||
info: '信息',
|
info: '信息',
|
||||||
success: '成功',
|
success: '成功',
|
||||||
@@ -37,18 +27,6 @@ const levelToneMap: Record<LogLevel, 'info' | 'success' | 'warning' | 'danger'>
|
|||||||
error: 'danger',
|
error: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
const logsSeed: SystemLog[] = [
|
|
||||||
{ id: 'LOG001', time: '2026-03-17 14:35:22', level: 'info', module: '用户管理', operator: '张三', action: '创建用户', detail: '创建用户账号:李四(lisi@example.com)', ip: '192.168.1.100' },
|
|
||||||
{ id: 'LOG002', time: '2026-03-17 14:20:15', level: 'success', module: '短信服务', operator: '李四', action: '发送短信', detail: '批量发送短信至500个号码,发送成功', ip: '192.168.1.101' },
|
|
||||||
{ id: 'LOG003', time: '2026-03-17 13:45:33', level: 'warning', module: '彩信服务', operator: '王五', action: '模板审核', detail: '彩信模板“春节祝福”审核未通过,原因:内容包含敏感词', ip: '192.168.1.102' },
|
|
||||||
{ id: 'LOG004', time: '2026-03-17 12:10:08', level: 'error', module: '系统管理', operator: '赵六', action: '登录失败', detail: '用户登录失败,错误:密码错误(连续3次)', ip: '192.168.1.103' },
|
|
||||||
{ id: 'LOG005', time: '2026-03-17 11:30:45', level: 'info', module: '用户管理', operator: '张三', action: '修改权限', detail: '修改用户“孙七”的角色:普通用户 → 管理员', ip: '192.168.1.100' },
|
|
||||||
{ id: 'LOG006', time: '2026-03-17 10:15:20', level: 'success', module: '短信服务', operator: '李四', action: '签名审核', detail: '短信签名“优品商城”审核通过', ip: '192.168.1.101' },
|
|
||||||
{ id: 'LOG007', time: '2026-03-17 09:50:12', level: 'info', module: '彩信服务', operator: '王五', action: '创建模板', detail: '创建彩信模板“新品发布”(模板ID:MMS_1a2b3c4d)', ip: '192.168.1.102' },
|
|
||||||
{ id: 'LOG008', time: '2026-03-17 09:05:33', level: 'error', module: '短信服务', operator: '李四', action: '发送失败', detail: '短信发送失败,错误:余额不足', ip: '192.168.1.101' },
|
|
||||||
{ id: 'LOG009', time: '2026-03-17 08:40:18', level: 'warning', module: '系统管理', operator: 'system', action: '系统告警', detail: '系统磁盘使用率超过80%,当前使用率:85%', ip: '127.0.0.1' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ClientSystemLogsPage() {
|
export function ClientSystemLogsPage() {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [level, setLevel] = useState('all');
|
const [level, setLevel] = useState('all');
|
||||||
@@ -56,30 +34,39 @@ export function ClientSystemLogsPage() {
|
|||||||
const [range, setRange] = useState('today');
|
const [range, setRange] = useState('today');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 5;
|
const pageSize = 5;
|
||||||
|
const [logs, setLogs] = useState<OperationLogItem[]>([]);
|
||||||
|
const [modules, setModules] = useState<string[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clientApi.listSystemLogs({ keyword, level, module, range, page, pageSize })
|
||||||
|
.then((data) => {
|
||||||
|
setLogs(data.items);
|
||||||
|
setModules(data.modules);
|
||||||
|
setTotal(data.total);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setLogs([]);
|
||||||
|
setTotal(0);
|
||||||
|
setError(err instanceof Error ? err.message : '系统日志加载失败');
|
||||||
|
});
|
||||||
|
}, [keyword, level, module, range, page]);
|
||||||
|
|
||||||
const moduleOptions = useMemo(() => {
|
const moduleOptions = useMemo(() => {
|
||||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
|
||||||
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
|
return [{ label: '全部模块', value: 'all' }, ...modules.map((item) => ({ label: item, value: item }))];
|
||||||
}, []);
|
}, [modules]);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const filteredLogs = logsSeed.filter((item) => {
|
|
||||||
const target = `${item.operator} ${item.action} ${item.detail}`;
|
|
||||||
const matchesKeyword = !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
|
||||||
const matchesLevel = level === 'all' || item.level === level;
|
|
||||||
const matchesModule = module === 'all' || item.module === module;
|
|
||||||
return matchesKeyword && matchesLevel && matchesModule;
|
|
||||||
});
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
|
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<SystemLog>>>(() => [
|
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{record.time}</span> },
|
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
||||||
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||||
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
|
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
|
||||||
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
|
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
|
||||||
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
|
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
|
||||||
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
|
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{JSON.stringify(record.detail)}</span> },
|
||||||
{ key: 'ip', title: 'IP地址', width: '150px', render: (record) => <span className="muted">{record.ip}</span> },
|
{ key: 'ip', title: 'IP地址', width: '150px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
@@ -134,14 +121,14 @@ export function ClientSystemLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface system-table-card">
|
<div className="surface system-table-card">
|
||||||
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
|
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} 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))}
|
||||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredLogs.length}
|
total={total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
cacheDir: 'node_modules/.vite-cmpp',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
@@ -11,6 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
strictPort: false,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
@@ -18,6 +20,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
noDiscovery: true,
|
||||||
|
include: [],
|
||||||
|
},
|
||||||
preview: {
|
preview: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user