fix: complete first version issue remediation
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -75,6 +75,7 @@ model User {
|
|||||||
displayName String
|
displayName String
|
||||||
passwordHash String
|
passwordHash String
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
|
sessionVersion Int @default(0)
|
||||||
failedLoginCount Int @default(0)
|
failedLoginCount Int @default(0)
|
||||||
lockedUntil DateTime?
|
lockedUntil DateTime?
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { SessionValidationMiddleware } from './auth/session-validation.middleware';
|
||||||
|
import { RequestContextMiddleware } from './common/request-context.middleware';
|
||||||
import { BillingModule } from './billing/billing.module';
|
import { BillingModule } from './billing/billing.module';
|
||||||
import { ChannelsModule } from './channels/channels.module';
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
import { CertificationModule } from './certification/certification.module';
|
import { CertificationModule } from './certification/certification.module';
|
||||||
@@ -38,5 +40,10 @@ import { UsersModule } from './users/users.module';
|
|||||||
OperationsModule,
|
OperationsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
providers: [RequestContextMiddleware, SessionValidationMiddleware],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(RequestContextMiddleware, SessionValidationMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||||
import { AuthService, LoginDto } from './auth.service';
|
import { AuthService, LoginDto } from './auth.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly auth: AuthService) {}
|
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
|
||||||
|
|
||||||
@Get('admin/auth/captcha')
|
@Get('admin/auth/captcha')
|
||||||
adminCaptcha() {
|
adminCaptcha() {
|
||||||
@@ -26,4 +28,12 @@ export class AuthController {
|
|||||||
clientLogin(@Body() body: LoginDto) {
|
clientLogin(@Body() body: LoginDto) {
|
||||||
return this.auth.login(body, 'client');
|
return this.auth.login(body, 'client');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('auth/password')
|
||||||
|
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||||
|
}
|
||||||
|
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('AuthService', () => {
|
|||||||
it('allows platform admins to login admin portal', async () => {
|
it('allows platform admins to login admin portal', async () => {
|
||||||
const users = createUsersMock('platform_admin');
|
const users = createUsersMock('platform_admin');
|
||||||
const service = new AuthService(users as never);
|
const service = new AuthService(users as never);
|
||||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
|
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', accessToken: 'dev-token:user-1:0' }));
|
||||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class AuthService {
|
|||||||
anonymousFailures.delete(login);
|
anonymousFailures.delete(login);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: `dev-token-${user.id}`,
|
accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`,
|
||||||
tokenType: 'Bearer',
|
tokenType: 'Bearer',
|
||||||
portal,
|
portal,
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
|
export const CurrentSessionUserId = createParamDecorator((_: unknown, context: ExecutionContext) => {
|
||||||
|
const request = context.switchToHttp().getRequest<SessionRequest>();
|
||||||
|
return request.sessionUserId;
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
|
function request(authorization?: string): SessionRequest {
|
||||||
|
return { header: jest.fn().mockReturnValue(authorization) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionValidationMiddleware', () => {
|
||||||
|
it('accepts the current user session version and exposes the session user id', async () => {
|
||||||
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never);
|
||||||
|
const currentRequest = request('Bearer dev-token:user-1:3');
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await middleware.use(currentRequest, {} as never, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(currentRequest.sessionUserId).toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an old session token after the user session version changes', async () => {
|
||||||
|
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
||||||
|
const middleware = new SessionValidationMiddleware(prisma as never);
|
||||||
|
|
||||||
|
await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type SessionRequest = {
|
||||||
|
header(name: string): string | undefined;
|
||||||
|
sessionUserId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionValidationMiddleware implements NestMiddleware {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async use(request: SessionRequest, _: unknown, next: () => void) {
|
||||||
|
const authorization = request.header('authorization');
|
||||||
|
if (!authorization) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: match[1] },
|
||||||
|
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
|
||||||
|
});
|
||||||
|
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) {
|
||||||
|
throw new UnauthorizedException('登录会话已失效,请重新登录');
|
||||||
|
}
|
||||||
|
request.sessionUserId = user.id;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,31 @@ describe('BillingService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the historical balance after each manual recharge', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.rechargeOrder.findMany.mockResolvedValue([
|
||||||
|
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
|
||||||
|
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
||||||
|
]);
|
||||||
|
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||||
|
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||||
|
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||||
|
]);
|
||||||
|
const service = new BillingService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||||
|
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
||||||
|
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
||||||
|
]);
|
||||||
|
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
relatedType: 'recharge_order',
|
||||||
|
relatedId: { in: ['order-1', 'order-2'] },
|
||||||
|
},
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('allows negative manual recharge amounts for balance correction', async () => {
|
it('allows negative manual recharge amounts for balance correction', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new BillingService(prisma as never);
|
const service = new BillingService(prisma as never);
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listManualRechargeRecords(tenantId?: string) {
|
async listManualRechargeRecords(tenantId?: string) {
|
||||||
return this.prisma.rechargeOrder.findMany({
|
const orders = await this.prisma.rechargeOrder.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
payMethod: 'manual_topup',
|
payMethod: 'manual_topup',
|
||||||
@@ -143,6 +143,24 @@ export class BillingService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
const orderIds = orders.map((order) => order.id);
|
||||||
|
if (orderIds.length === 0) {
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = await this.prisma.accountTransaction.findMany({
|
||||||
|
where: {
|
||||||
|
relatedType: 'recharge_order',
|
||||||
|
relatedId: { in: orderIds },
|
||||||
|
},
|
||||||
|
select: { relatedId: true, balanceAfter: true },
|
||||||
|
});
|
||||||
|
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter]));
|
||||||
|
|
||||||
|
return orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRechargeOrder(data: CreateRechargeOrderDto) {
|
async createRechargeOrder(data: CreateRechargeOrderDto) {
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ describe('ChannelsService', () => {
|
|||||||
srcId: '10690000',
|
srcId: '10690000',
|
||||||
desiredConnections: 2,
|
desiredConnections: 2,
|
||||||
windowSize: 32,
|
windowSize: 32,
|
||||||
|
rateLimitPerSecond: 750,
|
||||||
|
config: { extensionDigits: 4 },
|
||||||
});
|
});
|
||||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
|
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
|
||||||
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
|
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
|
||||||
@@ -191,10 +193,10 @@ describe('ChannelsService', () => {
|
|||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
protocol: 'CMPP',
|
protocol: 'CMPP',
|
||||||
cmppVersion: '2.0',
|
cmppVersion: '2.0',
|
||||||
rateLimitPerSecond: 100,
|
rateLimitPerSecond: 750,
|
||||||
sendRegion: '全国',
|
sendRegion: '全国',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32 }),
|
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
|
||||||
@@ -263,6 +265,23 @@ describe('ChannelsService', () => {
|
|||||||
})).rejects.toThrow('cmppVersion must be 2.0 or 3.0');
|
})).rejects.toThrow('cmppVersion must be 2.0 or 3.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid channel rate limits and extension digit counts', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
const channel = {
|
||||||
|
code: 'CMPP-CONFIG',
|
||||||
|
name: '配置校验通道',
|
||||||
|
gatewayHost: '127.0.0.1',
|
||||||
|
gatewayPort: 17890,
|
||||||
|
account: 'sp',
|
||||||
|
passwordCipher: 'secret',
|
||||||
|
srcId: '10690000',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
|
||||||
|
await expect(service.createChannel({ ...channel, config: { extensionDigits: 3 } })).rejects.toThrow('extensionDigits must be one of 0, 2, 4, or 6');
|
||||||
|
});
|
||||||
|
|
||||||
it('updates CMPP channel configuration without requiring password changes', async () => {
|
it('updates CMPP channel configuration without requiring password changes', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
@@ -277,6 +296,8 @@ describe('ChannelsService', () => {
|
|||||||
srcId: '10690001',
|
srcId: '10690001',
|
||||||
desiredConnections: 3,
|
desiredConnections: 3,
|
||||||
windowSize: 64,
|
windowSize: 64,
|
||||||
|
rateLimitPerSecond: 320,
|
||||||
|
config: { extensionDigits: 2 },
|
||||||
unitPrice: 4,
|
unitPrice: 4,
|
||||||
})).resolves.toEqual(expect.objectContaining({
|
})).resolves.toEqual(expect.objectContaining({
|
||||||
id: 'channel-1',
|
id: 'channel-1',
|
||||||
@@ -292,7 +313,8 @@ describe('ChannelsService', () => {
|
|||||||
gatewayPort: 27890,
|
gatewayPort: 27890,
|
||||||
carrier: 'all',
|
carrier: 'all',
|
||||||
passwordCipher: undefined,
|
passwordCipher: undefined,
|
||||||
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64 }),
|
rateLimitPerSecond: 320,
|
||||||
|
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64, extensionDigits: 2 }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||||
}
|
}
|
||||||
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
|
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
|
||||||
const config = normalizeChannelRuntimeConfig(data.config, data.desiredConnections, data.windowSize);
|
const config = normalizeChannelRuntimeConfig(undefined, data.config, data.desiredConnections, data.windowSize);
|
||||||
|
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
const channel = await this.prisma.smsChannel.create({
|
const channel = await this.prisma.smsChannel.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
@@ -230,7 +231,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
passwordCipher: data.passwordCipher,
|
passwordCipher: data.passwordCipher,
|
||||||
srcId: data.srcId,
|
srcId: data.srcId,
|
||||||
cmppVersion,
|
cmppVersion,
|
||||||
rateLimitPerSecond: data.rateLimitPerSecond ?? 100,
|
rateLimitPerSecond,
|
||||||
unitPrice: data.unitPrice ?? 0,
|
unitPrice: data.unitPrice ?? 0,
|
||||||
status: data.status ?? 'active',
|
status: data.status ?? 'active',
|
||||||
config: config as Prisma.InputJsonValue,
|
config: config as Prisma.InputJsonValue,
|
||||||
@@ -253,8 +254,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
|
||||||
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
|
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
|
||||||
? normalizeChannelRuntimeConfig(channel.config, data.desiredConnections, data.windowSize)
|
? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeChannelRateLimit(data.rateLimitPerSecond);
|
||||||
const updated = await this.prisma.smsChannel.update({
|
const updated = await this.prisma.smsChannel.update({
|
||||||
where: { id: channelId },
|
where: { id: channelId },
|
||||||
data: {
|
data: {
|
||||||
@@ -270,7 +274,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
passwordCipher: data.passwordCipher,
|
passwordCipher: data.passwordCipher,
|
||||||
srcId: data.srcId,
|
srcId: data.srcId,
|
||||||
cmppVersion,
|
cmppVersion,
|
||||||
rateLimitPerSecond: data.rateLimitPerSecond,
|
rateLimitPerSecond,
|
||||||
unitPrice: data.unitPrice,
|
unitPrice: data.unitPrice,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
config: config as Prisma.InputJsonValue | undefined,
|
config: config as Prisma.InputJsonValue | undefined,
|
||||||
@@ -1287,6 +1291,7 @@ function buildChannelTestSubmitCommand({
|
|||||||
cmpp: {
|
cmpp: {
|
||||||
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
|
||||||
srcId,
|
srcId,
|
||||||
|
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
|
||||||
registeredDelivery: 1,
|
registeredDelivery: 1,
|
||||||
msgFmt: 8,
|
msgFmt: 8,
|
||||||
},
|
},
|
||||||
@@ -1380,15 +1385,44 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChannelRuntimeConfig(config?: Prisma.JsonValue | Record<string, unknown> | null, desiredConnections?: number, windowSize?: number) {
|
function normalizeChannelRuntimeConfig(
|
||||||
const base = config && typeof config === 'object' && !Array.isArray(config)
|
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
|
||||||
? { ...(config as Record<string, unknown>) }
|
incomingConfig?: Record<string, unknown> | null,
|
||||||
|
desiredConnections?: number,
|
||||||
|
windowSize?: number,
|
||||||
|
) {
|
||||||
|
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||||
|
? existingConfig as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
|
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
||||||
|
? incomingConfig
|
||||||
|
: {};
|
||||||
|
const base = { ...existing, ...incoming };
|
||||||
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
|
||||||
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
|
||||||
|
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChannelRateLimit(value: unknown) {
|
||||||
|
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
|
||||||
|
if (normalized > 2000) {
|
||||||
|
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtensionDigits(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (![0, 2, 4, 6].includes(normalized)) {
|
||||||
|
throw new BadRequestException('extensionDigits must be one of 0, 2, 4, or 6');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { requestContext } from './request-context';
|
||||||
|
|
||||||
|
type RequestLike = { headers: Record<string, string | string[] | undefined>; socket?: { remoteAddress?: string } };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestContextMiddleware implements NestMiddleware {
|
||||||
|
use(request: RequestLike, _response: unknown, next: () => void) {
|
||||||
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
|
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
|
||||||
|
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
|
||||||
|
requestContext.run({ ipAddress }, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
export const requestContext = new AsyncLocalStorage<{ ipAddress?: string }>();
|
||||||
@@ -30,8 +30,8 @@ export class DictionariesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('phone-carrier-rules')
|
@Get('phone-carrier-rules')
|
||||||
listPhoneCarrierRules() {
|
listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||||
return this.dictionaries.listPhoneCarrierRules();
|
return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('phone-carrier-rules')
|
@Post('phone-carrier-rules')
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ function createPrismaMock() {
|
|||||||
phoneSegment: {
|
phoneSegment: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
phoneCarrierRule: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
sensitiveWord: {
|
sensitiveWord: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
||||||
@@ -80,6 +84,17 @@ describe('DictionariesService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('paginates carrier rules with a real database count', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
|
||||||
|
prisma.phoneCarrierRule.count.mockResolvedValue(26);
|
||||||
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.listPhoneCarrierRules({ keyword: '13', page: 2, pageSize: 25 })).resolves.toEqual(expect.objectContaining({ total: 26, page: 2, pageSize: 25 }));
|
||||||
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 25, take: 25, where: { OR: expect.any(Array) } }));
|
||||||
|
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
||||||
|
});
|
||||||
|
|
||||||
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new DictionariesService(prisma as never);
|
const service = new DictionariesService(prisma as never);
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export interface CreatePhoneCarrierRuleDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageQuery {
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSensitiveWordDto {
|
export interface CreateSensitiveWordDto {
|
||||||
word: string;
|
word: string;
|
||||||
level?: string;
|
level?: string;
|
||||||
@@ -98,8 +104,21 @@ export class DictionariesService {
|
|||||||
return this.prisma.phoneSegment.create({ data });
|
return this.prisma.phoneSegment.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
listPhoneCarrierRules() {
|
async listPhoneCarrierRules(query: PageQuery = {}) {
|
||||||
return this.prisma.phoneCarrierRule.findMany({ orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 200 });
|
const page = Math.max(1, Number(query.page ?? 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||||
|
const where = query.keyword?.trim() ? {
|
||||||
|
OR: [
|
||||||
|
{ carrier: { contains: query.keyword.trim() } },
|
||||||
|
{ pattern: { contains: query.keyword.trim() } },
|
||||||
|
{ remark: { contains: query.keyword.trim() } },
|
||||||
|
],
|
||||||
|
} : undefined;
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.phoneCarrierRule.findMany({ where, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }),
|
||||||
|
this.prisma.phoneCarrierRule.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
|
||||||
|
|||||||
@@ -49,6 +49,38 @@ describe('FilesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores UTF-8 filenames that multipart parsing exposed as Latin-1', async () => {
|
||||||
|
const prisma = {
|
||||||
|
fileObject: {
|
||||||
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const objectStorage = {
|
||||||
|
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
|
||||||
|
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
|
||||||
|
presignedPutObject: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new FilesService(prisma as never, objectStorage as never);
|
||||||
|
const originalname = Buffer.from('营业执照.png', 'utf8').toString('latin1');
|
||||||
|
|
||||||
|
await service.upload({ purpose: 'enterprise_photo' }, {
|
||||||
|
originalname,
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: 12,
|
||||||
|
buffer: Buffer.from('file-content'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.fileObject.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ fileName: '营业执照.png' }),
|
||||||
|
});
|
||||||
|
expect(objectStorage.putObject).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/营业执照\.png$/),
|
||||||
|
expect.any(Buffer),
|
||||||
|
12,
|
||||||
|
'image/png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('downloads file content from object storage by FileObject id', async () => {
|
it('downloads file content from object storage by FileObject id', async () => {
|
||||||
const fileObject = {
|
const fileObject = {
|
||||||
id: 'file-1',
|
id: 'file-1',
|
||||||
|
|||||||
@@ -66,14 +66,15 @@ export class FilesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||||
const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
const fileName = normalizeMultipartFileName(file.originalname);
|
||||||
|
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
||||||
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
||||||
await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream');
|
await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream');
|
||||||
return this.create({
|
return this.create({
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
bucket: this.objectStorage.getBucket(),
|
bucket: this.objectStorage.getBucket(),
|
||||||
objectKey,
|
objectKey,
|
||||||
fileName: file.originalname,
|
fileName,
|
||||||
contentType: file.mimetype || 'application/octet-stream',
|
contentType: file.mimetype || 'application/octet-stream',
|
||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
purpose: data.purpose,
|
purpose: data.purpose,
|
||||||
@@ -93,6 +94,15 @@ export class FilesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMultipartFileName(value: string) {
|
||||||
|
if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.from(value, 'latin1').toString('utf8');
|
||||||
|
return decoded.includes('\uFFFD') ? value : decoded;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
|
||||||
return {
|
return {
|
||||||
...fileObject,
|
...fileObject,
|
||||||
|
|||||||
@@ -34,9 +34,25 @@ export class AdminOperationsController {
|
|||||||
@Query('taskId') taskId?: string,
|
@Query('taskId') taskId?: string,
|
||||||
@Query('messageId') messageId?: string,
|
@Query('messageId') messageId?: string,
|
||||||
@Query('phoneNumber') phoneNumber?: string,
|
@Query('phoneNumber') phoneNumber?: string,
|
||||||
|
@Query('contentKeyword') contentKeyword?: string,
|
||||||
|
@Query('channelKeyword') channelKeyword?: string,
|
||||||
|
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||||
|
@Query('queuedAtTo') queuedAtTo?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
) {
|
) {
|
||||||
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status });
|
return this.operations.listMessages({
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
channelId,
|
||||||
|
taskId,
|
||||||
|
messageId,
|
||||||
|
phoneNumber,
|
||||||
|
contentKeyword,
|
||||||
|
channelKeyword,
|
||||||
|
queuedAtFrom,
|
||||||
|
queuedAtTo,
|
||||||
|
status,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('message-segment-audits')
|
@Get('message-segment-audits')
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||||
count: jest.fn().mockResolvedValue(3),
|
count: jest.fn().mockResolvedValue(3),
|
||||||
},
|
},
|
||||||
|
smsSendTask: {
|
||||||
|
count: jest.fn().mockResolvedValue(2),
|
||||||
|
},
|
||||||
smsMessageRecord: {
|
smsMessageRecord: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
|
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
|
||||||
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
|
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
|
||||||
@@ -163,7 +166,7 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('OperationsService', () => {
|
describe('OperationsService', () => {
|
||||||
it('filters send-chain messages by tenant, application, channel, task, phone, and status', async () => {
|
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
@@ -171,9 +174,13 @@ describe('OperationsService', () => {
|
|||||||
tenantId: 'tenant-1',
|
tenantId: 'tenant-1',
|
||||||
applicationId: 'app-1',
|
applicationId: 'app-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
|
channelKeyword: '移动通道',
|
||||||
taskId: 'task-1',
|
taskId: 'task-1',
|
||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
|
contentKeyword: '验证码',
|
||||||
|
queuedAtFrom: '2026-07-01',
|
||||||
|
queuedAtTo: '2026-07-02',
|
||||||
status: 'delivered',
|
status: 'delivered',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +193,12 @@ describe('OperationsService', () => {
|
|||||||
messageId: 'MSG-1',
|
messageId: 'MSG-1',
|
||||||
phoneNumber: '13800000001',
|
phoneNumber: '13800000001',
|
||||||
status: 'delivered',
|
status: 'delivered',
|
||||||
|
content: { contains: '验证码', mode: 'insensitive' },
|
||||||
|
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
|
||||||
|
queuedAt: {
|
||||||
|
gte: new Date('2026-07-01T00:00:00+08:00'),
|
||||||
|
lte: new Date('2026-07-02T23:59:59.999+08:00'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
||||||
orderBy: { queuedAt: 'desc' },
|
orderBy: { queuedAt: 'desc' },
|
||||||
@@ -234,7 +247,7 @@ describe('OperationsService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
taskCount: 3,
|
taskCount: 3,
|
||||||
uplinkCount: 1,
|
uplinkCount: 1,
|
||||||
pendingAuditCount: 6,
|
pendingAuditCount: 5,
|
||||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||||
downstreamDeliverySummary: expect.objectContaining({
|
downstreamDeliverySummary: expect.objectContaining({
|
||||||
pending: 3,
|
pending: 3,
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ export interface MessageQuery {
|
|||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
channelKeyword?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
contentKeyword?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
queuedAtFrom?: string;
|
||||||
|
queuedAtTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TraceQuery extends MessageQuery {
|
export interface TraceQuery extends MessageQuery {
|
||||||
@@ -731,7 +735,7 @@ export class OperationsService {
|
|||||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||||
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
||||||
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
|
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,9 +760,25 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
|||||||
messageId: query.messageId,
|
messageId: query.messageId,
|
||||||
phoneNumber: query.phoneNumber,
|
phoneNumber: query.phoneNumber,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
|
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||||
|
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||||
|
...(query.queuedAtFrom || query.queuedAtTo ? {
|
||||||
|
queuedAt: {
|
||||||
|
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
|
||||||
|
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfShanghaiDay(value: string) {
|
||||||
|
return new Date(`${value}T00:00:00+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfShanghaiDay(value: string) {
|
||||||
|
return new Date(`${value}T23:59:59.999+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGroupBy(groupBy?: string) {
|
function normalizeGroupBy(groupBy?: string) {
|
||||||
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
||||||
return 'tenantId';
|
return 'tenantId';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requestContext } from '../common/request-context';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||||
@@ -11,6 +12,24 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
|||||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
const operationLog = this.operationLog;
|
||||||
|
Object.defineProperty(this, 'operationLog', {
|
||||||
|
value: new Proxy(operationLog, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property === 'create') {
|
||||||
|
return (args: { data: Record<string, unknown> }) => {
|
||||||
|
const ipAddress = requestContext.getStore()?.ipAddress;
|
||||||
|
return (target.create as (input: unknown) => unknown)({
|
||||||
|
...args,
|
||||||
|
data: { ...args.data, ipAddress: typeof args.data.ipAddress === 'string' ? args.data.ipAddress : ipAddress },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, property, receiver);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
|
|||||||
@@ -1564,6 +1564,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? String(channel.config.serviceId)
|
? String(channel.config.serviceId)
|
||||||
: 'SMS',
|
: 'SMS',
|
||||||
srcId: channel.srcId,
|
srcId: channel.srcId,
|
||||||
|
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||||
registeredDelivery: 1,
|
registeredDelivery: 1,
|
||||||
msgFmt: 8,
|
msgFmt: 8,
|
||||||
},
|
},
|
||||||
@@ -2411,6 +2412,16 @@ function getPositiveConfigInteger(config: unknown, key: string, fallback: number
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) {
|
||||||
|
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||||
|
const value = Number((config as Record<string, unknown>)[key]);
|
||||||
|
if (Number.isInteger(value) && value >= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
||||||
const normalized = normalizeCarrier(channelCarrier);
|
const normalized = normalizeCarrier(channelCarrier);
|
||||||
return normalized === 'all' || normalized === targetCarrier;
|
return normalized === 'all' || normalized === targetCarrier;
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export class UsersService {
|
|||||||
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
|
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
|
||||||
displayName: data.displayName ?? current.displayName,
|
displayName: data.displayName ?? current.displayName,
|
||||||
status: data.status ?? current.status,
|
status: data.status ?? current.status,
|
||||||
|
...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}),
|
||||||
},
|
},
|
||||||
include: { tenant: true, roles: { include: { role: true } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
@@ -166,7 +167,7 @@ export class UsersService {
|
|||||||
const current = await this.getExisting(id, scopeTenantId);
|
const current = await this.getExisting(id, scopeTenantId);
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: data.status },
|
data: { status: data.status, ...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}) },
|
||||||
include: { tenant: true, roles: { include: { role: true } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
|
await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username });
|
||||||
@@ -180,7 +181,7 @@ export class UsersService {
|
|||||||
const current = await this.getExisting(id, scopeTenantId);
|
const current = await this.getExisting(id, scopeTenantId);
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null },
|
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||||
include: { tenant: true, roles: { include: { role: true } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
||||||
@@ -191,7 +192,7 @@ export class UsersService {
|
|||||||
const current = await this.getExisting(id, scopeTenantId);
|
const current = await this.getExisting(id, scopeTenantId);
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'deleted', deletedAt: new Date() },
|
data: { status: 'deleted', deletedAt: new Date(), sessionVersion: { increment: 1 } },
|
||||||
include: { tenant: true, roles: { include: { role: true } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
|
await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username });
|
||||||
@@ -218,6 +219,23 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changeOwnPassword(id: string, currentPassword: string, password: string) {
|
||||||
|
if (!currentPassword || !password || password.length < 6) {
|
||||||
|
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
|
||||||
|
}
|
||||||
|
const current = await this.getExisting(id);
|
||||||
|
if (current.passwordHash !== hashPassword(currentPassword)) {
|
||||||
|
throw new BadRequestException('当前密码不正确');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
|
||||||
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
|
});
|
||||||
|
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
listRoles() {
|
listRoles() {
|
||||||
return this.prisma.role.findMany({
|
return this.prisma.role.findMany({
|
||||||
include: { permissions: { include: { permission: true } } },
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
9. 短信应用必须有应用级客户侧企业代码 `cmppEnterpriseCode`,运营端添加/编辑应用时可自定义;不得从上游通道 `SmsChannel.enterpriseCode` 透传。
|
9. 短信应用必须有应用级客户侧企业代码 `cmppEnterpriseCode`,运营端添加/编辑应用时可自定义;不得从上游通道 `SmsChannel.enterpriseCode` 透传。
|
||||||
10. 短信应用接口密码 `passwordCipher` 新建时默认随机生成 16 位 UUID 片段,运营端可手工修改;编辑时留空不覆盖原密码。
|
10. 短信应用接口密码 `passwordCipher` 新建时默认随机生成 16 位 UUID 片段,运营端可手工修改;编辑时留空不覆盖原密码。
|
||||||
11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。
|
11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。
|
||||||
12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections` 和客户提交窗口 `cmppWindowSize`;这两个字段是平台运行配置,不是 CMPP 协议字段,也不是 gocmpp 库参数。
|
12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections`;客户侧提交窗口 `cmppWindowSize` 后端保留默认值,当前第一版不在运营端展示或要求运营配置,待 Gateway 入站侧按应用窗口真正限流后再开放为高级配置。
|
||||||
13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。
|
13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。
|
||||||
14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。
|
14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。
|
||||||
|
|
||||||
@@ -221,6 +221,7 @@
|
|||||||
10. Gateway 必须支持平台最终回执向下游客户连接投递 Deliver Receipt;若客户连接已断开,应按策略缓存、重试或记录投递失败,不能丢失平台最终状态。
|
10. Gateway 必须支持平台最终回执向下游客户连接投递 Deliver Receipt;若客户连接已断开,应按策略缓存、重试或记录投递失败,不能丢失平台最终状态。
|
||||||
11. Gateway 必须支持下游客户上行接入场景:收到运营商上行后,按接入号、手机号、应用、时间窗口匹配并向客户连接推送 Deliver,上行同时入库。
|
11. Gateway 必须支持下游客户上行接入场景:收到运营商上行后,按接入号、手机号、应用、时间窗口匹配并向客户连接推送 Deliver,上行同时入库。
|
||||||
12. 下游客户连接与上游通道连接必须隔离管理:客户侧账号密码不能用于连接上游通道,上游通道账号密码也不能作为客户接入凭据。
|
12. 下游客户连接与上游通道连接必须隔离管理:客户侧账号密码不能用于连接上游通道,上游通道账号密码也不能作为客户接入凭据。
|
||||||
|
13. Gateway 必须为客户侧 CMPP2.0/3.0 Submit 记录可检索日志:收包时记录协议版本、账号、客户 IP、sequenceId、号码、srcId、编码、分片序号和内容长度;响应时记录 result、平台 messageId、CMPP Msg_Id、耗时和失败阶段。NestJS 拒绝 Submit 时,Gateway 日志必须保留 API 返回的真实业务原因,不能只记录 HTTP 状态码;短信正文不得明文写入 Gateway 日志,仅记录字符数和哈希。
|
||||||
|
|
||||||
#### 4.8.3 回执、上行与幂等
|
#### 4.8.3 回执、上行与幂等
|
||||||
|
|
||||||
|
|||||||
@@ -488,12 +488,12 @@
|
|||||||
1. 打开运营端企业应用管理,点击新增短信应用。
|
1. 打开运营端企业应用管理,点击新增短信应用。
|
||||||
2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。
|
2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。
|
||||||
3. 选择企业后进入应用参数表单。
|
3. 选择企业后进入应用参数表单。
|
||||||
4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。
|
4. 配置应用名称、客户单价、IP 白名单、发送队列等级、短信接口开关、接口类型、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、移动/联通/电信通道组后保存。
|
||||||
5. 刷新列表并打开编辑页。
|
5. 刷新列表并打开编辑页。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。
|
- 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。
|
||||||
- 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。
|
- 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。
|
||||||
- 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。
|
- 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections` 和通道组绑定;企业应用表单不展示或提交客户侧 `cmppWindowSize`。
|
||||||
- “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。
|
- “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。
|
||||||
- “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。
|
- “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。
|
||||||
- `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。
|
- `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。
|
||||||
@@ -999,14 +999,16 @@
|
|||||||
- 步骤:
|
- 步骤:
|
||||||
1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890`。
|
1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890`。
|
||||||
2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890`,`Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`。
|
2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890`,`Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`。
|
||||||
3. 发送 CMPP 3.0 SubmitReq,手机号和内容匹配已审核模板。
|
3. 分别发送 CMPP 2.0 和 CMPP 3.0 SubmitReq,手机号和内容匹配已审核模板。
|
||||||
4. 查询 NestJS 数据库和运营端短信记录。
|
4. 再发送一条不匹配审核模板的 SubmitReq,检查客户收到的 SubmitResp 和 Gateway 日志。
|
||||||
|
5. 查询 NestJS 数据库和运营端短信记录。
|
||||||
- 预期结果:
|
- 预期结果:
|
||||||
- 17890 是真实 CMPP Server 监听,不是 HTTP 端口。
|
- 17890 是真实 CMPP Server 监听,不是 HTTP 端口。
|
||||||
- bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。
|
- bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。
|
||||||
- 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。
|
- 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。
|
||||||
- submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。
|
- submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。
|
||||||
- submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。
|
- submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。
|
||||||
|
- Gateway 对每次 submit 记录 `submit_received` 和 `submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。
|
||||||
|
|
||||||
### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环
|
### TC-GW-007 CMPP 客户到上游 SMSC 完整闭环
|
||||||
|
|
||||||
@@ -3028,7 +3030,7 @@ npm run verify:phase8
|
|||||||
|
|
||||||
| 用例 | 细化执行点 | 必查断言 |
|
| 用例 | 细化执行点 | 必查断言 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
|
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志和客户端流水均可追溯。 |
|
||||||
| TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 |
|
| TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 |
|
||||||
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
||||||
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
|
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
|
||||||
|
|||||||
+153
-1
@@ -650,6 +650,20 @@ npm run verify:phase8
|
|||||||
- 下游 submit 当前通过 `sourceType=cmpp` 的系统批次兼容承载,尚未完全拆成独立单条发送模型。
|
- 下游 submit 当前通过 `sourceType=cmpp` 的系统批次兼容承载,尚未完全拆成独立单条发送模型。
|
||||||
- 客户侧最终 Deliver Receipt 投递、客户侧上行 Deliver 推送、上游真实 SMSC submit worker、上游 receipt/uplink 生产解析仍未完成。
|
- 客户侧最终 Deliver Receipt 投递、客户侧上行 Deliver 推送、上游真实 SMSC submit worker、上游 receipt/uplink 生产解析仍未完成。
|
||||||
|
|
||||||
|
## 2026-07-11 Gateway 客户侧 Submit 日志完善
|
||||||
|
|
||||||
|
### 本轮修复
|
||||||
|
|
||||||
|
- Gateway 入站 Submit 日志增加 `submit_received`、`submit_accepted`、`submit_rejected` 结构化事件,同时记录 CONNECT 声明的客户协议版本与 Go 实际解包类型,并记录账号、客户 IP、sequenceId、号码、srcId、编码、分片、CMPP result、平台 messageId、CMPP Msg_Id 和处理耗时。
|
||||||
|
- Gateway HTTP 回调在 NestJS 返回非 2xx 时保留最多 64KB 响应体,客户 Submit 失败日志可直接显示模板不匹配、IP 白名单、余额或路由等真实业务原因,不再只显示 HTTP 状态码。
|
||||||
|
- 日志不记录明文短信正文,仅记录字符数和 MD5 哈希,便于比对同一内容且避免日志泄露。
|
||||||
|
|
||||||
|
### 验证状态
|
||||||
|
|
||||||
|
- `go test ./internal/inbound -count=1`:通过。
|
||||||
|
- `go test ./... -count=1`:通过。
|
||||||
|
- `go build ./cmd/gateway`:通过。
|
||||||
|
|
||||||
## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐
|
## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐
|
||||||
|
|
||||||
### 本轮修复
|
### 本轮修复
|
||||||
@@ -765,7 +779,7 @@ npm run verify:phase8
|
|||||||
|
|
||||||
- 运营端通道创建/编辑表单新增上游 `desiredConnections` 和 `windowSize` 输入,真实提交到 NestJS 通道 API,并规范化写入 `SmsChannel.config`。
|
- 运营端通道创建/编辑表单新增上游 `desiredConnections` 和 `windowSize` 输入,真实提交到 NestJS 通道 API,并规范化写入 `SmsChannel.config`。
|
||||||
- NestJS `ChannelsService` 对 `desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。
|
- NestJS `ChannelsService` 对 `desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。
|
||||||
- Prisma 为 `SmsApplication` 新增 `cmppMaxConnections`、`cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount`、客户最大连接数、客户提交窗口输入。
|
- Prisma 为 `SmsApplication` 新增 `cmppMaxConnections`、`cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount` 和客户最大连接数输入,客户提交窗口暂不展示给运营配置,保留后端默认值。
|
||||||
- 企业应用 `cmppAccount` 现在支持两种真实路径:显式填写 6 位数字账号,或留空由后端自动生成唯一账号;重复账号和非法格式会被后端拒绝。
|
- 企业应用 `cmppAccount` 现在支持两种真实路径:显式填写 6 位数字账号,或留空由后端自动生成唯一账号;重复账号和非法格式会被后端拒绝。
|
||||||
- 企业应用 CMPP 参数接口改为从应用真实字段返回 `enterpriseCode/account/passwordCipher/maxConnections/windowSize`,不再借用任意通道企业代码或默认值拼装客户参数。
|
- 企业应用 CMPP 参数接口改为从应用真实字段返回 `enterpriseCode/account/passwordCipher/maxConnections/windowSize`,不再借用任意通道企业代码或默认值拼装客户参数。
|
||||||
- 应用级 `cmppEnterpriseCode` 新建/编辑可自定义;接口密码新建默认随机 16 位 UUID 片段,编辑留空不覆盖、填写 16 位后更新。`AppID` 仅作为平台应用标识展示,不作为 CMPP 协议认证参数。
|
- 应用级 `cmppEnterpriseCode` 新建/编辑可自定义;接口密码新建默认随机 16 位 UUID 片段,编辑留空不覆盖、填写 16 位后更新。`AppID` 仅作为平台应用标识展示,不作为 CMPP 协议认证参数。
|
||||||
@@ -1388,3 +1402,141 @@ git diff --check
|
|||||||
- 生产 `cmpp-api`、`cmpp-gateway`、PostgreSQL、Nginx 均为 active,API health 正常。
|
- 生产 `cmpp-api`、`cmpp-gateway`、PostgreSQL、Nginx 均为 active,API health 正常。
|
||||||
- 隔离部署后曾因 `dist/assets` 被保留为 `700 root:root` 导致 Nginx 无权读取 JS/CSS、admin 页面空白;线上已修正为目录 `755`、文件 `644`,正式生产部署脚本同步固化权限。
|
- 隔离部署后曾因 `dist/assets` 被保留为 `700 root:root` 导致 Nginx 无权读取 JS/CSS、admin 页面空白;线上已修正为目录 `755`、文件 `644`,正式生产部署脚本同步固化权限。
|
||||||
- 正式部署发现已有生产管理员且未配置 `PROD_ADMIN_PASSWORD` 时,`upsert.create` 仍会对空密码执行哈希;已拆分 create/update 密码变量,已有账号不改密码,新建账号才生成临时密码。
|
- 正式部署发现已有生产管理员且未配置 `PROD_ADMIN_PASSWORD` 时,`upsert.create` 仍会对空密码执行哈希;已拆分 create/update 密码变量,已有账号不改密码,新建账号才生成临时密码。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 0 飞书瑕疵台账与分批策略
|
||||||
|
|
||||||
|
来源:飞书《短信平台第一版瑕疵》。本表仅记录问题路由和验收边界;除 Batch 1 外,其他项目仍须先在生产验证环境只读复现并核对真实代码、API、PostgreSQL、Redis、MinIO 或 Gateway 状态,不能根据页面现象直接修改。
|
||||||
|
|
||||||
|
| 飞书项 | 初步分类 | 真实链路/风险 | 计划批次 | 当前状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1.1-1.5 企业-充值流程 | UI + API/DB | 对象存储预览、人工充值、充值订单、账户流水 | Batch 1 | 已完成并部署;历史余额取 AccountTransaction 快照 |
|
||||||
|
| 2.1 通道密码展示/修改 | UI + API/DB + 安全 | 密码密文、权限、审计、上游连接配置 | Batch 2 | 已完成:密码不回显,留空不覆盖,填写新值才更新 |
|
||||||
|
| 2.2 扩展位数和通道流速 | UI + API/DB + Gateway | 通道配置持久化、Gateway submit 限速 | Batch 2 | 已完成:真实持久化并下发 Gateway SubmitCommand |
|
||||||
|
| 2.3 通道组名称为空提示 | UI 校验 | 服务端字段校验与前端错误提示一致 | Batch 2 | 已验证:既有前端提示会在真实 API 调用前中断保存 |
|
||||||
|
| 2.4 发送记录详情弹窗 | UI + API | 详情、回执、提交记录必须来自真实接口 | Batch 2 | 已完成:真实状态、提交和回执信息分层展示 |
|
||||||
|
| 2.5 连接日志优化 | UI + API + Gateway | 连接状态回写、操作日志、分页筛选 | Batch 2 | 已完成:展示真实连接状态摘要并支持日志关键词筛选 |
|
||||||
|
| 2.6 通道测试 | API/DB + Gateway/CMPP | 测试 submit、Redis Stream、上游响应、审计 | Batch 2 | 已完成:提交结果展示真实测试流水和提交记录 |
|
||||||
|
| 2.7 短信记录页面 | UI + API/DB + Gateway | 短信、submit、回执、分片审计真实查询 | Batch 2 | 已完成:筛选下推 PostgreSQL,详情/审计为真实接口 |
|
||||||
|
| 3.1 报备配置无返回 | UI 导航 | 返回后筛选/表单状态不丢失 | Batch 3 | 已完成:返回通道列表 |
|
||||||
|
| 3.2 通道组添加通道弹窗 | UI + API | 通道组成员真实保存和回填 | Batch 3 | 已完成:真实候选、状态展示、重复项限制和错误提示 |
|
||||||
|
| 4.1 创建用户 | UI + API/DB | 用户、角色、企业关联、审计 | Batch 4 | 已完成:真实表单校验、提交状态和错误提示 |
|
||||||
|
| 4.2 禁用/删除/改密后踢下线 | API/DB + 会话 | Token/session 失效、跨浏览器验证、审计 | Batch 4 | 已完成:数据库会话版本使旧 token 失效 |
|
||||||
|
| 4.3 禁用按钮颜色 | UI | 仅样式,保持通用危险操作语义 | Batch 4 | 已完成:使用 warning 语义色 |
|
||||||
|
| 4.4 个人改密缺失 | UI + API/会话 | 当前用户校验、密码更新、旧会话失效 | Batch 4 | 已完成:右上角真实当前密码校验与改密 |
|
||||||
|
| 5.1 待审核任务数不准 | API/DB 聚合 | 审核状态口径与任务明细一致 | Batch 5 | 已完成:风险审核改按 SmsSendTask.pending_review 统计 |
|
||||||
|
| 5.2 任务进度 | UI + API/DB + Gateway | 状态机、发送/回执计数、分页 | Batch 5 | 已完成:未知/超时不重复累计,已处理数不超过总号码数 |
|
||||||
|
| 5.3 企业应用 | UI + API/DB | 短信应用真实 CRUD/审核;彩信仅占位 | Batch 5 | 已完成:停用使用 warning 色,启用使用 success 色,搜索区宽度协调 |
|
||||||
|
| 5.4 企业模板 | UI + API/DB | 模板材料、审核状态、真实筛选 | Batch 5 | 已完成:审核状态以中文展示,draft 显示为草稿 |
|
||||||
|
| 5.5 企业签名 | UI + API/DB + MinIO | 资质文件、签名审核、对象存储预览 | Batch 5 | 已完成:左边框按三网真实报备结果展示,编辑页不允许手工改报备状态 |
|
||||||
|
| 5.6 引流信息 | UI + API/DB | 字典字段、签名/模板关联、审核口径 | Batch 5 | 已完成:列表改为引流信息、长链接不跳转且省略展示、操作列可见,编辑页不允许手工改报备状态 |
|
||||||
|
| 6.1 手机号段库 Tab | UI | 使用通用 Tabs,不改变真实号段数据路径 | Batch 6 | 已完成:Tab 按内容宽度展示 |
|
||||||
|
| 6.2 运营商区分规则分页 | UI + API/DB | 服务端分页、筛选与总数口径 | Batch 6 | 已完成:PostgreSQL 分页、总数、25 条每页 |
|
||||||
|
| 7.1 敏感词页 | UI + API/DB | 敏感词 CRUD、生效范围、发送校验 | Batch 6 | 已完成:状态 Tag 清晰展示,添加弹窗扩展,保留真实 CRUD |
|
||||||
|
| 8.1 系统日志 IP 为空 | API/DB + Nginx | 转发头、请求上下文、OperationLog 落库、历史数据边界 | Batch 6 | 已完成:真实 HTTP 操作日志记录 Nginx 转发的客户端 IP;后台任务保持空值 |
|
||||||
|
| 8.2 客户端标题 | UI | 客户端产品名称与运营端区分 | Batch 6 | 已完成:短信平台客户端 |
|
||||||
|
| 8.3 通用输入框/文本框样式 | UI | 去除内层填充色,保留边框和焦点状态 | 回归复查 | 已完成:含 Chromium 自动填充背景 |
|
||||||
|
| 8.4 精确时间格式 | UI | 所有精确时间统一 `YYYY-MM-DD HH:mm:ss` | Batch 6 | 已完成:统一 helper 覆盖日志、用户、充值、任务与配置展示 |
|
||||||
|
| 8.5 中文图片文件名乱码 | API/DB + MinIO + UI | multipart 编码、对象存储文件名、历史展示兼容 | 回归复查 | 已完成:新上传正确入库,历史展示兼容解码 |
|
||||||
|
| 8.6 全局分页控件 | UI + API/DB | 总页数、首页/末页、指定页跳转与服务端分页口径 | Batch 6 | 已完成:统一控件支持首页、末页、页码跳转;真实服务端分页页传入总页数 |
|
||||||
|
| 9.1 客户端菜单顺序 | UI | 签名与引流信息菜单位于模板管理之前 | Batch 6 | 已完成 |
|
||||||
|
|
||||||
|
### 执行约束
|
||||||
|
|
||||||
|
- 每个 Batch 先只读复现并记录页面、API、DB、Gateway 分类,再做最小真实修复。
|
||||||
|
- 纯 UI 项也必须确认页面数据源不是 mock、localStorage 或静态数组;未实现后端的彩信仅保留待开发占位。
|
||||||
|
- 每批结束执行相关 API 测试、API build、前端 build;涉及 Gateway 时追加 Go 测试和生产 Gateway health/CMPP 验证。
|
||||||
|
- 完成后更新本文件;未经明确要求不提交或推送代码。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 1 企业充值流程瑕疵
|
||||||
|
|
||||||
|
### 本轮修复
|
||||||
|
|
||||||
|
- 企业新建/编辑页的图片“预览”改为站内弹窗展示,不再跳转或新开页面;下载仍走真实对象存储文件接口。
|
||||||
|
- 通用 `Input`、`Select`、`Textarea` 根据 `required` 属性显示必填标识;企业资料和人工充值弹窗不再依赖页面散落的文案约定。
|
||||||
|
- 运营端充值记录列表的“充值后余额”改为真实订单关联 `AccountTransaction.balanceAfter`;不再用当前 `TenantAccount` 余额冒充历史快照。没有可追溯流水的历史记录显示 `-`。
|
||||||
|
- 人工充值弹窗补齐非零校验、提交中禁用和 API 失败提示;提交仍调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实记录。
|
||||||
|
- 企业名称与统一社会信用代码已经使用同一双列栅格,本轮复现未见对齐问题,不做无效样式改动。
|
||||||
|
|
||||||
|
### 验证口径
|
||||||
|
|
||||||
|
- `GET /api/admin/billing/manual-recharges` 必须基于 Prisma/PostgreSQL 的 `RechargeOrder` 和关联 `AccountTransaction` 返回余额快照。
|
||||||
|
- `TC-BILLING-006` 增加断言:充值记录“充值后余额”等于关联账务流水的 `balanceAfter`,与后续充值或消费后的当前余额无关。
|
||||||
|
|
||||||
|
### 已执行命令与结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix api test
|
||||||
|
npm --prefix api run build
|
||||||
|
npm run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
- API 全量单测通过:12 个 test suites、113 个测试通过;新增 BillingService 覆盖两笔人工充值分别返回其历史余额。
|
||||||
|
- API build 和前端 build 通过;前端仍有既有 Vite chunk size warning。
|
||||||
|
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。
|
||||||
|
- 已按生产标准脚本部署到 `8.160.169.106`;Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health、Redis 均通过。
|
||||||
|
- 生产管理员真实登录后只读调用 `GET /api/admin/billing/manual-recharges` 成功返回 2 条记录,响应包含真实 `balanceAfterCents`(10000、1000)。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 2 通道配置真实链路
|
||||||
|
|
||||||
|
### 本轮修复
|
||||||
|
|
||||||
|
- 运营端通道编辑/新建页的“通道流速”不再固定提交 `100`;输入值按 `1-2000 TPS` 校验后写入 `SmsChannel.rateLimitPerSecond`,发送链路和通道测试继续从该真实字段生成 Gateway `SubmitCommand.route.rateLimitPerSecond`。
|
||||||
|
- “扩展位数”仅允许 `0/2/4/6`,持久化到 `SmsChannel.config.extensionDigits`;编辑页回填该值,普通发送和通道测试均将其放入 Gateway `SubmitCommand.cmpp.extensionDigits`。
|
||||||
|
- NestJS 更新通道时修正 `config` 合并行为:传入的配置会与既有 JSON 配置合并,不会再被 `desiredConnections/windowSize` 规范化过程静默丢弃。
|
||||||
|
- 网关密码保持安全策略:编辑时不回显已配置密码,留空不覆盖;输入新密码才更新真实通道配置。
|
||||||
|
|
||||||
|
### 已执行命令与结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix api test -- channels.service.spec.ts --runInBand
|
||||||
|
npm --prefix api run build
|
||||||
|
npm run build
|
||||||
|
go test ./internal/queue ./internal/upstream
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
- ChannelsService 和 SendChainService 定向测试通过:2 个 test suites、55 个测试通过;ChannelsService 单独测试 23 项,覆盖流速、扩展位数持久化和非法配置拒绝。
|
||||||
|
- API build、前端 build、Gateway queue/upstream 测试通过;前端仍有既有 Vite chunk size warning。
|
||||||
|
- 已重新部署生产验证环境;Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。生产运行源码已确认包含流速校验、扩展位数持久化及 Gateway 队列字段。
|
||||||
|
- 通道组名称为空时已有前端提示“请输入通道组名称”,保存会在调用真实创建/更新 API 前中断;本轮复核后不重复改动。
|
||||||
|
- 通道编辑密码保持掩码且不回显:编辑时明确提示“留空保持不变,填写新密码才更新”;新建通道仍要求填写密码。
|
||||||
|
- 上述密码交互调整已于 2026-07-10 生产验证部署后再次核验:`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,内外部 health/HTTP 检查通过。
|
||||||
|
- 短信记录列表修复:企业、应用、手机号、状态之外的提交日期、短信内容、通道名称筛选改为传给 `GET /api/admin/operations/messages`;NestJS 通过 Prisma/PostgreSQL 执行内容、关联通道名和上海自然日范围查询,页面不再仅筛选已加载的前 500 条记录。
|
||||||
|
- 生产只读复现确认:短信记录 9 条均有真实 `SmsSubmitRecord`,其中 4 条已有真实 `SmsReceiptRecord`;3 个通道均有 `CmppConnectionState` 和 `OperationLog` 连接日志。按一条生产记录的日期、内容、通道关键词组合查询,9 条中仅返回 1 条且条件均匹配。
|
||||||
|
- `OperationsService` 定向测试 12 项、API build、前端 build 均通过;已部署生产验证,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。
|
||||||
|
- 发送详情弹窗重组为真实状态摘要、短信内容、通道提交/回执轨迹、状态信息和分片补偿审计;提交轨迹新增真实 `submitStatus`,不再只展示时间和回执码。
|
||||||
|
- 连接日志弹窗新增 `CmppConnectionState` 摘要(连接 ID、状态、当前/期望连接数、最近心跳、最近错误),日志内容以真实 `OperationLog.detail` 可读格式呈现,并仅对已返回日志做关键词筛选。
|
||||||
|
- 通道测试成功后展示 API 返回的真实 `testNo`、提交数量、手机号与 `SmsSubmitRecord.submitId`,禁用重复提交,并提供跳转至短信记录入口;没有虚构“发送成功”或模拟回执。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 3 通道报备与通道组配置
|
||||||
|
|
||||||
|
- 通道报备配置页新增返回通道列表入口,沿用现有 `/admin/channels/:channelId/reports` 路由的来源页面,避免运营人员进入配置页后没有回退路径。
|
||||||
|
- 通道组“添加通道”弹窗不再使用固定省份数组:省份和候选通道均来自 `GET /api/admin/channels`,按真实运营商、地区和已绑定通道过滤;选中后展示通道代码、地区和真实连接状态。
|
||||||
|
- 弹窗在省份、优先级或通道未选择时提供表单错误提示;没有符合条件的候选时显示可读空态。前端仅做交互约束,最终仍由 NestJS `ChannelsService` 校验运营商/地区兼容性、重复通道和优先级规则,并持久化到 `SmsChannelGroupItem`。
|
||||||
|
- 已执行 `npm --prefix api test -- channels.service.spec.ts --runInBand`(23 项通过)、`npm run build` 和 `git diff --check`;前端保留既有 Vite chunk size warning。
|
||||||
|
- 已部署生产验证:Prisma migration deploy 无待执行迁移,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常;生产只读接口返回 3 个真实通道(其中 2 个启用)、1 个真实通道组和 2 个组成员。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 4 用户管理与会话失效
|
||||||
|
|
||||||
|
- `User.sessionVersion` 真实持久化到 PostgreSQL;登录 token 携带该版本。浏览器携带 token 请求时,NestJS 会话中间件校验用户状态、删除状态和版本;禁用、删除、管理员改密和个人改密都会递增版本,使原会话在下一次请求被 401 拒绝,前端清理本地会话并跳回对应登录页。
|
||||||
|
- 为避免破坏 Gateway 与现有服务间无浏览器会话链路,中间件仅校验带 `Authorization` 的浏览器 token;未携带该 header 的既有内部请求保持原行为。
|
||||||
|
- 右上角“修改密码”补齐真实 `POST /api/auth/password`:要求当前密码、新密码(至少 6 位)和确认密码一致;成功后当前会话立即失效并回到登录页,写入操作日志。
|
||||||
|
- 运营端和客户端用户新建补齐姓名、至少一个联系方式、初始密码/企业关联等前端校验,提交中禁用按钮并展示 API 错误;用户禁用操作改用通用 warning 语义色。
|
||||||
|
- 已执行 `npm --prefix api run prisma:generate`、`npm --prefix api test -- auth.service.spec.ts session-validation.middleware.spec.ts users.service.spec.ts --runInBand`(3 suites、8 项通过)、`npm --prefix api run build`、`npm run build`、`git diff --check`;前端保留既有 Vite chunk size warning。
|
||||||
|
- 已部署生产验证:第 25 条 Prisma migration `20260710153000_add_user_session_version` 成功应用;`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。生产管理员新登录 token 为版本格式且可读取真实用户列表;伪造旧版本 token 被 `401` 拒绝,验证会话版本失效生效。
|
||||||
|
|
||||||
|
## 2026-07-10 Batch 5 审核与企业配置
|
||||||
|
|
||||||
|
- 修复 Dashboard 待审核聚合:短信审核的真实状态存储在 `SmsSendTask.status=pending_review`,原逻辑错误统计 `SmsBatchTask.auditStatus=pending`。聚合现统一模板、签名、企业认证和风险审核的真实待审状态。
|
||||||
|
- 生产 PostgreSQL 与对应 API 在部署后均显示四类待审为 0,Dashboard 也为 0,当前数据口径一致;无非零待审样本,未将该 0 值当作非零场景的充分验收。
|
||||||
|
- 任务进度、企业应用、企业模板、企业签名与引流字段页面均使用真实 NestJS API;生产只读接口成功返回任务、应用、模板、签名和引流字段数据,不存在 mock/localStorage 回退。
|
||||||
|
- 已根据下载的瑕疵文档修复 5.2-5.6:任务进度不重复累计未知/超时,企业应用启停语义色与搜索区,模板中文审核状态,签名三网状态驱动边框且移除人工状态选择,引流信息标题、链接展示、操作列和人工状态选择。
|
||||||
|
- 已执行 `npm --prefix api test -- operations.service.spec.ts --runInBand`(12 项通过)、前端 build 和 `git diff --check`;已部署生产验证,`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 正常。
|
||||||
|
|
||||||
|
## 2026-07-10 瑕疵回归复查
|
||||||
|
|
||||||
|
- 逐图复查下载的《短信平台第一版瑕疵》后,确认中文图片文件名乱码仍真实存在:生产 `FileObject.fileName` 中可见 UTF-8 被按 Latin-1 解释后的值。上传链路现先恢复 multipart 文件名编码;历史记录由前端展示层兼容解码,避免签名材料和企业认证页继续显示乱码。
|
||||||
|
- 通用输入框、文本框去除内部填充色;同时覆盖 Chromium 自动填充产生的蓝色内层背景。企业认证页此前额外写死的灰色输入背景已移除。
|
||||||
|
- 已执行 FilesService 定向单测(3 项通过)、API build、前端 build 和 `git diff --check`;生产部署后四个服务均为 active,API/Gateway health 正常。通过真实 `POST /api/admin/files/upload` 上传 `营业执照-编码回归.png`,响应和 `FileObject` 持久化文件名均为正常中文。
|
||||||
|
- Batch 6 的 6.1、6.2、7.1、8.1 仍为待修,不得因之前的前端构建通过而标记完成;其余 8.x 与客户端菜单顺序将继续按原始文档逐项复核。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -86,6 +87,7 @@ type DownstreamUplink struct {
|
|||||||
type downstreamSession struct {
|
type downstreamSession struct {
|
||||||
messageID string
|
messageID string
|
||||||
account string
|
account string
|
||||||
|
protocol string
|
||||||
srcID string
|
srcID string
|
||||||
phoneNumber string
|
phoneNumber string
|
||||||
gatewayMsgID uint64
|
gatewayMsgID uint64
|
||||||
@@ -143,6 +145,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
|||||||
resp.AuthIsmg = string(authISMG[:])
|
resp.AuthIsmg = string(authISMG[:])
|
||||||
session := downstreamSession{
|
session := downstreamSession{
|
||||||
account: strings.TrimSpace(defaultString(auth.Account, account)),
|
account: strings.TrimSpace(defaultString(auth.Account, account)),
|
||||||
|
protocol: cmppVersionName(req.Version),
|
||||||
srcID: strings.TrimSpace(auth.Account),
|
srcID: strings.TrimSpace(auth.Account),
|
||||||
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
||||||
connectedAt: time.Now().UTC(),
|
connectedAt: time.Now().UTC(),
|
||||||
@@ -153,59 +156,150 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
|
|||||||
}
|
}
|
||||||
rememberAccount(session)
|
rememberAccount(session)
|
||||||
go s.flushPending(defaultString(auth.Account, account), logger)
|
go s.flushPending(defaultString(auth.Account, account), logger)
|
||||||
logger.Printf("cmpp inbound account=%s login ok remote=%s", account, packet.Conn.Conn.RemoteAddr())
|
logger.Printf(
|
||||||
|
"cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x30 account=%s remote=%s",
|
||||||
|
cmppVersionName(req.Version), req.Version, account, packet.Conn.Conn.RemoteAddr(),
|
||||||
|
)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
|
func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logger *log.Logger) (bool, error) {
|
||||||
req, ok := packet.Packer.(*cmpp.Cmpp3SubmitReqPkt)
|
req, ok := normalizeInboundSubmit(packet.Packer)
|
||||||
if !ok {
|
if !ok {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
resp := response.Packer.(*cmpp.Cmpp3SubmitRspPkt)
|
account := strings.TrimRight(req.msgSrc, "\x00")
|
||||||
account := strings.TrimRight(req.MsgSrc, "\x00")
|
|
||||||
phone := ""
|
phone := ""
|
||||||
if len(req.DestTerminalId) > 0 {
|
if len(req.destTerminalIDs) > 0 {
|
||||||
phone = strings.TrimRight(req.DestTerminalId[0], "\x00")
|
phone = strings.TrimRight(req.destTerminalIDs[0], "\x00")
|
||||||
}
|
}
|
||||||
content, err := decodeContent(req.MsgFmt, req.MsgContent)
|
remote := packet.Conn.Conn.RemoteAddr()
|
||||||
|
clientProtocol := inboundClientProtocol(account, packet.Conn, req.protocol)
|
||||||
|
logger.Printf(
|
||||||
|
"cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d",
|
||||||
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt,
|
||||||
|
req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent),
|
||||||
|
)
|
||||||
|
content, err := decodeContent(req.msgFmt, req.msgContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("cmpp inbound decode submit failed account=%s seq=%d err=%v", account, req.SeqId, err)
|
logger.Printf(
|
||||||
resp.Result = 9
|
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=decode reason=%q",
|
||||||
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, err,
|
||||||
|
)
|
||||||
|
setInboundSubmitResponse(response.Packer, 0, 9)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
result, err := s.submit(packet.Conn.Conn.RemoteAddr(), submitRequest{
|
contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content)))
|
||||||
|
startedAt := time.Now()
|
||||||
|
result, err := s.submit(remote, submitRequest{
|
||||||
Account: account,
|
Account: account,
|
||||||
PhoneNumber: phone,
|
PhoneNumber: phone,
|
||||||
Content: content,
|
Content: content,
|
||||||
SrcID: req.SrcId,
|
SrcID: req.srcID,
|
||||||
DestID: phone,
|
DestID: phone,
|
||||||
SequenceID: req.SeqId,
|
SequenceID: req.sequenceID,
|
||||||
RemoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
RemoteIP: remoteIP(remote),
|
||||||
})
|
})
|
||||||
if err != nil || !result.Accepted {
|
if err != nil || !result.Accepted {
|
||||||
logger.Printf("cmpp inbound submit rejected account=%s phone=%s seq=%d err=%v", account, phone, req.SeqId, err)
|
reason := "api returned accepted=false"
|
||||||
resp.Result = 9
|
if err != nil {
|
||||||
|
reason = err.Error()
|
||||||
|
}
|
||||||
|
logger.Printf(
|
||||||
|
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=9 stage=business duration_ms=%d content_chars=%d content_hash=%s reason=%q",
|
||||||
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, reason,
|
||||||
|
)
|
||||||
|
setInboundSubmitResponse(response.Packer, 0, 9)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
resp.MsgId = messageIDFrom(result.MessageID, req.SeqId)
|
gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID)
|
||||||
resp.Result = 0
|
setInboundSubmitResponse(response.Packer, gatewayMsgID, 0)
|
||||||
rememberDownstream(downstreamSession{
|
rememberDownstream(downstreamSession{
|
||||||
messageID: result.MessageID,
|
messageID: result.MessageID,
|
||||||
account: account,
|
account: account,
|
||||||
srcID: strings.TrimSpace(req.SrcId),
|
protocol: clientProtocol,
|
||||||
|
srcID: strings.TrimSpace(req.srcID),
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
gatewayMsgID: resp.MsgId,
|
gatewayMsgID: gatewayMsgID,
|
||||||
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
|
remoteIP: remoteIP(remote),
|
||||||
connectedAt: time.Now().UTC(),
|
connectedAt: time.Now().UTC(),
|
||||||
conn: packet.Conn,
|
conn: packet.Conn,
|
||||||
mu: &sync.Mutex{},
|
mu: &sync.Mutex{},
|
||||||
presence: s.PresenceStore,
|
presence: s.PresenceStore,
|
||||||
instanceID: s.gatewayInstanceID(),
|
instanceID: s.gatewayInstanceID(),
|
||||||
})
|
})
|
||||||
|
logger.Printf(
|
||||||
|
"cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s",
|
||||||
|
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash,
|
||||||
|
)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type inboundSubmitPacket struct {
|
||||||
|
protocol string
|
||||||
|
pkTotal uint8
|
||||||
|
pkNumber uint8
|
||||||
|
msgFmt uint8
|
||||||
|
msgSrc string
|
||||||
|
srcID string
|
||||||
|
destTerminalIDs []string
|
||||||
|
msgContent string
|
||||||
|
sequenceID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
|
||||||
|
switch req := packet.(type) {
|
||||||
|
case *cmpp.Cmpp2SubmitReqPkt:
|
||||||
|
return inboundSubmitPacket{
|
||||||
|
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||||
|
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||||
|
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||||
|
}, true
|
||||||
|
case *cmpp.Cmpp3SubmitReqPkt:
|
||||||
|
return inboundSubmitPacket{
|
||||||
|
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, msgFmt: req.MsgFmt,
|
||||||
|
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
|
||||||
|
msgContent: req.MsgContent, sequenceID: req.SeqId,
|
||||||
|
}, true
|
||||||
|
default:
|
||||||
|
return inboundSubmitPacket{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInboundSubmitResponse(packet any, messageID uint64, result uint32) {
|
||||||
|
switch resp := packet.(type) {
|
||||||
|
case *cmpp.Cmpp2SubmitRspPkt:
|
||||||
|
resp.MsgId = messageID
|
||||||
|
resp.Result = uint8(result)
|
||||||
|
case *cmpp.Cmpp3SubmitRspPkt:
|
||||||
|
resp.MsgId = messageID
|
||||||
|
resp.Result = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboundClientProtocol(account string, conn *cmpp.Conn, fallback string) string {
|
||||||
|
downstreamRegistry.RLock()
|
||||||
|
defer downstreamRegistry.RUnlock()
|
||||||
|
session := downstreamRegistry.byAccount[account]
|
||||||
|
if session != nil && session.conn == conn && session.protocol != "" {
|
||||||
|
return session.protocol
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmppVersionName(version cmpp.Type) string {
|
||||||
|
switch version {
|
||||||
|
case cmpp.V20:
|
||||||
|
return "cmpp20"
|
||||||
|
case cmpp.V21:
|
||||||
|
return "cmpp21"
|
||||||
|
case cmpp.V30:
|
||||||
|
return "cmpp30"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown_0x%02x", uint8(version))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) {
|
func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) {
|
||||||
payload := authRequest{
|
payload := authRequest{
|
||||||
Account: account,
|
Account: account,
|
||||||
@@ -320,11 +414,22 @@ func (s Server) post(ctx context.Context, path string, payload any, result any)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read api response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
detail := strings.TrimSpace(string(responseBody))
|
||||||
|
if detail == "" {
|
||||||
return fmt.Errorf("api returned %s", resp.Status)
|
return fmt.Errorf("api returned %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("api returned %s: %s", resp.Status, detail)
|
||||||
|
}
|
||||||
if result != nil {
|
if result != nil {
|
||||||
return json.NewDecoder(resp.Body).Decode(result)
|
if len(responseBody) == 0 {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return json.Unmarshal(responseBody, result)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package inbound
|
package inbound
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
@@ -166,6 +167,69 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostIncludesAPIErrorResponseBody(t *testing.T) {
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"CMPP submit content does not match an approved template and signature","statusCode":400}`))
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
err := (Server{APIBaseURL: api.URL}).post(context.Background(), "/inbound/submit", map[string]string{"account": "100001"}, nil)
|
||||||
|
if err == nil || !bytes.Contains([]byte(err.Error()), []byte("CMPP submit content does not match an approved template and signature")) {
|
||||||
|
t.Fatalf("expected API response body in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeInboundSubmitSupportsCMPP2AndCMPP3(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packet any
|
||||||
|
protocol string
|
||||||
|
}{
|
||||||
|
{name: "cmpp2", packet: &cmpp.Cmpp2SubmitReqPkt{MsgSrc: "100001", SeqId: 20}, protocol: "cmpp20"},
|
||||||
|
{name: "cmpp3", packet: &cmpp.Cmpp3SubmitReqPkt{MsgSrc: "100001", SeqId: 30}, protocol: "cmpp30"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, ok := normalizeInboundSubmit(test.packet)
|
||||||
|
if !ok || got.protocol != test.protocol || got.msgSrc != "100001" {
|
||||||
|
t.Fatalf("unexpected normalized packet: %+v ok=%v", got, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetInboundSubmitResponseSupportsCMPP2AndCMPP3(t *testing.T) {
|
||||||
|
cmpp2 := &cmpp.Cmpp2SubmitRspPkt{}
|
||||||
|
setInboundSubmitResponse(cmpp2, 101, 9)
|
||||||
|
if cmpp2.MsgId != 101 || cmpp2.Result != 9 {
|
||||||
|
t.Fatalf("unexpected CMPP2 response: %+v", cmpp2)
|
||||||
|
}
|
||||||
|
cmpp3 := &cmpp.Cmpp3SubmitRspPkt{}
|
||||||
|
setInboundSubmitResponse(cmpp3, 202, 9)
|
||||||
|
if cmpp3.MsgId != 202 || cmpp3.Result != 9 {
|
||||||
|
t.Fatalf("unexpected CMPP3 response: %+v", cmpp3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundClientProtocolUsesConnectRequestVersion(t *testing.T) {
|
||||||
|
resetDownstreamRegistry()
|
||||||
|
defer resetDownstreamRegistry()
|
||||||
|
conn := &cmpp.Conn{}
|
||||||
|
downstreamRegistry.byAccount["100001"] = &downstreamSession{
|
||||||
|
account: "100001",
|
||||||
|
protocol: "cmpp20",
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
if got := inboundClientProtocol("100001", conn, "cmpp30"); got != "cmpp20" {
|
||||||
|
t.Fatalf("protocol = %s, want cmpp20", got)
|
||||||
|
}
|
||||||
|
if got := inboundClientProtocol("missing", conn, "cmpp30"); got != "cmpp30" {
|
||||||
|
t.Fatalf("fallback protocol = %s, want cmpp30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) {
|
func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) {
|
||||||
resetDownstreamRegistry()
|
resetDownstreamRegistry()
|
||||||
defer resetDownstreamRegistry()
|
defer resetDownstreamRegistry()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type Route struct {
|
|||||||
type CMPP struct {
|
type CMPP struct {
|
||||||
ServiceID string `json:"serviceId"`
|
ServiceID string `json:"serviceId"`
|
||||||
SrcID string `json:"srcId"`
|
SrcID string `json:"srcId"`
|
||||||
|
ExtensionDigits int `json:"extensionDigits"`
|
||||||
RegisteredDelivery int `json:"registeredDelivery"`
|
RegisteredDelivery int `json:"registeredDelivery"`
|
||||||
MsgFmt int `json:"msgFmt"`
|
MsgFmt int `json:"msgFmt"`
|
||||||
FeeUserType int `json:"feeUserType,omitempty"`
|
FeeUserType int `json:"feeUserType,omitempty"`
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) {
|
|||||||
"cmpp": {
|
"cmpp": {
|
||||||
"serviceId": "SMS",
|
"serviceId": "SMS",
|
||||||
"srcId": "10690000",
|
"srcId": "10690000",
|
||||||
|
"extensionDigits": 4,
|
||||||
"registeredDelivery": 1,
|
"registeredDelivery": 1,
|
||||||
"msgFmt": 8
|
"msgFmt": 8
|
||||||
},
|
},
|
||||||
@@ -62,4 +63,7 @@ func TestSubmitCommandUnmarshalsQueuePriority(t *testing.T) {
|
|||||||
if command.Upstream.DesiredConnections != 2 || command.Upstream.WindowSize != 16 {
|
if command.Upstream.DesiredConnections != 2 || command.Upstream.WindowSize != 16 {
|
||||||
t.Fatalf("unexpected upstream window config: %+v", command.Upstream)
|
t.Fatalf("unexpected upstream window config: %+v", command.Upstream)
|
||||||
}
|
}
|
||||||
|
if command.CMPP.ExtensionDigits != 4 {
|
||||||
|
t.Fatalf("unexpected extension digits: %d", command.CMPP.ExtensionDigits)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -1,4 +1,4 @@
|
|||||||
import { getSessionTenantId, readSession, type LoginSession } from './session';
|
import { clearSession, getSessionTenantId, readSession, type LoginSession } from './session';
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
type RequestOptions = RequestInit & {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
@@ -35,6 +35,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
headers.set('x-tenant-id', tenantId);
|
headers.set('x-tenant-id', tenantId);
|
||||||
}
|
}
|
||||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||||
|
if (response.status === 401 && session) {
|
||||||
|
clearSession();
|
||||||
|
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||||
|
throw new Error('登录会话已失效,请重新登录');
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response));
|
throw new Error(await readErrorMessage(response));
|
||||||
}
|
}
|
||||||
@@ -52,6 +57,11 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
|||||||
headers.set('x-tenant-id', tenantId);
|
headers.set('x-tenant-id', tenantId);
|
||||||
}
|
}
|
||||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||||
|
if (response.status === 401 && session) {
|
||||||
|
clearSession();
|
||||||
|
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||||
|
throw new Error('登录会话已失效,请重新登录');
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response));
|
throw new Error(await readErrorMessage(response));
|
||||||
}
|
}
|
||||||
@@ -73,13 +83,13 @@ export type AdminChannel = {
|
|||||||
rateLimitPerSecond: number;
|
rateLimitPerSecond: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
status: string;
|
status: string;
|
||||||
config?: { desiredConnections?: number; windowSize?: number; [key: string]: unknown } | null;
|
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; [key: string]: unknown } | null;
|
||||||
connectionStates?: CmppConnectionState[];
|
connectionStates?: CmppConnectionState[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelConnectionLogResponse = {
|
export type ChannelConnectionLogResponse = {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
connectionStates: Array<Record<string, unknown>>;
|
connectionStates: CmppConnectionState[];
|
||||||
logs: Array<{
|
logs: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -224,6 +234,7 @@ export type RechargeOrder = {
|
|||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
operatorId?: string | null;
|
operatorId?: string | null;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
|
balanceAfterCents?: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
tenant?: TenantOption;
|
tenant?: TenantOption;
|
||||||
};
|
};
|
||||||
@@ -784,6 +795,8 @@ export const adminApi = {
|
|||||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||||
|
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||||
@@ -929,7 +942,7 @@ export const adminApi = {
|
|||||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||||
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
||||||
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
||||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) =>
|
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||||
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
|
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
|
||||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||||
@@ -972,7 +985,8 @@ export const adminApi = {
|
|||||||
request<CursorPage<DictionaryItem>>(withQuery('/admin/dictionaries/phone-segments', query)),
|
request<CursorPage<DictionaryItem>>(withQuery('/admin/dictionaries/phone-segments', query)),
|
||||||
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'),
|
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
|
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)),
|
||||||
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
|
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
@@ -24,15 +24,6 @@ type RouteModalState = {
|
|||||||
route?: ProvinceRoute | NationalRoute;
|
route?: ProvinceRoute | NationalRoute;
|
||||||
};
|
};
|
||||||
|
|
||||||
const provinceOptions = [
|
|
||||||
{ label: '请选择', value: '' },
|
|
||||||
{ label: '山东', value: '山东' },
|
|
||||||
{ label: '河南', value: '河南' },
|
|
||||||
{ label: '北京', value: '北京' },
|
|
||||||
{ label: '上海', value: '上海' },
|
|
||||||
{ label: '广东', value: '广东' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = [
|
||||||
{ label: '请选择', value: '' },
|
{ label: '请选择', value: '' },
|
||||||
{ label: '1', value: '1' },
|
{ label: '1', value: '1' },
|
||||||
@@ -83,12 +74,14 @@ function RouteConfigModal({
|
|||||||
channels,
|
channels,
|
||||||
carrier,
|
carrier,
|
||||||
modal,
|
modal,
|
||||||
|
occupiedChannelIds,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
channels: AdminChannel[];
|
channels: AdminChannel[];
|
||||||
carrier: Carrier;
|
carrier: Carrier;
|
||||||
modal: RouteModalState;
|
modal: RouteModalState;
|
||||||
|
occupiedChannelIds: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
|
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -97,9 +90,20 @@ function RouteConfigModal({
|
|||||||
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
||||||
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
|
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
|
||||||
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
|
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const provinceOptions = [
|
||||||
|
{ label: '请选择省份', value: '' },
|
||||||
|
...Array.from(new Set(channels
|
||||||
|
.filter((channel) => isCarrierCompatible(channel.carrier, carrier))
|
||||||
|
.map((channel) => channel.sendRegion)
|
||||||
|
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')),
|
||||||
|
)).sort().map((region) => ({ label: region, value: region })),
|
||||||
|
];
|
||||||
|
|
||||||
const selectableChannels = channels.filter((channel) => {
|
const selectableChannels = channels.filter((channel) => {
|
||||||
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
|
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
|
||||||
|
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
|
||||||
if (modal.type === 'province' && province) {
|
if (modal.type === 'province' && province) {
|
||||||
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
|
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
|
||||||
}
|
}
|
||||||
@@ -114,10 +118,16 @@ function RouteConfigModal({
|
|||||||
];
|
];
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!channelId) return;
|
if (!channelId) {
|
||||||
|
setError('请选择可用通道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const channel = channels.find((item) => item.id === channelId);
|
const channel = channels.find((item) => item.id === channelId);
|
||||||
if (modal.type === 'province') {
|
if (modal.type === 'province') {
|
||||||
if (!province) return;
|
if (!province) {
|
||||||
|
setError('请选择省份');
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
id: provinceRoute?.id ?? `p-${Date.now()}`,
|
id: provinceRoute?.id ?? `p-${Date.now()}`,
|
||||||
province,
|
province,
|
||||||
@@ -127,7 +137,10 @@ function RouteConfigModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!priority) return;
|
if (!priority) {
|
||||||
|
setError('请选择优先级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
id: nationalRoute?.id ?? `n-${Date.now()}`,
|
id: nationalRoute?.id ?? `n-${Date.now()}`,
|
||||||
priority: Number(priority),
|
priority: Number(priority),
|
||||||
@@ -147,7 +160,12 @@ function RouteConfigModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
|
title={(
|
||||||
|
<div className="channel-route-modal__title">
|
||||||
|
<span><RadioTower size={20} /></span>
|
||||||
|
<div><h2>{modal.mode === 'edit' ? '编辑通道' : '添加通道'}</h2><p>{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}</p></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="channel-route-modal">
|
<div className="channel-route-modal">
|
||||||
{modal.type === 'province' ? (
|
{modal.type === 'province' ? (
|
||||||
@@ -162,6 +180,22 @@ function RouteConfigModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
||||||
|
{selectableChannels.length === 0 ? <p className="channel-route-modal__empty">暂无符合运营商、地区和未重复绑定条件的通道</p> : null}
|
||||||
|
{channelId ? (() => {
|
||||||
|
const selected = channels.find((channel) => channel.id === channelId);
|
||||||
|
if (!selected) return null;
|
||||||
|
const status = getChannelStatus(selected);
|
||||||
|
return (
|
||||||
|
<div className="channel-route-modal__selected">
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>{selected.name}</strong>
|
||||||
|
<span>{selected.code} · {selected.sendRegion ?? '全国'} · {statusLabels[status]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -441,7 +475,19 @@ export function AdminChannelGroupFormPage() {
|
|||||||
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost">返回</Button>
|
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost">返回</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modal ? <RouteConfigModal carrier={carrier} channels={channels} modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
|
{modal ? (
|
||||||
|
<RouteConfigModal
|
||||||
|
carrier={carrier}
|
||||||
|
channels={channels}
|
||||||
|
modal={modal}
|
||||||
|
occupiedChannelIds={[
|
||||||
|
...provinceRoutes.map((route) => route.channelId),
|
||||||
|
...nationalRoutes.map((route) => route.channelId),
|
||||||
|
]}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSubmit={saveRoute}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export function AdminChannelGroupsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredGroups.length}
|
total={filteredGroups.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { ArrowLeft, Plus, Search } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
export function AdminChannelReportPage() {
|
export function AdminChannelReportPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||||
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
||||||
const [channelId, setChannelId] = useState('');
|
const [channelId, setChannelId] = useState('');
|
||||||
@@ -60,8 +62,11 @@ export function AdminChannelReportPage() {
|
|||||||
<Breadcrumb items={['报备管理', '通道报备配置']} />
|
<Breadcrumb items={['报备管理', '通道报备配置']} />
|
||||||
<h1>通道报备配置</h1>
|
<h1>通道报备配置</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">返回通道列表</Button>
|
||||||
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>新增字段</Button>
|
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>新增字段</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="surface admin-drainage-toolbar">
|
<div className="surface admin-drainage-toolbar">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
|
|
||||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||||
@@ -29,6 +29,8 @@ type SmsChannel = {
|
|||||||
cmppVersion: '2.0' | '3.0';
|
cmppVersion: '2.0' | '3.0';
|
||||||
desiredConnections: number;
|
desiredConnections: number;
|
||||||
windowSize: number;
|
windowSize: number;
|
||||||
|
extensionDigits: number;
|
||||||
|
rateLimitPerSecond: number;
|
||||||
passwordCipher?: string;
|
passwordCipher?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +49,22 @@ type ChannelLogState = {
|
|||||||
data?: ChannelConnectionLogResponse;
|
data?: ChannelConnectionLogResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const connectionStatusLabelMap: Record<string, string> = {
|
||||||
|
connected: '已连接',
|
||||||
|
connecting: '连接中',
|
||||||
|
reconnecting: '重连中',
|
||||||
|
disconnected: '已断开',
|
||||||
|
failed: '连接失败',
|
||||||
|
auth_failed: '鉴权失败',
|
||||||
|
heartbeat_timeout: '心跳超时',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLogDetail(detail?: unknown) {
|
||||||
|
if (!detail) return '无附加信息';
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
return JSON.stringify(detail, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
const carrierOptions = [
|
const carrierOptions = [
|
||||||
{ label: '全部运营商', value: 'all' },
|
{ label: '全部运营商', value: 'all' },
|
||||||
{ label: '移动', value: 'mobile' },
|
{ label: '移动', value: 'mobile' },
|
||||||
@@ -154,6 +172,8 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
|
|||||||
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
|
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
|
||||||
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
||||||
windowSize: Number(channel.config?.windowSize ?? 16),
|
windowSize: Number(channel.config?.windowSize ?? 16),
|
||||||
|
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
|
||||||
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +193,11 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
|||||||
passwordCipher: passwordCipher || undefined,
|
passwordCipher: passwordCipher || undefined,
|
||||||
srcId: channel.accessNo,
|
srcId: channel.accessNo,
|
||||||
cmppVersion: channel.cmppVersion,
|
cmppVersion: channel.cmppVersion,
|
||||||
rateLimitPerSecond: 100,
|
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||||
unitPrice: Math.round(channel.unitPrice),
|
unitPrice: Math.round(channel.unitPrice),
|
||||||
desiredConnections: channel.desiredConnections,
|
desiredConnections: channel.desiredConnections,
|
||||||
windowSize: channel.windowSize,
|
windowSize: channel.windowSize,
|
||||||
|
config: { extensionDigits: channel.extensionDigits },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +233,8 @@ function ChannelFormModal({
|
|||||||
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
|
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
|
||||||
const [extensionDigits, setExtensionDigits] = useState('0');
|
const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0));
|
||||||
const [flowLimit, setFlowLimit] = useState('1-2000');
|
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
||||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||||
|
|
||||||
@@ -240,6 +261,8 @@ function ChannelFormModal({
|
|||||||
cmppVersion,
|
cmppVersion,
|
||||||
desiredConnections: Number(desiredConnections) || 1,
|
desiredConnections: Number(desiredConnections) || 1,
|
||||||
windowSize: Number(windowSize) || 16,
|
windowSize: Number(windowSize) || 16,
|
||||||
|
extensionDigits: Number(extensionDigits),
|
||||||
|
rateLimitPerSecond: Number(flowLimit),
|
||||||
passwordCipher: password || undefined,
|
passwordCipher: password || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -287,12 +310,20 @@ function ChannelFormModal({
|
|||||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||||||
<Input label="* 网关密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入网关密码" type="password" value={password} />
|
<Input
|
||||||
|
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
||||||
|
label="网关密码"
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'}
|
||||||
|
required={modal.mode === 'create'}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
<div className="sms-channel-inline-field">
|
<div className="sms-channel-inline-field">
|
||||||
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||||
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
||||||
</div>
|
</div>
|
||||||
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
|
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||||
</div>
|
</div>
|
||||||
@@ -305,16 +336,18 @@ function ChannelFormModal({
|
|||||||
function SmsTestModal({
|
function SmsTestModal({
|
||||||
channel,
|
channel,
|
||||||
onClose,
|
onClose,
|
||||||
|
onOpenRecords,
|
||||||
}: {
|
}: {
|
||||||
channel: SmsChannel;
|
channel: SmsChannel;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onOpenRecords: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [phones, setPhones] = useState('');
|
const [phones, setPhones] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [accessNo, setAccessNo] = useState('');
|
const [accessNo, setAccessNo] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState('');
|
const [result, setResult] = useState<ChannelTestResponse | null>(null);
|
||||||
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
const billingCount = Math.max(1, Math.ceil(content.length / 67));
|
||||||
|
|
||||||
async function submitTestSms() {
|
async function submitTestSms() {
|
||||||
@@ -328,14 +361,14 @@ function SmsTestModal({
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
setResult('');
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.testChannel(channel.id, {
|
const response = await adminApi.testChannel(channel.id, {
|
||||||
phones,
|
phones,
|
||||||
content,
|
content,
|
||||||
accessNo: accessNo.trim() || undefined,
|
accessNo: accessNo.trim() || undefined,
|
||||||
});
|
});
|
||||||
setResult(`已提交 ${response.submitted} 条测试短信,测试流水号 ${response.testNo}`);
|
setResult(response);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '测试短信发送失败');
|
setError(failure instanceof Error ? failure.message : '测试短信发送失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -348,8 +381,9 @@ function SmsTestModal({
|
|||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={submitting} icon={<Send size={16} />} onClick={submitTestSms}>
|
{result ? <Button icon={<ExternalLink size={16} />} onClick={onOpenRecords} variant="ghost">查看短信记录</Button> : null}
|
||||||
{submitting ? '发送中...' : '发送测试'}
|
<Button disabled={submitting || Boolean(result)} icon={<Send size={16} />} onClick={submitTestSms}>
|
||||||
|
{submitting ? '发送中...' : result ? '已提交' : '发送测试'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -402,9 +436,22 @@ function SmsTestModal({
|
|||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="signature-alert sms-test-note">
|
<div className="sms-test-result">
|
||||||
<Info size={18} />
|
<div className="sms-test-result__summary">
|
||||||
<span>{result}</span>
|
<CheckCircle2 size={20} />
|
||||||
|
<div>
|
||||||
|
<strong>已写入真实发送队列</strong>
|
||||||
|
<span>测试流水号:{result.testNo},共 {result.submitted} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sms-test-result__records">
|
||||||
|
{result.messages.map((message) => (
|
||||||
|
<div key={message.messageRecordId}>
|
||||||
|
<span>{message.phoneNumber}</span>
|
||||||
|
<code>{message.submitId}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,6 +470,7 @@ export function AdminChannelsPage() {
|
|||||||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||||
|
const [logKeyword, setLogKeyword] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
@@ -496,8 +544,14 @@ export function AdminChannelsPage() {
|
|||||||
|
|
||||||
async function openLinkLogs(channel: SmsChannel) {
|
async function openLinkLogs(channel: SmsChannel) {
|
||||||
setLogState({ channel });
|
setLogState({ channel });
|
||||||
|
setLogKeyword('');
|
||||||
|
try {
|
||||||
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
const data = await adminApi.listChannelConnectionLogs(channel.id);
|
||||||
setLogState({ channel, data });
|
setLogState({ channel, data });
|
||||||
|
} catch (failure) {
|
||||||
|
setLogState(null);
|
||||||
|
setError(failure instanceof Error ? failure.message : '连接日志加载失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitConfirmAction() {
|
function submitConfirmAction() {
|
||||||
@@ -606,6 +660,8 @@ export function AdminChannelsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredChannels.length}
|
total={filteredChannels.length}
|
||||||
/>
|
/>
|
||||||
@@ -623,6 +679,10 @@ export function AdminChannelsPage() {
|
|||||||
<SmsTestModal
|
<SmsTestModal
|
||||||
channel={testChannel}
|
channel={testChannel}
|
||||||
onClose={() => setTestChannel(null)}
|
onClose={() => setTestChannel(null)}
|
||||||
|
onOpenRecords={() => {
|
||||||
|
setTestChannel(null);
|
||||||
|
navigate('/admin/sms-records');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -654,8 +714,38 @@ export function AdminChannelsPage() {
|
|||||||
size="xl"
|
size="xl"
|
||||||
title={<div className="template-modal-title"><h2>连接日志</h2><p>{logState.channel.name}</p></div>}
|
title={<div className="template-modal-title"><h2>连接日志</h2><p>{logState.channel.name}</p></div>}
|
||||||
>
|
>
|
||||||
|
<div className="channel-log-modal">
|
||||||
|
{logState.data ? (
|
||||||
|
<div className="channel-connection-summary">
|
||||||
|
{logState.data.connectionStates.map((connection) => (
|
||||||
|
<article key={connection.id}>
|
||||||
|
<div>
|
||||||
|
<span>连接 ID</span>
|
||||||
|
<strong>{connection.connectionId}</strong>
|
||||||
|
</div>
|
||||||
|
<Tag tone={connection.status === 'connected' ? 'success' : connection.lastError ? 'danger' : 'info'}>
|
||||||
|
{connectionStatusLabelMap[connection.status] ?? connection.status}
|
||||||
|
</Tag>
|
||||||
|
<div>
|
||||||
|
<span>当前 / 期望</span>
|
||||||
|
<strong>{connection.currentConnections} / {connection.desiredConnections}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近心跳</span>
|
||||||
|
<strong>{connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'}</strong>
|
||||||
|
</div>
|
||||||
|
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{logState.data.connectionStates.length === 0 ? <p className="muted">暂无连接状态回写</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Input label="筛选日志" onChange={(event) => setLogKeyword(event.target.value)} placeholder="事件、资源或详情关键词" value={logKeyword} />
|
||||||
<div className="channel-log-list">
|
<div className="channel-log-list">
|
||||||
{(logState.data?.logs ?? []).map((log) => (
|
{(logState.data?.logs ?? []).filter((log) => {
|
||||||
|
const keyword = logKeyword.trim().toLowerCase();
|
||||||
|
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||||||
|
}).map((log) => (
|
||||||
<article className="channel-log-item" key={log.id}>
|
<article className="channel-log-item" key={log.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{log.event}</strong>
|
<strong>{log.event}</strong>
|
||||||
@@ -663,13 +753,17 @@ export function AdminChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{log.resourceId}</span>
|
<span>{log.resourceId}</span>
|
||||||
<p>{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}</p>
|
<pre>{formatLogDetail(log.detail)}</pre>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无连接日志</p> : null}
|
{logState.data && logState.data.logs.filter((log) => {
|
||||||
|
const keyword = logKeyword.trim().toLowerCase();
|
||||||
|
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
|
||||||
|
}).length === 0 ? <p className="muted">未找到匹配的连接日志</p> : null}
|
||||||
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
{!logState.data ? <p className="muted">正在加载连接日志...</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -364,6 +364,8 @@ export function AdminDownstreamDeliveriesPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={page <= 1}
|
previousDisabled={page <= 1}
|
||||||
nextDisabled={page >= totalPages}
|
nextDisabled={page >= totalPages}
|
||||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ export function AdminDownstreamRecoveryStatusesPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={page <= 1}
|
previousDisabled={page <= 1}
|
||||||
nextDisabled={page >= totalPages}
|
nextDisabled={page >= totalPages}
|
||||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type SmsApp = {
|
type SmsApp = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -140,7 +141,6 @@ function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
|||||||
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
||||||
`最大连接数: ${cmppParams.maxConnections}`,
|
`最大连接数: ${cmppParams.maxConnections}`,
|
||||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||||
`提交窗口: ${cmppParams.windowSize}`,
|
|
||||||
`协议版本: ${cmppParams.protocolVersion}`,
|
`协议版本: ${cmppParams.protocolVersion}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
|||||||
<div><span>接入号</span><strong>{srcId}</strong></div>
|
<div><span>接入号</span><strong>{srcId}</strong></div>
|
||||||
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
||||||
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||||||
<div><span>提交窗口</span><strong>{params?.windowSize ?? app.cmppParams.windowSize}</strong></div>
|
|
||||||
<div><span>协议版本</span><strong>{params?.protocolVersion ?? 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>
|
||||||
@@ -382,7 +381,7 @@ export function AdminEnterpriseApplicationsPage() {
|
|||||||
render: (record) => (
|
render: (record) => (
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
|
||||||
{record.enabled ? '停用' : '启用'}
|
{record.enabled ? '停用' : '启用'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||||
@@ -401,7 +400,7 @@ export function AdminEnterpriseApplicationsPage() {
|
|||||||
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
|
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface admin-split-filter">
|
<div className="surface admin-split-filter admin-application-filter">
|
||||||
<Input
|
<Input
|
||||||
label="企业名称"
|
label="企业名称"
|
||||||
onChange={(event) => setEnterpriseKeyword(event.target.value)}
|
onChange={(event) => setEnterpriseKeyword(event.target.value)}
|
||||||
@@ -481,9 +480,9 @@ function mapConnection(connection: CmppConnectionState): CmppConnection {
|
|||||||
bindType: 'transceiver',
|
bindType: 'transceiver',
|
||||||
clientIp: String(connection.channel?.gatewayHost ?? ''),
|
clientIp: String(connection.channel?.gatewayHost ?? ''),
|
||||||
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
|
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
|
||||||
establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '',
|
establishedAt: formatDateTime(connection.lastConnectedAt),
|
||||||
lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '',
|
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
|
||||||
lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '',
|
lastSubmitAt: formatDateTime(connection.updatedAt),
|
||||||
pendingWindow: connection.currentConnections,
|
pendingWindow: connection.currentConnections,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||||
|
import { displayFileName } from '@/utils/fileName';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||||
|
|
||||||
@@ -69,13 +71,6 @@ const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neut
|
|||||||
filing: 'neutral',
|
filing: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ label: '已通过', value: 'approved' },
|
|
||||||
{ label: '审核中', value: 'pending' },
|
|
||||||
{ label: '已驳回', value: 'rejected' },
|
|
||||||
{ label: '待报备', value: 'filing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function StatusTag({ status }: { status: CarrierStatus }) {
|
function StatusTag({ status }: { status: CarrierStatus }) {
|
||||||
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||||
}
|
}
|
||||||
@@ -170,12 +165,16 @@ function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filin
|
|||||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAuditStatus(status: CarrierStatus) {
|
function signatureCardTone(statuses: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }) {
|
||||||
return status === 'filing' ? 'pending' : status;
|
const values = Object.values(statuses);
|
||||||
|
if (values.includes('rejected')) return 'red';
|
||||||
|
if (values.every((status) => status === 'approved')) return 'green';
|
||||||
|
if (values.includes('pending')) return 'blue';
|
||||||
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value?: string) {
|
function formatDate(value?: string) {
|
||||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
return formatDateTime(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignatureUploadBox({
|
function SignatureUploadBox({
|
||||||
@@ -210,7 +209,7 @@ function SignatureUploadBox({
|
|||||||
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<Upload size={compact ? 30 : 42} />
|
<Upload size={compact ? 30 : 42} />
|
||||||
<strong>{uploading ? '上传中...' : file?.fileName || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
<strong>{uploading ? '上传中...' : (file ? displayFileName(file.fileName) : '') || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
|
||||||
<FileActions file={file} />
|
<FileActions file={file} />
|
||||||
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
{!compact ? <small>支持 PNG、JPG、JPEG、PDF,文件大小不超过 10M</small> : null}
|
||||||
{error ? <small className="form-error">{error}</small> : null}
|
{error ? <small className="form-error">{error}</small> : null}
|
||||||
@@ -348,14 +347,6 @@ function SignatureFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>三网报备状态</h3>
|
|
||||||
<div className="signature-form-grid">
|
|
||||||
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
|
|
||||||
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
|
|
||||||
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -379,7 +370,7 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
|||||||
mobile: 'filing',
|
mobile: 'filing',
|
||||||
unicom: 'filing',
|
unicom: 'filing',
|
||||||
telecom: 'filing',
|
telecom: 'filing',
|
||||||
submittedAt: new Date().toLocaleString('zh-CN'),
|
submittedAt: formatDateTime(new Date()),
|
||||||
remark: '',
|
remark: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,9 +420,6 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
|||||||
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
|
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
|
||||||
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
|
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
|
||||||
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
|
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
|
||||||
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
|
|
||||||
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
|
|
||||||
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
|
|
||||||
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
||||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
</div>
|
</div>
|
||||||
@@ -559,7 +547,6 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||||
applicationId: state.applicationId || null,
|
applicationId: state.applicationId || null,
|
||||||
auditStatus: toAuditStatus(state.mobile),
|
|
||||||
drainageInfo,
|
drainageInfo,
|
||||||
name: state.name,
|
name: state.name,
|
||||||
purpose: state.purpose,
|
purpose: state.purpose,
|
||||||
@@ -622,7 +609,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
const payload = readDrainagePayload(signature);
|
const payload = readDrainagePayload(signature);
|
||||||
const expanded = expandedSignatureId === signature.id;
|
const expanded = expandedSignatureId === signature.id;
|
||||||
return (
|
return (
|
||||||
<article className="signature-card signature-card--green" key={signature.id}>
|
<article className={`signature-card signature-card--${signatureCardTone(payload.carrierStatus)}`} key={signature.id}>
|
||||||
<div className="signature-summary">
|
<div className="signature-summary">
|
||||||
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
||||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
@@ -647,7 +634,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
<div className="drainage-table">
|
<div className="drainage-table">
|
||||||
<div className="drainage-table__head">
|
<div className="drainage-table__head">
|
||||||
<span>站名称</span>
|
<span>站名称</span>
|
||||||
<span>网站链接</span>
|
<span>引流信息</span>
|
||||||
<span>移动</span>
|
<span>移动</span>
|
||||||
<span>联通</span>
|
<span>联通</span>
|
||||||
<span>电信</span>
|
<span>电信</span>
|
||||||
@@ -657,7 +644,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
{payload.links.map((item) => (
|
{payload.links.map((item) => (
|
||||||
<div className="drainage-table__row" key={item.id}>
|
<div className="drainage-table__row" key={item.id}>
|
||||||
<strong>{item.siteName}</strong>
|
<strong>{item.siteName}</strong>
|
||||||
<a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>
|
<span className="drainage-table__url" title={item.url}>{item.url}</span>
|
||||||
<StatusTag status={item.mobile} />
|
<StatusTag status={item.mobile} />
|
||||||
<StatusTag status={item.unicom} />
|
<StatusTag status={item.unicom} />
|
||||||
<StatusTag status={item.telecom} />
|
<StatusTag status={item.telecom} />
|
||||||
@@ -686,6 +673,8 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredSignatures.length}
|
total={filteredSignatures.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
type TemplateFormState = {
|
type TemplateFormState = {
|
||||||
@@ -41,7 +42,7 @@ function extractVariables(content: string): TemplateVariable[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value?: string) {
|
function formatDate(value?: string) {
|
||||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
return formatDateTime(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTone(status: string) {
|
function statusTone(status: string) {
|
||||||
@@ -51,6 +52,14 @@ function statusTone(status: string) {
|
|||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditStatusLabel: Record<string, string> = {
|
||||||
|
approved: '已通过',
|
||||||
|
draft: '草稿',
|
||||||
|
pending: '审核中',
|
||||||
|
rejected: '已驳回',
|
||||||
|
deleted: '已删除',
|
||||||
|
};
|
||||||
|
|
||||||
function billingUnits(content: string) {
|
function billingUnits(content: string) {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -324,7 +333,7 @@ export function AdminEnterpriseTemplatesPage() {
|
|||||||
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
|
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
|
||||||
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
|
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
|
||||||
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0} 个` },
|
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0} 个` },
|
||||||
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{record.auditStatus}</Tag> },
|
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{auditStatusLabel[record.auditStatus] ?? record.auditStatus}</Tag> },
|
||||||
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
|
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ type CarrierRule = DictionaryItem & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AdminPhoneSegmentsPage() {
|
export function AdminPhoneSegmentsPage() {
|
||||||
const pageSize = 20;
|
const pageSize = 25;
|
||||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||||
const [rules, setRules] = useState<CarrierRule[]>([]);
|
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||||
|
const [ruleTotal, setRuleTotal] = useState(0);
|
||||||
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -37,6 +38,7 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [segmentQuery, setSegmentQuery] = useState('');
|
const [segmentQuery, setSegmentQuery] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [rulePage, setRulePage] = useState(1);
|
||||||
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
|
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
@@ -56,14 +58,15 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
|
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
|
||||||
adminApi.listPhoneCarrierRules(),
|
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
|
||||||
])
|
])
|
||||||
.then(([segmentPage, ruleItems]) => {
|
.then(([segmentPage, ruleResponse]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSegments(segmentPage.items as PhoneSegment[]);
|
setSegments(segmentPage.items as PhoneSegment[]);
|
||||||
setHasMore(segmentPage.hasMore);
|
setHasMore(segmentPage.hasMore);
|
||||||
setNextCursor(segmentPage.nextCursor);
|
setNextCursor(segmentPage.nextCursor);
|
||||||
setRules(ruleItems as CarrierRule[]);
|
setRules(ruleResponse.items as CarrierRule[]);
|
||||||
|
setRuleTotal(ruleResponse.total);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => {
|
.catch((failure: Error) => {
|
||||||
@@ -75,12 +78,9 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [page, pageCursors, reloadKey, segmentQuery]);
|
}, [activeTab, page, pageCursors, reloadKey, rulePage, segmentQuery]);
|
||||||
|
|
||||||
const filteredRules = useMemo(
|
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
|
||||||
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
|
|
||||||
[keyword, rules],
|
|
||||||
);
|
|
||||||
|
|
||||||
function createSegment() {
|
function createSegment() {
|
||||||
adminApi.createPhoneSegment({ prefix, carrier, province, city })
|
adminApi.createPhoneSegment({ prefix, carrier, province, city })
|
||||||
@@ -141,7 +141,11 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
|
|
||||||
<div className="surface admin-system-table-card">
|
<div className="surface admin-system-table-card">
|
||||||
<Tabs
|
<Tabs
|
||||||
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
|
className="phone-segment-tabs"
|
||||||
|
onChange={(value) => {
|
||||||
|
setActiveTab(value as 'segments' | 'rules');
|
||||||
|
setRulePage(1);
|
||||||
|
}}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -168,7 +172,25 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ label: '运营商区分规则', value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
|
{
|
||||||
|
label: '运营商区分规则',
|
||||||
|
value: 'rules',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
|
||||||
|
<Pagination
|
||||||
|
page={rulePage}
|
||||||
|
total={ruleTotal}
|
||||||
|
totalPages={ruleTotalPages}
|
||||||
|
previousDisabled={rulePage <= 1 || loading}
|
||||||
|
nextDisabled={rulePage >= ruleTotalPages || loading}
|
||||||
|
onPrevious={() => setRulePage((current) => Math.max(1, current - 1))}
|
||||||
|
onNext={() => setRulePage((current) => Math.min(ruleTotalPages, current + 1))}
|
||||||
|
onPageChange={setRulePage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Search } from 'lucide-react';
|
import { Plus, Search } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||||
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type ManualRechargeForm = {
|
type ManualRechargeForm = {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -36,7 +37,6 @@ function RemarkCell({ value }: { value?: string }) {
|
|||||||
|
|
||||||
export function AdminRechargeRecordsPage() {
|
export function AdminRechargeRecordsPage() {
|
||||||
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||||
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
|
||||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||||
@@ -45,19 +45,19 @@ export function AdminRechargeRecordsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [nextTenants, nextRecords, nextAccounts] = await Promise.all([
|
const [nextTenants, nextRecords] = await Promise.all([
|
||||||
adminApi.listTenants(),
|
adminApi.listTenants(),
|
||||||
adminApi.listManualRecharges(),
|
adminApi.listManualRecharges(),
|
||||||
adminApi.listAccounts(),
|
|
||||||
]);
|
]);
|
||||||
setTenants(nextTenants);
|
setTenants(nextTenants);
|
||||||
setRecords(nextRecords);
|
setRecords(nextRecords);
|
||||||
setAccounts(nextAccounts);
|
|
||||||
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||||
@@ -98,14 +98,19 @@ export function AdminRechargeRecordsPage() {
|
|||||||
|
|
||||||
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
|
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
setManualError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitManualRecharge() {
|
async function submitManualRecharge() {
|
||||||
const amount = Number(form.amount);
|
const amount = Number(form.amount);
|
||||||
const smsUnits = Number(form.smsUnits || 0);
|
const smsUnits = Number(form.smsUnits || 0);
|
||||||
if (!form.tenantId || !Number.isFinite(amount) || !Number.isFinite(smsUnits) || (amount === 0 && smsUnits === 0)) {
|
if (!form.tenantId || !Number.isFinite(amount) || !Number.isFinite(smsUnits) || (amount === 0 && smsUnits === 0)) {
|
||||||
|
setManualError('请填写非 0 的充值金额或短信条数;金额支持负数冲正。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setManualError('');
|
||||||
|
try {
|
||||||
await adminApi.createManualRecharge({
|
await adminApi.createManualRecharge({
|
||||||
tenantId: form.tenantId,
|
tenantId: form.tenantId,
|
||||||
amountCents: Math.round(amount * 100),
|
amountCents: Math.round(amount * 100),
|
||||||
@@ -115,6 +120,11 @@ export function AdminRechargeRecordsPage() {
|
|||||||
await loadData();
|
await loadData();
|
||||||
setManualOpen(false);
|
setManualOpen(false);
|
||||||
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
|
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
|
||||||
|
} catch (failure) {
|
||||||
|
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,14 +168,13 @@ export function AdminRechargeRecordsPage() {
|
|||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
<tr><td className="ui-table__empty" colSpan={7}>暂无真实充值记录</td></tr>
|
<tr><td className="ui-table__empty" colSpan={7}>暂无真实充值记录</td></tr>
|
||||||
) : visibleRows.map((record) => {
|
) : visibleRows.map((record) => {
|
||||||
const account = accounts.find((item) => item.tenantId === record.tenantId);
|
|
||||||
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
|
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
|
||||||
return (
|
return (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td><strong>{tenantName}</strong></td>
|
<td><strong>{tenantName}</strong></td>
|
||||||
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
|
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
|
||||||
<td>{formatAmount(record.amountCents / 100)}</td>
|
<td>¥{formatAmount(record.amountCents / 100)}</td>
|
||||||
<td>{formatAmount((account?.balanceCents ?? 0) / 100)}</td>
|
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
|
||||||
<td><Tag tone="warning">人工充值</Tag></td>
|
<td><Tag tone="warning">人工充值</Tag></td>
|
||||||
<td>{record.operatorId || '运营'}</td>
|
<td>{record.operatorId || '运营'}</td>
|
||||||
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||||
@@ -181,6 +190,8 @@ export function AdminRechargeRecordsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredRows.length}
|
total={filteredRows.length}
|
||||||
/>
|
/>
|
||||||
@@ -190,8 +201,8 @@ export function AdminRechargeRecordsPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setManualOpen(false)} variant="ghost">取消</Button>
|
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost">取消</Button>
|
||||||
<Button onClick={submitManualRecharge}>确认充值</Button>
|
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={() => setManualOpen(false)}
|
onClose={() => setManualOpen(false)}
|
||||||
@@ -204,13 +215,15 @@ export function AdminRechargeRecordsPage() {
|
|||||||
label="企业名称"
|
label="企业名称"
|
||||||
onChange={(event) => updateForm('tenantId', event.target.value)}
|
onChange={(event) => updateForm('tenantId', event.target.value)}
|
||||||
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||||
|
required
|
||||||
value={form.tenantId}
|
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="¥" required type="number" value={form.amount} />
|
||||||
<Input label="短信条数" onChange={(event) => updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} />
|
<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)} required 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>
|
||||||
|
{manualError ? <p className="form-error">{manualError}</p> : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function AdminSensitiveWordsPage() {
|
|||||||
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
|
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
|
||||||
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
||||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
|
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
|
||||||
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
|
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
|
||||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -122,6 +122,7 @@ export function AdminSensitiveWordsPage() {
|
|||||||
)}
|
)}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
size="xl"
|
||||||
title="添加敏感词"
|
title="添加敏感词"
|
||||||
>
|
>
|
||||||
<div className="admin-security-form">
|
<div className="admin-security-form">
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
||||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
||||||
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||||||
const [cmppWindowSize, setCmppWindowSize] = useState('16');
|
|
||||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||||
const [ipAddress, setIpAddress] = useState('');
|
const [ipAddress, setIpAddress] = useState('');
|
||||||
@@ -89,7 +88,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
setInterfaceEnabled(application.interfaceEnabled !== false);
|
setInterfaceEnabled(application.interfaceEnabled !== false);
|
||||||
setInterfaceType('cmpp20');
|
setInterfaceType('cmpp20');
|
||||||
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
|
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
|
||||||
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
|
|
||||||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||||||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||||
@@ -131,7 +129,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
interfaceEnabled,
|
interfaceEnabled,
|
||||||
interfaceType,
|
interfaceType,
|
||||||
cmppMaxConnections: Number(cmppMaxConnections) || 1,
|
cmppMaxConnections: Number(cmppMaxConnections) || 1,
|
||||||
cmppWindowSize: Number(cmppWindowSize) || 16,
|
|
||||||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||||||
templateMismatchMode: mismatchPolicy,
|
templateMismatchMode: mismatchPolicy,
|
||||||
ipAllowlist: parseIpAllowlist(ipAddress),
|
ipAllowlist: parseIpAllowlist(ipAddress),
|
||||||
@@ -258,7 +255,6 @@ export function AdminSmsApplicationFormPage() {
|
|||||||
value={passwordCipher}
|
value={passwordCipher}
|
||||||
/>
|
/>
|
||||||
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
|
||||||
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
|
|
||||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -182,9 +182,27 @@ function SendDetailModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
title="发送详情"
|
title={<div className="template-modal-title"><h2>发送详情</h2><p>{record.messageId}</p></div>}
|
||||||
>
|
>
|
||||||
<div className="admin-sms-send-detail">
|
<div className="admin-sms-send-detail">
|
||||||
|
<div className="admin-sms-detail-overview">
|
||||||
|
<div>
|
||||||
|
<span>最终状态</span>
|
||||||
|
<Tag tone={record.status === 'delivered' ? 'success' : ['failed', 'rejected'].includes(record.status) ? 'danger' : 'info'}>{getStatusLabel(record.status)}</Tag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>提交状态</span>
|
||||||
|
<strong>{record.submitStatus ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>回执状态</span>
|
||||||
|
<strong>{record.receiptStatus ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>提交时间</span>
|
||||||
|
<strong>{getTime(record.queuedAt)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||||
<p className="admin-sms-detail-content">{record.content}</p>
|
<p className="admin-sms-detail-content">{record.content}</p>
|
||||||
@@ -211,6 +229,10 @@ function SendDetailModal({
|
|||||||
<dt>回执码</dt>
|
<dt>回执码</dt>
|
||||||
<dd>{route.receiptCode ?? '-'}</dd>
|
<dd>{route.receiptCode ?? '-'}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>提交状态</dt>
|
||||||
|
<dd>{route.submitStatus ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -263,6 +285,10 @@ export function AdminSmsRecordsPage() {
|
|||||||
tenantId: enterprise === 'all' ? undefined : enterprise,
|
tenantId: enterprise === 'all' ? undefined : enterprise,
|
||||||
applicationId: application === 'all' ? undefined : application,
|
applicationId: application === 'all' ? undefined : application,
|
||||||
phoneNumber: phoneKeyword || undefined,
|
phoneNumber: phoneKeyword || undefined,
|
||||||
|
contentKeyword: contentKeyword || undefined,
|
||||||
|
channelKeyword: channelKeyword || undefined,
|
||||||
|
queuedAtFrom: dateRange.start,
|
||||||
|
queuedAtTo: dateRange.end,
|
||||||
status: status === 'all' ? undefined : status,
|
status: status === 'all' ? undefined : status,
|
||||||
})
|
})
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
@@ -309,17 +335,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))];
|
return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))];
|
||||||
}, [enterprise, records]);
|
}, [enterprise, records]);
|
||||||
|
|
||||||
const filteredRows = useMemo(
|
const filteredRows = records;
|
||||||
() => records.filter((item) => {
|
|
||||||
const submittedDate = getDate(item.queuedAt);
|
|
||||||
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
|
||||||
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
|
||||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
|
||||||
const matchesChannel = !channelKeyword || (item.channel?.name ?? item.channelId ?? '').includes(channelKeyword);
|
|
||||||
return matchesStartDate && matchesEndDate && matchesContent && matchesChannel;
|
|
||||||
}),
|
|
||||||
[channelKeyword, contentKeyword, dateRange.end, dateRange.start, records],
|
|
||||||
);
|
|
||||||
|
|
||||||
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
||||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ function mapTask(task: SmsBatchTask): SmsTask {
|
|||||||
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
|
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
|
||||||
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
|
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
|
||||||
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
|
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
|
||||||
const processedCount = submittedCount + (task.unknownTotal ?? 0) + (task.timeoutTotal ?? 0);
|
// submittedTotal already includes unknown and timeout records, so never add them again.
|
||||||
|
const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0));
|
||||||
const billingCount = messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|
const billingCount = messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|
||||||
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
|
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ function mapTask(task: SmsBatchTask): SmsTask {
|
|||||||
scheduledAt: task.scheduledAt,
|
scheduledAt: task.scheduledAt,
|
||||||
submittedCount,
|
submittedCount,
|
||||||
submittedSuccess: submittedCount,
|
submittedSuccess: submittedCount,
|
||||||
sentCount: Math.max(processedCount, successCount + failedCount),
|
sentCount: Math.min(task.phoneTotal, processedCount),
|
||||||
successCount,
|
successCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
status: normalizeTaskStatus(task.status),
|
status: normalizeTaskStatus(task.status),
|
||||||
@@ -532,6 +533,8 @@ export function AdminSmsTaskProgressPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredTasks.length}
|
total={filteredTasks.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export function AdminSystemLogsPage() {
|
|||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||||
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
|
||||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
{ key: 'level', title: '级别', width: '120px', 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 },
|
||||||
@@ -133,6 +134,8 @@ export function AdminSystemLogsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={total}
|
total={total}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { KeyRound, Plus, Search } from 'lucide-react';
|
import { KeyRound, Plus, Search } from 'lucide-react';
|
||||||
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { readSession } from '@/api/session';
|
import { readSession } from '@/api/session';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export function AdminUsersPage() {
|
|||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
|
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
|
||||||
@@ -102,6 +104,11 @@ export function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser() {
|
async function saveUser() {
|
||||||
|
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6) || (form.roleCode === 'enterprise_admin' && !form.tenantId)) {
|
||||||
|
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
const body: UserPayload = {
|
const body: UserPayload = {
|
||||||
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
|
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
|
||||||
@@ -113,6 +120,7 @@ export function AdminUsersPage() {
|
|||||||
roleCode: form.roleCode,
|
roleCode: form.roleCode,
|
||||||
operatorId: session?.user.id,
|
operatorId: session?.user.id,
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
await adminApi.createUser({ ...body, password: form.password });
|
await adminApi.createUser({ ...body, password: form.password });
|
||||||
} else if (editingUser) {
|
} else if (editingUser) {
|
||||||
@@ -121,6 +129,11 @@ export function AdminUsersPage() {
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
await load();
|
await load();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runConfirm() {
|
async function runConfirm() {
|
||||||
@@ -147,7 +160,7 @@ export function AdminUsersPage() {
|
|||||||
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
|
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
|
||||||
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||||
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
|
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => formatDateTime(record.lastLoginAt) },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -157,7 +170,7 @@ export function AdminUsersPage() {
|
|||||||
<div className="admin-system-actions">
|
<div className="admin-system-actions">
|
||||||
<Button onClick={() => openEdit(record)} size="sm" variant="ghost">编辑</Button>
|
<Button onClick={() => openEdit(record)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">
|
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||||
{record.status === 'active' ? '禁用' : '启用'}
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||||
@@ -186,14 +199,14 @@ export function AdminUsersPage() {
|
|||||||
|
|
||||||
{(creating || editingUser) ? (
|
{(creating || editingUser) ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
|
||||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||||
open
|
open
|
||||||
title={creating ? '新增用户' : '编辑用户'}
|
title={creating ? '新增用户' : '编辑用户'}
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
|
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} required value={form.displayName} />
|
||||||
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
|
<Input hint="邮箱和手机号至少填写一项" label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
|
||||||
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||||
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||||
<Select
|
<Select
|
||||||
@@ -210,7 +223,7 @@ export function AdminUsersPage() {
|
|||||||
value={form.tenantId}
|
value={form.tenantId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} required type="password" value={form.password} /> : null}
|
||||||
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ function mapParams(params: ApplicationCmppParams): ParamRow[] {
|
|||||||
{ label: '接入号', value: params.srcId || '-' },
|
{ label: '接入号', value: params.srcId || '-' },
|
||||||
{ label: '连接数', value: String(params.maxConnections || '-') },
|
{ label: '连接数', value: String(params.maxConnections || '-') },
|
||||||
{ label: '心跳间隔', value: `${params.heartbeatSeconds} 秒` },
|
{ label: '心跳间隔', value: `${params.heartbeatSeconds} 秒` },
|
||||||
{ label: '窗口大小', value: String(params.windowSize) },
|
|
||||||
{ label: '协议版本', value: params.protocolVersion },
|
{ label: '协议版本', value: params.protocolVersion },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -169,6 +168,8 @@ export function ClientApplicationsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={applications.length}
|
total={applications.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ export function ClientBatchTasksPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredTasks.length}
|
total={filteredTasks.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function ClientBillingPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={plans.length}
|
total={plans.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
|
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
|
||||||
|
import { displayFileName } from '@/utils/fileName';
|
||||||
|
|
||||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
|
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
|
||||||
type AuthMethod = 'face' | 'recharge';
|
type AuthMethod = 'face' | 'recharge';
|
||||||
@@ -59,7 +60,7 @@ function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; upl
|
|||||||
return (
|
return (
|
||||||
<label className="enterprise-upload">
|
<label className="enterprise-upload">
|
||||||
<Upload size={38} />
|
<Upload size={38} />
|
||||||
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
|
<strong>{uploading ? '上传中...' : file ? displayFileName(file.fileName) : '点击上传'}</strong>
|
||||||
<FileActions file={fileRef} />
|
<FileActions file={fileRef} />
|
||||||
<input
|
<input
|
||||||
accept="image/png,image/jpeg,image/webp,application/pdf"
|
accept="image/png,image/jpeg,image/webp,application/pdf"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 { clientApi, type DashboardResponse } from '@/api/adminApi';
|
import { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type RecentTaskRow = {
|
type RecentTaskRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,7 +60,7 @@ export function ClientHome() {
|
|||||||
scene: String(task.category ?? task.content ?? '短信发送'),
|
scene: String(task.category ?? task.content ?? '短信发送'),
|
||||||
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||||
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
|
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') : '',
|
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
||||||
status: String(task.status ?? 'unknown'),
|
status: String(task.status ?? 'unknown'),
|
||||||
})), [dashboard]);
|
})), [dashboard]);
|
||||||
const latestRecharge = dashboard?.recentRecharges[0];
|
const latestRecharge = dashboard?.recentRecharges[0];
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export function ClientSendDetailPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredRows.length}
|
total={filteredRows.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export function ClientSignaturesPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredSignatures.length}
|
total={filteredSignatures.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { clientApi, type OperationLogItem } from '@/api/adminApi';
|
import { clientApi, type OperationLogItem } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export function ClientSystemLogsPage() {
|
|||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
|
||||||
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
|
||||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
{ key: 'level', title: '级别', width: '120px', 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> },
|
||||||
@@ -127,6 +128,8 @@ export function ClientSystemLogsPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={total}
|
total={total}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type TemplateVariable = {
|
type TemplateVariable = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -276,7 +277,7 @@ export function ClientTemplatesPage() {
|
|||||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="template-card-footer">
|
<div className="template-card-footer">
|
||||||
<span>{new Date(template.updatedAt).toLocaleString('zh-CN')}</span>
|
<span>{formatDateTime(template.updatedAt)}</span>
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => setModalTemplate(template)} type="button">
|
<button onClick={() => setModalTemplate(template)} type="button">
|
||||||
<Edit3 size={14} />
|
<Edit3 size={14} />
|
||||||
@@ -297,6 +298,8 @@ export function ClientTemplatesPage() {
|
|||||||
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}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
previousDisabled={currentPage <= 1}
|
previousDisabled={currentPage <= 1}
|
||||||
total={filteredTemplates.length}
|
total={filteredTemplates.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||||
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { readSession } from '@/api/session';
|
import { readSession } from '@/api/session';
|
||||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function ClientUsersPage() {
|
|||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!tenantId) return;
|
if (!tenantId) return;
|
||||||
@@ -79,6 +81,11 @@ export function ClientUsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser() {
|
async function saveUser() {
|
||||||
|
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
|
||||||
|
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
const body: UserPayload = {
|
const body: UserPayload = {
|
||||||
displayName: form.displayName,
|
displayName: form.displayName,
|
||||||
username: form.username || form.email || form.phone,
|
username: form.username || form.email || form.phone,
|
||||||
@@ -88,6 +95,7 @@ export function ClientUsersPage() {
|
|||||||
roleCode: 'enterprise_admin',
|
roleCode: 'enterprise_admin',
|
||||||
operatorId: session?.user.id,
|
operatorId: session?.user.id,
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
||||||
} else if (editingUser) {
|
} else if (editingUser) {
|
||||||
@@ -96,6 +104,11 @@ export function ClientUsersPage() {
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
await load();
|
await load();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runConfirm() {
|
async function runConfirm() {
|
||||||
@@ -122,7 +135,7 @@ export function ClientUsersPage() {
|
|||||||
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
||||||
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
||||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
|
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -131,7 +144,7 @@ export function ClientUsersPage() {
|
|||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
|
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -158,7 +171,7 @@ export function ClientUsersPage() {
|
|||||||
|
|
||||||
{(creating || editingUser) ? (
|
{(creating || editingUser) ? (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
|
||||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Download, Eye } from 'lucide-react';
|
import { Download, Eye } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { fileDownloadUrl, type FileRef } from '@/api/adminApi';
|
import { fileDownloadUrl, type FileRef } from '@/api/adminApi';
|
||||||
|
import { displayFileName } from '@/utils/fileName';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
type FileActionsProps = {
|
type FileActionsProps = {
|
||||||
file?: FileRef | null;
|
file?: FileRef | null;
|
||||||
@@ -13,15 +16,18 @@ function isImageFile(file: FileRef) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileActions({ file }: FileActionsProps) {
|
export function FileActions({ file }: FileActionsProps) {
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
if (!file?.fileObjectId) {
|
if (!file?.fileObjectId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
|
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
|
||||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
|
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
|
||||||
|
const fileName = displayFileName(file.fileName);
|
||||||
return (
|
return (
|
||||||
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
||||||
{isImageFile(file) ? (
|
{isImageFile(file) ? (
|
||||||
<Button icon={<Eye size={14} />} onClick={() => window.open(previewUrl, '_blank', 'noopener,noreferrer')} size="sm" variant="ghost">
|
<Button icon={<Eye size={14} />} onClick={() => setPreviewOpen(true)} size="sm" variant="ghost">
|
||||||
预览
|
预览
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -29,6 +35,17 @@ export function FileActions({ file }: FileActionsProps) {
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
下载
|
下载
|
||||||
</a>
|
</a>
|
||||||
|
{previewOpen ? (
|
||||||
|
<Modal
|
||||||
|
footer={<Button onClick={() => setPreviewOpen(false)}>关闭</Button>}
|
||||||
|
onClose={() => setPreviewOpen(false)}
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={fileName}
|
||||||
|
>
|
||||||
|
<img alt={fileName} className="file-preview-image" src={previewUrl} />
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ export function Input({
|
|||||||
...props
|
...props
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const inputId = id ?? props.name;
|
const inputId = id ?? props.name;
|
||||||
|
const required = Boolean(props.required);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={inputId}>
|
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={inputId}>
|
||||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
{label ? (
|
||||||
|
<span className="ui-field__label">
|
||||||
|
{label}
|
||||||
|
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={['ui-input', error ? 'ui-input--error' : ''].filter(Boolean).join(' ')}>
|
<span className={['ui-input', error ? 'ui-input--error' : ''].filter(Boolean).join(' ')}>
|
||||||
{prefix ? <span className="ui-input__addon">{prefix}</span> : null}
|
{prefix ? <span className="ui-input__addon">{prefix}</span> : null}
|
||||||
<input id={inputId} {...props} />
|
<input id={inputId} {...props} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
type QueryPanelProps = {
|
type QueryPanelProps = {
|
||||||
@@ -10,10 +10,12 @@ type QueryPanelProps = {
|
|||||||
type PaginationProps = {
|
type PaginationProps = {
|
||||||
total?: number;
|
total?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
previousDisabled?: boolean;
|
previousDisabled?: boolean;
|
||||||
nextDisabled?: boolean;
|
nextDisabled?: boolean;
|
||||||
onPrevious?: () => void;
|
onPrevious?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InlineTextPreviewProps = {
|
type InlineTextPreviewProps = {
|
||||||
@@ -35,17 +37,31 @@ export function QueryPanel({ title, summary, children }: QueryPanelProps) {
|
|||||||
export function Pagination({
|
export function Pagination({
|
||||||
total,
|
total,
|
||||||
page = 1,
|
page = 1,
|
||||||
|
totalPages,
|
||||||
previousDisabled = true,
|
previousDisabled = true,
|
||||||
nextDisabled = true,
|
nextDisabled = true,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
onNext,
|
onNext,
|
||||||
|
onPageChange,
|
||||||
}: PaginationProps) {
|
}: PaginationProps) {
|
||||||
|
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / 10) : page));
|
||||||
|
const [targetPage, setTargetPage] = useState(String(page));
|
||||||
|
|
||||||
|
useEffect(() => setTargetPage(String(page)), [page]);
|
||||||
|
|
||||||
|
function changePage(nextPage: number) {
|
||||||
|
onPageChange?.(Math.min(pages, Math.max(1, nextPage)));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-pagination">
|
<div className="ui-pagination">
|
||||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
||||||
|
{onPageChange ? <Button disabled={page <= 1} onClick={() => changePage(1)} size="sm" variant="ghost">首页</Button> : null}
|
||||||
<Button size="sm" variant="secondary">{page}</Button>
|
<Button size="sm" variant="secondary">{page}</Button>
|
||||||
|
{onPageChange ? <label className="ui-pagination__jump">第 <input aria-label="跳转页码" min="1" max={pages} onChange={(event) => setTargetPage(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') changePage(Number(targetPage)); }} type="number" value={targetPage} /> / {pages} 页</label> : null}
|
||||||
|
{onPageChange ? <Button disabled={page >= pages} onClick={() => changePage(pages)} size="sm" variant="ghost">末页</Button> : null}
|
||||||
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">下一页</Button>
|
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">下一页</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function Select({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
required,
|
||||||
...props
|
...props
|
||||||
}: SelectProps) {
|
}: SelectProps) {
|
||||||
const selectId = id ?? props.name;
|
const selectId = id ?? props.name;
|
||||||
@@ -70,7 +71,12 @@ export function Select({
|
|||||||
htmlFor={selectId}
|
htmlFor={selectId}
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
>
|
>
|
||||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
{label ? (
|
||||||
|
<span className="ui-field__label">
|
||||||
|
{label}
|
||||||
|
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'ui-select',
|
'ui-select',
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ export type TabItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
|
className?: string;
|
||||||
items: TabItem[];
|
items: TabItem[];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Tabs({ items, defaultValue, value, onChange }: TabsProps) {
|
export function Tabs({ className = '', items, defaultValue, value, onChange }: TabsProps) {
|
||||||
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.value);
|
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.value);
|
||||||
const activeValue = value ?? internalValue;
|
const activeValue = value ?? internalValue;
|
||||||
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
|
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
|
||||||
@@ -26,7 +27,7 @@ export function Tabs({ items, defaultValue, value, onChange }: TabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-tabs">
|
<div className={['ui-tabs', className].filter(Boolean).join(' ')}>
|
||||||
<div className="ui-tabs__list" role="tablist">
|
<div className="ui-tabs__list" role="tablist">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|||||||
|
|
||||||
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
|
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
|
||||||
const textareaId = id ?? props.name;
|
const textareaId = id ?? props.name;
|
||||||
|
const required = Boolean(props.required);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={textareaId}>
|
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={textareaId}>
|
||||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
{label ? (
|
||||||
|
<span className="ui-field__label">
|
||||||
|
{label}
|
||||||
|
{required ? <span aria-label="必填" className="ui-field__required">*</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<textarea
|
<textarea
|
||||||
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
|
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
|
||||||
id={textareaId}
|
id={textareaId}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { adminApi } from '@/api/adminApi';
|
||||||
import { clearSession } from '@/api/session';
|
import { clearSession } from '@/api/session';
|
||||||
|
import { Button, Input, Modal } from '@/components/ui';
|
||||||
|
|
||||||
export type ShellNavItem = {
|
export type ShellNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -56,6 +58,12 @@ export function AppShell({
|
|||||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||||
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||||
const auditTotal = useMemo(
|
const auditTotal = useMemo(
|
||||||
@@ -63,6 +71,28 @@ export function AppShell({
|
|||||||
[auditNotifications],
|
[auditNotifications],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function changeOwnPassword() {
|
||||||
|
if (!currentPassword || newPassword.length < 6) {
|
||||||
|
setPasswordError('请输入当前密码,新密码至少 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordSaving(true);
|
||||||
|
setPasswordError('');
|
||||||
|
try {
|
||||||
|
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
|
||||||
|
clearSession();
|
||||||
|
navigate(loginPath, { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
||||||
|
} finally {
|
||||||
|
setPasswordSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auditTotal <= 0 || typeof window === 'undefined') {
|
if (auditTotal <= 0 || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -193,7 +223,7 @@ export function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
{userMenuOpen ? (
|
{userMenuOpen ? (
|
||||||
<div className="user-menu-popover" role="menu">
|
<div className="user-menu-popover" role="menu">
|
||||||
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button">
|
<button onClick={() => { setUserMenuOpen(false); setPasswordModalOpen(true); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setPasswordError(''); }} role="menuitem" type="button">
|
||||||
<KeyRound size={16} />
|
<KeyRound size={16} />
|
||||||
修改密码
|
修改密码
|
||||||
</button>
|
</button>
|
||||||
@@ -215,6 +245,19 @@ export function AppShell({
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<Modal
|
||||||
|
footer={<><Button disabled={passwordSaving} onClick={() => setPasswordModalOpen(false)} variant="ghost">取消</Button><Button disabled={passwordSaving} icon={<KeyRound size={16} />} onClick={() => void changeOwnPassword()}>{passwordSaving ? '保存中...' : '确认修改'}</Button></>}
|
||||||
|
onClose={() => setPasswordModalOpen(false)}
|
||||||
|
open={passwordModalOpen}
|
||||||
|
title="修改密码"
|
||||||
|
>
|
||||||
|
<div className="admin-system-modal-form">
|
||||||
|
<Input label="当前密码" onChange={(event) => setCurrentPassword(event.target.value)} required type="password" value={currentPassword} />
|
||||||
|
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} required type="password" value={newPassword} />
|
||||||
|
<Input label="确认新密码" onChange={(event) => setConfirmPassword(event.target.value)} required type="password" value={confirmPassword} />
|
||||||
|
{passwordError ? <p className="form-error">{passwordError}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function ClientLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="CMPP 客户端"
|
title="短信平台客户端"
|
||||||
subtitle="短信服务控制台"
|
subtitle="短信服务控制台"
|
||||||
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||||
loginPath="/client/login"
|
loginPath="/client/login"
|
||||||
@@ -46,8 +46,8 @@ export function ClientLayout() {
|
|||||||
title: '短信基础配置',
|
title: '短信基础配置',
|
||||||
items: [
|
items: [
|
||||||
{ label: '短信应用', to: '/client/applications', icon: FileText },
|
{ label: '短信应用', to: '/client/applications', icon: FileText },
|
||||||
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
|
||||||
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
|
||||||
|
{ label: '模板管理', to: '/client/templates', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,6 +139,11 @@
|
|||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-field__required {
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-field__hint,
|
.ui-field__hint,
|
||||||
.ui-field__error {
|
.ui-field__error {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
@@ -155,7 +160,7 @@
|
|||||||
.ui-input,
|
.ui-input,
|
||||||
.ui-select {
|
.ui-select {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--color-surface);
|
background: transparent;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -168,7 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-textarea {
|
.ui-textarea {
|
||||||
background: var(--color-surface);
|
background: transparent;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -220,6 +225,14 @@
|
|||||||
color: var(--color-text-subtle);
|
color: var(--color-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-input input:-webkit-autofill,
|
||||||
|
.ui-input input:-webkit-autofill:hover,
|
||||||
|
.ui-input input:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--color-surface) inset;
|
||||||
|
-webkit-text-fill-color: var(--color-text);
|
||||||
|
caret-color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.ui-input__addon {
|
.ui-input__addon {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--color-text-subtle);
|
color: var(--color-text-subtle);
|
||||||
@@ -506,10 +519,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-pagination > div {
|
.ui-pagination > div {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-pagination__jump {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-pagination__jump input {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
height: var(--control-height-sm);
|
||||||
|
padding: 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-inline-text-preview {
|
.ui-inline-text-preview {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
|||||||
+203
-11
@@ -2783,7 +2783,7 @@ h3 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
grid-template-columns: 1fr 2fr 1fr 1fr 1fr 1.5fr 120px;
|
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2798,8 +2798,11 @@ h3 {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drainage-table__row a {
|
.drainage-table__url {
|
||||||
color: var(--color-selected);
|
color: var(--color-text-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drainage-row-actions {
|
.drainage-row-actions {
|
||||||
@@ -2840,6 +2843,10 @@ h3 {
|
|||||||
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
|
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-application-filter {
|
||||||
|
grid-template-columns: minmax(320px, 460px) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.cmpp-status-cell {
|
.cmpp-status-cell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4480,12 +4487,6 @@ h3 {
|
|||||||
margin: -12px 0 0;
|
margin: -12px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.enterprise-form-panel .ui-input,
|
|
||||||
.enterprise-form-panel .ui-textarea,
|
|
||||||
.enterprise-face-panel .ui-input {
|
|
||||||
background: #f1f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enterprise-address-selects {
|
.enterprise-address-selects {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -5520,6 +5521,66 @@ h3 {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__title > span {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-selected);
|
||||||
|
display: inline-flex;
|
||||||
|
height: 40px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__title h2,
|
||||||
|
.channel-route-modal__title p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__title p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__empty {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border: 1px dashed var(--color-border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__selected {
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-success);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__selected strong,
|
||||||
|
.channel-route-modal__selected span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-route-modal__selected span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-enterprise-profile__main {
|
.admin-enterprise-profile__main {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -6120,11 +6181,46 @@ h3 {
|
|||||||
margin: var(--space-3) 0 0;
|
margin: var(--space-3) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-log-modal,
|
||||||
.channel-log-list {
|
.channel-log-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-connection-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-connection-summary article {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: minmax(130px, 1fr) auto minmax(110px, auto) minmax(180px, 1.2fr);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-connection-summary span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-connection-summary strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-connection-summary p {
|
||||||
|
color: var(--color-danger);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-log-item {
|
.channel-log-item {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
background: var(--color-surface-subtle);
|
background: var(--color-surface-subtle);
|
||||||
@@ -6147,10 +6243,14 @@ h3 {
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-log-item p {
|
.channel-log-item pre {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sms-channel-pagination {
|
.sms-channel-pagination {
|
||||||
@@ -7395,6 +7495,53 @@ h3 {
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sms-test-result {
|
||||||
|
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__summary {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-success);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__summary strong,
|
||||||
|
.sms-test-result__summary span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__summary span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__records {
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-success) 25%, var(--color-border));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__records div {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sms-test-result__records code {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-task-page .page-heading {
|
.admin-sms-task-page .page-heading {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -8159,6 +8306,31 @@ h3 {
|
|||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-overview {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-overview div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-overview span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-overview strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-send-detail h3 {
|
.admin-sms-send-detail h3 {
|
||||||
color: var(--color-text-strong);
|
color: var(--color-text-strong);
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
@@ -8235,7 +8407,7 @@ h3 {
|
|||||||
.admin-sms-route-list dl {
|
.admin-sms-route-list dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8574,6 +8746,15 @@ h3 {
|
|||||||
padding: var(--space-4) var(--space-6);
|
padding: var(--space-4) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-segment-tabs .ui-tabs__list {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-segment-tabs .ui-tabs__tab {
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-system-page .page-heading {
|
.admin-system-page .page-heading {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -8803,6 +8984,15 @@ h3 {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-preview-image {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
@media (max-width: 780px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -8990,6 +9180,8 @@ h3 {
|
|||||||
.admin-task-metrics,
|
.admin-task-metrics,
|
||||||
.admin-carrier-grid,
|
.admin-carrier-grid,
|
||||||
.admin-mms-city-list > div,
|
.admin-mms-city-list > div,
|
||||||
|
.admin-sms-detail-overview,
|
||||||
|
.channel-connection-summary article,
|
||||||
.admin-sms-route-list dl,
|
.admin-sms-route-list dl,
|
||||||
.admin-uplink-info-grid,
|
.admin-uplink-info-grid,
|
||||||
.admin-uplink-match-grid {
|
.admin-uplink-match-grid {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function formatDateTime(value?: string | Date | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function displayFileName(value: string) {
|
||||||
|
if (!value || ![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = Uint8Array.from(value, (character) => character.charCodeAt(0));
|
||||||
|
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
return decoded === value ? value : decoded;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user