fix: complete first version issue remediation

This commit is contained in:
hectorzhao
2026-07-11 10:14:37 +08:00
parent 709ac97764
commit 208a6c23f8
73 changed files with 1549 additions and 245 deletions
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -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?
+9 -2
View File
@@ -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('*');
}
}
+12 -2
View File
@@ -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 ?? '');
}
} }
+1 -1
View File
@@ -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');
}); });
+1 -1
View File
@@ -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();
}
}
+25
View File
@@ -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);
+20 -2
View File
@@ -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) {
+25 -3
View File
@@ -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({
+41 -7
View File
@@ -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);
}
}
+3
View File
@@ -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);
+21 -2
View File
@@ -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) {
+32
View File
@@ -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',
+12 -2
View File
@@ -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')
+15 -2
View File
@@ -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,
+21 -1
View File
@@ -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';
+19
View File
@@ -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() {
+11
View File
@@ -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;
+21 -3
View File
@@ -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 回执、上行与幂等
+7 -5
View File
@@ -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_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | | TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;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
View File
@@ -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 均为 activeAPI health 正常。 - 生产 `cmpp-api``cmpp-gateway`、PostgreSQL、Nginx 均为 activeAPI 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 均为 activeAPI/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 均为 activeAPI/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 均为 activeAPI/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 均为 activeAPI/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 均为 activeAPI/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 均为 activeAPI/Gateway health 正常。
## 2026-07-10 瑕疵回归复查
- 逐图复查下载的《短信平台第一版瑕疵》后,确认中文图片文件名乱码仍真实存在:生产 `FileObject.fileName` 中可见 UTF-8 被按 Latin-1 解释后的值。上传链路现先恢复 multipart 文件名编码;历史记录由前端展示层兼容解码,避免签名材料和企业认证页继续显示乱码。
- 通用输入框、文本框去除内部填充色;同时覆盖 Chromium 自动填充产生的蓝色内层背景。企业认证页此前额外写死的灰色输入背景已移除。
- 已执行 FilesService 定向单测(3 项通过)、API build、前端 build 和 `git diff --check`;生产部署后四个服务均为 activeAPI/Gateway health 正常。通过真实 `POST /api/admin/files/upload` 上传 `营业执照-编码回归.png`,响应和 `FileObject` 持久化文件名均为正常中文。
- Batch 6 的 6.1、6.2、7.1、8.1 仍为待修,不得因之前的前端构建通过而标记完成;其余 8.x 与客户端菜单顺序将继续按原始文档逐项复核。
+126 -21
View File
@@ -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
} }
+64
View File
@@ -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()
+1
View File
@@ -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"`
+4
View File
@@ -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
View File
@@ -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'),
+61 -15
View File
@@ -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}
/> />
+6 -1
View File
@@ -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">
+112 -18
View File
@@ -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> PNGJPGJPEGPDF 10M</small> : null} {!compact ? <small> PNGJPGJPEGPDF 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',
+33 -11
View File
@@ -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>
+26 -13
View File
@@ -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>
+2 -1
View File
@@ -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>
+28 -12
View File
@@ -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}` },
+5 -2
View File
@@ -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}
/> />
+4 -1
View File
@@ -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}
/> />
+19 -6
View File
@@ -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>
+2 -1
View File
@@ -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}
/> />
+2
View File
@@ -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}
/> />
+2
View File
@@ -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}
/> />
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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];
+2
View File
@@ -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}
/> />
+2
View File
@@ -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}
/> />
+4 -1
View File
@@ -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}
/> />
+4 -1
View File
@@ -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}
/> />
+16 -3
View File
@@ -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"
+18 -1
View File
@@ -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>
); );
} }
+7 -1
View File
@@ -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} />
+17 -1
View File
@@ -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>
+7 -1
View File
@@ -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',
+3 -2
View File
@@ -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
+7 -1
View File
@@ -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}
+44 -1
View File
@@ -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>
); );
} }
+2 -2
View File
@@ -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 },
], ],
}, },
{ {
+36 -2
View File
@@ -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
View File
@@ -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 {
+7
View File
@@ -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())}`;
}
+13
View File
@@ -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;
}
}