feat: complete phase2 baseline cdr quality rbac
This commit is contained in:
@@ -9,6 +9,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||
import { AuditModule } from './audit/audit.module.js';
|
||||
import { ActiveCallsModule } from './active-calls/active-calls.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { BusinessPrefixesModule } from './business-prefixes/business-prefixes.module.js';
|
||||
import { CdrsModule } from './cdrs/cdrs.module.js';
|
||||
import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
|
||||
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
|
||||
@@ -59,6 +60,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
ActiveCallsModule,
|
||||
BusinessPrefixesModule,
|
||||
CdrsModule,
|
||||
DashboardModule,
|
||||
CustomersModule,
|
||||
|
||||
@@ -23,7 +23,8 @@ class E2eAuthRepository implements AuthRepository {
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
requirePasswordChange: false,
|
||||
roles: ['admin']
|
||||
roles: ['admin'],
|
||||
permissions: ['dashboard.view', 'customers.view']
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ class E2eAuthRepository implements AuthRepository {
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
|
||||
}
|
||||
|
||||
async markLoginSuccess(): Promise<void> {
|
||||
@@ -52,7 +53,7 @@ class E2eAuthRepository implements AuthRepository {
|
||||
refreshTokenHash: input.refreshTokenHash,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
user: { ...this.user, roles: [...this.user.roles] }
|
||||
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -130,6 +131,7 @@ describe('LisgloSIPS Auth API', () => {
|
||||
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
|
||||
|
||||
expect(login.body.accessToken).toBeTypeOf('string');
|
||||
expect(login.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
|
||||
expect(loginCookie).toContain('HttpOnly');
|
||||
expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken)));
|
||||
|
||||
@@ -137,6 +139,7 @@ describe('LisgloSIPS Auth API', () => {
|
||||
const refreshCookie = refresh.headers['set-cookie'][0];
|
||||
|
||||
expect(refresh.body.accessToken).toBeTypeOf('string');
|
||||
expect(refresh.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
|
||||
expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date);
|
||||
await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js';
|
||||
|
||||
@@ -7,6 +8,45 @@ function sessionId(): string {
|
||||
return `ses_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
}
|
||||
|
||||
type AuthUserWithRoles = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
function toAuthUserRecord(user: AuthUserWithRoles): AuthUserRecord {
|
||||
const enabledRoles = user.userRoles.map((userRole) => userRole.role).filter((role) => role.status === 'ENABLED' && !role.deletedAt);
|
||||
const permissions = new Set<string>();
|
||||
|
||||
for (const role of enabledRoles) {
|
||||
for (const rolePermission of role.permissions) {
|
||||
permissions.add(rolePermission.permissionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
passwordHash: user.passwordHash,
|
||||
passwordAlgo: user.passwordAlgo,
|
||||
status: user.status,
|
||||
failedLoginCount: user.failedLoginCount,
|
||||
lockedUntil: user.lockedUntil,
|
||||
requirePasswordChange: user.requirePasswordChange,
|
||||
roles: enabledRoles.map((role) => role.name),
|
||||
permissions: [...permissions].sort()
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAuthRepository implements AuthRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
@@ -17,7 +57,11 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
role: {
|
||||
include: {
|
||||
permissions: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,18 +71,7 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
passwordHash: user.passwordHash,
|
||||
passwordAlgo: user.passwordAlgo,
|
||||
status: user.status,
|
||||
failedLoginCount: user.failedLoginCount,
|
||||
lockedUntil: user.lockedUntil,
|
||||
requirePasswordChange: user.requirePasswordChange,
|
||||
roles: user.userRoles.map((userRole) => userRole.role.name)
|
||||
};
|
||||
return toAuthUserRecord(user);
|
||||
}
|
||||
|
||||
async markLoginSuccess(userId: string, ip?: string): Promise<void> {
|
||||
@@ -79,7 +112,11 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
role: {
|
||||
include: {
|
||||
permissions: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,18 +130,7 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
refreshTokenHash: session.refreshTokenHash,
|
||||
expiresAt: session.expiresAt,
|
||||
revokedAt: session.revokedAt,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
passwordHash: session.user.passwordHash,
|
||||
passwordAlgo: session.user.passwordAlgo,
|
||||
status: session.user.status,
|
||||
failedLoginCount: session.user.failedLoginCount,
|
||||
lockedUntil: session.user.lockedUntil,
|
||||
requirePasswordChange: session.user.requirePasswordChange,
|
||||
roles: session.user.userRoles.map((userRole) => userRole.role.name)
|
||||
}
|
||||
user: toAuthUserRecord(session.user)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,7 +142,11 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
include: {
|
||||
userRoles: {
|
||||
include: {
|
||||
role: true
|
||||
role: {
|
||||
include: {
|
||||
permissions: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,18 +164,7 @@ export class PrismaAuthRepository implements AuthRepository {
|
||||
refreshTokenHash: session.refreshTokenHash,
|
||||
expiresAt: session.expiresAt,
|
||||
revokedAt: session.revokedAt,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
displayName: session.user.displayName,
|
||||
passwordHash: session.user.passwordHash,
|
||||
passwordAlgo: session.user.passwordAlgo,
|
||||
status: session.user.status,
|
||||
failedLoginCount: session.user.failedLoginCount,
|
||||
lockedUntil: session.user.lockedUntil,
|
||||
requirePasswordChange: session.user.requirePasswordChange,
|
||||
roles: session.user.userRoles.map((userRole) => userRole.role.name)
|
||||
}
|
||||
user: toAuthUserRecord(session.user)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ class MemoryAuthRepository implements AuthRepository {
|
||||
failedLoginCount: 0,
|
||||
lockedUntil: null,
|
||||
requirePasswordChange: false,
|
||||
roles: ['admin']
|
||||
roles: ['admin'],
|
||||
permissions: ['dashboard.view', 'customers.view']
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ class MemoryAuthRepository implements AuthRepository {
|
||||
}
|
||||
|
||||
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
|
||||
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
|
||||
}
|
||||
|
||||
async markLoginSuccess(_userId: string, _ip?: string): Promise<void> {
|
||||
@@ -54,7 +55,7 @@ class MemoryAuthRepository implements AuthRepository {
|
||||
refreshTokenHash: input.refreshTokenHash,
|
||||
expiresAt: input.expiresAt,
|
||||
revokedAt: null,
|
||||
user: { ...this.user, roles: [...this.user.roles] }
|
||||
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -112,6 +113,7 @@ describe('AuthService', () => {
|
||||
const session = [...repo.sessions.values()][0];
|
||||
|
||||
expect(result.response.user.username).toBe('operator');
|
||||
expect(result.response.user.permissions).toEqual(['dashboard.view', 'customers.view']);
|
||||
expect(result.response.accessToken.split('.')).toHaveLength(3);
|
||||
expect(session.refreshTokenHash).toBe(sha256Token(result.tokens.refreshToken));
|
||||
expect(session.refreshTokenHash).not.toBe(result.tokens.refreshToken);
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AuthResponse {
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
requirePasswordChange: boolean;
|
||||
};
|
||||
}
|
||||
@@ -171,6 +172,7 @@ export class AuthService {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
requirePasswordChange: user.requirePasswordChange
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AuthUserRecord {
|
||||
lockedUntil: Date | null;
|
||||
requirePasswordChange: boolean;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AuthSessionRecord {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { BusinessPrefixesService } from './business-prefixes.service.js';
|
||||
|
||||
@ApiTags('business-prefixes')
|
||||
@Controller('business-prefixes')
|
||||
export class BusinessPrefixesController {
|
||||
constructor(@Inject(BusinessPrefixesService) private readonly businessPrefixesService: BusinessPrefixesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
list(@Query() query: unknown) {
|
||||
return this.businessPrefixesService.list(query as never);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.businessPrefixesService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'business-prefixes', action: 'create', objectType: 'business-prefix' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.businessPrefixesService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'business-prefixes', action: 'update', objectType: 'business-prefix', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.businessPrefixesService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'business-prefixes', action: 'enable', objectType: 'business-prefix', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.businessPrefixesService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'business-prefixes', action: 'disable', objectType: 'business-prefix', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.businessPrefixesService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'business-prefixes', action: 'delete', objectType: 'business-prefix', objectIdParam: 'id' })
|
||||
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.businessPrefixesService.remove(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BusinessPrefixesController } from './business-prefixes.controller.js';
|
||||
import { BUSINESS_PREFIXES_REPOSITORY, PrismaBusinessPrefixesRepository } from './business-prefixes.repository.js';
|
||||
import { BusinessPrefixesService } from './business-prefixes.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [BusinessPrefixesController],
|
||||
providers: [
|
||||
BusinessPrefixesService,
|
||||
{
|
||||
provide: BUSINESS_PREFIXES_REPOSITORY,
|
||||
useClass: PrismaBusinessPrefixesRepository
|
||||
}
|
||||
],
|
||||
exports: [BusinessPrefixesService]
|
||||
})
|
||||
export class BusinessPrefixesModule {}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type BusinessPrefixStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface BusinessPrefixSummary {
|
||||
id: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
priority: number;
|
||||
status: BusinessPrefixStatus;
|
||||
gatewayCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateBusinessPrefixInput {
|
||||
prefix: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
priority: number;
|
||||
status?: BusinessPrefixStatus;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export type UpdateBusinessPrefixInput = Partial<Omit<CreateBusinessPrefixInput, 'actorId'>> & {
|
||||
actorId?: string;
|
||||
};
|
||||
|
||||
export interface BusinessPrefixesRepository {
|
||||
list(query?: { keyword?: string; status?: BusinessPrefixStatus }): Promise<BusinessPrefixSummary[]>;
|
||||
get(prefixId: string): Promise<BusinessPrefixSummary>;
|
||||
create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
|
||||
update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
|
||||
setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary>;
|
||||
softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary>;
|
||||
}
|
||||
|
||||
export const BUSINESS_PREFIXES_REPOSITORY = Symbol('BUSINESS_PREFIXES_REPOSITORY');
|
||||
|
||||
function businessPrefixId(): string {
|
||||
return `bp_${crypto.randomUUID().replaceAll('-', '').slice(0, 29)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaBusinessPrefixesRepository implements BusinessPrefixesRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { keyword?: string; status?: BusinessPrefixStatus } = {}): Promise<BusinessPrefixSummary[]> {
|
||||
const where: Prisma.BusinessPrefixWhereInput = {
|
||||
deletedAt: null,
|
||||
status: query.status,
|
||||
OR: query.keyword
|
||||
? [{ prefix: { contains: query.keyword } }, { name: { contains: query.keyword } }, { description: { contains: query.keyword } }]
|
||||
: undefined
|
||||
};
|
||||
|
||||
const items = await this.prisma.businessPrefix.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return items.map((item) => this.toSummary(item));
|
||||
}
|
||||
|
||||
async get(prefixId: string): Promise<BusinessPrefixSummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(prefixId));
|
||||
}
|
||||
|
||||
async create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
|
||||
const item = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.businessPrefix.create({
|
||||
data: {
|
||||
id: businessPrefixId(),
|
||||
prefix: input.prefix,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
priority: input.priority,
|
||||
status: input.status ?? 'ENABLED',
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'business_prefix.created');
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.toSummary(item);
|
||||
}
|
||||
|
||||
async update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
|
||||
await this.findActiveOrThrow(prefixId);
|
||||
|
||||
const item = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.businessPrefix.update({
|
||||
where: { id: prefixId },
|
||||
data: {
|
||||
prefix: input.prefix,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.updated');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(item);
|
||||
}
|
||||
|
||||
async setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
return this.update(prefixId, { status, actorId });
|
||||
}
|
||||
|
||||
async softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
const existing = await this.findActiveOrThrow(prefixId);
|
||||
const linkedGateways = await this.prisma.customerGatewayBusinessPrefix.count({
|
||||
where: { businessPrefixId: prefixId }
|
||||
});
|
||||
|
||||
if (linkedGateways > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'BUSINESS_PREFIX_IN_USE',
|
||||
message: 'Business prefix in use by customer gateways cannot be deleted.'
|
||||
});
|
||||
}
|
||||
|
||||
const item = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.businessPrefix.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.deleted');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(item);
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'business_prefix_config',
|
||||
aggregateId,
|
||||
eventType,
|
||||
payload: Prisma.JsonNull
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
_count: {
|
||||
select: {
|
||||
customerGateways: true
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.BusinessPrefixInclude;
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(prefixId: string) {
|
||||
const item = await this.prisma.businessPrefix.findUnique({
|
||||
where: { id: prefixId },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
if (!item || item.deletedAt) {
|
||||
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private toSummary(item: {
|
||||
id: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
priority: number;
|
||||
status: BusinessPrefixStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { customerGateways: number };
|
||||
}): BusinessPrefixSummary {
|
||||
return {
|
||||
id: item.id,
|
||||
prefix: item.prefix,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
priority: item.priority,
|
||||
status: item.status,
|
||||
gatewayCount: item._count.customerGateways,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BUSINESS_PREFIXES_REPOSITORY,
|
||||
type BusinessPrefixesRepository,
|
||||
type BusinessPrefixStatus,
|
||||
type BusinessPrefixSummary,
|
||||
type CreateBusinessPrefixInput,
|
||||
type UpdateBusinessPrefixInput
|
||||
} from './business-prefixes.repository.js';
|
||||
|
||||
interface BusinessPrefixDto {
|
||||
prefix?: unknown;
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
priority?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface ListQuery {
|
||||
keyword?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessPrefixesService {
|
||||
constructor(@Inject(BUSINESS_PREFIXES_REPOSITORY) private readonly businessPrefixes: BusinessPrefixesRepository) {}
|
||||
|
||||
list(query: ListQuery = {}): Promise<BusinessPrefixSummary[]> {
|
||||
return this.businessPrefixes.list({
|
||||
keyword: this.optionalTrimmed(query.keyword, 80),
|
||||
status: query.status === undefined || query.status === 'all' ? undefined : this.status(query.status)
|
||||
});
|
||||
}
|
||||
|
||||
get(prefixId: string): Promise<BusinessPrefixSummary> {
|
||||
return this.businessPrefixes.get(prefixId);
|
||||
}
|
||||
|
||||
create(body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
const input: CreateBusinessPrefixInput = {
|
||||
prefix: this.prefix(body.prefix),
|
||||
name: this.requiredString(body.name, 'name', 120),
|
||||
description: this.nullableString(body.description, 'description', 500),
|
||||
priority: body.priority === undefined ? 100 : this.priority(body.priority),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.businessPrefixes.create(input);
|
||||
}
|
||||
|
||||
update(prefixId: string, body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
const input: UpdateBusinessPrefixInput = {
|
||||
prefix: body.prefix === undefined ? undefined : this.prefix(body.prefix),
|
||||
name: body.name === undefined ? undefined : this.requiredString(body.name, 'name', 120),
|
||||
description: body.description === undefined ? undefined : this.nullableString(body.description, 'description', 500),
|
||||
priority: body.priority === undefined ? undefined : this.priority(body.priority),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.businessPrefixes.update(prefixId, input);
|
||||
}
|
||||
|
||||
enable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
return this.businessPrefixes.setStatus(prefixId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
return this.businessPrefixes.setStatus(prefixId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
|
||||
return this.businessPrefixes.softDelete(prefixId, actorId);
|
||||
}
|
||||
|
||||
private prefix(value: unknown): string {
|
||||
const trimmed = this.requiredString(value, 'prefix', 32);
|
||||
if (!/^[A-Za-z0-9]{1,32}$/.test(trimmed)) {
|
||||
throw new BadRequestException({ code: 'BUSINESS_PREFIX_INVALID', message: 'Business prefix must be 1-32 letters or digits.' });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
return this.requiredString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private optionalTrimmed(value: unknown, maxLength: number): string | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
return this.requiredString(value, 'keyword', maxLength);
|
||||
}
|
||||
|
||||
private status(value: unknown): BusinessPrefixStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private priority(value: unknown): number {
|
||||
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : Number.NaN;
|
||||
if (!Number.isInteger(numeric) || numeric < 1 || numeric > 9999) {
|
||||
throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'Priority must be an integer between 1 and 9999.' });
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,19 @@ export interface CdrQuery {
|
||||
vendorGatewayId?: string;
|
||||
cityCode?: string;
|
||||
carrier?: CdrCarrier;
|
||||
startedFrom?: Date;
|
||||
startedTo?: Date;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface CdrPageMeta {
|
||||
total: number;
|
||||
take: number;
|
||||
skip: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface CdrListItem {
|
||||
id: string;
|
||||
eventId: string;
|
||||
@@ -22,6 +31,9 @@ export interface CdrListItem {
|
||||
sourceIp: string | null;
|
||||
caller: string;
|
||||
callee: string;
|
||||
rawCallee: string | null;
|
||||
businessPrefixId: string | null;
|
||||
businessPrefix: string | null;
|
||||
calleeCityCode: string | null;
|
||||
calleeCityName: string | null;
|
||||
calleeProvinceName: string | null;
|
||||
@@ -39,6 +51,8 @@ export interface CdrListItem {
|
||||
vendorGatewayPort: number | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
landingCaller: string | null;
|
||||
landingCallee: string | null;
|
||||
startedAt: Date;
|
||||
answeredAt: Date | null;
|
||||
endedAt: Date;
|
||||
@@ -52,11 +66,73 @@ export interface CdrListItem {
|
||||
vendorCost: string | null;
|
||||
grossProfit: string | null;
|
||||
billSec: number | null;
|
||||
hasRecording: boolean;
|
||||
recordingId: string | null;
|
||||
recordingStatus: string | null;
|
||||
}
|
||||
|
||||
export interface CdrEntityRef {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CdrGatewayRef extends CdrEntityRef {
|
||||
authMode?: string;
|
||||
sourceIp?: string | null;
|
||||
host?: string;
|
||||
port?: number;
|
||||
transport?: string;
|
||||
}
|
||||
|
||||
export interface CdrPolicyRef extends CdrEntityRef {
|
||||
priority: number;
|
||||
callerMode: string;
|
||||
callerValue: string | null;
|
||||
calleeMode: string;
|
||||
calleeValue: string | null;
|
||||
}
|
||||
|
||||
export interface CdrRatedDetail {
|
||||
id: string;
|
||||
billSec: number;
|
||||
customerFee: string;
|
||||
vendorCost: string;
|
||||
grossProfit: string;
|
||||
customerRate: Prisma.JsonValue | null;
|
||||
vendorRate: Prisma.JsonValue | null;
|
||||
ratedAt: Date;
|
||||
}
|
||||
|
||||
export interface CdrRecordingDetail {
|
||||
id: string;
|
||||
storageKey: string;
|
||||
sha256: string | null;
|
||||
bytes: string;
|
||||
durationSec: number;
|
||||
status: string;
|
||||
movedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CdrDetail extends CdrListItem {
|
||||
customer: CdrEntityRef | null;
|
||||
customerGateway: CdrGatewayRef | null;
|
||||
customerGatewayPolicy: CdrPolicyRef | null;
|
||||
businessPrefixRef: CdrEntityRef & { prefix: string; priority: number } | null;
|
||||
vendor: CdrEntityRef | null;
|
||||
vendorGateway: CdrGatewayRef | null;
|
||||
lineGroup: CdrEntityRef | null;
|
||||
rated: CdrRatedDetail | null;
|
||||
recording: CdrRecordingDetail | null;
|
||||
payload: Prisma.JsonValue | null;
|
||||
receivedAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CdrsRepository {
|
||||
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
|
||||
get(id: string): Promise<CdrListItem>;
|
||||
list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }>;
|
||||
get(id: string): Promise<CdrDetail>;
|
||||
}
|
||||
|
||||
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
|
||||
@@ -69,6 +145,54 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
|
||||
vendorGateway: { select: { name: true; host: true; port: true } };
|
||||
lineGroup: { select: { name: true } };
|
||||
ratedCdr: { select: { customerFee: true; vendorCost: true; grossProfit: true; billSec: true } };
|
||||
recording: { select: { id: true; status: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
type RawCdrDetailRecord = Prisma.RawCdrGetPayload<{
|
||||
include: {
|
||||
customer: { select: { id: true; name: true; status: true } };
|
||||
customerGateway: { select: { id: true; name: true; status: true; authMode: true; sourceIp: true } };
|
||||
customerGatewayPolicy: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
status: true;
|
||||
priority: true;
|
||||
callerMode: true;
|
||||
callerValue: true;
|
||||
calleeMode: true;
|
||||
calleeValue: true;
|
||||
};
|
||||
};
|
||||
businessPrefixRef: { select: { id: true; name: true; prefix: true; priority: true; status: true } };
|
||||
vendor: { select: { id: true; name: true; status: true } };
|
||||
vendorGateway: { select: { id: true; name: true; status: true; authMode: true; host: true; port: true; transport: true } };
|
||||
lineGroup: { select: { id: true; name: true; status: true } };
|
||||
ratedCdr: {
|
||||
select: {
|
||||
id: true;
|
||||
billSec: true;
|
||||
customerFee: true;
|
||||
vendorCost: true;
|
||||
grossProfit: true;
|
||||
customerRate: true;
|
||||
vendorRate: true;
|
||||
ratedAt: true;
|
||||
};
|
||||
};
|
||||
recording: {
|
||||
select: {
|
||||
id: true;
|
||||
storageKey: true;
|
||||
sha256: true;
|
||||
bytes: true;
|
||||
durationSec: true;
|
||||
status: true;
|
||||
movedAt: true;
|
||||
createdAt: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -76,7 +200,7 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
|
||||
export class PrismaCdrsRepository implements CdrsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
|
||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
|
||||
const where = this.where(query);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.rawCdr.findMany({
|
||||
@@ -88,18 +212,26 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
}),
|
||||
this.prisma.rawCdr.count({ where })
|
||||
]);
|
||||
return { items: items.map((item) => this.toItem(item)), total };
|
||||
return {
|
||||
items: items.map((item) => this.toItem(item)),
|
||||
meta: {
|
||||
total,
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
hasMore: query.skip + items.length < total
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
async get(id: string): Promise<CdrDetail> {
|
||||
const item = await this.prisma.rawCdr.findUnique({
|
||||
where: { id },
|
||||
include: this.includeCdr()
|
||||
include: this.includeDetail()
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
|
||||
}
|
||||
return this.toItem(item);
|
||||
return this.toDetail(item);
|
||||
}
|
||||
|
||||
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
|
||||
@@ -109,7 +241,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
customerGatewayId: query.customerGatewayId,
|
||||
vendorGatewayId: query.vendorGatewayId,
|
||||
calleeCityCode: query.cityCode,
|
||||
calleeOperator: query.carrier
|
||||
calleeOperator: query.carrier,
|
||||
startedAt: query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +253,55 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
vendor: { select: { name: true } },
|
||||
vendorGateway: { select: { name: true, host: true, port: true } },
|
||||
lineGroup: { select: { name: true } },
|
||||
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } }
|
||||
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } },
|
||||
recording: { select: { id: true, status: true } }
|
||||
} satisfies Prisma.RawCdrInclude;
|
||||
}
|
||||
|
||||
private includeDetail() {
|
||||
return {
|
||||
customer: { select: { id: true, name: true, status: true } },
|
||||
customerGateway: { select: { id: true, name: true, status: true, authMode: true, sourceIp: true } },
|
||||
customerGatewayPolicy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
callerMode: true,
|
||||
callerValue: true,
|
||||
calleeMode: true,
|
||||
calleeValue: true
|
||||
}
|
||||
},
|
||||
businessPrefixRef: { select: { id: true, name: true, prefix: true, priority: true, status: true } },
|
||||
vendor: { select: { id: true, name: true, status: true } },
|
||||
vendorGateway: { select: { id: true, name: true, status: true, authMode: true, host: true, port: true, transport: true } },
|
||||
lineGroup: { select: { id: true, name: true, status: true } },
|
||||
ratedCdr: {
|
||||
select: {
|
||||
id: true,
|
||||
billSec: true,
|
||||
customerFee: true,
|
||||
vendorCost: true,
|
||||
grossProfit: true,
|
||||
customerRate: true,
|
||||
vendorRate: true,
|
||||
ratedAt: true
|
||||
}
|
||||
},
|
||||
recording: {
|
||||
select: {
|
||||
id: true,
|
||||
storageKey: true,
|
||||
sha256: true,
|
||||
bytes: true,
|
||||
durationSec: true,
|
||||
status: true,
|
||||
movedAt: true,
|
||||
createdAt: true
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.RawCdrInclude;
|
||||
}
|
||||
|
||||
@@ -132,6 +313,9 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
sourceIp: item.sourceIp,
|
||||
caller: item.caller,
|
||||
callee: item.callee,
|
||||
rawCallee: item.rawCallee,
|
||||
businessPrefixId: item.businessPrefixId,
|
||||
businessPrefix: item.businessPrefix,
|
||||
calleeCityCode: item.calleeCityCode,
|
||||
calleeCityName: item.calleeCityName,
|
||||
calleeProvinceName: item.calleeProvinceName,
|
||||
@@ -149,6 +333,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
vendorGatewayPort: item.vendorGateway?.port ?? null,
|
||||
lineGroupId: item.lineGroupId,
|
||||
lineGroupName: item.lineGroup?.name ?? null,
|
||||
landingCaller: item.landingCaller,
|
||||
landingCallee: item.landingCallee,
|
||||
startedAt: item.startedAt,
|
||||
answeredAt: item.answeredAt,
|
||||
endedAt: item.endedAt,
|
||||
@@ -161,7 +347,88 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
customerFee: item.ratedCdr?.customerFee.toFixed(6) ?? null,
|
||||
vendorCost: item.ratedCdr?.vendorCost.toFixed(6) ?? null,
|
||||
grossProfit: item.ratedCdr?.grossProfit.toFixed(6) ?? null,
|
||||
billSec: item.ratedCdr?.billSec ?? null
|
||||
billSec: item.ratedCdr?.billSec ?? null,
|
||||
hasRecording: Boolean(item.recording),
|
||||
recordingId: item.recording?.id ?? null,
|
||||
recordingStatus: item.recording?.status ?? null
|
||||
};
|
||||
}
|
||||
|
||||
private toDetail(item: RawCdrDetailRecord): CdrDetail {
|
||||
const base = this.toItem(item);
|
||||
return {
|
||||
...base,
|
||||
customer: item.customer ? { id: item.customer.id, name: item.customer.name, status: item.customer.status } : null,
|
||||
customerGateway: item.customerGateway
|
||||
? {
|
||||
id: item.customerGateway.id,
|
||||
name: item.customerGateway.name,
|
||||
status: item.customerGateway.status,
|
||||
authMode: item.customerGateway.authMode,
|
||||
sourceIp: item.customerGateway.sourceIp
|
||||
}
|
||||
: null,
|
||||
customerGatewayPolicy: item.customerGatewayPolicy
|
||||
? {
|
||||
id: item.customerGatewayPolicy.id,
|
||||
name: item.customerGatewayPolicy.name,
|
||||
status: item.customerGatewayPolicy.status,
|
||||
priority: item.customerGatewayPolicy.priority,
|
||||
callerMode: item.customerGatewayPolicy.callerMode,
|
||||
callerValue: item.customerGatewayPolicy.callerValue,
|
||||
calleeMode: item.customerGatewayPolicy.calleeMode,
|
||||
calleeValue: item.customerGatewayPolicy.calleeValue
|
||||
}
|
||||
: null,
|
||||
businessPrefixRef: item.businessPrefixRef
|
||||
? {
|
||||
id: item.businessPrefixRef.id,
|
||||
name: item.businessPrefixRef.name,
|
||||
prefix: item.businessPrefixRef.prefix,
|
||||
priority: item.businessPrefixRef.priority,
|
||||
status: item.businessPrefixRef.status
|
||||
}
|
||||
: null,
|
||||
vendor: item.vendor ? { id: item.vendor.id, name: item.vendor.name, status: item.vendor.status } : null,
|
||||
vendorGateway: item.vendorGateway
|
||||
? {
|
||||
id: item.vendorGateway.id,
|
||||
name: item.vendorGateway.name,
|
||||
status: item.vendorGateway.status,
|
||||
authMode: item.vendorGateway.authMode,
|
||||
host: item.vendorGateway.host,
|
||||
port: item.vendorGateway.port,
|
||||
transport: item.vendorGateway.transport
|
||||
}
|
||||
: null,
|
||||
lineGroup: item.lineGroup ? { id: item.lineGroup.id, name: item.lineGroup.name, status: item.lineGroup.status } : null,
|
||||
rated: item.ratedCdr
|
||||
? {
|
||||
id: item.ratedCdr.id,
|
||||
billSec: item.ratedCdr.billSec,
|
||||
customerFee: item.ratedCdr.customerFee.toFixed(6),
|
||||
vendorCost: item.ratedCdr.vendorCost.toFixed(6),
|
||||
grossProfit: item.ratedCdr.grossProfit.toFixed(6),
|
||||
customerRate: item.ratedCdr.customerRate,
|
||||
vendorRate: item.ratedCdr.vendorRate,
|
||||
ratedAt: item.ratedCdr.ratedAt
|
||||
}
|
||||
: null,
|
||||
recording: item.recording
|
||||
? {
|
||||
id: item.recording.id,
|
||||
storageKey: item.recording.storageKey,
|
||||
sha256: item.recording.sha256,
|
||||
bytes: item.recording.bytes.toString(),
|
||||
durationSec: item.recording.durationSec,
|
||||
status: item.recording.status,
|
||||
movedAt: item.recording.movedAt,
|
||||
createdAt: item.recording.createdAt
|
||||
}
|
||||
: null,
|
||||
payload: item.payload,
|
||||
receivedAt: item.receivedAt,
|
||||
createdAt: item.createdAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CdrsService } from './cdrs.service.js';
|
||||
import type { CdrListItem, CdrQuery, CdrsRepository } from './cdrs.repository.js';
|
||||
import type { CdrDetail, CdrQuery, CdrsRepository } from './cdrs.repository.js';
|
||||
|
||||
class MemoryCdrsRepository implements CdrsRepository {
|
||||
lastQuery: CdrQuery | null = null;
|
||||
|
||||
async list(query: CdrQuery) {
|
||||
this.lastQuery = query;
|
||||
return { items: [], total: 0 };
|
||||
return { items: [], meta: { total: 0, take: query.take, skip: query.skip, hasMore: false } };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CdrListItem> {
|
||||
async get(id: string): Promise<CdrDetail> {
|
||||
const now = new Date();
|
||||
return {
|
||||
id,
|
||||
eventId: 'evt_1',
|
||||
@@ -19,6 +20,9 @@ class MemoryCdrsRepository implements CdrsRepository {
|
||||
sourceIp: null,
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
rawCallee: '67113800138000',
|
||||
businessPrefixId: 'bp_seed',
|
||||
businessPrefix: '671',
|
||||
calleeCityCode: '340100',
|
||||
calleeCityName: '合肥市',
|
||||
calleeProvinceName: '安徽省',
|
||||
@@ -36,9 +40,11 @@ class MemoryCdrsRepository implements CdrsRepository {
|
||||
vendorGatewayPort: null,
|
||||
lineGroupId: null,
|
||||
lineGroupName: null,
|
||||
startedAt: new Date(),
|
||||
landingCaller: '02160010001',
|
||||
landingCallee: '8613800138000',
|
||||
startedAt: now,
|
||||
answeredAt: null,
|
||||
endedAt: new Date(),
|
||||
endedAt: now,
|
||||
durationSec: 0,
|
||||
sipCode: 503,
|
||||
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
|
||||
@@ -48,22 +54,47 @@ class MemoryCdrsRepository implements CdrsRepository {
|
||||
customerFee: null,
|
||||
vendorCost: null,
|
||||
grossProfit: null,
|
||||
billSec: null
|
||||
billSec: null,
|
||||
hasRecording: false,
|
||||
recordingId: null,
|
||||
recordingStatus: null,
|
||||
customer: null,
|
||||
customerGateway: null,
|
||||
customerGatewayPolicy: null,
|
||||
businessPrefixRef: null,
|
||||
vendor: null,
|
||||
vendorGateway: null,
|
||||
lineGroup: null,
|
||||
rated: null,
|
||||
recording: null,
|
||||
payload: null,
|
||||
receivedAt: now,
|
||||
createdAt: now
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('CDR service', () => {
|
||||
it('normalizes filters for city and carrier queries', async () => {
|
||||
it('normalizes filters for city, carrier and time range queries', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
|
||||
await service.list({
|
||||
caller: '1001',
|
||||
cityCode: '340100',
|
||||
carrier: 'MOBILE',
|
||||
startedFrom: '2026-06-24T00:00:00.000Z',
|
||||
startedTo: '2026-06-25T00:00:00.000Z',
|
||||
take: '20',
|
||||
skip: '5'
|
||||
});
|
||||
|
||||
expect(repository.lastQuery).toMatchObject({
|
||||
caller: '1001',
|
||||
cityCode: '340100',
|
||||
carrier: 'MOBILE',
|
||||
startedFrom: new Date('2026-06-24T00:00:00.000Z'),
|
||||
startedTo: new Date('2026-06-25T00:00:00.000Z'),
|
||||
take: 20,
|
||||
skip: 5
|
||||
});
|
||||
@@ -74,4 +105,10 @@ describe('CDR service', () => {
|
||||
|
||||
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects inverted time ranges', () => {
|
||||
const service = new CdrsService(new MemoryCdrsRepository());
|
||||
|
||||
expect(() => service.list({ from: '2026-06-25T00:00:00.000Z', to: '2026-06-24T00:00:00.000Z' })).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,14 @@ export class CdrsService {
|
||||
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
|
||||
cityCode: this.optionalString(rawQuery.cityCode, 12),
|
||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
|
||||
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
|
||||
take: this.int(rawQuery.take, 100, 1, 500),
|
||||
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||
};
|
||||
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
|
||||
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
|
||||
}
|
||||
return this.cdrs.list(query);
|
||||
}
|
||||
|
||||
@@ -44,6 +49,20 @@ export class CdrsService {
|
||||
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown): Date | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private int(value: unknown, defaultValue: number, min: number, max: number): number {
|
||||
if (value === undefined) return defaultValue;
|
||||
const parsed = Number(value);
|
||||
|
||||
@@ -44,6 +44,15 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
name: 'Seed IP Gateway',
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.30',
|
||||
sourceIps: ['100.93.185.30', '100.93.185.31'],
|
||||
lineGroupId: 'llg_seed',
|
||||
lineGroupName: 'Seed Line Group',
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.010000',
|
||||
callerMatchMode: 'PREFIXES',
|
||||
callerPrefixes: ['021'],
|
||||
calleeMatchMode: 'BUSINESS_PREFIXES',
|
||||
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
|
||||
hasSipCredential: false,
|
||||
policyCount: 1
|
||||
})
|
||||
@@ -64,9 +73,18 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp ?? null,
|
||||
sourceIp: input.sourceIps?.[0] ?? null,
|
||||
sourceIps: input.sourceIps ?? [],
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
sipDomain: input.sipDomain ?? null,
|
||||
lineGroupId: input.lineGroupId,
|
||||
lineGroupName: 'Created Line Group',
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate,
|
||||
callerMatchMode: input.callerMatchMode,
|
||||
callerPrefixes: input.callerPrefixes,
|
||||
calleeMatchMode: input.calleeMatchMode,
|
||||
businessPrefixes: input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
|
||||
hasSipCredential: Boolean(input.sipHa1)
|
||||
});
|
||||
this.gateways.set(gateway.id, gateway);
|
||||
@@ -80,9 +98,19 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
customerId: input.customerId ?? current.customerId,
|
||||
name: input.name ?? current.name,
|
||||
authMode: input.authMode ?? current.authMode,
|
||||
sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp,
|
||||
sourceIp: input.sourceIps === undefined ? current.sourceIp : input.sourceIps[0] ?? null,
|
||||
sourceIps: input.sourceIps === undefined ? current.sourceIps : input.sourceIps,
|
||||
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
|
||||
sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain,
|
||||
lineGroupId: input.lineGroupId ?? current.lineGroupId,
|
||||
lineGroupName: input.lineGroupId === undefined ? current.lineGroupName : 'Updated Line Group',
|
||||
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
|
||||
cycleRate: input.cycleRate ?? current.cycleRate,
|
||||
callerMatchMode: input.callerMatchMode ?? current.callerMatchMode,
|
||||
callerPrefixes: input.callerPrefixes ?? current.callerPrefixes,
|
||||
calleeMatchMode: input.calleeMatchMode ?? current.calleeMatchMode,
|
||||
businessPrefixes:
|
||||
input.businessPrefixIds === undefined ? current.businessPrefixes : input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
|
||||
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
|
||||
updatedAt: new Date('2026-06-21T03:00:00.000Z')
|
||||
};
|
||||
@@ -110,8 +138,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
name: string;
|
||||
authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
sourceIp?: string | null;
|
||||
sourceIps?: string[];
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
lineGroupId?: string | null;
|
||||
lineGroupName?: string | null;
|
||||
billingCycleSec?: number;
|
||||
cycleRate?: string;
|
||||
callerMatchMode?: 'ANY' | 'PREFIXES';
|
||||
callerPrefixes?: string[];
|
||||
calleeMatchMode?: 'ANY' | 'BUSINESS_PREFIXES';
|
||||
businessPrefixes?: Array<{ id: string; prefix: string; name: string }>;
|
||||
hasSipCredential?: boolean;
|
||||
status?: CustomerGatewayStatus;
|
||||
policyCount?: number;
|
||||
@@ -123,8 +160,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
name: input.name,
|
||||
authMode: input.authMode ?? 'IP',
|
||||
sourceIp: input.sourceIp ?? null,
|
||||
sourceIps: input.sourceIps ?? (input.sourceIp ? [input.sourceIp] : []),
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
sipDomain: input.sipDomain ?? null,
|
||||
lineGroupId: input.lineGroupId ?? 'llg_seed',
|
||||
lineGroupName: input.lineGroupName ?? 'Seed Line Group',
|
||||
billingCycleSec: input.billingCycleSec ?? 60,
|
||||
cycleRate: input.cycleRate ?? '0.000000',
|
||||
callerMatchMode: input.callerMatchMode ?? 'ANY',
|
||||
callerPrefixes: input.callerPrefixes ?? [],
|
||||
calleeMatchMode: input.calleeMatchMode ?? 'ANY',
|
||||
businessPrefixes: input.businessPrefixes ?? [],
|
||||
hasSipCredential: input.hasSipCredential ?? false,
|
||||
status: input.status ?? 'ENABLED',
|
||||
policyCount: input.policyCount ?? 0,
|
||||
@@ -208,6 +254,13 @@ describe('S13 customer gateways API', () => {
|
||||
id: 'cgw_seed',
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.30',
|
||||
sourceIps: ['100.93.185.30', '100.93.185.31'],
|
||||
lineGroupId: 'llg_seed',
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.010000',
|
||||
callerMatchMode: 'PREFIXES',
|
||||
callerPrefixes: ['021'],
|
||||
calleeMatchMode: 'BUSINESS_PREFIXES',
|
||||
policyCount: 1
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('sipPassword');
|
||||
@@ -232,7 +285,13 @@ describe('S13 customer gateways API', () => {
|
||||
authMode: 'SIP_DIGEST',
|
||||
sipUsername: 'alice-gw',
|
||||
sipDomain: 'SIP.EXAMPLE.LOCAL',
|
||||
sipPassword: 'change-me-very-strong'
|
||||
sipPassword: 'change-me-very-strong',
|
||||
lineGroupId: 'llg_seed',
|
||||
billingCycleSec: 60,
|
||||
cycleRate: '0.020000',
|
||||
callerMatchMode: 'ANY',
|
||||
calleeMatchMode: 'BUSINESS_PREFIXES',
|
||||
businessPrefixIds: ['bp_seed']
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
@@ -242,6 +301,10 @@ describe('S13 customer gateways API', () => {
|
||||
sipUsername: 'alice-gw',
|
||||
sipDomain: 'sip.example.local',
|
||||
sourceIp: null,
|
||||
sourceIps: [],
|
||||
lineGroupId: 'llg_seed',
|
||||
cycleRate: '0.020000',
|
||||
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
|
||||
hasSipCredential: true
|
||||
});
|
||||
expect(response.body.sipPassword).toBeUndefined();
|
||||
@@ -262,12 +325,13 @@ describe('S13 customer gateways API', () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v2/customer-gateways/cgw_created')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({ authMode: 'IP', sourceIp: '100.93.185.32' })
|
||||
.send({ authMode: 'IP', sourceIps: ['100.93.185.32', '100.93.185.33'], lineGroupId: 'llg_seed' })
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.32',
|
||||
sourceIps: ['100.93.185.32', '100.93.185.33'],
|
||||
sipUsername: null,
|
||||
sipDomain: null,
|
||||
hasSipCredential: false
|
||||
|
||||
@@ -5,6 +5,14 @@ import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED';
|
||||
export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
export type CustomerGatewayCallerMatchMode = 'ANY' | 'PREFIXES';
|
||||
export type CustomerGatewayCalleeMatchMode = 'ANY' | 'BUSINESS_PREFIXES';
|
||||
|
||||
export interface CustomerGatewayBusinessPrefixSummary {
|
||||
id: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CustomerGatewaySummary {
|
||||
id: string;
|
||||
@@ -13,9 +21,18 @@ export interface CustomerGatewaySummary {
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
sourceIps: string[];
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
hasSipCredential: boolean;
|
||||
lineGroupId: string | null;
|
||||
lineGroupName: string | null;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
callerMatchMode: CustomerGatewayCallerMatchMode;
|
||||
callerPrefixes: string[];
|
||||
calleeMatchMode: CustomerGatewayCalleeMatchMode;
|
||||
businessPrefixes: CustomerGatewayBusinessPrefixSummary[];
|
||||
status: CustomerGatewayStatus;
|
||||
policyCount: number;
|
||||
createdAt: Date;
|
||||
@@ -26,10 +43,17 @@ export interface CreateCustomerGatewayInput {
|
||||
customerId: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sourceIps?: string[];
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string;
|
||||
lineGroupId: string;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
callerMatchMode: CustomerGatewayCallerMatchMode;
|
||||
callerPrefixes: string[];
|
||||
calleeMatchMode: CustomerGatewayCalleeMatchMode;
|
||||
businessPrefixIds: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
@@ -37,10 +61,17 @@ export interface UpdateCustomerGatewayInput {
|
||||
customerId?: string;
|
||||
name?: string;
|
||||
authMode?: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sourceIps?: string[];
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string | null;
|
||||
lineGroupId?: string;
|
||||
billingCycleSec?: number;
|
||||
cycleRate?: string;
|
||||
callerMatchMode?: CustomerGatewayCallerMatchMode;
|
||||
callerPrefixes?: string[];
|
||||
calleeMatchMode?: CustomerGatewayCalleeMatchMode;
|
||||
businessPrefixIds?: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +94,10 @@ function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
function childId(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
@@ -86,6 +121,8 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
|
||||
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
await this.ensureLineGroupExists(input.lineGroupId);
|
||||
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -95,17 +132,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sourceIp: input.sourceIps?.[0] ?? null,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
lineGroupId: input.lineGroupId,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: new Prisma.Decimal(input.cycleRate),
|
||||
callerMatchMode: input.callerMatchMode,
|
||||
calleeMatchMode: input.calleeMatchMode,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
}
|
||||
});
|
||||
await this.replaceChildConfig(tx, created.id, input);
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed');
|
||||
return created;
|
||||
return this.findActiveOrThrowInTx(tx, created.id);
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
@@ -120,6 +162,12 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
if (input.customerId) {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
}
|
||||
if (input.lineGroupId) {
|
||||
await this.ensureLineGroupExists(input.lineGroupId);
|
||||
}
|
||||
if (input.businessPrefixIds) {
|
||||
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -129,17 +177,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sourceIp: input.sourceIps === undefined ? undefined : input.sourceIps[0] ?? null,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
lineGroupId: input.lineGroupId,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
|
||||
callerMatchMode: input.callerMatchMode,
|
||||
calleeMatchMode: input.calleeMatchMode,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
}
|
||||
});
|
||||
await this.replaceChildConfig(tx, updated.id, input);
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
|
||||
return updated;
|
||||
return this.findActiveOrThrowInTx(tx, updated.id);
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
@@ -181,6 +234,14 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
updatedBy: actorId
|
||||
}
|
||||
});
|
||||
await tx.customerGatewayIp.updateMany({
|
||||
where: { gatewayId: gatewayIdValue, deletedAt: null },
|
||||
data: {
|
||||
status: 'DISABLED',
|
||||
deletedAt: new Date(),
|
||||
updatedBy: actorId
|
||||
}
|
||||
});
|
||||
const deleted = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
@@ -224,6 +285,34 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureLineGroupExists(lineGroupId: string): Promise<void> {
|
||||
const lineGroup = await this.prisma.landingLineGroup.findUnique({
|
||||
where: { id: lineGroupId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
|
||||
if (!lineGroup || lineGroup.deletedAt) {
|
||||
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBusinessPrefixesExist(businessPrefixIds: string[]): Promise<void> {
|
||||
if (businessPrefixIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.prisma.businessPrefix.count({
|
||||
where: {
|
||||
id: { in: businessPrefixIds },
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
if (count !== new Set(businessPrefixIds).size) {
|
||||
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(gatewayIdValue: string) {
|
||||
const gateway = await this.prisma.customerGateway.findUnique({
|
||||
where: { id: gatewayIdValue },
|
||||
@@ -237,6 +326,74 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private async findActiveOrThrowInTx(tx: Prisma.TransactionClient, gatewayIdValue: string) {
|
||||
const gateway = await tx.customerGateway.findUnique({
|
||||
where: { id: gatewayIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
|
||||
}
|
||||
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private async replaceChildConfig(
|
||||
tx: Prisma.TransactionClient,
|
||||
gatewayIdValue: string,
|
||||
input: {
|
||||
sourceIps?: string[];
|
||||
callerPrefixes?: string[];
|
||||
businessPrefixIds?: string[];
|
||||
actorId?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (input.sourceIps !== undefined) {
|
||||
await tx.customerGatewayIp.deleteMany({ where: { gatewayId: gatewayIdValue } });
|
||||
if (input.sourceIps.length) {
|
||||
await tx.customerGatewayIp.createMany({
|
||||
data: input.sourceIps.map((sourceIp) => ({
|
||||
id: childId('cgip'),
|
||||
gatewayId: gatewayIdValue,
|
||||
sourceIp,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.callerPrefixes !== undefined) {
|
||||
await tx.customerGatewayCallerPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
|
||||
if (input.callerPrefixes.length) {
|
||||
await tx.customerGatewayCallerPrefix.createMany({
|
||||
data: input.callerPrefixes.map((prefix, index) => ({
|
||||
id: childId('cgcp'),
|
||||
gatewayId: gatewayIdValue,
|
||||
prefix,
|
||||
priority: (index + 1) * 10,
|
||||
createdBy: input.actorId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.businessPrefixIds !== undefined) {
|
||||
await tx.customerGatewayBusinessPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
|
||||
if (input.businessPrefixIds.length) {
|
||||
await tx.customerGatewayBusinessPrefix.createMany({
|
||||
data: input.businessPrefixIds.map((businessPrefixId) => ({
|
||||
id: childId('cgbp'),
|
||||
gatewayId: gatewayIdValue,
|
||||
businessPrefixId,
|
||||
createdBy: input.actorId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
customer: {
|
||||
@@ -245,6 +402,31 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
name: true
|
||||
}
|
||||
},
|
||||
lineGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
ips: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ createdAt: 'asc' }]
|
||||
},
|
||||
callerPrefixes: {
|
||||
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }]
|
||||
},
|
||||
businessPrefixes: {
|
||||
include: {
|
||||
businessPrefix: {
|
||||
select: {
|
||||
id: true,
|
||||
prefix: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }]
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
policies: {
|
||||
@@ -261,9 +443,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
ips: Array<{ sourceIp: string }>;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
sipHa1: string | null;
|
||||
lineGroupId: string | null;
|
||||
lineGroup: { name: string } | null;
|
||||
billingCycleSec: number;
|
||||
cycleRate: Prisma.Decimal;
|
||||
callerMatchMode: CustomerGatewayCallerMatchMode;
|
||||
callerPrefixes: Array<{ prefix: string }>;
|
||||
calleeMatchMode: CustomerGatewayCalleeMatchMode;
|
||||
businessPrefixes: Array<{ businessPrefix: CustomerGatewayBusinessPrefixSummary }>;
|
||||
status: CustomerGatewayStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -277,9 +468,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode,
|
||||
sourceIp: gateway.sourceIp,
|
||||
sourceIps: gateway.ips.length ? gateway.ips.map((item) => item.sourceIp) : gateway.sourceIp ? [gateway.sourceIp] : [],
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipDomain: gateway.sipDomain,
|
||||
hasSipCredential: Boolean(gateway.sipHa1),
|
||||
lineGroupId: gateway.lineGroupId,
|
||||
lineGroupName: gateway.lineGroup?.name ?? null,
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
callerMatchMode: gateway.callerMatchMode,
|
||||
callerPrefixes: gateway.callerPrefixes.map((item) => item.prefix),
|
||||
calleeMatchMode: gateway.calleeMatchMode,
|
||||
businessPrefixes: gateway.businessPrefixes.map((item) => item.businessPrefix),
|
||||
status: gateway.status,
|
||||
policyCount: gateway._count.policies,
|
||||
createdAt: gateway.createdAt,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
type CreateCustomerGatewayInput,
|
||||
type CustomerGatewayAuthMode,
|
||||
type CustomerGatewayCalleeMatchMode,
|
||||
type CustomerGatewayCallerMatchMode,
|
||||
type CustomerGatewaySummary,
|
||||
type CustomerGatewaysRepository,
|
||||
type UpdateCustomerGatewayInput
|
||||
@@ -15,9 +17,17 @@ interface CreateCustomerGatewayDto {
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sourceIps?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
lineGroupId?: unknown;
|
||||
billingCycleSec?: unknown;
|
||||
cycleRate?: unknown;
|
||||
callerMatchMode?: unknown;
|
||||
callerPrefixes?: unknown;
|
||||
calleeMatchMode?: unknown;
|
||||
businessPrefixIds?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateCustomerGatewayDto {
|
||||
@@ -25,9 +35,17 @@ interface UpdateCustomerGatewayDto {
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sourceIps?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
lineGroupId?: unknown;
|
||||
billingCycleSec?: unknown;
|
||||
cycleRate?: unknown;
|
||||
callerMatchMode?: unknown;
|
||||
callerPrefixes?: unknown;
|
||||
calleeMatchMode?: unknown;
|
||||
businessPrefixIds?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -47,14 +65,26 @@ export class CustomerGatewaysService {
|
||||
const authMode = this.authMode(body.authMode);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
|
||||
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
|
||||
const callerMatchMode = body.callerMatchMode === undefined ? 'ANY' : this.callerMatchMode(body.callerMatchMode);
|
||||
const calleeMatchMode = body.calleeMatchMode === undefined ? 'ANY' : this.calleeMatchMode(body.calleeMatchMode);
|
||||
const sourceIps = this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
|
||||
const callerPrefixes = this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
|
||||
const businessPrefixIds = this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
|
||||
const input: CreateCustomerGatewayInput = {
|
||||
customerId: this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: this.normalizeSourceIp(authMode, body.sourceIp),
|
||||
sourceIps,
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined,
|
||||
lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
|
||||
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
|
||||
callerMatchMode,
|
||||
callerPrefixes,
|
||||
calleeMatchMode,
|
||||
businessPrefixIds,
|
||||
actorId
|
||||
};
|
||||
|
||||
@@ -67,9 +97,19 @@ export class CustomerGatewaysService {
|
||||
const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120);
|
||||
const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain);
|
||||
const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45);
|
||||
const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp);
|
||||
const sourceIps =
|
||||
body.sourceIps === undefined && body.sourceIp === undefined
|
||||
? current.sourceIps
|
||||
: this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
|
||||
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
|
||||
const callerMatchMode = body.callerMatchMode === undefined ? current.callerMatchMode : this.callerMatchMode(body.callerMatchMode);
|
||||
const calleeMatchMode = body.calleeMatchMode === undefined ? current.calleeMatchMode : this.calleeMatchMode(body.calleeMatchMode);
|
||||
const callerPrefixes =
|
||||
body.callerPrefixes === undefined ? current.callerPrefixes : this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
|
||||
const businessPrefixIds =
|
||||
body.businessPrefixIds === undefined
|
||||
? current.businessPrefixes.map((item) => item.id)
|
||||
: this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
|
||||
|
||||
if (this.requiresSip(authMode)) {
|
||||
const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain;
|
||||
@@ -85,10 +125,18 @@ export class CustomerGatewaysService {
|
||||
customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: normalizedSourceIp,
|
||||
sourceIps,
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined,
|
||||
lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32),
|
||||
billingCycleSec:
|
||||
body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
|
||||
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
|
||||
callerMatchMode,
|
||||
callerPrefixes,
|
||||
calleeMatchMode,
|
||||
businessPrefixIds,
|
||||
actorId
|
||||
};
|
||||
|
||||
@@ -136,17 +184,26 @@ export class CustomerGatewaysService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null {
|
||||
private normalizeSourceIps(authMode: CustomerGatewayAuthMode, value: unknown): string[] {
|
||||
if (!this.requiresIp(authMode)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceIp = this.limitedString(value, 'sourceIp', 45);
|
||||
if (net.isIP(sourceIp) === 0) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' });
|
||||
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,,\s]+/) : [];
|
||||
const sourceIps = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
|
||||
if (sourceIps.length === 0) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_REQUIRED', message: 'At least one source IP is required.' });
|
||||
}
|
||||
if (sourceIps.length > 20) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_TOO_MANY', message: 'sourceIps supports up to 20 IP addresses.' });
|
||||
}
|
||||
for (const sourceIp of sourceIps) {
|
||||
if (sourceIp.length > 45 || net.isIP(sourceIp) === 0) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIps must be IPv4 or IPv6 addresses.' });
|
||||
}
|
||||
}
|
||||
|
||||
return sourceIp;
|
||||
return sourceIps;
|
||||
}
|
||||
|
||||
private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) {
|
||||
@@ -197,4 +254,76 @@ export class CustomerGatewaysService {
|
||||
private requiresSip(authMode: CustomerGatewayAuthMode): boolean {
|
||||
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
|
||||
}
|
||||
|
||||
private callerMatchMode(value: unknown): CustomerGatewayCallerMatchMode {
|
||||
if (value !== 'ANY' && value !== 'PREFIXES') {
|
||||
throw new BadRequestException({ code: 'CALLER_MATCH_MODE_INVALID', message: 'callerMatchMode is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private calleeMatchMode(value: unknown): CustomerGatewayCalleeMatchMode {
|
||||
if (value !== 'ANY' && value !== 'BUSINESS_PREFIXES') {
|
||||
throw new BadRequestException({ code: 'CALLEE_MATCH_MODE_INVALID', message: 'calleeMatchMode is invalid.' });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeCallerPrefixes(mode: CustomerGatewayCallerMatchMode, value: unknown): string[] {
|
||||
if (mode === 'ANY') {
|
||||
return [];
|
||||
}
|
||||
const prefixes = this.stringList(value, 'callerPrefixes', 20, 64);
|
||||
if (prefixes.length === 0) {
|
||||
throw new BadRequestException({ code: 'CALLER_PREFIX_REQUIRED', message: 'callerPrefixes is required.' });
|
||||
}
|
||||
for (const prefix of prefixes) {
|
||||
if (!/^[A-Za-z0-9+*#._-]+$/.test(prefix)) {
|
||||
throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' });
|
||||
}
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
private normalizeBusinessPrefixIds(mode: CustomerGatewayCalleeMatchMode, value: unknown): string[] {
|
||||
if (mode === 'ANY') {
|
||||
return [];
|
||||
}
|
||||
const ids = this.stringList(value, 'businessPrefixIds', 50, 32);
|
||||
if (ids.length === 0) {
|
||||
throw new BadRequestException({ code: 'BUSINESS_PREFIX_REQUIRED', message: 'businessPrefixIds is required.' });
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
|
||||
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,,\s]+/) : [];
|
||||
const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
|
||||
if (items.length > maxItems) {
|
||||
throw new BadRequestException({ code: 'LIST_TOO_LARGE', message: `${field} supports up to ${maxItems} items.` });
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} item is too long.` });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private integer(value: unknown, field: string, min: number, max: number): number {
|
||||
const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer between ${min} and ${max}.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
|
||||
}
|
||||
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
cpsLimit: 20,
|
||||
concurrencyLimit: 200,
|
||||
cycleRate: '0.120000',
|
||||
billingCycleSec: 60
|
||||
billingCycleSec: 60,
|
||||
landingCalleePrefix: '86',
|
||||
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -75,6 +77,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate,
|
||||
landingCalleePrefix: input.landingCalleePrefix ?? null,
|
||||
callerRewritePool: input.callerRewritePool.map((item, index) => ({ id: `caller_${index}`, ...item })),
|
||||
forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })),
|
||||
codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })),
|
||||
prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule }))
|
||||
@@ -100,6 +104,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
|
||||
cycleRate: input.cycleRate ?? current.cycleRate,
|
||||
landingCalleePrefix: input.landingCalleePrefix === undefined ? current.landingCalleePrefix : input.landingCalleePrefix,
|
||||
callerRewritePool:
|
||||
input.callerRewritePool === undefined ? current.callerRewritePool : input.callerRewritePool.map((item, index) => ({ id: `caller_updated_${index}`, ...item })),
|
||||
status: input.status ?? current.status,
|
||||
forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })),
|
||||
codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })),
|
||||
@@ -141,6 +148,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
billingCycleSec,
|
||||
cycleRate,
|
||||
minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6),
|
||||
landingCalleePrefix: input.landingCalleePrefix ?? null,
|
||||
callerRewritePool: input.callerRewritePool ?? [],
|
||||
status: input.status ?? 'ENABLED',
|
||||
forbiddenPeriods: input.forbiddenPeriods ?? [],
|
||||
codecs: input.codecs ?? [],
|
||||
@@ -226,7 +235,9 @@ describe('S16 vendor gateways API', () => {
|
||||
cpsLimit: 20,
|
||||
concurrencyLimit: 200,
|
||||
cycleRate: '0.120000',
|
||||
minuteRate: '0.120000'
|
||||
minuteRate: '0.120000',
|
||||
landingCalleePrefix: '86',
|
||||
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
|
||||
});
|
||||
@@ -256,6 +267,11 @@ describe('S16 vendor gateways API', () => {
|
||||
concurrencyLimit: 300,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.012',
|
||||
landingCalleePrefix: '86',
|
||||
callerRewritePool: [
|
||||
{ caller: '02160010001', weight: 80 },
|
||||
{ caller: '02160010002', weight: 20 }
|
||||
],
|
||||
forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }],
|
||||
codecs: [
|
||||
{ codec: 'PCMA', priority: 1 },
|
||||
@@ -272,8 +288,10 @@ describe('S16 vendor gateways API', () => {
|
||||
hasSipCredential: true,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: '0.012000',
|
||||
minuteRate: '0.120000'
|
||||
minuteRate: '0.120000',
|
||||
landingCalleePrefix: '86'
|
||||
});
|
||||
expect(response.body.callerRewritePool).toHaveLength(2);
|
||||
expect(response.body.sipHa1).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -283,8 +301,10 @@ describe('S16 vendor gateways API', () => {
|
||||
.send({
|
||||
authMode: 'IP',
|
||||
host: '203.0.113.10',
|
||||
landingCalleePrefix: 'ABC',
|
||||
callerRewritePool: [{ caller: '02170020001', weight: 100 }],
|
||||
codecs: [{ codec: 'G729', priority: 1 }],
|
||||
prefixRules: [{ direction: 'CALLER', matchPrefix: '+86', replacePrefix: '0', priority: 1 }]
|
||||
prefixRules: []
|
||||
})
|
||||
.expect(200)
|
||||
.expect((response) => {
|
||||
@@ -295,7 +315,9 @@ describe('S16 vendor gateways API', () => {
|
||||
hasSipCredential: false
|
||||
});
|
||||
expect(response.body.codecs).toHaveLength(1);
|
||||
expect(response.body.prefixRules[0]).toMatchObject({ direction: 'CALLER', matchPrefix: '+86' });
|
||||
expect(response.body.prefixRules).toHaveLength(0);
|
||||
expect(response.body).toMatchObject({ landingCalleePrefix: 'ABC' });
|
||||
expect(response.body.callerRewritePool[0]).toMatchObject({ caller: '02170020001', weight: 100 });
|
||||
});
|
||||
|
||||
expect(repository.outboxEvents).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface PrefixRuleSummary {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface CallerRewriteSummary {
|
||||
id: string;
|
||||
caller: string;
|
||||
weight: number;
|
||||
status: VendorGatewayStatus;
|
||||
}
|
||||
|
||||
export interface VendorGatewaySummary {
|
||||
id: string;
|
||||
vendorId: string;
|
||||
@@ -44,6 +51,8 @@ export interface VendorGatewaySummary {
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
minuteRate: string;
|
||||
landingCalleePrefix: string | null;
|
||||
callerRewritePool: CallerRewriteSummary[];
|
||||
status: VendorGatewayStatus;
|
||||
forbiddenPeriods: ForbiddenPeriodSummary[];
|
||||
codecs: CodecSummary[];
|
||||
@@ -70,6 +79,12 @@ export interface PrefixRuleInput {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface CallerRewriteInput {
|
||||
caller: string;
|
||||
weight: number;
|
||||
status: VendorGatewayStatus;
|
||||
}
|
||||
|
||||
export interface CreateVendorGatewayInput {
|
||||
vendorId: string;
|
||||
name: string;
|
||||
@@ -83,10 +98,12 @@ export interface CreateVendorGatewayInput {
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
landingCalleePrefix?: string | null;
|
||||
status: VendorGatewayStatus;
|
||||
forbiddenPeriods: ForbiddenPeriodInput[];
|
||||
codecs: CodecInput[];
|
||||
prefixRules: PrefixRuleInput[];
|
||||
callerRewritePool: CallerRewriteInput[];
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
@@ -110,7 +127,7 @@ function gatewayId(): string {
|
||||
}
|
||||
|
||||
function childId(prefix: string): string {
|
||||
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
@@ -156,12 +173,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: new Prisma.Decimal(input.cycleRate),
|
||||
landingCalleePrefix: input.landingCalleePrefix,
|
||||
status: input.status,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId,
|
||||
forbiddenPeriods: { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) },
|
||||
codecs: { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) },
|
||||
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) }
|
||||
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) },
|
||||
callerRewritePool: { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
@@ -192,6 +211,9 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
if (input.prefixRules) {
|
||||
await tx.vendorGatewayPrefixRule.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
|
||||
}
|
||||
if (input.callerRewritePool) {
|
||||
await tx.vendorGatewayCallerRewrite.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
|
||||
}
|
||||
|
||||
const updated = await tx.vendorGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
@@ -208,12 +230,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
concurrencyLimit: input.concurrencyLimit,
|
||||
billingCycleSec: input.billingCycleSec,
|
||||
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
|
||||
landingCalleePrefix: input.landingCalleePrefix,
|
||||
status: input.status,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 },
|
||||
forbiddenPeriods: input.forbiddenPeriods ? { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) } : undefined,
|
||||
codecs: input.codecs ? { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) } : undefined,
|
||||
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined
|
||||
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined,
|
||||
callerRewritePool: input.callerRewritePool ? { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) } : undefined
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
@@ -331,12 +355,24 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
};
|
||||
}
|
||||
|
||||
private callerRewriteCreate(item: CallerRewriteInput, actorId?: string) {
|
||||
return {
|
||||
id: childId('vgcr'),
|
||||
caller: item.caller,
|
||||
weight: item.weight,
|
||||
status: item.status,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId
|
||||
};
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
vendor: { select: { name: true } },
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
|
||||
callerRewritePool: { where: { deletedAt: null }, orderBy: [{ weight: 'desc' }, { caller: 'asc' }] }
|
||||
} satisfies Prisma.VendorGatewayInclude;
|
||||
}
|
||||
|
||||
@@ -366,6 +402,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: Prisma.Decimal;
|
||||
landingCalleePrefix: string | null;
|
||||
status: VendorGatewayStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -373,6 +410,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
forbiddenPeriods: Array<{ id: string; weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ id: string; codec: string; priority: number }>;
|
||||
prefixRules: Array<{ id: string; direction: PrefixDirection; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
callerRewritePool: Array<{ id: string; caller: string; weight: number; status: VendorGatewayStatus }>;
|
||||
}): VendorGatewaySummary {
|
||||
const minuteRate = gateway.cycleRate.mul(new Prisma.Decimal(60)).div(gateway.billingCycleSec);
|
||||
return {
|
||||
@@ -391,6 +429,8 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
minuteRate: minuteRate.toFixed(6),
|
||||
landingCalleePrefix: gateway.landingCalleePrefix,
|
||||
callerRewritePool: gateway.callerRewritePool,
|
||||
status: gateway.status,
|
||||
forbiddenPeriods: gateway.forbiddenPeriods,
|
||||
codecs: gateway.codecs,
|
||||
|
||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
||||
import {
|
||||
VENDOR_GATEWAYS_REPOSITORY,
|
||||
type CodecInput,
|
||||
type CallerRewriteInput,
|
||||
type CreateVendorGatewayInput,
|
||||
type ForbiddenPeriodInput,
|
||||
type PrefixDirection,
|
||||
@@ -28,6 +29,8 @@ interface VendorGatewayDto {
|
||||
concurrencyLimit?: unknown;
|
||||
billingCycleSec?: unknown;
|
||||
cycleRate?: unknown;
|
||||
landingCalleePrefix?: unknown;
|
||||
callerRewritePool?: unknown;
|
||||
status?: unknown;
|
||||
forbiddenPeriods?: unknown;
|
||||
codecs?: unknown;
|
||||
@@ -65,10 +68,12 @@ export class VendorGatewaysService {
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
|
||||
landingCalleePrefix: this.optionalLandingPrefix(body.landingCalleePrefix),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
forbiddenPeriods: this.forbiddenPeriods(body.forbiddenPeriods),
|
||||
codecs: this.codecs(body.codecs),
|
||||
prefixRules: this.prefixRules(body.prefixRules),
|
||||
callerRewritePool: this.callerRewritePool(body.callerRewritePool),
|
||||
actorId
|
||||
};
|
||||
return this.gateways.create(input);
|
||||
@@ -104,10 +109,12 @@ export class VendorGatewaysService {
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
|
||||
landingCalleePrefix: body.landingCalleePrefix === undefined ? undefined : this.optionalLandingPrefix(body.landingCalleePrefix),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
forbiddenPeriods: body.forbiddenPeriods === undefined ? undefined : this.forbiddenPeriods(body.forbiddenPeriods),
|
||||
codecs: body.codecs === undefined ? undefined : this.codecs(body.codecs),
|
||||
prefixRules: body.prefixRules === undefined ? undefined : this.prefixRules(body.prefixRules),
|
||||
callerRewritePool: body.callerRewritePool === undefined ? undefined : this.callerRewritePool(body.callerRewritePool),
|
||||
actorId
|
||||
};
|
||||
return this.gateways.update(gatewayId, input);
|
||||
@@ -316,6 +323,52 @@ export class VendorGatewaysService {
|
||||
return text;
|
||||
}
|
||||
|
||||
private optionalLandingPrefix(value: unknown): string | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const text = this.limitedString(value, 'landingCalleePrefix', 64);
|
||||
if (!/^[A-Za-z0-9]+$/.test(text)) {
|
||||
throw new BadRequestException({
|
||||
code: 'LANDING_CALLEE_PREFIX_INVALID',
|
||||
message: 'landingCalleePrefix must contain only letters or digits.'
|
||||
});
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private callerRewritePool(value: unknown): CallerRewriteInput[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_INVALID', message: 'callerRewritePool must be an array.' });
|
||||
}
|
||||
if (value.length > 50) {
|
||||
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_TOO_LARGE', message: 'callerRewritePool supports up to 50 items.' });
|
||||
}
|
||||
const pool = value
|
||||
.map((item, index) => {
|
||||
const record = item as Record<string, unknown>;
|
||||
return {
|
||||
caller: this.rewriteCaller(record.caller, `callerRewritePool[${index}].caller`),
|
||||
weight: this.integer(record.weight, `callerRewritePool[${index}].weight`, 1, 100000),
|
||||
status: record.status === undefined ? 'ENABLED' as const : this.status(record.status)
|
||||
};
|
||||
})
|
||||
.filter((item) => item.caller);
|
||||
this.ensureUnique(pool.map((item) => item.caller), 'CALLER_REWRITE_DUPLICATE', 'callerRewritePool callers must be unique.');
|
||||
return pool;
|
||||
}
|
||||
|
||||
private rewriteCaller(value: unknown, field: string): string {
|
||||
const text = this.limitedString(value, field, 64);
|
||||
if (!/^[A-Za-z0-9]+$/.test(text)) {
|
||||
throw new BadRequestException({ code: 'CALLER_REWRITE_INVALID', message: `${field} must contain only letters or digits.` });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private ensureUnique(values: string[], code: string, message: string): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new BadRequestException({ code, message });
|
||||
|
||||
+131
-3421
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,31 @@ async function request(path, options = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestBlob(path, options = {}) {
|
||||
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
|
||||
throw new ApiError(errorMessage(payload, response.status), {
|
||||
status: response.status,
|
||||
code: typeof payload === 'object' && payload ? payload.code : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
function errorMessage(payload, status) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
return payload.message || payload.error || `API request failed with HTTP ${status}.`;
|
||||
@@ -96,6 +121,17 @@ export const api = {
|
||||
activeCalls: () => request('/active-calls'),
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
|
||||
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
||||
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
||||
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
|
||||
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
|
||||
qualityRules: () => request('/quality/rules'),
|
||||
createQualityRule: (body) => request('/quality/rules', { method: 'POST', body: jsonBody(body) }),
|
||||
updateQualityRule: (id, body) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
customers: () => request('/customers'),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
@@ -115,10 +151,14 @@ export const api = {
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
customerGateways: () => request('/customer-gateways'),
|
||||
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
vendorGateways: () => request('/vendor-gateways'),
|
||||
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
@@ -130,6 +170,12 @@ export const api = {
|
||||
roles: () => request('/roles'),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
|
||||
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
||||
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`),
|
||||
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
|
||||
@@ -165,6 +211,12 @@ export function explainApiError(error) {
|
||||
if (error instanceof ApiError && error.code === 'BUILT_IN_ROLE_PROTECTED') {
|
||||
return '系统内置角色受保护,不能删除或修改关键权限。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_IN_USE') {
|
||||
return '该业务前缀仍被客户网关使用,不能删除。';
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'BUSINESS_PREFIX_INVALID') {
|
||||
return '业务前缀只能包含 1-32 位英文或数字。';
|
||||
}
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return '登录状态已失效,请重新登录。';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Alert, Badge, Button } from './ui.jsx';
|
||||
|
||||
const selectedBlue = '#2563EB';
|
||||
|
||||
export function toneForStatus(status) {
|
||||
if (['启用', '在线', '正常', '生效', '已计费', '完成', '已完成', '开启', '可试听', '成功'].includes(status)) return 'success';
|
||||
if (['观察', '抖动', '待质检', '处理中', '失败不计费'].includes(status)) return 'warning';
|
||||
if (['停用', '禁用', '暂停', '告警', '关闭', '失败'].includes(status)) return 'danger';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function Icon({ type }) {
|
||||
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||
const paths = {
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-3.4-3.4" /></>,
|
||||
export: <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>,
|
||||
reload: <><path d="M21 12a9 9 0 0 1-15 6.7" /><path d="M3 12a9 9 0 0 1 15-6.7" /><path d="M18 3v5h-5" /><path d="M6 21v-5h5" /></>,
|
||||
arrow: <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
|
||||
phoneOff: <><path d="M10.7 13.3a10.8 10.8 0 0 0 3.9 2.9" /><path d="M17 13.5l3 3v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 2.5 6.2 2 2 0 0 1 4.5 4h3l3 3-2 2a13 13 0 0 0 1.2 2.3" /><path d="M22 2 2 22" /></>,
|
||||
collapse: <><path d="M15 18l-6-6 6-6" /><path d="M20 4v16" /></>,
|
||||
expand: <><path d="M9 18l6-6-6-6" /><path d="M4 4v16" /></>,
|
||||
logout: <><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /><path d="M21 19V5a2 2 0 0 0-2-2h-5" /></>,
|
||||
};
|
||||
return <svg {...common}>{paths[type]}</svg>;
|
||||
}
|
||||
|
||||
export function PageTitle({ title, desc, actions }) {
|
||||
return (
|
||||
<div className="page-title">
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
<div className="page-actions">{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toolbar({ children }) {
|
||||
return <div className="toolbar">{children}</div>;
|
||||
}
|
||||
|
||||
export function Panel({ title, aside, children, className = '' }) {
|
||||
return (
|
||||
<section className={`prototype-panel ${className}`}>
|
||||
<div className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
{aside}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiNotice({ loading, error, onRetry }) {
|
||||
if (loading) {
|
||||
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据。</Alert>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert title="API 数据不可用" tone="warning">
|
||||
{error}
|
||||
{onRetry ? <Button size="sm" variant="outline" onClick={onRetry}>重试</Button> : null}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<strong>{title}</strong>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({ title, aside, children, onClose, size = 'lg' }) {
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<section className={`modal-dialog modal-${size}`} role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{aside}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
children,
|
||||
confirmLabel = '确认',
|
||||
confirmVariant = 'primary',
|
||||
busy = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
return (
|
||||
<Modal title={title} onClose={busy ? () => {} : onCancel} size="sm">
|
||||
<div className="confirm-copy">
|
||||
{typeof children === 'string' ? <p>{children}</p> : children}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={onCancel}>取消</Button>
|
||||
<Button type="button" variant={confirmVariant} disabled={busy} onClick={onConfirm}>
|
||||
{busy ? '处理中' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Drawer({ title, aside, children, onClose }) {
|
||||
return (
|
||||
<div className="drawer-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<aside className="drawer-panel" role="dialog" aria-modal="true" aria-label={title} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{aside}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>关闭</Button>
|
||||
</div>
|
||||
<div className="drawer-body">{children}</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ children }) {
|
||||
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
|
||||
}
|
||||
|
||||
export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
|
||||
return (
|
||||
<div className="table-shell">
|
||||
<table className="proto-table">
|
||||
{columns.some((column) => column.width) ? (
|
||||
<colgroup>
|
||||
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
|
||||
</colgroup>
|
||||
) : null}
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => <th key={column.key} className={column.className}>{column.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length}>
|
||||
<EmptyState />
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr
|
||||
key={row.id || row.callId || row.name || index}
|
||||
className={selectedKey && selectedKey === (row.id || row.callId) ? 'is-selected' : ''}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={column.className}>
|
||||
{column.status ? <StatusBadge>{row[column.key]}</StatusBadge> : column.render ? column.render(row) : row[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MiniBarChart({ data, color = selectedBlue }) {
|
||||
const max = Math.max(1, ...data);
|
||||
return (
|
||||
<div className="mini-chart" aria-label="趋势图">
|
||||
{data.map((value, index) => (
|
||||
<span key={index} style={{ height: `${Math.max(14, (value / max) * 100)}%`, background: color }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineChart({ data }) {
|
||||
const points = data.map((value, index) => `${(index / Math.max(1, data.length - 1)) * 100},${100 - value}`).join(' ');
|
||||
return (
|
||||
<svg className="line-chart" viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="接通率趋势">
|
||||
<polyline points={points} fill="none" stroke={selectedBlue} strokeWidth="3" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValue({ label, value }) {
|
||||
return (
|
||||
<div className="kv">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export const metrics = [
|
||||
{ label: '今日通话数', value: '128,640', delta: '+12.4%', tone: 'up' },
|
||||
{ label: '当前在线通话', value: '1,284', delta: '+86', tone: 'up' },
|
||||
{ label: '今日接通率', value: '92.8%', delta: '-1.1%', tone: 'down' },
|
||||
{ label: '客户消费', value: '¥86,420', delta: '+9.7%', tone: 'up' },
|
||||
{ label: '供应商成本', value: '¥52,180', delta: '+7.8%', tone: 'up' },
|
||||
{ label: '今日毛利', value: '¥34,240', delta: '39.6%', tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: '18,920', delta: '+312', tone: 'up' },
|
||||
{ label: 'RTPEngine 节点', value: '1/1', delta: '正常', tone: 'up' },
|
||||
{ label: 'OpenSIPS 节点', value: '1/1', delta: '单节点 V1', tone: 'neutral' },
|
||||
{ label: '异常网关', value: '2', delta: '需关注', tone: 'warn' },
|
||||
{ label: '质检待处理', value: '248', delta: 'P1', tone: 'warn' },
|
||||
];
|
||||
|
||||
export const callTrend = [82, 96, 74, 118, 136, 162, 148, 176, 158, 190, 214, 188];
|
||||
export const answerTrend = [90, 91, 88, 92, 94, 93, 91, 95, 94, 92, 93, 92];
|
||||
|
||||
export const customers = [
|
||||
{ id: 'C1001', name: '上海示例通信', contact: '张经理', phone: '13800010001', email: 'ops-sh@example.net', domain: 'sh-voice.example.net', auth: 'IP 白名单', status: '启用', balance: '¥56,820.40', credit: '¥200,000', billing: '预付费', routeGroup: '华东优先线路', gateways: 3, createdAt: '2026-05-18' },
|
||||
{ id: 'C1002', name: '杭州云呼叫中心', contact: '李主管', phone: '13900020002', email: 'contact-hz@example.net', domain: 'hz-call.example.net', auth: 'SIP 账号', status: '启用', balance: '¥18,445.80', credit: '¥80,000', billing: '后付费', routeGroup: '成本最低线路', gateways: 2, createdAt: '2026-05-22' },
|
||||
{ id: 'C1003', name: '深圳跨境业务部', contact: '王经理', phone: '13700030003', email: 'global-sz@example.net', domain: 'sz-global.example.net', auth: '混合认证', status: '观察', balance: '¥6,921.00', credit: '¥50,000', billing: '预付费', routeGroup: '国际备线池', gateways: 4, createdAt: '2026-06-01' },
|
||||
{ id: 'C1004', name: '北京金融外呼', contact: '赵经理', phone: '13600040004', email: 'bj-fin@example.net', domain: 'bj-fin.example.net', auth: 'IP 白名单', status: '停用', balance: '¥0.00', credit: '¥100,000', billing: '后付费', routeGroup: '合规外呼线路', gateways: 1, createdAt: '2026-06-08' },
|
||||
];
|
||||
|
||||
export const sipAccounts = [
|
||||
{ user: '10010001', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.21:5060', expires: '274s' },
|
||||
{ user: '10010002', domain: 'sh-voice.example.net', register: '在线', contact: '10.12.8.22:5060', expires: '280s' },
|
||||
{ user: '10010003', domain: 'sh-voice.example.net', register: '离线', contact: '-', expires: '-' },
|
||||
];
|
||||
|
||||
export const vendors = [
|
||||
{ id: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, status: '启用', cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' },
|
||||
{ id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, status: '启用', cycle: '周结', ratePlan: 'CN-LowCost-2026', contact: '王经理', createdAt: '2026-04-16' },
|
||||
{ id: 'V2003', name: '国际供应商 C', balance: '¥8,450.00', credit: '¥80,000', gateways: 3, status: '观察', cycle: '月结', ratePlan: 'Global-Std', contact: 'Lina', createdAt: '2026-05-03' },
|
||||
];
|
||||
|
||||
export const gateways = [
|
||||
{ id: 'GW-A-01', vendor: '供应商 A', name: '华东移动主用', authMode: 'IP', ipAddress: '203.0.113.18', sipAccount: '', sipPassword: '', concurrencyLimit: 800, billingCycle: 60, cycleRate: 0.031, requestRate: '120 CPS', blockedProvinces: '新疆、西藏', callTimeLimit: '08:00-22:00', codecs: 'PCMA, PCMU', calleePrefixTransform: '13/15/18 保持原样', callerPrefixTransform: '0216001* -> 0216001*', status: '启用' },
|
||||
{ id: 'GW-A-02', vendor: '供应商 A', name: '华东联通备用', authMode: 'IP', ipAddress: '203.0.113.19', sipAccount: '', sipPassword: '', concurrencyLimit: 500, billingCycle: 30, cycleRate: 0.017, requestRate: '80 CPS', blockedProvinces: '无', callTimeLimit: '00:00-23:59', codecs: 'PCMA', calleePrefixTransform: '021 保持原样', callerPrefixTransform: '0217002* -> 0217002*', status: '启用' },
|
||||
{ id: 'GW-B-01', vendor: '供应商 B', name: '成本最低路由', authMode: 'SIP注册', ipAddress: '', sipAccount: 'vendor-b-main', sipPassword: '******', concurrencyLimit: 360, billingCycle: 6, cycleRate: 0.0028, requestRate: '60 CPS', blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' },
|
||||
];
|
||||
|
||||
export const vendorLineGroups = [
|
||||
{ id: 'VLG-01', name: '华东主备线路组', gatewayIds: ['GW-A-01', 'GW-A-02'] },
|
||||
{ id: 'VLG-02', name: '低成本线路组', gatewayIds: ['GW-B-01', 'GW-A-02'] },
|
||||
{ id: 'VLG-03', name: '移动优先线路组', gatewayIds: ['GW-A-01'] },
|
||||
];
|
||||
|
||||
export const customerGatewayPolicies = [
|
||||
{ id: 'CGP-001', gateway: 'C-GW-SH-01', name: '上海移动外呼', callerMode: 'prefix', callerValue: '0216001', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 10, status: '启用' },
|
||||
{ id: 'CGP-002', gateway: 'C-GW-SH-01', name: '上海本地回访', callerMode: 'prefix', callerValue: '0217002', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 20, status: '启用' },
|
||||
{ id: 'CGP-003', gateway: 'C-GW-SH-01', name: '重点号码专线', callerMode: 'equals', callerValue: '02160010001', calleeMode: 'equals', calleeValue: '13800138000', routeGroup: '成本最低线路', priority: 30, status: '启用' },
|
||||
{ id: 'CGP-004', gateway: 'C-GW-SH-02', name: '固话回访', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '021', routeGroup: '华东优先线路', priority: 10, status: '启用' },
|
||||
{ id: 'CGP-005', gateway: 'C-GW-HZ-01', name: '浙江固话业务', callerMode: 'prefix', callerValue: '0571', calleeMode: 'prefix', calleeValue: '0571/0574', routeGroup: '成本最低线路', priority: 10, status: '启用' },
|
||||
{ id: 'CGP-006', gateway: 'C-GW-HZ-01', name: '杭州移动外呼', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '13/15/18', routeGroup: '华东优先线路', priority: 20, status: '启用' },
|
||||
{ id: 'CGP-007', gateway: 'C-GW-SZ-03', name: '国际直拨', callerMode: 'prefix', callerValue: '0755', calleeMode: 'prefix', calleeValue: '00', routeGroup: '国际备线池', priority: 10, status: '启用' },
|
||||
{ id: 'CGP-008', gateway: 'C-GW-SZ-03', name: '美国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '001', routeGroup: '国际备线池', priority: 20, status: '启用' },
|
||||
{ id: 'CGP-009', gateway: 'C-GW-SZ-03', name: '英国方向', callerMode: 'any', callerValue: '', calleeMode: 'prefix', calleeValue: '0044', routeGroup: '国际备线池', priority: 30, status: '启用' },
|
||||
{ id: 'CGP-010', gateway: 'C-GW-SZ-03', name: '指定客户号码', callerMode: 'equals', callerValue: '075588801111', calleeMode: 'any', calleeValue: '', routeGroup: '成本最低线路', priority: 40, status: '停用' },
|
||||
];
|
||||
|
||||
export const customerGateways = [
|
||||
{ id: 'C-GW-SH-01', name: '上海主接入网关', authMode: 'IP', ipAddress: '10.10.1.11', sipAccount: '', sipPassword: '', routePolicyCount: 3, status: '启用' },
|
||||
{ id: 'C-GW-SH-02', name: '上海固话回访入口', authMode: 'IP', ipAddress: '10.10.1.12', sipAccount: '', sipPassword: '', routePolicyCount: 1, status: '启用' },
|
||||
{ id: 'C-GW-HZ-01', name: '杭州坐席接入', authMode: 'SIP注册', ipAddress: '', sipAccount: 'hz-seat-1001', sipPassword: '******', routePolicyCount: 2, status: '启用' },
|
||||
{ id: 'C-GW-SZ-03', name: '深圳国际业务入口', authMode: 'SIP注册', ipAddress: '', sipAccount: 'sz-global-3003', sipPassword: '******', routePolicyCount: 4, status: '停用' },
|
||||
];
|
||||
|
||||
export const initialRechargeRecords = [
|
||||
{ id: 'RCG-20260618-003', type: 'customer', owner: '上海示例通信', amount: '¥10,000.00', beforeBalance: '¥46,820.40', afterBalance: '¥56,820.40', remark: '月初预存', operator: '财务', time: '2026-06-18 09:42:16', status: '成功' },
|
||||
{ id: 'RCG-20260617-012', type: 'customer', owner: '杭州云呼叫中心', amount: '¥5,000.00', beforeBalance: '¥13,445.80', afterBalance: '¥18,445.80', remark: '补充测试额度', operator: '运营管理员', time: '2026-06-17 16:18:33', status: '成功' },
|
||||
{ id: 'RCG-20260616-006', type: 'customer', owner: '深圳跨境业务部', amount: '¥2,000.00', beforeBalance: '¥4,921.00', afterBalance: '¥6,921.00', remark: '-', operator: '财务', time: '2026-06-16 11:07:25', status: '成功' },
|
||||
{ id: 'RCG-20260618-V02', type: 'vendor', owner: '供应商 A', amount: '¥20,000.00', beforeBalance: '¥22,860.00', afterBalance: '¥42,860.00', remark: '线路预存款', operator: '财务', time: '2026-06-18 10:22:43', status: '成功' },
|
||||
{ id: 'RCG-20260617-V01', type: 'vendor', owner: '国际供应商 C', amount: '¥5,000.00', beforeBalance: '¥3,450.00', afterBalance: '¥8,450.00', remark: '国际线路补款', operator: '运营管理员', time: '2026-06-17 15:39:08', status: '成功' },
|
||||
];
|
||||
|
||||
export const vendorGatewayPolicies = [
|
||||
{ vendor: '供应商 A', gateway: 'GW-A-01', caller: '0216001*', calleePrefix: '13/15/18', business: '国内移动成本线', vendorRate: 'A-Mobile-60/6', priority: 10, status: '启用' },
|
||||
{ vendor: '供应商 A', gateway: 'GW-A-02', caller: '0217002*', calleePrefix: '021', business: '上海固话成本线', vendorRate: 'A-SH-Local-6/6', priority: 20, status: '启用' },
|
||||
{ vendor: '供应商 B', gateway: 'GW-B-01', caller: '0571888*', calleePrefix: '0571', business: '杭州本地固话', vendorRate: 'B-HZ-Local-6/6', priority: 30, status: '观察' },
|
||||
{ vendor: '国际供应商 C', gateway: 'GW-C-01', caller: '0755888*', calleePrefix: '001/0044', business: '国际长途', vendorRate: 'C-Global-60/60', priority: 10, status: '启用' },
|
||||
];
|
||||
|
||||
export const routeGroups = [
|
||||
{ id: 'RG-01', name: '华东优先线路', customers: '上海示例通信', gateways: 'GW-A-01, GW-A-02', strategy: '按优先级', retry: '503/408 自动重试', status: '启用' },
|
||||
{ id: 'RG-02', name: '成本最低线路', customers: '杭州云呼叫中心', gateways: 'GW-B-01, GW-A-02', strategy: '按成本最低', retry: '失败换供应商', status: '启用' },
|
||||
{ id: 'RG-03', name: '国际备线池', customers: '深圳跨境业务部', gateways: 'GW-C-01', strategy: '按号码前缀', retry: '不重试', status: '观察' },
|
||||
];
|
||||
|
||||
export const routeRules = [
|
||||
{ id: 'DR-001', customer: '上海示例通信', prefix: '13,15,18', routeGroup: '华东优先线路', priority: 10, window: '00:00-23:59', status: '生效', remark: '国内移动优先' },
|
||||
{ id: 'DR-002', customer: '杭州云呼叫中心', prefix: '0571', routeGroup: '成本最低线路', priority: 20, window: '08:00-21:00', status: '生效', remark: '本地固话' },
|
||||
{ id: 'DR-003', customer: '深圳跨境业务部', prefix: '00', routeGroup: '国际备线池', priority: 30, window: '00:00-23:59', status: '暂停', remark: '国际前缀' },
|
||||
];
|
||||
|
||||
export const rates = [
|
||||
{ type: '客户费率', owner: '上海示例通信', business: '国内移动外呼', gateway: 'C-GW-SH-01', caller: '-', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.052/分钟', cycle: '60/6', first: '60s', start: '2026-06-01', end: '-' },
|
||||
{ type: '客户费率', owner: '杭州云呼叫中心', business: '浙江固话业务', gateway: 'C-GW-HZ-01', caller: '-', region: '中国大陆-固话', prefix: '0571', price: '¥0.038/分钟', cycle: '6/6', first: '6s', start: '2026-06-01', end: '-' },
|
||||
{ type: '供应商费率', owner: '供应商 A', business: '国内移动成本线', gateway: 'GW-A-01', caller: '0216001*', region: '中国大陆-移动', prefix: '13/15/18', price: '¥0.031/分钟', cycle: '60/6', first: '60s', start: '2026-05-15', end: '-' },
|
||||
{ type: '供应商费率', owner: '国际供应商 C', business: '国际长途', gateway: 'GW-C-01', caller: '0755888*', region: '美国', prefix: '001', price: '¥0.120/分钟', cycle: '60/60', first: '60s', start: '2026-05-21', end: '-' },
|
||||
];
|
||||
|
||||
export const opsItems = [
|
||||
{ name: 'OpenSIPS 主节点', target: '10.0.2.11:8080/mi', status: '正常', value: 'ps/get_statistics 可用' },
|
||||
{ name: 'RTPEngine 节点 A', target: '10.0.2.21:2223', status: '正常', value: '会话 824' },
|
||||
{ name: 'SIP 5060 监听', target: 'udp:0.0.0.0:5060', status: '正常', value: '监听中' },
|
||||
{ name: 'Billing Worker 堆积', target: 'billing.queue', status: '观察', value: '1,284 条' },
|
||||
{ name: '录音文件入库队列', target: 'recording.queue', status: '告警', value: '428 条' },
|
||||
];
|
||||
|
||||
export const auditLogs = [
|
||||
{ time: '2026-06-15 10:31:44', user: '运营管理员', action: '新增客户', object: '杭州云呼叫中心', ip: '10.1.8.32' },
|
||||
{ time: '2026-06-15 10:24:09', user: '财务', action: '修改费率', object: 'CN-Mobile-60/6', ip: '10.1.8.45' },
|
||||
{ time: '2026-06-15 09:58:51', user: '技术运维', action: 'dr_reload', object: 'OpenSIPS 主节点', ip: '10.1.8.88' },
|
||||
{ time: '2026-06-15 09:42:18', user: '质检', action: '下载录音', object: '8b4d-0977', ip: '10.1.8.62' },
|
||||
];
|
||||
|
||||
export const initialAdminUsers = [
|
||||
{ id: 'U1001', username: 'admin', name: '系统管理员', phone: '13800000001', email: 'admin@lisglosips.local', roleId: 'R001', status: '启用', lastLogin: '2026-06-15 10:36:12', lastIp: '10.1.8.10' },
|
||||
{ id: 'U1002', username: 'operator.li', name: '李运营', phone: '13800000012', email: 'operator@lisglosips.local', roleId: 'R002', status: '启用', lastLogin: '2026-06-15 10:31:44', lastIp: '10.1.8.32' },
|
||||
{ id: 'U1003', username: 'finance.zhou', name: '周财务', phone: '13800000023', email: 'finance@lisglosips.local', roleId: 'R003', status: '启用', lastLogin: '2026-06-15 10:24:09', lastIp: '10.1.8.45' },
|
||||
{ id: 'U1004', username: 'quality.wang', name: '王质检', phone: '13800000034', email: 'quality@lisglosips.local', roleId: 'R004', status: '启用', lastLogin: '2026-06-15 09:42:18', lastIp: '10.1.8.62' },
|
||||
{ id: 'U1005', username: 'ops.chen', name: '陈运维', phone: '13800000045', email: 'ops@lisglosips.local', roleId: 'R005', status: '禁用', lastLogin: '2026-06-14 22:18:09', lastIp: '10.1.8.88' },
|
||||
];
|
||||
|
||||
export const permissionGroups = [
|
||||
{ name: '客户与供应商', permissions: [{ key: 'customers.view', label: '查看客户' }, { key: 'customers.manage', label: '管理客户' }, { key: 'vendors.view', label: '查看供应商' }, { key: 'vendors.manage', label: '管理供应商' }] },
|
||||
{ name: '网关与路由', permissions: [{ key: 'customer_gateways.view', label: '查看客户网关' }, { key: 'customer_gateways.manage', label: '管理客户网关' }, { key: 'vendor_gateways.view', label: '查看落地网关' }, { key: 'vendor_gateways.manage', label: '管理落地网关' }, { key: 'line_groups.view', label: '查看落地线路组' }, { key: 'line_groups.manage', label: '管理落地线路组' }] },
|
||||
{ name: '计费与财务', permissions: [{ key: 'recharges.view', label: '查看充值记录' }, { key: 'recharges.manage', label: '客户/供应商充值' }] },
|
||||
{ name: '话单与质检', permissions: [{ key: 'active_calls.view', label: '查看当前通话' }, { key: 'active_calls.manage', label: '管理当前通话' }, { key: 'cdr.view', label: '查看话单' }, { key: 'quality.view', label: '查看质检' }, { key: 'quality.manage', label: '质检与评分' }, { key: 'recordings.play', label: '播放录音' }] },
|
||||
{ name: '号码库与监控', permissions: [{ key: 'number_library.view', label: '查看号码库' }, { key: 'number_library.manage', label: '管理号码库' }, { key: 'dashboard.view', label: '查看概览' }] },
|
||||
{ name: '系统管理', permissions: [{ key: 'users.view', label: '查看用户' }, { key: 'users.manage', label: '管理用户' }, { key: 'roles.view', label: '查看角色权限' }, { key: 'roles.manage', label: '管理角色权限' }, { key: 'audit.view', label: '查看操作日志' }] },
|
||||
];
|
||||
|
||||
export const allPermissionKeys = permissionGroups.flatMap((group) => group.permissions.map((permission) => permission.key));
|
||||
|
||||
export const initialRoles = [
|
||||
{ id: 'R001', name: '超级管理员', description: '拥有平台全部功能与数据权限', status: '启用', builtIn: true, permissions: allPermissionKeys },
|
||||
{ id: 'R002', name: '运营管理员', description: '负责客户、网关、线路与日常运营', status: '启用', builtIn: true, permissions: ['dashboard.view', 'customers.view', 'customers.manage', 'vendors.view', 'customer_gateways.view', 'customer_gateways.manage', 'vendor_gateways.view', 'line_groups.view', 'line_groups.manage', 'cdr.view'] },
|
||||
{ id: 'R003', name: '财务', description: '负责充值、账单、成本与导出', status: '启用', builtIn: true, permissions: ['dashboard.view', 'customers.view', 'vendors.view', 'recharges.view', 'recharges.manage', 'cdr.view'] },
|
||||
{ id: 'R004', name: '质检', description: '负责录音抽检、质检评分与报告', status: '启用', builtIn: true, permissions: ['customers.view', 'cdr.view', 'quality.view', 'quality.manage', 'recordings.play'] },
|
||||
{ id: 'R005', name: '技术运维', description: '负责 SIP 运维、监控和底层排障', status: '启用', builtIn: true, permissions: ['dashboard.view', 'active_calls.view', 'active_calls.manage', 'customer_gateways.view', 'vendor_gateways.view', 'line_groups.view', 'cdr.view', 'audit.view'] },
|
||||
];
|
||||
|
||||
export const operationLogRows = [
|
||||
{ id: 'LOG-20260615-001', time: '2026-06-15 10:31:44', user: '李运营', username: 'operator.li', module: '客户管理', action: '新增客户', object: '杭州云呼叫中心(C1002)', result: '成功', ip: '10.1.8.32', summary: '创建客户并初始化默认账户配置', userAgent: 'Chrome 137 / Windows 11' },
|
||||
{ id: 'LOG-20260615-002', time: '2026-06-15 10:24:09', user: '周财务', username: 'finance.zhou', module: '费率与计费', action: '修改费率', object: 'CN-Mobile-60/6', result: '成功', ip: '10.1.8.45', summary: '周期费率由 ¥0.036 调整为 ¥0.038', userAgent: 'Edge 137 / Windows 11' },
|
||||
{ id: 'LOG-20260615-003', time: '2026-06-15 09:58:51', user: '陈运维', username: 'ops.chen', module: 'SIP 运维', action: '重载路由', object: 'OpenSIPS 主节点', result: '成功', ip: '10.1.8.88', summary: '执行 dr_reload,动态路由表重载完成', userAgent: 'Chrome 136 / Windows 10' },
|
||||
{ id: 'LOG-20260615-004', time: '2026-06-15 09:42:18', user: '王质检', username: 'quality.wang', module: '质检中心', action: '播放录音', object: '8b4d-0977-202606150940', result: '成功', ip: '10.1.8.62', summary: '试听话单录音用于质检评分', userAgent: 'Chrome 137 / macOS 15' },
|
||||
{ id: 'LOG-20260615-005', time: '2026-06-15 09:17:03', user: '李运营', username: 'operator.li', module: '线路与路由', action: '删除策略', object: '夜间国际备用线路', result: '失败', ip: '10.1.8.32', summary: '策略仍被客户网关引用,系统拒绝删除', userAgent: 'Chrome 137 / Windows 11' },
|
||||
{ id: 'LOG-20260615-006', time: '2026-06-15 08:55:26', user: '系统管理员', username: 'admin', module: '用户管理', action: '禁用用户', object: 'ops.chen(U1005)', result: '成功', ip: '10.1.8.10', summary: '因账号交接临时禁用技术运维账号', userAgent: 'Chrome 137 / Windows 11' },
|
||||
{ id: 'LOG-20260614-007', time: '2026-06-14 22:18:09', user: '陈运维', username: 'ops.chen', module: '认证', action: '用户登录', object: 'LisgloSIPS 运营端', result: '成功', ip: '10.1.8.88', summary: '密码认证成功', userAgent: 'Chrome 136 / Windows 10' },
|
||||
];
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
|
||||
import { metrics } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
|
||||
const [busyId, setBusyId] = useState('');
|
||||
const [hangupTarget, setHangupTarget] = useState(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const canManage = can('active_calls.manage');
|
||||
|
||||
const rows = activeCalls.map((call) => ({
|
||||
...call,
|
||||
callerText: call.caller || '-',
|
||||
calleeText: call.callee || '-',
|
||||
callerIpText: call.callerIp || '-',
|
||||
landingIpText: call.landingIp || '-',
|
||||
stateText: call.state || '未知',
|
||||
startedText: formatDateTime(call.startedAt),
|
||||
durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec),
|
||||
}));
|
||||
|
||||
const hangup = async (call) => {
|
||||
setBusyId(call.id);
|
||||
try {
|
||||
await onHangupActiveCall(call.id);
|
||||
} finally {
|
||||
setBusyId('');
|
||||
setHangupTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
if (!activeCallsLoading) {
|
||||
void refreshActiveCalls();
|
||||
}
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="当前通话"
|
||||
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||
actions={(
|
||||
<div className="table-actions">
|
||||
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
|
||||
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
|
||||
</Button>
|
||||
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} />
|
||||
<section className="metric-grid active-call-metrics">
|
||||
<div className="metric-card">
|
||||
<span>当前通话数</span>
|
||||
<strong>{rows.length}</strong>
|
||||
<em className="metric-neutral">OpenSIPS MI</em>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span>最长通话</span>
|
||||
<strong>{rows.length ? rows[0].durationText : '00:00'}</strong>
|
||||
<em className="metric-neutral">按持续时长排序</em>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span>控制面</span>
|
||||
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
|
||||
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em>
|
||||
</div>
|
||||
</section>
|
||||
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} 路</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={rows} columns={[
|
||||
{ key: 'callId', label: 'Call-ID' },
|
||||
{ key: 'callerText', label: '主叫' },
|
||||
{ key: 'calleeText', label: '被叫' },
|
||||
{ key: 'callerIpText', label: '呼叫方 IP' },
|
||||
{ key: 'landingIpText', label: '落地 IP' },
|
||||
{ key: 'stateText', label: '状态', status: true },
|
||||
{ key: 'durationText', label: '持续时长' },
|
||||
{ key: 'startedText', label: '开始时间' },
|
||||
{
|
||||
key: 'action',
|
||||
label: '操作',
|
||||
render: (row) => canManage ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
icon={<Icon type="phoneOff" />}
|
||||
disabled={busyId === row.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setHangupTarget(row);
|
||||
}}
|
||||
>
|
||||
{busyId === row.id ? '挂断中' : '强制挂断'}
|
||||
</Button>
|
||||
) : '-'
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
{hangupTarget ? (
|
||||
<ConfirmDialog
|
||||
title="强制挂断确认"
|
||||
confirmLabel="强制挂断"
|
||||
confirmVariant="danger"
|
||||
busy={busyId === hangupTarget.id}
|
||||
onCancel={() => setHangupTarget(null)}
|
||||
onConfirm={() => void hangup(hangupTarget)}
|
||||
>
|
||||
<p>确认强制挂断当前通话?</p>
|
||||
<p className="muted-text">{hangupTarget.callId || hangupTarget.id}</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Alert, Button, Progress } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { rates } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function BillingPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="费率与计费" desc="客户费率、供应商费率、按业务映射匹配和 Billing Worker 计费链路。" actions={<Button icon={<Icon type="plus" />}>新增费率</Button>} />
|
||||
<section className="content-grid">
|
||||
<Panel title="客户/供应商费率" className="wide-panel">
|
||||
<SimpleTable rows={rates} columns={[
|
||||
{ key: 'type', label: '类型' },
|
||||
{ key: 'owner', label: '客户/供应商' },
|
||||
{ key: 'business', label: '业务名称' },
|
||||
{ key: 'gateway', label: '网关' },
|
||||
{ key: 'caller', label: '主叫号码/号段' },
|
||||
{ key: 'region', label: '国家/地区' },
|
||||
{ key: 'prefix', label: '号码前缀' },
|
||||
{ key: 'price', label: '单价' },
|
||||
{ key: 'cycle', label: '计费周期' },
|
||||
{ key: 'first', label: '首周期' },
|
||||
{ key: 'start', label: '生效时间' },
|
||||
{ key: 'end', label: '失效时间' },
|
||||
]} />
|
||||
</Panel>
|
||||
<Panel title="计费匹配维度">
|
||||
<div className="flow">
|
||||
{['客户', '客户网关', '来源 IP', '被叫前缀', '主叫号码', '业务类型', '供应商网关'].map((step) => <span key={step}>{step}</span>)}
|
||||
</div>
|
||||
<Alert title="计费说明">客户侧按客户网关 + 来源 IP + 被叫前缀匹配业务与费率;供应商侧按供应商网关 + 被叫前缀或主叫号码匹配成本费率。</Alert>
|
||||
</Panel>
|
||||
<Panel title="Billing Worker 流程">
|
||||
<div className="flow">
|
||||
{['读取未计费 CDR', '识别客户业务', '匹配客户费率', '匹配供应商业务', '匹配供应商费率', '计算费用/成本/毛利', '写入已计费话单', '更新余额/授信'].map((step) => <span key={step}>{step}</span>)}
|
||||
</div>
|
||||
<div className="formula">计费秒数 = ceil(实际通话秒数 / 计费周期) * 计费周期</div>
|
||||
</Panel>
|
||||
<Panel title="Worker 队列">
|
||||
<KeyValue label="未计费 CDR" value="1,284 条" />
|
||||
<Progress value={64} />
|
||||
<KeyValue label="最近计费时间" value="2026-06-15 10:31:52" />
|
||||
<KeyValue label="失败重试" value="12 条" />
|
||||
</Panel>
|
||||
<Panel title="预付费余额实时控制">
|
||||
<div className="balance-control">
|
||||
<KeyValue label="呼叫前检查" value="客户状态、余额、授信、外呼时段" />
|
||||
<KeyValue label="通话中占用" value="按最大可通话时长冻结余额" />
|
||||
<KeyValue label="计费后更新" value="扣减余额或更新授信占用" />
|
||||
<Progress value={72} />
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { enStatus } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
export function BusinessPrefixesPage({ can = () => true }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [editingPrefix, setEditingPrefix] = useState(undefined);
|
||||
const [prefixForm, setPrefixForm] = useState(emptyBusinessPrefixForm);
|
||||
const [statusTarget, setStatusTarget] = useState(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const canManage = can('customer_gateways.manage');
|
||||
|
||||
const loadPrefixes = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await api.businessPrefixes(filters);
|
||||
setRows((Array.isArray(payload) ? payload : []).map(normalizeBusinessPrefix));
|
||||
} catch (loadError) {
|
||||
setError(explainApiError(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadPrefixes();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingPrefix(null);
|
||||
setPrefixForm(emptyBusinessPrefixForm);
|
||||
setError('');
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setEditingPrefix(row);
|
||||
setPrefixForm({
|
||||
prefix: row.prefix,
|
||||
name: row.name,
|
||||
description: row.description === '-' ? '' : row.description,
|
||||
priority: row.priority,
|
||||
status: enStatus(row.status),
|
||||
});
|
||||
setError('');
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setEditingPrefix(undefined);
|
||||
setPrefixForm(emptyBusinessPrefixForm);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const submitPrefix = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = {
|
||||
prefix: prefixForm.prefix.trim(),
|
||||
name: prefixForm.name.trim(),
|
||||
description: prefixForm.description.trim() || null,
|
||||
priority: Number(prefixForm.priority),
|
||||
status: prefixForm.status,
|
||||
};
|
||||
if (editingPrefix) {
|
||||
await api.updateBusinessPrefix(editingPrefix.id, body);
|
||||
setMessage(`业务前缀「${body.prefix}」已更新。`);
|
||||
} else {
|
||||
await api.createBusinessPrefix(body);
|
||||
setMessage(`业务前缀「${body.prefix}」已创建。`);
|
||||
}
|
||||
closeForm();
|
||||
await loadPrefixes();
|
||||
} catch (submitError) {
|
||||
setError(explainApiError(submitError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (row) => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
if (row.status === '启用') {
|
||||
await api.disableBusinessPrefix(row.id);
|
||||
} else {
|
||||
await api.enableBusinessPrefix(row.id);
|
||||
}
|
||||
setStatusTarget(null);
|
||||
await loadPrefixes();
|
||||
} catch (toggleError) {
|
||||
setError(explainApiError(toggleError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePrefix = async (row) => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.deleteBusinessPrefix(row.id);
|
||||
setDeleteTarget(null);
|
||||
await loadPrefixes();
|
||||
} catch (deleteError) {
|
||||
setError(explainApiError(deleteError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formOpen = editingPrefix !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="业务前缀管理"
|
||||
desc="维护客户呼入被叫号码前置的业务标识,后续客户网关按 IP、主叫规则和业务前缀识别归属。"
|
||||
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Panel
|
||||
title="业务前缀"
|
||||
className="wide-panel"
|
||||
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={loadPrefixes}>刷新</Button>}
|
||||
>
|
||||
<Toolbar>
|
||||
<Field label="关键字">
|
||||
<Input value={filters.keyword} onChange={(event) => setFilters({ ...filters, keyword: event.target.value })} placeholder="前缀或名称" />
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<Select value={filters.status} onChange={(event) => setFilters({ ...filters, status: event.target.value })}>
|
||||
<option value="all">全部</option>
|
||||
<option value="ENABLED">启用</option>
|
||||
<option value="DISABLED">停用</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
|
||||
</Toolbar>
|
||||
<SimpleTable rows={rows} columns={[
|
||||
{ key: 'prefix', label: '业务前缀', width: '120px' },
|
||||
{ key: 'name', label: '名称', width: '160px' },
|
||||
{ key: 'priority', label: '优先级', width: '90px' },
|
||||
{ key: 'gatewayCount', label: '使用客户网关数', width: '140px' },
|
||||
{ key: 'status', label: '状态', width: '90px', status: true },
|
||||
{ key: 'description', label: '备注' },
|
||||
{ key: 'createdAt', label: '创建日期', width: '120px' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '220px',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => openEdit(row)}>编辑</Button> : null}
|
||||
{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setStatusTarget(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}
|
||||
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>删除</Button> : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
{formOpen ? (
|
||||
<Modal title={editingPrefix ? '编辑业务前缀' : '新增业务前缀'} onClose={submitting ? () => {} : closeForm} size="sm">
|
||||
<form className="modal-form" onSubmit={submitPrefix}>
|
||||
<Field label={<span>业务前缀 <span className="required-star">*</span></span>}>
|
||||
<Input value={prefixForm.prefix} onChange={(event) => setPrefixForm({ ...prefixForm, prefix: event.target.value })} placeholder="如 671" required />
|
||||
</Field>
|
||||
<Field label={<span>名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={prefixForm.name} onChange={(event) => setPrefixForm({ ...prefixForm, name: event.target.value })} placeholder="如 国内移动业务" required />
|
||||
</Field>
|
||||
<Field label="优先级">
|
||||
<Input type="number" min="1" max="9999" value={prefixForm.priority} onChange={(event) => setPrefixForm({ ...prefixForm, priority: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<Select value={prefixForm.status} onChange={(event) => setPrefixForm({ ...prefixForm, status: event.target.value })}>
|
||||
<option value="ENABLED">启用</option>
|
||||
<option value="DISABLED">停用</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Textarea rows={3} value={prefixForm.description} onChange={(event) => setPrefixForm({ ...prefixForm, description: event.target.value })} />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" disabled={submitting} onClick={closeForm}>取消</Button>
|
||||
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{statusTarget ? (
|
||||
<ConfirmDialog
|
||||
title={`${statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀确认`}
|
||||
confirmLabel={`确认${statusTarget.status === '启用' ? '禁用' : '启用'}`}
|
||||
busy={submitting}
|
||||
onCancel={() => setStatusTarget(null)}
|
||||
onConfirm={() => void toggleStatus(statusTarget)}
|
||||
>
|
||||
<p>确认{statusTarget.status === '启用' ? '禁用' : '启用'}业务前缀「{statusTarget.prefix}」吗?</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除业务前缀确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
busy={submitting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => void deletePrefix(deleteTarget)}
|
||||
>
|
||||
<p>确认删除业务前缀「{deleteTarget.prefix}」吗?删除后不能再被客户网关选择。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const numberLibraryTabs = [
|
||||
{ value: 'cities', label: '地级市字典' },
|
||||
{ value: 'phoneSegments', label: '手机号码库' },
|
||||
{ value: 'areaCodes', label: '城市区号' },
|
||||
{ value: 'carrierPrefixRules', label: '运营商号码段规则' },
|
||||
];
|
||||
|
||||
const numberLibraryImportExamples = {
|
||||
cities: [
|
||||
{ code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
|
||||
],
|
||||
phoneSegments: [
|
||||
{ segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
|
||||
],
|
||||
areaCodes: [
|
||||
{ areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
|
||||
],
|
||||
carrierPrefixRules: [
|
||||
{ prefix: '138', carrier: 'MOBILE', priority: 100 },
|
||||
],
|
||||
};
|
||||
|
||||
const emptyNumberLibraryRows = {
|
||||
cities: [],
|
||||
phoneSegments: [],
|
||||
areaCodes: [],
|
||||
carrierPrefixRules: [],
|
||||
};
|
||||
|
||||
const emptyNumberLibraryTotals = {
|
||||
cities: 0,
|
||||
phoneSegments: 0,
|
||||
areaCodes: 0,
|
||||
carrierPrefixRules: 0,
|
||||
};
|
||||
|
||||
function normalizeNumberLibraryList(payload, mapItem) {
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
return {
|
||||
rows: items.map(mapItem),
|
||||
total: payload?.total ?? items.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
||||
const [detailCdr, setDetailCdr] = useState(null);
|
||||
const [cdrRows, setCdrRows] = useState([]);
|
||||
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false });
|
||||
const [cdrLoading, setCdrLoading] = useState(false);
|
||||
const [cdrError, setCdrError] = useState('');
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [playbackLoading, setPlaybackLoading] = useState(false);
|
||||
const [playbackError, setPlaybackError] = useState('');
|
||||
const [playbackUrl, setPlaybackUrl] = useState('');
|
||||
const [signalOpen, setSignalOpen] = useState(false);
|
||||
const canPlayRecordings = can('recordings.play');
|
||||
const [filters, setFilters] = useState({
|
||||
caller: '',
|
||||
callee: '',
|
||||
customerGatewayId: 'all',
|
||||
vendorGatewayId: 'all',
|
||||
cityCode: '',
|
||||
carrier: 'all',
|
||||
startedFrom: '',
|
||||
startedTo: '',
|
||||
take: '50',
|
||||
skip: 0,
|
||||
});
|
||||
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
|
||||
const ratingStatusLabel = (value) => ({
|
||||
UNRATED: '未计费',
|
||||
RATED: '已计费',
|
||||
SKIPPED: '跳过计费',
|
||||
FAILED: '计费失败',
|
||||
}[value] || value || '-');
|
||||
const normalizeCdr = (cdr) => {
|
||||
const location = cdr.calleeCityName && cdr.calleeCityName !== 'UNKNOWN'
|
||||
? `${cdr.calleeProvinceName || '-'} / ${cdr.calleeCityName}`
|
||||
: '-';
|
||||
const vendorGatewayHost = cdr.vendorGateway?.host || cdr.vendorGatewayHost;
|
||||
const vendorGatewayPort = cdr.vendorGateway?.port || cdr.vendorGatewayPort;
|
||||
const customerGatewayName = cdr.customerGateway?.name || cdr.customerGatewayName || cdr.customerGatewayId || '-';
|
||||
const vendorGatewayName = cdr.vendorGateway?.name || cdr.vendorGatewayName || cdr.vendorGatewayId || '-';
|
||||
const recording = cdr.recording || null;
|
||||
const rated = cdr.rated || null;
|
||||
return {
|
||||
...cdr,
|
||||
id: cdr.id,
|
||||
customerName: cdr.customer?.name || cdr.customerName || cdr.customerId || '-',
|
||||
vendorName: cdr.vendor?.name || cdr.vendorName || cdr.vendorId || '-',
|
||||
lineGroupName: cdr.lineGroup?.name || cdr.lineGroupName || cdr.lineGroupId || '-',
|
||||
customerGatewayName,
|
||||
callIp: cdr.sourceIp || '-',
|
||||
vendorGatewayName,
|
||||
lineIp: vendorGatewayHost ? `${vendorGatewayHost}:${vendorGatewayPort || 5060}` : '-',
|
||||
rawCalleeText: cdr.rawCallee || '-',
|
||||
businessPrefixText: cdr.businessPrefix && cdr.businessPrefix !== 'none' ? cdr.businessPrefix : '-',
|
||||
landingCallerText: cdr.landingCaller || '-',
|
||||
landingCalleeText: cdr.landingCallee || '-',
|
||||
callTime: formatDateTime(cdr.startedAt),
|
||||
connectedTime: formatDateTime(cdr.answeredAt),
|
||||
endTime: formatDateTime(cdr.endedAt),
|
||||
durationText: formatDurationText(cdr.durationSec),
|
||||
customerFee: money(cdr.customerFee),
|
||||
costFee: money(cdr.vendorCost),
|
||||
grossProfit: money(cdr.grossProfit),
|
||||
customerRateText: rated?.customerRate ? JSON.stringify(rated.customerRate) : '-',
|
||||
vendorRateText: rated?.vendorRate ? JSON.stringify(rated.vendorRate) : '-',
|
||||
billSecText: cdr.billSec === null || cdr.billSec === undefined ? '-' : `${cdr.billSec}s`,
|
||||
ratedAtText: formatDateTime(rated?.ratedAt),
|
||||
location,
|
||||
operatorText: carrierLabel(cdr.calleeOperator),
|
||||
numberTypeText: cdr.calleeNumberType || '-',
|
||||
hangupReason: cdr.hangupReason || '-',
|
||||
sipCodeText: String(cdr.sipCode),
|
||||
ratingStatusText: ratingStatusLabel(cdr.ratingStatus),
|
||||
recordingId: cdr.recordingId || recording?.id || null,
|
||||
recordingStatus: cdr.recordingStatus || recording?.status || null,
|
||||
recordingText: cdr.hasRecording || recording ? zhStatus(cdr.recordingStatus || recording?.status || 'READY') : '无录音',
|
||||
recordingKeyText: recording?.storageKey || cdr.recordingKey || '-',
|
||||
recordingSizeText: recording?.bytes ? `${(Number(recording.bytes) / 1024 / 1024).toFixed(2)} MB` : '-',
|
||||
traceText: JSON.stringify({
|
||||
callId: cdr.callId,
|
||||
eventId: cdr.eventId,
|
||||
sourceIp: cdr.sourceIp,
|
||||
customerGatewayId: cdr.customerGatewayId,
|
||||
vendorGatewayId: cdr.vendorGatewayId,
|
||||
configVersion: cdr.configVersion,
|
||||
payload: cdr.payload || null,
|
||||
}, null, 2),
|
||||
};
|
||||
};
|
||||
const queryParams = (nextFilters = filters) => ({
|
||||
caller: nextFilters.caller.trim(),
|
||||
callee: nextFilters.callee.trim(),
|
||||
customerGatewayId: nextFilters.customerGatewayId,
|
||||
vendorGatewayId: nextFilters.vendorGatewayId,
|
||||
cityCode: nextFilters.cityCode.trim(),
|
||||
carrier: nextFilters.carrier,
|
||||
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '',
|
||||
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
|
||||
take: nextFilters.take,
|
||||
skip: String(nextFilters.skip),
|
||||
});
|
||||
const loadCdrs = async (nextFilters = filters) => {
|
||||
setCdrLoading(true);
|
||||
setCdrError('');
|
||||
try {
|
||||
const response = await api.cdrs(queryParams(nextFilters));
|
||||
setCdrRows((response.items || []).map(normalizeCdr));
|
||||
setCdrMeta(response.meta || { total: response.total ?? 0, take: Number(nextFilters.take), skip: nextFilters.skip, hasMore: false });
|
||||
} catch (error) {
|
||||
setCdrError(explainApiError(error));
|
||||
} finally {
|
||||
setCdrLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void loadCdrs();
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (playbackUrl) {
|
||||
URL.revokeObjectURL(playbackUrl);
|
||||
}
|
||||
}, [playbackUrl]);
|
||||
const updateFilter = (key, value) => setFilters((current) => ({ ...current, [key]: value }));
|
||||
const searchCdrs = () => {
|
||||
const nextFilters = { ...filters, skip: 0 };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const changePage = (direction) => {
|
||||
const take = Number(filters.take) || 50;
|
||||
const nextSkip = Math.max(0, filters.skip + direction * take);
|
||||
const nextFilters = { ...filters, skip: nextSkip };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const openCdrDetail = async (row) => {
|
||||
setDetailCdr(row);
|
||||
setDetailError('');
|
||||
setSignalOpen(false);
|
||||
setPlaybackError('');
|
||||
setPlaybackLoading(false);
|
||||
if (playbackUrl) {
|
||||
URL.revokeObjectURL(playbackUrl);
|
||||
setPlaybackUrl('');
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await api.cdrDetail(row.id);
|
||||
setDetailCdr(normalizeCdr(detail));
|
||||
} catch (error) {
|
||||
setDetailError(explainApiError(error));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
const closeCdrDetail = () => {
|
||||
setDetailCdr(null);
|
||||
setDetailError('');
|
||||
setSignalOpen(false);
|
||||
setPlaybackError('');
|
||||
setPlaybackLoading(false);
|
||||
if (playbackUrl) {
|
||||
URL.revokeObjectURL(playbackUrl);
|
||||
setPlaybackUrl('');
|
||||
}
|
||||
};
|
||||
const loadRecordingPlayback = async () => {
|
||||
if (!detailCdr?.recordingId || playbackLoading) return;
|
||||
setPlaybackLoading(true);
|
||||
setPlaybackError('');
|
||||
try {
|
||||
const blob = await api.recordingPlayback(detailCdr.recordingId);
|
||||
if (playbackUrl) {
|
||||
URL.revokeObjectURL(playbackUrl);
|
||||
}
|
||||
setPlaybackUrl(URL.createObjectURL(blob));
|
||||
} catch (error) {
|
||||
setPlaybackError(explainApiError(error));
|
||||
} finally {
|
||||
setPlaybackLoading(false);
|
||||
}
|
||||
};
|
||||
const pageStart = cdrMeta.total ? cdrMeta.skip + 1 : 0;
|
||||
const pageEnd = Math.min(cdrMeta.total, cdrMeta.skip + cdrRows.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="话单中心"
|
||||
desc="查询客户网关到落地网关的通话记录、费用、录音与信令详情。"
|
||||
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
|
||||
/>
|
||||
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
|
||||
<Toolbar>
|
||||
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
|
||||
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
|
||||
<Field label="客户网关">
|
||||
<Select value={filters.customerGatewayId} onChange={(event) => updateFilter('customerGatewayId', event.target.value)}>
|
||||
<option value="all">全部客户网关</option>
|
||||
{customerGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.customer} / {gateway.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="落地网关">
|
||||
<Select value={filters.vendorGatewayId} onChange={(event) => updateFilter('vendorGatewayId', event.target.value)}>
|
||||
<option value="all">全部落地网关</option>
|
||||
{vendorGatewayRows.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="地级市代码"><Input value={filters.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
|
||||
<Field label="运营商">
|
||||
<Select value={filters.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
|
||||
<option value="all">全部</option>
|
||||
<option value="MOBILE">移动</option>
|
||||
<option value="UNICOM">联通</option>
|
||||
<option value="TELECOM">电信</option>
|
||||
<option value="BROADCAST">广电</option>
|
||||
<option value="MVNO">虚拟运营商</option>
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field>
|
||||
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
|
||||
<Field label="每页">
|
||||
<Select value={filters.take} onChange={(event) => {
|
||||
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
}}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} disabled={cdrLoading} onClick={searchCdrs}>查询</Button>
|
||||
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} 条</Badge>}>
|
||||
<SimpleTable rows={cdrRows} columns={[
|
||||
{ key: 'caller', label: '主叫号码' },
|
||||
{ key: 'callee', label: '被叫号码' },
|
||||
{ key: 'location', label: '地级市' },
|
||||
{ key: 'operatorText', label: '运营商' },
|
||||
{ key: 'customerGatewayName', label: '客户网关名称' },
|
||||
{ key: 'callIp', label: '呼叫IP地址' },
|
||||
{ key: 'vendorGatewayName', label: '落地网关名称' },
|
||||
{ key: 'lineIp', label: '线路IP地址' },
|
||||
{ key: 'callTime', label: '呼叫时间' },
|
||||
{ key: 'durationText', label: '通话时长' },
|
||||
{ key: 'ratingStatusText', label: '计费状态', status: true },
|
||||
{ key: 'recordingText', label: '录音', status: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => void openCdrDetail(row)}>详情</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
<div className="drawer-toolbar">
|
||||
<div>
|
||||
<strong>{pageStart}-{pageEnd}</strong>
|
||||
<span>共 {cdrMeta.total} 条,按呼叫时间倒序。</span>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
<Button variant="outline" disabled={cdrLoading || cdrMeta.skip <= 0} onClick={() => changePage(-1)}>上一页</Button>
|
||||
<Button variant="outline" disabled={cdrLoading || !cdrMeta.hasMore} onClick={() => changePage(1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
{detailCdr ? (
|
||||
<Drawer title="话单详情" aside={<Badge tone="info">{detailCdr.callId}</Badge>} onClose={closeCdrDetail}>
|
||||
{detailLoading ? <Alert title="正在读取话单详情">正在加载计费、录音和关联对象字段。</Alert> : null}
|
||||
{detailError ? <Alert title="话单详情读取失败" tone="warning">{detailError}</Alert> : null}
|
||||
<div className="cdr-detail">
|
||||
<section className="cdr-detail-hero">
|
||||
<div>
|
||||
<span>主叫</span>
|
||||
<strong>{detailCdr.caller}</strong>
|
||||
</div>
|
||||
<Icon type="arrow" />
|
||||
<div>
|
||||
<span>被叫</span>
|
||||
<strong>{detailCdr.callee}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cdr-detail-strip">
|
||||
<KeyValue label="通话时长" value={detailCdr.durationText} />
|
||||
<KeyValue label="挂断原因" value={detailCdr.hangupReason} />
|
||||
<KeyValue label="地级市" value={detailCdr.location} />
|
||||
<KeyValue label="运营商" value={detailCdr.operatorText} />
|
||||
<KeyValue label="客户费用" value={detailCdr.customerFee} />
|
||||
<KeyValue label="成本费用" value={detailCdr.costFee} />
|
||||
<KeyValue label="毛利" value={detailCdr.grossProfit} />
|
||||
<KeyValue label="计费秒数" value={detailCdr.billSecText} />
|
||||
</section>
|
||||
|
||||
<section className="cdr-detail-section">
|
||||
<h3>链路信息</h3>
|
||||
<div className="cdr-detail-grid">
|
||||
<KeyValue label="客户网关名称" value={detailCdr.customerGatewayName} />
|
||||
<KeyValue label="呼叫 IP 地址" value={detailCdr.callIp} />
|
||||
<KeyValue label="落地网关名称" value={detailCdr.vendorGatewayName} />
|
||||
<KeyValue label="线路 IP 地址" value={detailCdr.lineIp} />
|
||||
<KeyValue label="原始被叫" value={detailCdr.rawCalleeText} />
|
||||
<KeyValue label="业务前缀" value={detailCdr.businessPrefixText} />
|
||||
<KeyValue label="落地主叫" value={detailCdr.landingCallerText} />
|
||||
<KeyValue label="落地被叫" value={detailCdr.landingCalleeText} />
|
||||
<KeyValue label="号码类型" value={detailCdr.numberTypeText} />
|
||||
<KeyValue label="SIP 状态码" value={detailCdr.sipCodeText} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cdr-detail-section">
|
||||
<h3>关联对象</h3>
|
||||
<div className="cdr-detail-grid">
|
||||
<KeyValue label="客户" value={detailCdr.customerName} />
|
||||
<KeyValue label="供应商" value={detailCdr.vendorName} />
|
||||
<KeyValue label="客户策略" value={detailCdr.customerGatewayPolicy?.name || detailCdr.customerGatewayPolicyId || '-'} />
|
||||
<KeyValue label="落地线路组" value={detailCdr.lineGroupName} />
|
||||
<KeyValue label="业务前缀名称" value={detailCdr.businessPrefixRef?.name || '-'} />
|
||||
<KeyValue label="配置版本" value={detailCdr.configVersion || '-'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cdr-detail-section">
|
||||
<h3>计费结果</h3>
|
||||
<div className="cdr-detail-grid">
|
||||
<KeyValue label="状态" value={detailCdr.ratingStatusText} />
|
||||
<KeyValue label="计费时间" value={detailCdr.ratedAtText} />
|
||||
<KeyValue label="客户费率" value={detailCdr.customerRateText} />
|
||||
<KeyValue label="供应商费率" value={detailCdr.vendorRateText} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="cdr-detail-section">
|
||||
<h3>时间轴</h3>
|
||||
<div className="cdr-timeline">
|
||||
<KeyValue label="呼叫时间" value={detailCdr.callTime} />
|
||||
<KeyValue label="接通时间" value={detailCdr.connectedTime} />
|
||||
<KeyValue label="结束时间" value={detailCdr.endTime} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="drawer-toolbar">
|
||||
<div>
|
||||
<strong>录音与信令</strong>
|
||||
<span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : detailCdr.recordingId ? detailCdr.recordingKeyText : '当前话单没有可播放录音。'}</span>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
{canPlayRecordings ? <Button variant="secondary" disabled={!detailCdr.recordingId || playbackLoading} onClick={loadRecordingPlayback}>
|
||||
{playbackLoading ? '读取录音' : playbackUrl ? '重新读取' : '录音播放'}
|
||||
</Button> : null}
|
||||
<Button variant={signalOpen ? 'secondary' : 'outline'} onClick={() => setSignalOpen((open) => !open)}>信令入口</Button>
|
||||
</div>
|
||||
</div>
|
||||
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
|
||||
{playbackUrl ? (
|
||||
<div className="cdr-detail-section">
|
||||
<h3>录音播放</h3>
|
||||
<audio className="recording-player" src={playbackUrl} controls preload="metadata" />
|
||||
<div className="cdr-detail-grid">
|
||||
<KeyValue label="录音状态" value={detailCdr.recordingText} />
|
||||
<KeyValue label="文件大小" value={detailCdr.recordingSizeText} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{signalOpen ? (
|
||||
<section className="cdr-detail-section">
|
||||
<h3>信令索引</h3>
|
||||
<pre className="trace-box">{detailCdr.traceText}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
</Drawer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
|
||||
import { authModeLabel } from '../utils/formatters.js';
|
||||
import { customers, customerGatewayPolicies, customerGateways, routeGroups } from '../fixtures/devFixtures.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, customerRows: apiCustomerRows, lineGroupRows: apiLineGroupRows, apiLoading, apiError, refreshApi, can = () => true, onCreateGateway, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
|
||||
const [localGatewayRows, setLocalGatewayRows] = useState(customerGateways);
|
||||
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
|
||||
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
|
||||
const customerOptions = apiCustomerRows?.length ? apiCustomerRows : customers;
|
||||
const lineGroupOptions = apiLineGroupRows?.length ? apiLineGroupRows : [];
|
||||
const [businessPrefixOptions, setBusinessPrefixOptions] = useState([]);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [policyRows, setPolicyRows] = useState(customerGatewayPolicies);
|
||||
const [strategyGateway, setStrategyGateway] = useState(null);
|
||||
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
|
||||
const [editingGateway, setEditingGateway] = useState(null);
|
||||
const [policyModalOpen, setPolicyModalOpen] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState(null);
|
||||
const [deletePolicyTarget, setDeletePolicyTarget] = useState(null);
|
||||
const [gatewayConfirm, setGatewayConfirm] = useState(null);
|
||||
const emptyGatewayForm = { customerId: '', name: '', authMode: 'IP', sourceIps: '', sipAccount: '', sipDomain: 'lisglosips.local', sipPassword: '', lineGroupId: '', billingCycleSec: 60, cycleRate: '0.000000', callerMatchMode: 'ANY', callerPrefixes: '', calleeMatchMode: 'ANY', businessPrefixIds: [] };
|
||||
const emptyPolicyForm = { name: '', callerMode: 'any', callerValue: '', calleeMode: 'any', calleeValue: '', routeGroup: routeGroups[0].name };
|
||||
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
|
||||
const [policyForm, setPolicyForm] = useState(emptyPolicyForm);
|
||||
const canManage = can('customer_gateways.manage');
|
||||
const strategyGatewayData = gatewayRows.find((item) => item.id === strategyGateway);
|
||||
const strategyPolicies = strategyGateway
|
||||
? policyRows.filter((item) => item.gateway === strategyGateway).sort((first, second) => first.priority - second.priority)
|
||||
: [];
|
||||
useEffect(() => {
|
||||
api.businessPrefixes({ status: 'ENABLED' })
|
||||
.then((items) => setBusinessPrefixOptions(Array.isArray(items) ? items : []))
|
||||
.catch((error) => setLocalError(explainApiError(error)));
|
||||
}, []);
|
||||
const formatMatch = (mode, value) => {
|
||||
if (mode === 'equals') return `等于 ${value}`;
|
||||
if (mode === 'prefix') return `前缀 ${value}`;
|
||||
return '不限';
|
||||
};
|
||||
const openCreateGateway = () => {
|
||||
setEditingGateway(null);
|
||||
setGatewayForm({ ...emptyGatewayForm, customerId: customerOptions[0]?.id || '', lineGroupId: lineGroupOptions[0]?.id || '' });
|
||||
setLocalError('');
|
||||
setGatewayModalOpen(true);
|
||||
};
|
||||
const openEditGateway = (gateway) => {
|
||||
setEditingGateway(gateway);
|
||||
setGatewayForm({
|
||||
customerId: gateway.customerId || '',
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode === '混合认证' ? 'MIXED' : gateway.authMode,
|
||||
sourceIps: (gateway.sourceIps?.length ? gateway.sourceIps : gateway.ipAddress ? [gateway.ipAddress] : []).join('\n'),
|
||||
sipAccount: gateway.sipAccount || '',
|
||||
sipDomain: gateway.sipDomain || 'lisglosips.local',
|
||||
sipPassword: '',
|
||||
lineGroupId: gateway.lineGroupId || '',
|
||||
billingCycleSec: gateway.billingCycleSec ?? 60,
|
||||
cycleRate: String(gateway.cycleRate ?? '0.000000'),
|
||||
callerMatchMode: gateway.callerMatchMode || 'ANY',
|
||||
callerPrefixes: (gateway.callerPrefixes || []).join('\n'),
|
||||
calleeMatchMode: gateway.calleeMatchMode || 'ANY',
|
||||
businessPrefixIds: (gateway.businessPrefixes || []).map((item) => item.id),
|
||||
});
|
||||
setLocalError('');
|
||||
setGatewayModalOpen(true);
|
||||
};
|
||||
const closeGatewayModal = () => {
|
||||
setGatewayModalOpen(false);
|
||||
setEditingGateway(null);
|
||||
setGatewayForm(emptyGatewayForm);
|
||||
setSubmitting(false);
|
||||
};
|
||||
const submitGateway = async (event) => {
|
||||
event.preventDefault();
|
||||
const name = gatewayForm.name.trim();
|
||||
if (!gatewayForm.customerId || !name || !gatewayForm.lineGroupId) return;
|
||||
const authMode = gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode;
|
||||
const body = {
|
||||
customerId: gatewayForm.customerId,
|
||||
name,
|
||||
authMode,
|
||||
sourceIps: gatewayForm.sourceIps.split(/[\n,,\s]+/).map((item) => item.trim()).filter(Boolean),
|
||||
sipUsername: gatewayForm.sipAccount.trim() || undefined,
|
||||
sipDomain: gatewayForm.sipDomain.trim() || undefined,
|
||||
lineGroupId: gatewayForm.lineGroupId,
|
||||
billingCycleSec: Number(gatewayForm.billingCycleSec),
|
||||
cycleRate: String(gatewayForm.cycleRate || '0'),
|
||||
callerMatchMode: gatewayForm.callerMatchMode,
|
||||
callerPrefixes: gatewayForm.callerPrefixes.split(/[\n,,\s]+/).map((item) => item.trim()).filter(Boolean),
|
||||
calleeMatchMode: gatewayForm.calleeMatchMode,
|
||||
businessPrefixIds: gatewayForm.businessPrefixIds,
|
||||
};
|
||||
if (!editingGateway || gatewayForm.sipPassword.trim()) {
|
||||
body.sipPassword = gatewayForm.sipPassword.trim();
|
||||
}
|
||||
setSubmitting(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
if (editingGateway && onUpdateGateway) {
|
||||
await onUpdateGateway(editingGateway.id, body);
|
||||
} else if (!editingGateway && onCreateGateway) {
|
||||
await onCreateGateway(body);
|
||||
} else {
|
||||
const nextGateway = {
|
||||
id: editingGateway?.id || `C-GW-${Date.now()}`,
|
||||
customerId: body.customerId,
|
||||
customer: customerOptions.find((item) => item.id === body.customerId)?.name || '-',
|
||||
name,
|
||||
authMode: authModeLabel(authMode),
|
||||
sourceIps: body.sourceIps,
|
||||
ipAddress: body.sourceIps[0] || '',
|
||||
sipAccount: body.sipUsername || '',
|
||||
sipDomain: body.sipDomain || '',
|
||||
lineGroupId: body.lineGroupId,
|
||||
lineGroupName: lineGroupOptions.find((item) => item.id === body.lineGroupId)?.name || '-',
|
||||
billingCycleSec: body.billingCycleSec,
|
||||
cycleRate: Number(body.cycleRate || 0),
|
||||
callerMatchMode: body.callerMatchMode,
|
||||
callerPrefixes: body.callerPrefixes,
|
||||
calleeMatchMode: body.calleeMatchMode,
|
||||
businessPrefixes: businessPrefixOptions.filter((item) => body.businessPrefixIds.includes(item.id)),
|
||||
routePolicyCount: editingGateway?.routePolicyCount ?? 0,
|
||||
status: editingGateway?.status ?? '启用',
|
||||
};
|
||||
setGatewayRows((rows) => (editingGateway ? rows.map((gateway) => (gateway.id === editingGateway.id ? nextGateway : gateway)) : [...rows, nextGateway]));
|
||||
}
|
||||
closeGatewayModal();
|
||||
} catch (error) {
|
||||
setLocalError(explainApiError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const toggleGatewayStatus = async (gateway) => {
|
||||
setSubmitting(true);
|
||||
setLocalError('');
|
||||
if (onToggleGatewayStatus) {
|
||||
try {
|
||||
await onToggleGatewayStatus(gateway);
|
||||
} catch (error) {
|
||||
setLocalError(explainApiError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setGatewayRows((rows) => rows.map((item) => (
|
||||
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item
|
||||
)));
|
||||
setSubmitting(false);
|
||||
};
|
||||
const deleteGateway = async (gateway) => {
|
||||
setSubmitting(true);
|
||||
setLocalError('');
|
||||
if (onDeleteGateway) {
|
||||
try {
|
||||
await onDeleteGateway(gateway.id);
|
||||
} catch (error) {
|
||||
setLocalError(explainApiError(error));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
|
||||
setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id));
|
||||
setSubmitting(false);
|
||||
};
|
||||
const openPolicyDrawer = (gatewayId) => {
|
||||
setStrategyGateway(gatewayId);
|
||||
setPolicyModalOpen(false);
|
||||
setEditingPolicy(null);
|
||||
setDeletePolicyTarget(null);
|
||||
setPolicyForm(emptyPolicyForm);
|
||||
};
|
||||
const closePolicyDrawer = () => {
|
||||
setStrategyGateway(null);
|
||||
setPolicyModalOpen(false);
|
||||
setEditingPolicy(null);
|
||||
setDeletePolicyTarget(null);
|
||||
setPolicyForm(emptyPolicyForm);
|
||||
};
|
||||
const openCreatePolicy = () => {
|
||||
setEditingPolicy(null);
|
||||
setPolicyForm(emptyPolicyForm);
|
||||
setPolicyModalOpen(true);
|
||||
};
|
||||
const openEditPolicy = (policy) => {
|
||||
setEditingPolicy(policy);
|
||||
setPolicyForm({
|
||||
name: policy.name,
|
||||
callerMode: policy.callerMode,
|
||||
callerValue: policy.callerValue,
|
||||
calleeMode: policy.calleeMode,
|
||||
calleeValue: policy.calleeValue,
|
||||
routeGroup: policy.routeGroup,
|
||||
});
|
||||
setPolicyModalOpen(true);
|
||||
};
|
||||
const closePolicyModal = () => {
|
||||
setPolicyModalOpen(false);
|
||||
setEditingPolicy(null);
|
||||
setPolicyForm(emptyPolicyForm);
|
||||
};
|
||||
const submitPolicy = (event) => {
|
||||
event.preventDefault();
|
||||
if (!strategyGatewayData || !policyForm.name.trim()) return;
|
||||
const callerValue = policyForm.callerValue.trim();
|
||||
const calleeValue = policyForm.calleeValue.trim();
|
||||
const callerValid = policyForm.callerMode === 'any' || callerValue;
|
||||
const calleeValid = policyForm.calleeMode === 'any' || calleeValue;
|
||||
if (!callerValid || !calleeValid || (policyForm.callerMode === 'any' && policyForm.calleeMode === 'any')) return;
|
||||
const nextPriority = strategyPolicies.length ? Math.max(...strategyPolicies.map((policy) => policy.priority)) + 10 : 10;
|
||||
const nextPolicy = {
|
||||
name: policyForm.name.trim(),
|
||||
callerMode: policyForm.callerMode,
|
||||
callerValue: policyForm.callerMode === 'any' ? '' : callerValue,
|
||||
calleeMode: policyForm.calleeMode,
|
||||
calleeValue: policyForm.calleeMode === 'any' ? '' : calleeValue,
|
||||
routeGroup: policyForm.routeGroup,
|
||||
};
|
||||
if (editingPolicy) {
|
||||
setPolicyRows((rows) => rows.map((policy) => (
|
||||
policy.id === editingPolicy.id ? { ...policy, ...nextPolicy } : policy
|
||||
)));
|
||||
} else {
|
||||
setPolicyRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
...nextPolicy,
|
||||
id: `CGP-${Date.now()}`,
|
||||
gateway: strategyGatewayData.id,
|
||||
priority: nextPriority,
|
||||
status: '启用',
|
||||
},
|
||||
]);
|
||||
setGatewayRows((rows) => rows.map((gateway) => (
|
||||
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: gateway.routePolicyCount + 1 } : gateway
|
||||
)));
|
||||
}
|
||||
closePolicyModal();
|
||||
};
|
||||
const renumberPolicies = (policies) => policies.map((policy, index) => ({ ...policy, priority: (index + 1) * 10 }));
|
||||
const movePolicy = (policyId, direction) => {
|
||||
if (!strategyGatewayData) return;
|
||||
const ordered = [...strategyPolicies];
|
||||
const currentIndex = ordered.findIndex((policy) => policy.id === policyId);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ordered.length) return;
|
||||
[ordered[currentIndex], ordered[nextIndex]] = [ordered[nextIndex], ordered[currentIndex]];
|
||||
const movedPolicies = renumberPolicies(ordered);
|
||||
setPolicyRows((rows) => rows.map((policy) => (
|
||||
policy.gateway === strategyGatewayData.id
|
||||
? movedPolicies.find((item) => item.id === policy.id) || policy
|
||||
: policy
|
||||
)));
|
||||
};
|
||||
const togglePolicyStatus = (policyId) => {
|
||||
setPolicyRows((rows) => rows.map((policy) => (
|
||||
policy.id === policyId ? { ...policy, status: policy.status === '启用' ? '停用' : '启用' } : policy
|
||||
)));
|
||||
};
|
||||
const deletePolicy = (policyId) => {
|
||||
if (!strategyGatewayData) return;
|
||||
setPolicyRows((rows) => renumberPolicies(rows.filter((policy) => policy.gateway === strategyGatewayData.id && policy.id !== policyId))
|
||||
.concat(rows.filter((policy) => policy.gateway !== strategyGatewayData.id)));
|
||||
setGatewayRows((rows) => rows.map((gateway) => (
|
||||
gateway.id === strategyGatewayData.id ? { ...gateway, routePolicyCount: Math.max(0, gateway.routePolicyCount - 1) } : gateway
|
||||
)));
|
||||
setDeletePolicyTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="客户网关管理"
|
||||
desc="独立管理客户接入网关、多 IP、单落地线路组、客户侧费率和主被叫匹配规则。"
|
||||
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateGateway}>新增客户网关</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
|
||||
<section className="content-grid">
|
||||
<Panel title="客户网关列表" className="wide-panel">
|
||||
<SimpleTable rows={gatewayRows} columns={[
|
||||
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'customer', label: '客户', width: '148px', className: 'table-cell-compact' },
|
||||
{ key: 'authMode', label: '认证方式' },
|
||||
{ key: 'authTarget', label: 'IP地址 / 账号名称', render: (row) => (row.authMode === 'IP' || row.authMode === '混合认证' ? (row.sourceIps || []).join(', ') || row.ipAddress : row.sipAccount) },
|
||||
{ key: 'lineGroupName', label: '落地线路组' },
|
||||
{ key: 'rate', label: '客户费率', render: (row) => `${row.billingCycleSec || 60}s / ¥${Number(row.cycleRate || 0).toFixed(6)}` },
|
||||
{ key: 'callerRule', label: '主叫规则', render: (row) => row.callerMatchMode === 'PREFIXES' ? (row.callerPrefixes || []).join(', ') : '任意号码' },
|
||||
{ key: 'calleeRule', label: '被叫规则', render: (row) => row.calleeMatchMode === 'BUSINESS_PREFIXES' ? (row.businessPrefixes || []).map((item) => item.prefix).join(', ') : '任意号码' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditGateway(row)}>编辑</Button> : null}
|
||||
{canManage ? (
|
||||
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setGatewayConfirm({ type: 'toggle', row })}>
|
||||
{row.status === '启用' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? <Button size="sm" variant="danger" onClick={() => setGatewayConfirm({ type: 'delete', row })}>删除</Button> : null}
|
||||
{!canManage ? '-' : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
{gatewayModalOpen ? (
|
||||
<Modal title={editingGateway ? '编辑客户网关' : '新增客户网关'} onClose={submitting ? () => {} : closeGatewayModal} size="lg">
|
||||
<form className="modal-form" onSubmit={submitGateway}>
|
||||
<Field label={<span>所属客户 <span className="required-star">*</span></span>}>
|
||||
<Select value={gatewayForm.customerId} onChange={(event) => setGatewayForm({ ...gatewayForm, customerId: event.target.value })} required>
|
||||
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={<span>名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.name} onChange={(event) => setGatewayForm({ ...gatewayForm, name: event.target.value })} placeholder="请输入客户网关名称" required />
|
||||
</Field>
|
||||
<Field label={<span>认证方式 <span className="required-star">*</span></span>}>
|
||||
<Select value={gatewayForm.authMode} onChange={(event) => setGatewayForm({ ...gatewayForm, authMode: event.target.value, sourceIps: '', sipAccount: '', sipPassword: '' })} required>
|
||||
<option>IP</option>
|
||||
<option>SIP注册</option>
|
||||
<option value="MIXED">混合认证</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{gatewayForm.authMode === 'IP' || gatewayForm.authMode === 'MIXED' ? (
|
||||
<Field label={<span>客户网关 IP <span className="required-star">*</span></span>}>
|
||||
<Textarea rows={3} value={gatewayForm.sourceIps} onChange={(event) => setGatewayForm({ ...gatewayForm, sourceIps: event.target.value })} placeholder="每行一个 IP,例如 10.10.1.11" required />
|
||||
</Field>
|
||||
) : null}
|
||||
{gatewayForm.authMode === 'SIP注册' || gatewayForm.authMode === 'MIXED' ? (
|
||||
<>
|
||||
<Field label={<span>SIP账号 <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.sipAccount} onChange={(event) => setGatewayForm({ ...gatewayForm, sipAccount: event.target.value })} placeholder="请输入 SIP 账号" required />
|
||||
</Field>
|
||||
<Field label={<span>SIP域 <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.sipDomain} onChange={(event) => setGatewayForm({ ...gatewayForm, sipDomain: event.target.value })} placeholder="例如 lisglosips.local" required />
|
||||
</Field>
|
||||
<Field label={editingGateway ? 'SIP密码(留空不修改)' : <span>SIP密码 <span className="required-star">*</span></span>}>
|
||||
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" required={!editingGateway} />
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
<Field label={<span>落地线路组 <span className="required-star">*</span></span>}>
|
||||
<Select value={gatewayForm.lineGroupId} onChange={(event) => setGatewayForm({ ...gatewayForm, lineGroupId: event.target.value })} required>
|
||||
<option value="">请选择落地线路组</option>
|
||||
{lineGroupOptions.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="计费周期(秒)">
|
||||
<Input type="number" min="1" value={gatewayForm.billingCycleSec} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycleSec: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="周期内费率">
|
||||
<Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" />
|
||||
</Field>
|
||||
<Field label="主叫匹配">
|
||||
<Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}>
|
||||
<option value="ANY">任意号码</option>
|
||||
<option value="PREFIXES">指定前缀</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{gatewayForm.callerMatchMode === 'PREFIXES' ? (
|
||||
<Field label="主叫前缀">
|
||||
<Textarea rows={3} value={gatewayForm.callerPrefixes} onChange={(event) => setGatewayForm({ ...gatewayForm, callerPrefixes: event.target.value })} placeholder="每行一个前缀" />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="被叫业务前缀">
|
||||
<Select value={gatewayForm.calleeMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, calleeMatchMode: event.target.value, businessPrefixIds: [] })}>
|
||||
<option value="ANY">任意号码</option>
|
||||
<option value="BUSINESS_PREFIXES">指定业务前缀</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{gatewayForm.calleeMatchMode === 'BUSINESS_PREFIXES' ? (
|
||||
<div className="checkbox-grid">
|
||||
{businessPrefixOptions.map((prefix) => (
|
||||
<Checkbox
|
||||
key={prefix.id}
|
||||
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
|
||||
onChange={(checked) => setGatewayForm((current) => ({
|
||||
...current,
|
||||
businessPrefixIds: checked
|
||||
? [...current.businessPrefixIds, prefix.id]
|
||||
: current.businessPrefixIds.filter((id) => id !== prefix.id),
|
||||
}))}
|
||||
>
|
||||
{prefix.prefix} / {prefix.name}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" disabled={submitting} onClick={closeGatewayModal}>取消</Button>
|
||||
<Button type="submit" disabled={submitting}>{submitting ? '保存中' : editingGateway ? '保存修改' : '保存网关'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{gatewayConfirm ? (
|
||||
<ConfirmDialog
|
||||
title={gatewayConfirm.type === 'delete' ? '删除客户网关确认' : `${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关确认`}
|
||||
confirmLabel={gatewayConfirm.type === 'delete' ? '确认删除' : `确认${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}`}
|
||||
confirmVariant={gatewayConfirm.type === 'delete' ? 'danger' : 'primary'}
|
||||
busy={submitting}
|
||||
onCancel={() => setGatewayConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const { type, row } = gatewayConfirm;
|
||||
setGatewayConfirm(null);
|
||||
return type === 'delete' ? void deleteGateway(row) : void toggleGatewayStatus(row);
|
||||
}}
|
||||
>
|
||||
{gatewayConfirm.type === 'delete' ? (
|
||||
<p>确认删除客户网关「{gatewayConfirm.row.name}」吗?删除后该网关不会再参与客户呼入匹配。</p>
|
||||
) : (
|
||||
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}客户网关「{gatewayConfirm.row.name}」吗?</p>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
{strategyGatewayData ? (
|
||||
<Drawer title={`${strategyGatewayData.name} 策略配置`} aside={<Badge tone="info">{strategyGatewayData.id}</Badge>} onClose={closePolicyDrawer}>
|
||||
<div className="drawer-toolbar">
|
||||
<div>
|
||||
<strong>{strategyPolicies.length} 条策略</strong>
|
||||
<span>按优先级从小到大匹配,主叫和被叫条件可并存。</span>
|
||||
</div>
|
||||
{canManage ? <Button icon={<Icon type="plus" />} onClick={openCreatePolicy}>添加策略</Button> : null}
|
||||
</div>
|
||||
<SimpleTable rows={strategyPolicies} columns={[
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'name', label: '策略名称' },
|
||||
{ key: 'callerMatch', label: '主叫匹配', render: (row) => formatMatch(row.callerMode, row.callerValue) },
|
||||
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => formatMatch(row.calleeMode, row.calleeValue) },
|
||||
{ key: 'routeGroup', label: '呼叫至线路群组' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditPolicy(row)}>编辑</Button> : null}
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, -1)}>上移</Button> : null}
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => movePolicy(row.id, 1)}>下移</Button> : null}
|
||||
{canManage ? (
|
||||
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => togglePolicyStatus(row.id)}>
|
||||
{row.status === '启用' ? '停用' : '启用'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeletePolicyTarget(row)}>删除</Button> : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Drawer>
|
||||
) : null}
|
||||
{policyModalOpen ? (
|
||||
<Modal title={editingPolicy ? '编辑策略' : '添加策略'} onClose={closePolicyModal} size="lg">
|
||||
<form className="strategy-form" onSubmit={submitPolicy}>
|
||||
<Field label={<span>策略名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={policyForm.name} onChange={(event) => setPolicyForm({ ...policyForm, name: event.target.value })} placeholder="例如 华东移动号码优先" required />
|
||||
</Field>
|
||||
<div className="match-grid">
|
||||
<div className="match-card">
|
||||
<strong>主叫号码匹配</strong>
|
||||
<Field label="匹配方式">
|
||||
<Select value={policyForm.callerMode} onChange={(event) => setPolicyForm({ ...policyForm, callerMode: event.target.value, callerValue: '' })}>
|
||||
<option value="any">不限</option>
|
||||
<option value="equals">等于指定号码</option>
|
||||
<option value="prefix">按前缀匹配</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{policyForm.callerMode !== 'any' ? (
|
||||
<Field label={<span>主叫号码 <span className="required-star">*</span></span>}>
|
||||
<Input value={policyForm.callerValue} onChange={(event) => setPolicyForm({ ...policyForm, callerValue: event.target.value })} placeholder={policyForm.callerMode === 'equals' ? '例如 02160010001' : '例如 0216001'} required />
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="match-card">
|
||||
<strong>被叫号码匹配</strong>
|
||||
<Field label="匹配方式">
|
||||
<Select value={policyForm.calleeMode} onChange={(event) => setPolicyForm({ ...policyForm, calleeMode: event.target.value, calleeValue: '' })}>
|
||||
<option value="any">不限</option>
|
||||
<option value="equals">等于指定号码</option>
|
||||
<option value="prefix">按前缀匹配</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{policyForm.calleeMode !== 'any' ? (
|
||||
<Field label={<span>被叫号码 <span className="required-star">*</span></span>}>
|
||||
<Input value={policyForm.calleeValue} onChange={(event) => setPolicyForm({ ...policyForm, calleeValue: event.target.value })} placeholder={policyForm.calleeMode === 'equals' ? '例如 13800138000' : '例如 13/15/18'} required />
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-toolbar">
|
||||
<Field label={<span>呼叫至线路群组 <span className="required-star">*</span></span>}>
|
||||
<Select value={policyForm.routeGroup} onChange={(event) => setPolicyForm({ ...policyForm, routeGroup: event.target.value })} required>
|
||||
{routeGroups.map((group) => <option key={group.id}>{group.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closePolicyModal}>取消</Button>
|
||||
<Button type="submit">{editingPolicy ? '保存修改' : '保存策略'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{deletePolicyTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除策略确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeletePolicyTarget(null)}
|
||||
onConfirm={() => deletePolicy(deletePolicyTarget.id)}
|
||||
>
|
||||
<p>确认删除策略「{deletePolicyTarget.name}」吗?删除后当前网关的策略优先级会自动重排。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { gateways } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onDeleteCustomer, onRechargeCustomer }) {
|
||||
const [showCreateCustomer, setShowCreateCustomer] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState(null);
|
||||
const [rechargeCustomer, setRechargeCustomer] = useState(null);
|
||||
const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null);
|
||||
const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' });
|
||||
const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' });
|
||||
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
|
||||
const [actionError, setActionError] = useState('');
|
||||
const canManageCustomers = can('customers.manage');
|
||||
const canManageRecharges = can('recharges.manage');
|
||||
const openEditCustomer = (customer) => {
|
||||
setEditingCustomer(customer);
|
||||
setEditCustomerForm({
|
||||
name: customer.name || '',
|
||||
contact: customer.contact === '-' ? '' : customer.contact || '',
|
||||
phone: customer.phone === '-' ? '' : customer.phone || '',
|
||||
email: customer.email === '-' ? '' : customer.email || '',
|
||||
});
|
||||
};
|
||||
const closeEditCustomer = () => {
|
||||
setEditingCustomer(null);
|
||||
setEditCustomerForm({ name: '', contact: '', phone: '', email: '' });
|
||||
};
|
||||
const openRechargeCustomer = (customer) => {
|
||||
setRechargeCustomer(customer);
|
||||
setRechargeForm({ amount: '', remark: '' });
|
||||
};
|
||||
const closeRechargeCustomer = () => {
|
||||
setRechargeCustomer(null);
|
||||
setRechargeForm({ amount: '', remark: '' });
|
||||
};
|
||||
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
|
||||
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const submitCustomer = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!newCustomer.name.trim()) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onCreateCustomer) {
|
||||
await onCreateCustomer({
|
||||
name: newCustomer.name.trim(),
|
||||
contactName: newCustomer.contact.trim() || undefined,
|
||||
phone: newCustomer.phone.trim() || undefined,
|
||||
email: newCustomer.email.trim() || undefined,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '0.000000',
|
||||
minBalance: '0.000000',
|
||||
});
|
||||
} else {
|
||||
const nextIndex = customerRows.length + 1;
|
||||
setCustomerRows([...customerRows, { id: `C${String(1000 + nextIndex)}`, name: newCustomer.name.trim(), contact: newCustomer.contact.trim() || '-', phone: newCustomer.phone.trim() || '-', email: newCustomer.email.trim() || '-', domain: '-', auth: '待配置', status: '启用', balance: '¥0.00', credit: '¥0', billing: '待配置', routeGroup: '待配置', gateways: 0, createdAt: '2026-06-18' }]);
|
||||
}
|
||||
setNewCustomer({ name: '', contact: '', phone: '', email: '' });
|
||||
setShowCreateCustomer(false);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const submitEditCustomer = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!editCustomerForm.name.trim() || !editingCustomer) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onUpdateCustomer) {
|
||||
await onUpdateCustomer(editingCustomer.id, {
|
||||
name: editCustomerForm.name.trim(),
|
||||
contactName: editCustomerForm.contact.trim() || null,
|
||||
phone: editCustomerForm.phone.trim() || null,
|
||||
email: editCustomerForm.email.trim() || null,
|
||||
});
|
||||
} else {
|
||||
setCustomerRows(customerRows.map((customer) => (
|
||||
customer.id === editingCustomer.id ? { ...customer, name: editCustomerForm.name.trim(), contact: editCustomerForm.contact.trim() || '-', phone: editCustomerForm.phone.trim() || '-', email: editCustomerForm.email.trim() || '-' } : customer
|
||||
)));
|
||||
}
|
||||
closeEditCustomer();
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const submitRecharge = async (event) => {
|
||||
event.preventDefault();
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!rechargeCustomer || !Number.isFinite(amount) || amount <= 0) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onRechargeCustomer) {
|
||||
await onRechargeCustomer(rechargeCustomer.id, {
|
||||
amount: amount.toFixed(2),
|
||||
remark: rechargeForm.remark.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
const beforeBalance = parseMoney(rechargeCustomer.balance);
|
||||
const afterBalance = beforeBalance + amount;
|
||||
setCustomerRows(customerRows.map((customer) => (
|
||||
customer.id === rechargeCustomer.id ? { ...customer, balance: formatMoney(afterBalance) } : customer
|
||||
)));
|
||||
addRechargeRecord({ id: `RCG-${Date.now()}`, type: 'customer', owner: rechargeCustomer.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
|
||||
}
|
||||
closeRechargeCustomer();
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const deleteCustomer = async (customer) => {
|
||||
setActionError('');
|
||||
try {
|
||||
if (onDeleteCustomer) {
|
||||
await onDeleteCustomer(customer.id);
|
||||
} else {
|
||||
if ((customer.gateways ?? 0) > 0) {
|
||||
throw new Error('该客户仍有关联客户网关,不能删除。');
|
||||
}
|
||||
setCustomerRows((rows) => rows.filter((item) => item.id !== customer.id));
|
||||
}
|
||||
setDeleteCustomerTarget(null);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="客户管理"
|
||||
desc="客户开通、SIP 账号/IP 白名单、费率方案、线路组、余额授信和操作日志。"
|
||||
actions={canManageCustomers ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateCustomer(true)}>新增客户</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label="客户名称"><Input placeholder="搜索客户名称 / 域名" /></Field>
|
||||
<Field label="状态"><Select defaultValue="all"><option value="all">全部状态</option><option>启用</option><option>观察</option><option>停用</option></Select></Field>
|
||||
<Button icon={<Icon type="search" />}>查询</Button>
|
||||
</Toolbar>
|
||||
<section className="master-detail">
|
||||
<Panel title="客户列表" className="main-list wide-panel">
|
||||
<SimpleTable
|
||||
rows={customerRows}
|
||||
columns={[
|
||||
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '名称', width: '180px', className: 'table-cell-compact' },
|
||||
{ key: 'balance', label: '余额' },
|
||||
{ key: 'credit', label: '授信额度' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{ key: 'gateways', label: '客户网关数' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManageCustomers ? <Button size="sm" variant="outline" onClick={() => openEditCustomer(row)}>编辑</Button> : null}
|
||||
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeCustomer(row)}>充值</Button> : null}
|
||||
{canManageCustomers ? <Button size="sm" variant="danger" onClick={() => setDeleteCustomerTarget(row)}>删除</Button> : null}
|
||||
{!canManageCustomers && !canManageRecharges ? '-' : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</section>
|
||||
{showCreateCustomer ? (
|
||||
<Modal title="新增客户" onClose={() => setShowCreateCustomer(false)} size="sm">
|
||||
<form className="modal-form" onSubmit={submitCustomer}>
|
||||
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={newCustomer.name} onChange={(event) => setNewCustomer({ ...newCustomer, name: event.target.value })} placeholder="请输入企业名称" required />
|
||||
</Field>
|
||||
<Field label="联系人">
|
||||
<Input value={newCustomer.contact} onChange={(event) => setNewCustomer({ ...newCustomer, contact: event.target.value })} placeholder="请输入联系人" />
|
||||
</Field>
|
||||
<Field label="联系电话">
|
||||
<Input value={newCustomer.phone} onChange={(event) => setNewCustomer({ ...newCustomer, phone: event.target.value })} placeholder="请输入联系电话" />
|
||||
</Field>
|
||||
<Field label="邮箱">
|
||||
<Input type="email" value={newCustomer.email} onChange={(event) => setNewCustomer({ ...newCustomer, email: event.target.value })} placeholder="请输入邮箱" />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreateCustomer(false)}>取消</Button>
|
||||
<Button type="submit">保存客户</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{rechargeCustomer ? (
|
||||
<Modal title={`${rechargeCustomer.name} 充值`} onClose={closeRechargeCustomer} size="sm">
|
||||
<form className="modal-form" onSubmit={submitRecharge}>
|
||||
<KeyValue label="当前余额" value={rechargeCustomer.balance} />
|
||||
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
|
||||
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeRechargeCustomer}>取消</Button>
|
||||
<Button type="submit">确认充值</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{editingCustomer ? (
|
||||
<Modal title="编辑客户" onClose={closeEditCustomer} size="sm">
|
||||
<form className="modal-form" onSubmit={submitEditCustomer}>
|
||||
<Field label={<span>客户名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={editCustomerForm.name} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, name: event.target.value })} placeholder="请输入客户名称" required />
|
||||
</Field>
|
||||
<Field label="联系人">
|
||||
<Input value={editCustomerForm.contact} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, contact: event.target.value })} placeholder="请输入联系人" />
|
||||
</Field>
|
||||
<Field label="联系电话">
|
||||
<Input value={editCustomerForm.phone} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, phone: event.target.value })} placeholder="请输入联系电话" />
|
||||
</Field>
|
||||
<Field label="邮箱">
|
||||
<Input type="email" value={editCustomerForm.email} onChange={(event) => setEditCustomerForm({ ...editCustomerForm, email: event.target.value })} placeholder="请输入邮箱" />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeEditCustomer}>取消</Button>
|
||||
<Button type="submit">保存修改</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{deleteCustomerTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除客户确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteCustomerTarget(null)}
|
||||
onConfirm={() => void deleteCustomer(deleteCustomerTarget)}
|
||||
>
|
||||
<p>确认删除客户「{deleteCustomerTarget.name}」吗?删除后该客户将不再出现在客户列表中。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
import { formatCurrency } from '../utils/formatters.js';
|
||||
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
|
||||
|
||||
function dashboardMetrics(summary) {
|
||||
if (!summary) {
|
||||
return metrics;
|
||||
}
|
||||
return [
|
||||
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
|
||||
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' },
|
||||
{ label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
|
||||
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
|
||||
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' },
|
||||
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' },
|
||||
{ label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' },
|
||||
{ label: '质检待处理', value: String(summary.quality.pendingReviews), delta: '录音', tone: summary.quality.pendingReviews ? 'warn' : 'neutral' },
|
||||
];
|
||||
}
|
||||
|
||||
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) {
|
||||
const trendBuckets = dashboardTrends?.buckets || [];
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend;
|
||||
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="概览 Dashboard"
|
||||
desc="展示平台整体运行状态,覆盖通话、收入、成本、注册、节点和质检待办。"
|
||||
actions={<Button icon={<Icon type="reload" />} onClick={refreshApi}>刷新指标</Button>}
|
||||
/>
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
<section className="metric-grid">
|
||||
{dashboardMetrics(dashboardSummary).map((metric) => (
|
||||
<div className="metric-card" key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<em className={`metric-${metric.tone}`}>{metric.delta}</em>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="content-grid">
|
||||
<Panel title="最近 24 小时通话量趋势" aside={<Badge tone="info">OpenSIPS acc/CDR</Badge>}>
|
||||
<MiniBarChart data={callTrendData.length ? callTrendData : [0]} />
|
||||
</Panel>
|
||||
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
|
||||
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
|
||||
</Panel>
|
||||
<Panel title="客户消费 TOP 10">
|
||||
<div className="rank-list">
|
||||
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
|
||||
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
|
||||
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表。</EmptyState>}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="失败响应码分布">
|
||||
<div className="code-grid">
|
||||
{failureCodes.length ? failureCodes.map((code) => (
|
||||
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
|
||||
)) : <EmptyState title="暂无失败码">今日没有失败 CDR 或 API 尚未返回数据。</EmptyState>}
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Badge, Button, Checkbox } from '../components/ui.jsx';
|
||||
import { PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
|
||||
import { opsItems } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function MonitoringPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="监控告警" desc="OpenSIPS、RTPEngine、端口监听、CDR 堆积、计费队列、录音入库和磁盘空间。" actions={<Button>新增告警规则</Button>} />
|
||||
<section className="content-grid">
|
||||
<Panel title="监控项" className="wide-panel">
|
||||
<SimpleTable rows={opsItems} columns={[{ key: 'name', label: '监控项' }, { key: 'target', label: '目标' }, { key: 'status', label: '状态', status: true }, { key: 'value', label: '当前值' }]} />
|
||||
</Panel>
|
||||
<Panel title="告警方式">
|
||||
<div className="setting-list">
|
||||
<Checkbox label="邮件" checked readOnly />
|
||||
<Checkbox label="企业微信/钉钉/飞书" checked readOnly />
|
||||
<Checkbox label="短信" checked={false} readOnly />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="推荐工具">
|
||||
<div className="tag-cloud">{['Prometheus', 'Grafana', 'Loki', 'ELK', 'Alertmanager', 'Monit'].map((tag) => <Badge key={tag} tone="brand">{tag}</Badge>)}</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
export function NumberLibraryPage({ can = () => true }) {
|
||||
const [activeTab, setActiveTab] = useState('cities');
|
||||
const [rows, setRows] = useState(emptyNumberLibraryRows);
|
||||
const [totals, setTotals] = useState(emptyNumberLibraryTotals);
|
||||
const [filters, setFilters] = useState({
|
||||
cities: { keyword: '' },
|
||||
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' },
|
||||
areaCodes: { areaCode: '', cityCode: '' },
|
||||
carrierPrefixRules: { prefix: '', carrier: 'all' },
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [importTarget, setImportTarget] = useState(null);
|
||||
const [importText, setImportText] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const canManage = can('number_library.manage');
|
||||
|
||||
const readTab = async (tab) => {
|
||||
const params = filters[tab] || {};
|
||||
if (tab === 'cities') {
|
||||
const payload = await api.numberLibraryCities(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
id: item.code,
|
||||
code: item.code,
|
||||
provinceName: item.provinceName,
|
||||
cityName: item.cityName,
|
||||
cityLevel: item.cityLevel || '-',
|
||||
status: zhStatus(item.status),
|
||||
effectiveFrom: formatDate(item.effectiveFrom),
|
||||
effectiveTo: formatDate(item.effectiveTo),
|
||||
}));
|
||||
}
|
||||
if (tab === 'phoneSegments') {
|
||||
const payload = await api.numberLibraryPhoneSegments(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
id: item.segment7,
|
||||
segment7: item.segment7,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
carrier: carrierLabel(item.carrier),
|
||||
numberType: item.numberType || '-',
|
||||
source: item.source || '-',
|
||||
batchId: item.batchId || '-',
|
||||
}));
|
||||
}
|
||||
if (tab === 'areaCodes') {
|
||||
const payload = await api.numberLibraryAreaCodes(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
id: item.areaCode,
|
||||
areaCode: item.areaCode,
|
||||
provinceName: item.provinceName,
|
||||
cityCode: item.cityCode,
|
||||
cityName: item.cityName,
|
||||
source: item.source || '-',
|
||||
batchId: item.batchId || '-',
|
||||
}));
|
||||
}
|
||||
const payload = await api.numberLibraryCarrierPrefixRules(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
id: item.prefix,
|
||||
prefix: item.prefix,
|
||||
carrier: carrierLabel(item.carrier),
|
||||
priority: item.priority,
|
||||
source: item.source || '-',
|
||||
batchId: item.batchId || '-',
|
||||
effectiveFrom: formatDate(item.effectiveFrom),
|
||||
effectiveTo: formatDate(item.effectiveTo),
|
||||
}));
|
||||
};
|
||||
|
||||
const loadTab = async (tab = activeTab) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await readTab(tab);
|
||||
setRows((current) => ({ ...current, [tab]: result.rows }));
|
||||
setTotals((current) => ({ ...current, [tab]: result.total }));
|
||||
} catch (loadError) {
|
||||
setError(explainApiError(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadTab('cities');
|
||||
void loadTab('phoneSegments');
|
||||
void loadTab('areaCodes');
|
||||
void loadTab('carrierPrefixRules');
|
||||
}, []);
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[activeTab]: { ...current[activeTab], [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const openImport = (tab) => {
|
||||
setImportTarget(tab);
|
||||
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
|
||||
setMessage('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const submitImport = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!importTarget) return;
|
||||
setImporting(true);
|
||||
setError('');
|
||||
try {
|
||||
const parsed = JSON.parse(importText);
|
||||
const items = Array.isArray(parsed) ? parsed : parsed.items;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('导入内容必须是非空数组。');
|
||||
}
|
||||
const importers = {
|
||||
cities: api.importNumberLibraryCities,
|
||||
phoneSegments: api.importNumberLibraryPhoneSegments,
|
||||
areaCodes: api.importNumberLibraryAreaCodes,
|
||||
carrierPrefixRules: api.importNumberLibraryCarrierPrefixRules,
|
||||
};
|
||||
const result = await importers[importTarget](items);
|
||||
setMessage(`导入完成:新增 ${result?.created ?? 0} 条,更新 ${result?.updated ?? 0} 条。`);
|
||||
setImportTarget(null);
|
||||
setImportText('');
|
||||
await loadTab(importTarget);
|
||||
} catch (submitError) {
|
||||
setError(explainApiError(submitError));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFilters = () => {
|
||||
const current = filters[activeTab];
|
||||
if (activeTab === 'cities') {
|
||||
return (
|
||||
<>
|
||||
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (activeTab === 'phoneSegments') {
|
||||
return (
|
||||
<>
|
||||
<Field label="前 7 位号段"><Input value={current.segment7} onChange={(event) => updateFilter('segment7', event.target.value)} placeholder="如 1380013" /></Field>
|
||||
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
|
||||
<Field label="运营商">
|
||||
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
|
||||
<option value="all">全部</option>
|
||||
<option value="MOBILE">移动</option>
|
||||
<option value="UNICOM">联通</option>
|
||||
<option value="TELECOM">电信</option>
|
||||
<option value="BROADCAST">广电</option>
|
||||
<option value="MVNO">虚拟运营商</option>
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (activeTab === 'areaCodes') {
|
||||
return (
|
||||
<>
|
||||
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
|
||||
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Field label="号码前缀"><Input value={current.prefix} onChange={(event) => updateFilter('prefix', event.target.value)} placeholder="如 138" /></Field>
|
||||
<Field label="运营商">
|
||||
<Select value={current.carrier} onChange={(event) => updateFilter('carrier', event.target.value)}>
|
||||
<option value="all">全部</option>
|
||||
<option value="MOBILE">移动</option>
|
||||
<option value="UNICOM">联通</option>
|
||||
<option value="TELECOM">电信</option>
|
||||
<option value="BROADCAST">广电</option>
|
||||
<option value="MVNO">虚拟运营商</option>
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTable = (tab) => {
|
||||
if (tab === 'cities') {
|
||||
return (
|
||||
<SimpleTable rows={rows.cities} columns={[
|
||||
{ key: 'code', label: '地级市代码', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||
{ key: 'cityLevel', label: '级别', width: '120px' },
|
||||
{ key: 'status', label: '状态', width: '90px', status: true },
|
||||
{ key: 'effectiveFrom', label: '生效时间' },
|
||||
{ key: 'effectiveTo', label: '失效时间' },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
if (tab === 'phoneSegments') {
|
||||
return (
|
||||
<SimpleTable rows={rows.phoneSegments} columns={[
|
||||
{ key: 'segment7', label: '前 7 位', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||
{ key: 'carrier', label: '运营商', width: '100px' },
|
||||
{ key: 'numberType', label: '号码类型', width: '110px' },
|
||||
{ key: 'batchId', label: '批次' },
|
||||
{ key: 'source', label: '来源' },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
if (tab === 'areaCodes') {
|
||||
return (
|
||||
<SimpleTable rows={rows.areaCodes} columns={[
|
||||
{ key: 'areaCode', label: '区号', width: '100px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||
{ key: 'batchId', label: '批次' },
|
||||
{ key: 'source', label: '来源' },
|
||||
]} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SimpleTable rows={rows.carrierPrefixRules} columns={[
|
||||
{ key: 'prefix', label: '前缀', width: '100px' },
|
||||
{ key: 'carrier', label: '运营商', width: '110px' },
|
||||
{ key: 'priority', label: '优先级', width: '90px' },
|
||||
{ key: 'batchId', label: '批次' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'effectiveFrom', label: '生效时间' },
|
||||
{ key: 'effectiveTo', label: '失效时间' },
|
||||
]} />
|
||||
);
|
||||
};
|
||||
|
||||
const importTitle = importTarget ? numberLibraryTabs.find((tab) => tab.value === importTarget)?.label : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="号码库"
|
||||
desc="维护地级市、手机前 7 位号段、固话区号和运营商前缀规则。"
|
||||
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
setActiveTab(value);
|
||||
if (rows[value].length === 0) {
|
||||
void loadTab(value);
|
||||
}
|
||||
}}
|
||||
tabs={numberLibraryTabs.map((tab) => ({
|
||||
...tab,
|
||||
label: `${tab.label}(${totals[tab.value]})`,
|
||||
content: (
|
||||
<Panel
|
||||
title={tab.label}
|
||||
className="wide-panel"
|
||||
aside={<Button size="sm" variant="outline" icon={<Icon type="reload" />} onClick={() => void loadTab(tab.value)}>刷新</Button>}
|
||||
>
|
||||
{tab.value === activeTab ? (
|
||||
<Toolbar>
|
||||
{renderFilters()}
|
||||
</Toolbar>
|
||||
) : null}
|
||||
{renderTable(tab.value)}
|
||||
</Panel>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
{importTarget ? (
|
||||
<Modal title={`导入${importTitle}`} onClose={importing ? () => {} : () => setImportTarget(null)}>
|
||||
<form className="modal-form" onSubmit={submitImport}>
|
||||
<Field label="JSON 数据">
|
||||
<Textarea rows={12} value={importText} onChange={(event) => setImportText(event.target.value)} />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" disabled={importing} onClick={() => setImportTarget(null)}>取消</Button>
|
||||
<Button type="submit" disabled={importing}>{importing ? '导入中' : '确认导入'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { operationLogRows } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiError, refreshApi }) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('all');
|
||||
const [resultFilter, setResultFilter] = useState('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [detailLog, setDetailLog] = useState(null);
|
||||
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
|
||||
const visibleLogs = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
return logRows.filter((log) => {
|
||||
const matchesKeyword = !normalized || [log.user, log.username, log.action, log.object, log.ip].some((value) => value.toLowerCase().includes(normalized));
|
||||
const logDate = log.time.slice(0, 10);
|
||||
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
|
||||
});
|
||||
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
|
||||
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
|
||||
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
|
||||
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
|
||||
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field>
|
||||
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field>
|
||||
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="日志列表" aside={<Badge tone="neutral">共 {visibleLogs.length} 条</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={visibleLogs} columns={[
|
||||
{ key: 'time', label: '操作时间' },
|
||||
{ key: 'user', label: '操作用户' },
|
||||
{ key: 'module', label: '功能模块' },
|
||||
{ key: 'action', label: '操作类型' },
|
||||
{ key: 'object', label: '操作对象' },
|
||||
{ key: 'result', label: '结果', status: true },
|
||||
{ key: 'ip', label: '操作 IP' },
|
||||
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => setDetailLog(row)}>查看详情</Button> },
|
||||
]} />
|
||||
</Panel>
|
||||
{detailLog ? (
|
||||
<Drawer title="操作日志详情" aside={<Badge tone={toneForStatus(detailLog.result)}>{detailLog.result}</Badge>} onClose={() => setDetailLog(null)}>
|
||||
<div className="detail-stack">
|
||||
<KeyValue label="日志 ID" value={detailLog.id} />
|
||||
<KeyValue label="操作时间" value={detailLog.time} />
|
||||
<KeyValue label="操作用户" value={`${detailLog.user}(${detailLog.username})`} />
|
||||
<KeyValue label="功能模块" value={detailLog.module} />
|
||||
<KeyValue label="操作类型" value={detailLog.action} />
|
||||
<KeyValue label="操作对象" value={detailLog.object} />
|
||||
<KeyValue label="执行结果" value={detailLog.result} />
|
||||
<KeyValue label="操作 IP" value={detailLog.ip} />
|
||||
<KeyValue label="客户端" value={detailLog.userAgent} />
|
||||
</div>
|
||||
<section className="log-summary"><strong>操作摘要</strong><p>{detailLog.summary}</p></section>
|
||||
</Drawer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
|
||||
|
||||
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) {
|
||||
const [ruleRows, setRuleRows] = useState([]);
|
||||
const [recordingRows, setRecordingRows] = useState([]);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const [qualityError, setQualityError] = useState('');
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' });
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(undefined);
|
||||
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
||||
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
|
||||
const [ruleBusy, setRuleBusy] = useState(false);
|
||||
const [ruleError, setRuleError] = useState('');
|
||||
const [detailRecording, setDetailRecording] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [reviewForm, setReviewForm] = useState({ result: '通过', issue: '', score: '90', issueTags: '' });
|
||||
const [reviewSaving, setReviewSaving] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [autoPlay, setAutoPlay] = useState(false);
|
||||
const [playbackProgress, setPlaybackProgress] = useState(0);
|
||||
const [playbackUrl, setPlaybackUrl] = useState('');
|
||||
const [playbackError, setPlaybackError] = useState('');
|
||||
const [playbackLoading, setPlaybackLoading] = useState(false);
|
||||
const [saveFeedback, setSaveFeedback] = useState('');
|
||||
const audioRef = useRef(null);
|
||||
const canManageQuality = can('quality.manage');
|
||||
const canPlayRecordings = can('recordings.play');
|
||||
const detailRecordingIndex = detailRecording ? recordingRows.findIndex((recording) => recording.id === detailRecording.id) : -1;
|
||||
const closePlayback = () => {
|
||||
setIsPlaying(false);
|
||||
setPlaybackProgress(0);
|
||||
setPlaybackError('');
|
||||
setPlaybackLoading(false);
|
||||
if (playbackUrl) {
|
||||
URL.revokeObjectURL(playbackUrl);
|
||||
setPlaybackUrl('');
|
||||
}
|
||||
};
|
||||
const refreshQuality = async (nextFilter = recordingFilter) => {
|
||||
setQualityLoading(true);
|
||||
setQualityError('');
|
||||
try {
|
||||
const params = {
|
||||
limit: nextFilter.limit,
|
||||
reviewStatus: nextFilter.reviewStatus,
|
||||
};
|
||||
const [recordingList, ruleList] = await Promise.all([
|
||||
api.recordings(params),
|
||||
api.qualityRules(),
|
||||
]);
|
||||
setRecordingRows((recordingList || []).map(normalizeRecording));
|
||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
||||
} catch (error) {
|
||||
setQualityError(explainApiError(error));
|
||||
} finally {
|
||||
setQualityLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void refreshQuality();
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
|
||||
}, [playbackUrl]);
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || !playbackUrl) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(() => setIsPlaying(false));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, [isPlaying, playbackUrl]);
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !detailRecording || playbackUrl) return undefined;
|
||||
const timer = window.setInterval(() => setPlaybackProgress((progress) => Math.min(100, progress + 5)), 120);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detailRecording?.id, isPlaying, playbackUrl]);
|
||||
useEffect(() => {
|
||||
if (playbackUrl || playbackProgress < 100 || !detailRecording) return;
|
||||
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
|
||||
const nextRecording = recordingRows[detailRecordingIndex + 1];
|
||||
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
|
||||
return;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
}, [autoPlay, detailRecording, detailRecordingIndex, playbackProgress, playbackUrl, recordingRows]);
|
||||
const openCreateRule = () => {
|
||||
setEditingRule(null);
|
||||
setRuleError('');
|
||||
setRuleForm({ ...emptySamplingRuleForm, start: new Date().toISOString().slice(0, 10) });
|
||||
};
|
||||
const openEditRule = (rule) => {
|
||||
setEditingRule(rule);
|
||||
setRuleError('');
|
||||
setRuleForm({
|
||||
name: rule.name,
|
||||
customerId: rule.customerId,
|
||||
ratio: rule.ratio,
|
||||
lineGroupId: rule.lineGroupId,
|
||||
start: rule.start,
|
||||
expiresAt: rule.expiresAt,
|
||||
status: rule.status,
|
||||
});
|
||||
};
|
||||
const closeRuleModal = () => {
|
||||
setEditingRule(undefined);
|
||||
setRuleForm(emptySamplingRuleForm);
|
||||
setRuleError('');
|
||||
};
|
||||
const ruleBody = () => ({
|
||||
name: ruleForm.name.trim(),
|
||||
customerId: ruleForm.customerId || null,
|
||||
lineGroupId: ruleForm.lineGroupId || null,
|
||||
ratio: String(ruleForm.ratio),
|
||||
status: ruleForm.status === '启用' ? 'ENABLED' : 'DISABLED',
|
||||
effectiveAt: ruleForm.start ? new Date(`${ruleForm.start}T00:00:00`).toISOString() : undefined,
|
||||
expiresAt: ruleForm.expiresAt ? new Date(`${ruleForm.expiresAt}T23:59:59`).toISOString() : null,
|
||||
});
|
||||
const submitRule = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!ruleForm.name.trim()) return;
|
||||
setRuleBusy(true);
|
||||
setRuleError('');
|
||||
try {
|
||||
if (editingRule) {
|
||||
await api.updateQualityRule(editingRule.id, ruleBody());
|
||||
} else {
|
||||
await api.createQualityRule(ruleBody());
|
||||
}
|
||||
await refreshQuality();
|
||||
closeRuleModal();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
setRuleBusy(false);
|
||||
}
|
||||
};
|
||||
const toggleRuleStatus = async (rule) => {
|
||||
setRuleBusy(true);
|
||||
setRuleError('');
|
||||
try {
|
||||
if (rule.status === '启用') {
|
||||
await api.disableQualityRule(rule.id);
|
||||
} else {
|
||||
await api.enableQualityRule(rule.id);
|
||||
}
|
||||
await refreshQuality();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
setRuleBusy(false);
|
||||
}
|
||||
};
|
||||
const deleteRule = async () => {
|
||||
if (!deleteRuleTarget) return;
|
||||
setRuleBusy(true);
|
||||
setRuleError('');
|
||||
try {
|
||||
await api.deleteQualityRule(deleteRuleTarget.id);
|
||||
await refreshQuality();
|
||||
setDeleteRuleTarget(null);
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
setRuleBusy(false);
|
||||
}
|
||||
};
|
||||
const setReviewDraft = (recording) => {
|
||||
setReviewForm({
|
||||
result: recording.result && recording.result !== '-' ? recording.result : '通过',
|
||||
issue: recording.issue || '',
|
||||
score: recording.score === '' || recording.score === null || recording.score === undefined ? '90' : String(recording.score),
|
||||
issueTags: recording.issueTagsText || '',
|
||||
});
|
||||
};
|
||||
const openRecordingDetail = async (recording, options = {}) => {
|
||||
closePlayback();
|
||||
setDetailRecording(recording);
|
||||
setReviewDraft(recording);
|
||||
setDetailLoading(true);
|
||||
setIsPlaying(false);
|
||||
setAutoPlay(Boolean(options.keepAutoPlay));
|
||||
setPlaybackProgress(0);
|
||||
setSaveFeedback('');
|
||||
try {
|
||||
const detail = normalizeRecording(await api.recordingDetail(recording.id));
|
||||
setDetailRecording(detail);
|
||||
setReviewDraft(detail);
|
||||
setRecordingRows((rows) => rows.map((row) => (row.id === detail.id ? detail : row)));
|
||||
if (options.startPlayback) {
|
||||
await loadPlayback(detail);
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveFeedback(explainApiError(error));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
const closeRecordingDetail = () => {
|
||||
setDetailRecording(null);
|
||||
closePlayback();
|
||||
setSaveFeedback('');
|
||||
};
|
||||
const navigateRecording = (direction) => {
|
||||
const nextIndex = detailRecordingIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= recordingRows.length) return;
|
||||
const nextRecording = recordingRows[nextIndex];
|
||||
void openRecordingDetail(nextRecording, { keepAutoPlay: autoPlay });
|
||||
};
|
||||
const loadPlayback = async (recording = detailRecording) => {
|
||||
if (!recording || playbackLoading) return;
|
||||
setPlaybackLoading(true);
|
||||
setPlaybackError('');
|
||||
try {
|
||||
const blob = await api.recordingPlayback(recording.id);
|
||||
if (playbackUrl) URL.revokeObjectURL(playbackUrl);
|
||||
setPlaybackUrl(URL.createObjectURL(blob));
|
||||
setIsPlaying(true);
|
||||
setPlaybackProgress(0);
|
||||
} catch (error) {
|
||||
setPlaybackError(explainApiError(error));
|
||||
} finally {
|
||||
setPlaybackLoading(false);
|
||||
}
|
||||
};
|
||||
const togglePlayback = async () => {
|
||||
if (!playbackUrl) {
|
||||
await loadPlayback();
|
||||
return;
|
||||
}
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
} else {
|
||||
await audioRef.current?.play().catch(() => setPlaybackError('浏览器阻止了自动播放,请使用播放器控件开始试听。'));
|
||||
}
|
||||
setIsPlaying((playing) => !playing);
|
||||
};
|
||||
const handleAudioEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setPlaybackProgress(100);
|
||||
if (autoPlay && detailRecordingIndex < recordingRows.length - 1) {
|
||||
const nextRecording = recordingRows[detailRecordingIndex + 1];
|
||||
void openRecordingDetail(nextRecording, { keepAutoPlay: true, startPlayback: true });
|
||||
}
|
||||
};
|
||||
const saveRecordingReview = async () => {
|
||||
if (!detailRecording || reviewSaving) return;
|
||||
setReviewSaving(true);
|
||||
setSaveFeedback('');
|
||||
try {
|
||||
await api.saveRecordingReview(detailRecording.id, {
|
||||
result: reviewResultValue(reviewForm.result),
|
||||
score: reviewForm.score === '' ? null : Number(reviewForm.score),
|
||||
notes: reviewForm.issue,
|
||||
issueTags: reviewForm.issueTags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
});
|
||||
const [detail] = await Promise.all([
|
||||
api.recordingDetail(detailRecording.id),
|
||||
refreshQuality(),
|
||||
]);
|
||||
const normalized = normalizeRecording(detail);
|
||||
setDetailRecording(normalized);
|
||||
setReviewDraft(normalized);
|
||||
setRecordingRows((rows) => rows.map((recording) => (recording.id === normalized.id ? normalized : recording)));
|
||||
setSaveFeedback('质检结果已保存,并已刷新录音列表与详情。');
|
||||
} catch (error) {
|
||||
setSaveFeedback(explainApiError(error));
|
||||
} finally {
|
||||
setReviewSaving(false);
|
||||
}
|
||||
};
|
||||
const changeRecordingFilter = (key, value) => {
|
||||
const nextFilter = { ...recordingFilter, [key]: value };
|
||||
setRecordingFilter(nextFilter);
|
||||
void refreshQuality(nextFilter);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="质检中心"
|
||||
desc="集中查看录音、完成试听、问题标注和人工质检评分。"
|
||||
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={() => setShowRules(true)}>抽检规则</Button></div>}
|
||||
/>
|
||||
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
|
||||
<Toolbar>
|
||||
<Field label="质检状态">
|
||||
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
|
||||
<option value="all">全部</option>
|
||||
<option value="PENDING">待质检</option>
|
||||
<option value="REVIEWED">已完成</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="读取条数">
|
||||
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</Toolbar>
|
||||
<Panel title="录音列表" aside={<Badge tone="neutral">共 {recordingRows.length} 条录音</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={recordingRows} columns={[
|
||||
{ key: 'callId', label: 'Call-ID' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'caller', label: '主叫' },
|
||||
{ key: 'callee', label: '被叫' },
|
||||
{ key: 'business', label: '业务' },
|
||||
{ key: 'time', label: '通话时间' },
|
||||
{ key: 'duration', label: '时长' },
|
||||
{ key: 'samplingText', label: '抽样状态', status: true },
|
||||
{ key: 'review', label: '质检状态', status: true },
|
||||
{ key: 'actions', label: '操作', render: (row) => <Button size="sm" variant="outline" onClick={() => void openRecordingDetail(row)}>录音详情</Button> },
|
||||
]} />
|
||||
</Panel>
|
||||
|
||||
{showRules ? (
|
||||
<Drawer title="抽检规则" aside={<Badge tone="info">{ruleRows.length} 条规则</Badge>} onClose={() => setShowRules(false)}>
|
||||
{ruleError ? <Alert title="规则操作失败" tone="warning">{ruleError}</Alert> : null}
|
||||
<div className="drawer-toolbar">
|
||||
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例。</span></div>
|
||||
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
|
||||
</div>
|
||||
<SimpleTable rows={ruleRows} columns={[
|
||||
{ key: 'name', label: '规则名称' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
|
||||
{ key: 'route', label: '指定线路' },
|
||||
{ key: 'start', label: '生效时间' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManageQuality ? <Button size="sm" variant="outline" disabled={ruleBusy} onClick={() => openEditRule(row)}>编辑</Button> : null}{canManageQuality ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} disabled={ruleBusy} onClick={() => void toggleRuleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManageQuality ? <Button size="sm" variant="danger" disabled={ruleBusy} onClick={() => setDeleteRuleTarget(row)}>删除</Button> : '-'}</div> },
|
||||
]} />
|
||||
</Drawer>
|
||||
) : null}
|
||||
|
||||
{editingRule !== undefined ? (
|
||||
<Modal title={editingRule ? '编辑抽检规则' : '新增抽检规则'} onClose={closeRuleModal}>
|
||||
{ruleError ? <Alert title="规则保存失败" tone="warning">{ruleError}</Alert> : null}
|
||||
<form className="modal-form" onSubmit={submitRule}>
|
||||
<div className="form-grid admin-form-grid">
|
||||
<Field label={<span>规则名称 <span className="required-star">*</span></span>}><Input value={ruleForm.name} onChange={(event) => setRuleForm({ ...ruleForm, name: event.target.value })} placeholder="请输入规则名称" required /></Field>
|
||||
<Field label="客户"><Select value={ruleForm.customerId} onChange={(event) => setRuleForm({ ...ruleForm, customerId: event.target.value })}><option value="">全部客户</option>{customerRows.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}</Select></Field>
|
||||
<Field label={<span>抽检比例(%) <span className="required-star">*</span></span>}><Input type="number" min="0" max="100" step="0.01" value={ruleForm.ratio} onChange={(event) => setRuleForm({ ...ruleForm, ratio: event.target.value })} required /></Field>
|
||||
<Field label="指定线路"><Select value={ruleForm.lineGroupId} onChange={(event) => setRuleForm({ ...ruleForm, lineGroupId: event.target.value })}><option value="">全部线路</option>{lineGroupRows.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}</Select></Field>
|
||||
<Field label="生效时间"><Input type="date" value={ruleForm.start} onChange={(event) => setRuleForm({ ...ruleForm, start: event.target.value })} /></Field>
|
||||
<Field label="失效时间"><Input type="date" value={ruleForm.expiresAt} onChange={(event) => setRuleForm({ ...ruleForm, expiresAt: event.target.value })} /></Field>
|
||||
<Field label="状态"><Select value={ruleForm.status} onChange={(event) => setRuleForm({ ...ruleForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
|
||||
</div>
|
||||
<div className="modal-actions"><Button type="button" variant="outline" disabled={ruleBusy} onClick={closeRuleModal}>取消</Button><Button type="submit" disabled={ruleBusy}>{ruleBusy ? '保存中' : editingRule ? '保存修改' : '保存规则'}</Button></div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{deleteRuleTarget ? (
|
||||
<ConfirmDialog
|
||||
title="确认删除抽检规则"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteRuleTarget(null)}
|
||||
onConfirm={() => void deleteRule()}
|
||||
busy={ruleBusy}
|
||||
>
|
||||
<p>确认删除规则「{deleteRuleTarget.name}」吗?删除后该规则将不再参与后续录音抽检。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
|
||||
{detailRecording ? (
|
||||
<Drawer title="录音详情" aside={<StatusBadge>{detailRecording.review}</StatusBadge>} onClose={closeRecordingDetail}>
|
||||
{detailLoading ? <Alert title="正在读取录音详情">正在加载最新质检记录与抽样结果。</Alert> : null}
|
||||
<div className="drawer-toolbar">
|
||||
<div><strong>录音导航</strong><span>第 {detailRecordingIndex + 1} 条,共 {recordingRows.length} 条</span></div>
|
||||
<div className="table-actions"><Button variant="outline" disabled={detailRecordingIndex <= 0} onClick={() => navigateRecording(-1)}>上一个</Button><Button variant="outline" disabled={detailRecordingIndex >= recordingRows.length - 1} onClick={() => navigateRecording(1)}>下一个</Button></div>
|
||||
</div>
|
||||
<div className="detail-stack">
|
||||
<KeyValue label="Call-ID" value={detailRecording.callId} />
|
||||
<KeyValue label="客户" value={detailRecording.customer} />
|
||||
<KeyValue label="主叫号码" value={detailRecording.caller} />
|
||||
<KeyValue label="被叫号码" value={detailRecording.callee} />
|
||||
<KeyValue label="业务" value={detailRecording.business} />
|
||||
<KeyValue label="通话时间" value={detailRecording.time} />
|
||||
<KeyValue label="录音时长" value={detailRecording.duration} />
|
||||
<KeyValue label="抽样状态" value={detailRecording.samplingText} />
|
||||
<KeyValue label="命中规则" value={detailRecording.samplingRuleText} />
|
||||
</div>
|
||||
<div className="audio-bar"><span style={{ background: `linear-gradient(90deg, var(--selected) ${playbackProgress}%, #dfe3ec ${playbackProgress}%)` }} /> <strong>{detailRecording.file}</strong></div>
|
||||
<div className="drawer-toolbar"><div><strong>录音试听</strong><span>{!canPlayRecordings ? '当前账号没有录音播放权限。' : playbackError || (isPlaying ? '正在播放真实录音流' : playbackUrl ? '已读取录音,可使用播放器控制。' : '用于人工抽检、申诉复核和服务质量核查。')}</span></div><div className="table-actions">{canPlayRecordings ? <Button variant="secondary" disabled={playbackLoading} onClick={() => void togglePlayback()}>{playbackLoading ? '读取中' : isPlaying ? '暂停播放' : playbackUrl ? '继续播放' : '播放录音'}</Button> : null}{canPlayRecordings ? <Button variant={autoPlay ? 'secondary' : 'outline'} onClick={() => setAutoPlay((enabled) => !enabled)}>自动播放:{autoPlay ? '开' : '关'}</Button> : null}</div></div>
|
||||
{playbackUrl ? <audio ref={audioRef} className="recording-player" src={playbackUrl} controls autoPlay={isPlaying} onEnded={handleAudioEnded} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onTimeUpdate={(event) => {
|
||||
const audio = event.currentTarget;
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
setPlaybackProgress(Math.min(100, Math.round((audio.currentTime / audio.duration) * 100)));
|
||||
}
|
||||
}} /> : null}
|
||||
{playbackError ? <Alert title="录音播放失败" tone="warning">{playbackError}</Alert> : null}
|
||||
{canManageQuality ? <Field label="质检结果">
|
||||
<Select value={reviewForm.result} onChange={(event) => setReviewForm({ ...reviewForm, result: event.target.value })}>
|
||||
<option value="通过">通过</option>
|
||||
<option value="有问题">有问题</option>
|
||||
<option value="升级处理">升级处理</option>
|
||||
</Select>
|
||||
</Field> : null}
|
||||
{canManageQuality ? <Field label="问题标签"><Input value={reviewForm.issueTags} onChange={(event) => setReviewForm({ ...reviewForm, issueTags: event.target.value })} placeholder="多个标签用逗号分隔" /></Field> : null}
|
||||
{canManageQuality ? <Field label="问题标注"><Textarea rows="4" value={reviewForm.issue} onChange={(event) => setReviewForm({ ...reviewForm, issue: event.target.value })} placeholder="标注关键词、服务态度、合规风险" /></Field> : null}
|
||||
{canManageQuality ? <Field label="评分"><Input type="number" min="0" max="100" value={reviewForm.score} onChange={(event) => setReviewForm({ ...reviewForm, score: event.target.value })} /></Field> : null}
|
||||
{saveFeedback ? <Alert title={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? '操作提示' : '保存反馈'} tone={saveFeedback.includes('失败') || saveFeedback.includes('不可用') || saveFeedback.includes('failed') ? 'warning' : 'success'}>{saveFeedback}</Alert> : null}
|
||||
<div className="drawer-actions"><Button variant="outline" onClick={closeRecordingDetail}>关闭</Button>{canManageQuality ? <Button disabled={reviewSaving} onClick={() => void saveRecordingReview()}>{reviewSaving ? '保存中' : '保存质检结果'}</Button> : null}</div>
|
||||
</Drawer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Field, Input, Select, Tabs } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, SimpleTable } from '../components/layout.jsx';
|
||||
|
||||
export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refreshApi }) {
|
||||
const [activeTab, setActiveTab] = useState('customer');
|
||||
const visibleRows = rechargeRows.filter((row) => row.type === activeTab);
|
||||
const ownerLabel = activeTab === 'customer' ? '客户' : '供应商';
|
||||
const recordTable = (
|
||||
<section className="content-grid">
|
||||
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
|
||||
<SimpleTable rows={visibleRows} columns={[
|
||||
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
|
||||
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
|
||||
{ key: 'amount', label: '充值金额' },
|
||||
{ key: 'beforeBalance', label: '充值前余额' },
|
||||
{ key: 'afterBalance', label: '充值后余额' },
|
||||
{ key: 'remark', label: '备注' },
|
||||
{ key: 'operator', label: '操作人' },
|
||||
{ key: 'time', label: '充值时间' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="充值记录"
|
||||
desc="记录客户和供应商充值金额、充值前后余额、备注、操作人和充值时间。"
|
||||
actions={<Button icon={<Icon type="export" />} variant="outline">导出记录</Button>}
|
||||
/>
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label={`${ownerLabel}名称`}><Input placeholder={`搜索${ownerLabel}名称`} /></Field>
|
||||
<Field label="状态">
|
||||
<Select defaultValue="all">
|
||||
<option value="all">全部状态</option>
|
||||
<option>成功</option>
|
||||
<option>失败</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />}>查询</Button>
|
||||
</Toolbar>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
tabs={[
|
||||
{ value: 'customer', label: '客户充值', content: recordTable },
|
||||
{ value: 'vendor', label: '供应商充值', content: recordTable },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Button, Checkbox, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
|
||||
import { permissionGroups } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) {
|
||||
const [editingRole, setEditingRole] = useState(undefined);
|
||||
const [roleForm, setRoleForm] = useState(emptyRoleForm);
|
||||
const [permissionRole, setPermissionRole] = useState(null);
|
||||
const [permissionDraft, setPermissionDraft] = useState([]);
|
||||
const [deleteRoleTarget, setDeleteRoleTarget] = useState(null);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const canManage = can('roles.manage');
|
||||
const roleUserCounts = useMemo(() => userRows.reduce((counts, user) => ({ ...counts, [user.roleId]: (counts[user.roleId] || 0) + 1 }), {}), [userRows]);
|
||||
const openCreateRole = () => { setEditingRole(null); setRoleForm(emptyRoleForm); };
|
||||
const openEditRole = (role) => { setEditingRole(role); setRoleForm({ name: role.name, description: role.description, status: role.status }); };
|
||||
const closeRoleModal = () => { setEditingRole(undefined); setRoleForm(emptyRoleForm); };
|
||||
const submitRole = (event) => {
|
||||
event.preventDefault();
|
||||
if (!roleForm.name.trim()) return;
|
||||
if (editingRole) {
|
||||
setRoleRows((rows) => rows.map((role) => role.id === editingRole.id ? { ...role, ...roleForm } : role));
|
||||
} else {
|
||||
setRoleRows((rows) => [...rows, { id: `R${String(rows.length + 1).padStart(3, '0')}`, ...roleForm, builtIn: false, permissions: [] }]);
|
||||
}
|
||||
closeRoleModal();
|
||||
};
|
||||
const toggleRoleStatus = (role) => {
|
||||
if (role.builtIn) return;
|
||||
setRoleRows((rows) => rows.map((item) => item.id === role.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
|
||||
};
|
||||
const deleteRole = async (role) => {
|
||||
try {
|
||||
setActionError('');
|
||||
if (onDeleteRole) {
|
||||
await onDeleteRole(role.id);
|
||||
} else {
|
||||
setRoleRows((rows) => rows.filter((item) => item.id !== role.id));
|
||||
}
|
||||
setDeleteRoleTarget(null);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const openPermissions = (role) => { setPermissionRole(role); setPermissionDraft(role.permissions); };
|
||||
const togglePermission = (permissionKey) => {
|
||||
setPermissionDraft((permissions) => permissions.includes(permissionKey) ? permissions.filter((key) => key !== permissionKey) : [...permissions, permissionKey]);
|
||||
};
|
||||
const togglePermissionGroup = (group) => {
|
||||
const groupKeys = group.permissions.map((permission) => permission.key);
|
||||
const allSelected = groupKeys.every((key) => permissionDraft.includes(key));
|
||||
setPermissionDraft((permissions) => allSelected ? permissions.filter((key) => !groupKeys.includes(key)) : Array.from(new Set([...permissions, ...groupKeys])));
|
||||
};
|
||||
const savePermissions = () => {
|
||||
setRoleRows((rows) => rows.map((role) => role.id === permissionRole.id ? { ...role, permissions: permissionDraft } : role));
|
||||
setPermissionRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="角色与权限" desc="按岗位定义菜单与操作权限,并查看角色关联用户。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateRole}>新增角色</Button> : null} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
||||
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
|
||||
{ key: 'name', label: '角色名称' },
|
||||
{ key: 'type', label: '类型' },
|
||||
{ key: 'description', label: '角色说明' },
|
||||
{ key: 'userCount', label: '用户数' },
|
||||
{ key: 'permissionCount', label: '权限项' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions"><Button size="sm" variant="secondary" onClick={() => openPermissions(row)}>{canManage && !row.builtIn ? '配置权限' : '查看权限'}</Button>{canManage ? <Button size="sm" variant="outline" onClick={() => openEditRole(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant="ghost" disabled={row.builtIn} onClick={() => toggleRoleStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="danger" disabled={row.builtIn} onClick={() => setDeleteRoleTarget(row)}>删除</Button> : null}</div> },
|
||||
]} />
|
||||
</Panel>
|
||||
{editingRole !== undefined ? (
|
||||
<Modal title={editingRole ? '编辑角色' : '新增角色'} onClose={closeRoleModal} size="sm">
|
||||
<form className="modal-form" onSubmit={submitRole}>
|
||||
<Field label={<span>角色名称 <span className="required-star">*</span></span>}><Input value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} placeholder="请输入角色名称" disabled={Boolean(editingRole?.builtIn)} required /></Field>
|
||||
<Field label="角色说明"><Textarea rows="4" value={roleForm.description} onChange={(event) => setRoleForm({ ...roleForm, description: event.target.value })} placeholder="说明该角色的职责范围" /></Field>
|
||||
<Field label="状态"><Select value={roleForm.status} onChange={(event) => setRoleForm({ ...roleForm, status: event.target.value })} disabled={Boolean(editingRole?.builtIn)}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
|
||||
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeRoleModal}>取消</Button><Button type="submit">保存角色</Button></div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{permissionRole ? (
|
||||
<Drawer title={permissionRole.builtIn ? '查看角色权限' : '配置角色权限'} aside={<><Badge tone="info">{permissionRole.name}</Badge><span className="drawer-subtitle">已选择 {permissionDraft.length} 项权限</span></>} onClose={() => setPermissionRole(null)}>
|
||||
{permissionRole.builtIn || !canManage ? <Alert title={permissionRole.builtIn ? '系统内置角色' : '只读权限'} tone="info">{permissionRole.builtIn ? '内置角色权限由系统维护,仅支持查看。' : '当前账号没有角色管理权限,仅支持查看。'}</Alert> : null}
|
||||
<div className="permission-groups">
|
||||
{permissionGroups.map((group) => {
|
||||
const groupKeys = group.permissions.map((permission) => permission.key);
|
||||
const selectedCount = groupKeys.filter((key) => permissionDraft.includes(key)).length;
|
||||
return (
|
||||
<section className="permission-group" key={group.name}>
|
||||
<div className="permission-group-head"><div><strong>{group.name}</strong><span>{selectedCount}/{groupKeys.length} 已选择</span></div><Checkbox label="全选" checked={selectedCount === groupKeys.length} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermissionGroup(group)} /></div>
|
||||
<div className="permission-options">{group.permissions.map((permission) => <Checkbox key={permission.key} label={permission.label} checked={permissionDraft.includes(permission.key)} disabled={permissionRole.builtIn || !canManage} onChange={() => togglePermission(permission.key)} />)}</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="drawer-actions"><Button variant="outline" onClick={() => setPermissionRole(null)}>关闭</Button>{permissionRole.builtIn || !canManage ? null : <Button onClick={savePermissions}>保存权限</Button>}</div>
|
||||
</Drawer>
|
||||
) : null}
|
||||
{deleteRoleTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除角色确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteRoleTarget(null)}
|
||||
onConfirm={() => void deleteRole(deleteRoleTarget)}
|
||||
>
|
||||
<p>确认删除角色「{deleteRoleTarget.name}」吗?如果仍有用户使用该角色,系统会拒绝删除。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
|
||||
import { customers, gateways, customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function RoutesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="线路与路由" desc="线路组、客户网关业务分流、供应商网关映射、失败重试策略、号码前缀和生效时段。" actions={<Button icon={<Icon type="reload" />}>模拟 dr_reload</Button>} />
|
||||
<section className="content-grid">
|
||||
<Panel title="线路组" className="wide-panel">
|
||||
<SimpleTable rows={routeGroups} columns={[
|
||||
{ key: 'id', label: '线路组 ID' },
|
||||
{ key: 'name', label: '线路组名称' },
|
||||
{ key: 'customers', label: '适用客户' },
|
||||
{ key: 'gateways', label: '网关列表' },
|
||||
{ key: 'strategy', label: '路由策略' },
|
||||
{ key: 'retry', label: '失败重试策略' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
]} />
|
||||
</Panel>
|
||||
<Panel title="路由规则" className="wide-panel" aside={<Badge tone="info">dr_rules / dialplan</Badge>}>
|
||||
<SimpleTable rows={routeRules} columns={[
|
||||
{ key: 'id', label: '规则 ID' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'prefix', label: '号码前缀' },
|
||||
{ key: 'routeGroup', label: '线路组' },
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'window', label: '生效时间' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{ key: 'remark', label: '备注' },
|
||||
]} />
|
||||
</Panel>
|
||||
<Panel title="客户网关业务分流" className="wide-panel">
|
||||
<SimpleTable rows={customerGatewayPolicies} columns={[
|
||||
{ key: 'gateway', label: '客户网关' },
|
||||
{ key: 'name', label: '策略名称' },
|
||||
{ key: 'callerMatch', label: '主叫匹配', render: (row) => row.callerMode === 'any' ? '不限' : `${row.callerMode === 'equals' ? '等于' : '前缀'} ${row.callerValue}` },
|
||||
{ key: 'calleeMatch', label: '被叫匹配', render: (row) => row.calleeMode === 'any' ? '不限' : `${row.calleeMode === 'equals' ? '等于' : '前缀'} ${row.calleeValue}` },
|
||||
{ key: 'routeGroup', label: '呼叫至线路群组' },
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
]} />
|
||||
</Panel>
|
||||
<Panel title="供应商网关业务匹配" className="wide-panel">
|
||||
<SimpleTable rows={vendorGatewayPolicies} columns={[
|
||||
{ key: 'vendor', label: '供应商' },
|
||||
{ key: 'gateway', label: '供应商网关' },
|
||||
{ key: 'caller', label: '主叫号码/号段' },
|
||||
{ key: 'calleePrefix', label: '被叫前缀' },
|
||||
{ key: 'business', label: '成本业务' },
|
||||
{ key: 'vendorRate', label: '供应商费率' },
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button, Field, Input, Select, Switch } from '../components/ui.jsx';
|
||||
import { PageTitle, Panel } from '../components/layout.jsx';
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="系统设置" desc="SIP 域名、监听地址、MI 连接、RTPEngine、录音存储和全局计费参数。" actions={<Button>保存配置</Button>} />
|
||||
<section className="settings-grid">
|
||||
<Panel title="SIP 与 MI">
|
||||
<Field label="默认 SIP 域名"><Input defaultValue="voice.example.net" /></Field>
|
||||
<Field label="监听地址"><Input defaultValue="udp:0.0.0.0:5060" /></Field>
|
||||
<Field label="MI 连接配置"><Input defaultValue="http://10.0.2.11:8080/mi" /></Field>
|
||||
</Panel>
|
||||
<Panel title="媒体与录音">
|
||||
<Field label="RTPEngine 节点"><Input defaultValue="udp:10.0.2.21:2223" /></Field>
|
||||
<Field label="录音存储"><Input defaultValue="s3://softswitch-recordings" /></Field>
|
||||
<Field label="录音保留策略"><Select defaultValue="180"><option value="30">30 天</option><option value="90">90 天</option><option value="180">180 天</option></Select></Field>
|
||||
</Panel>
|
||||
<Panel title="全局策略">
|
||||
<Switch label="启用预付费余额控制" checked readOnly />
|
||||
<Switch label="启用页面试听录音" checked readOnly />
|
||||
<Switch label="启用操作审计" checked readOnly />
|
||||
<Field label="默认时区"><Input defaultValue="Asia/Shanghai" /></Field>
|
||||
<Field label="时间格式"><Input defaultValue="YYYY-MM-DD HH:mm:ss" /></Field>
|
||||
</Panel>
|
||||
<Panel title="安全策略">
|
||||
<Switch label="强制 MFA" checked={false} readOnly />
|
||||
<Switch label="敏感操作二次确认" checked readOnly />
|
||||
<Field label="会话超时"><Input defaultValue="30 分钟" /></Field>
|
||||
<Field label="导出水印"><Input defaultValue="用户 + 时间 + IP" /></Field>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
|
||||
const emptyRoleForm = { name: '', description: '', status: '启用' };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Badge, Button, Progress } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, StatusBadge, SimpleTable } from '../components/layout.jsx';
|
||||
import { sipAccounts, opsItems } from '../fixtures/devFixtures.js';
|
||||
|
||||
export function SipOpsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="SIP 运维" desc="面向技术运维的在线注册、在线通话、SIP Trace、网关状态和 RTPEngine 状态原型。" actions={<Button icon={<Icon type="reload" />}>刷新 MI 状态</Button>} />
|
||||
<section className="content-grid">
|
||||
<Panel title="在线注册" className="wide-panel" aside={<Badge tone="info">usrloc</Badge>}>
|
||||
<SimpleTable rows={sipAccounts} columns={[{ key: 'user', label: '账号' }, { key: 'domain', label: 'Domain' }, { key: 'register', label: '注册状态', status: true }, { key: 'contact', label: 'Contact' }, { key: 'expires', label: 'Expires' }]} />
|
||||
</Panel>
|
||||
<Panel title="运行检查">
|
||||
{opsItems.slice(0, 3).map((item) => (
|
||||
<div className="ops-row" key={item.name}><div><strong>{item.name}</strong><span>{item.target}</span></div><StatusBadge>{item.status}</StatusBadge></div>
|
||||
))}
|
||||
</Panel>
|
||||
<Panel title="SIP Trace 摘要">
|
||||
<pre className="trace-box">{'INVITE sip:13800138000@carrier.example SIP/2.0\n100 Trying\n183 Session Progress\n200 OK\nACK\nBYE\n200 OK'}</pre>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [editingUser, setEditingUser] = useState(undefined);
|
||||
const [userForm, setUserForm] = useState(emptyUserForm);
|
||||
const [resetTarget, setResetTarget] = useState(null);
|
||||
const [deleteUserTarget, setDeleteUserTarget] = useState(null);
|
||||
const [resetMessage, setResetMessage] = useState('');
|
||||
const [actionError, setActionError] = useState('');
|
||||
const canManage = can('users.manage');
|
||||
const roleMap = useMemo(() => new Map(roleRows.map((role) => [role.id, role.name])), [roleRows]);
|
||||
const visibleUsers = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
return userRows.filter((user) => {
|
||||
const matchesKeyword = !normalized || [user.username, user.name, user.phone, user.email].some((value) => value.toLowerCase().includes(normalized));
|
||||
return matchesKeyword && (roleFilter === 'all' || user.roleId === roleFilter) && (statusFilter === 'all' || user.status === statusFilter);
|
||||
});
|
||||
}, [keyword, roleFilter, statusFilter, userRows]);
|
||||
const openCreateUser = () => {
|
||||
setEditingUser(null);
|
||||
setUserForm(emptyUserForm);
|
||||
};
|
||||
const openEditUser = (user) => {
|
||||
setEditingUser(user);
|
||||
setUserForm({ username: user.username, name: user.name, phone: user.phone, email: user.email, roleId: user.roleId, status: user.status });
|
||||
};
|
||||
const closeUserModal = () => {
|
||||
setEditingUser(undefined);
|
||||
setUserForm(emptyUserForm);
|
||||
};
|
||||
const submitUser = (event) => {
|
||||
event.preventDefault();
|
||||
if (!userForm.username.trim() || !userForm.name.trim() || !userForm.roleId) return;
|
||||
if (editingUser) {
|
||||
setUserRows((rows) => rows.map((user) => user.id === editingUser.id ? { ...user, ...userForm } : user));
|
||||
} else {
|
||||
const nextId = `U${String(1001 + userRows.length).padStart(4, '0')}`;
|
||||
setUserRows((rows) => [...rows, { id: nextId, ...userForm, lastLogin: '从未登录', lastIp: '-' }]);
|
||||
}
|
||||
closeUserModal();
|
||||
};
|
||||
const toggleUserStatus = (user) => {
|
||||
setUserRows((rows) => rows.map((item) => item.id === user.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item));
|
||||
};
|
||||
const deleteUser = async (user) => {
|
||||
try {
|
||||
setActionError('');
|
||||
if (onDeleteUser) {
|
||||
await onDeleteUser(user.id);
|
||||
} else {
|
||||
setUserRows((rows) => rows.filter((item) => item.id !== user.id));
|
||||
}
|
||||
setDeleteUserTarget(null);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const confirmResetPassword = () => {
|
||||
setResetMessage(`${resetTarget.name}(${resetTarget.username})的密码已重置,下次登录需修改密码。`);
|
||||
setResetTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="用户管理" desc="管理运营端登录用户、所属角色、账号状态和登录安全。" actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreateUser}>新增用户</Button> : null} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
{resetMessage ? <Alert title="密码重置完成" tone="success">{resetMessage}</Alert> : null}
|
||||
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
||||
<Toolbar>
|
||||
<Field label="用户信息"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户名、姓名、手机号或邮箱" /></Field>
|
||||
<Field label="角色"><Select value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}><option value="all">全部角色</option>{roleRows.map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
|
||||
<Field label="状态"><Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">全部状态</option><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
|
||||
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="用户列表" aside={<Badge tone="neutral">共 {visibleUsers.length} 个用户</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
{ key: 'roleName', label: '角色' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{ key: 'lastLogin', label: '最后登录时间' },
|
||||
{ key: 'lastIp', label: '最后登录 IP' },
|
||||
{ key: 'actions', label: '操作', render: (row) => <div className="table-actions">{canManage ? <Button size="sm" variant="outline" onClick={() => openEditUser(row)}>编辑</Button> : null}{canManage ? <Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => toggleUserStatus(row)}>{row.status === '启用' ? '禁用' : '启用'}</Button> : null}{canManage ? <Button size="sm" variant="outline" onClick={() => setResetTarget(row)}>重置密码</Button> : null}{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteUserTarget(row)}>删除</Button> : '-'}</div> },
|
||||
]} />
|
||||
</Panel>
|
||||
{editingUser !== undefined ? (
|
||||
<Modal title={editingUser ? '编辑用户' : '新增用户'} onClose={closeUserModal}>
|
||||
<form className="modal-form" onSubmit={submitUser}>
|
||||
<div className="form-grid admin-form-grid">
|
||||
<Field label={<span>用户名 <span className="required-star">*</span></span>}><Input value={userForm.username} onChange={(event) => setUserForm({ ...userForm, username: event.target.value })} placeholder="用于登录,不可重复" disabled={Boolean(editingUser)} required /></Field>
|
||||
<Field label={<span>姓名 <span className="required-star">*</span></span>}><Input value={userForm.name} onChange={(event) => setUserForm({ ...userForm, name: event.target.value })} placeholder="请输入用户姓名" required /></Field>
|
||||
<Field label="手机号"><Input value={userForm.phone} onChange={(event) => setUserForm({ ...userForm, phone: event.target.value })} placeholder="请输入手机号" /></Field>
|
||||
<Field label="邮箱"><Input type="email" value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} placeholder="请输入邮箱" /></Field>
|
||||
<Field label={<span>角色 <span className="required-star">*</span></span>}><Select value={userForm.roleId} onChange={(event) => setUserForm({ ...userForm, roleId: event.target.value })} required>{roleRows.filter((role) => role.status === '启用').map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Field>
|
||||
<Field label="状态"><Select value={userForm.status} onChange={(event) => setUserForm({ ...userForm, status: event.target.value })}><option value="启用">启用</option><option value="禁用">禁用</option></Select></Field>
|
||||
</div>
|
||||
<div className="modal-actions"><Button type="button" variant="outline" onClick={closeUserModal}>取消</Button><Button type="submit">{editingUser ? '保存修改' : '保存用户'}</Button></div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{resetTarget ? (
|
||||
<ConfirmDialog
|
||||
title="确认重置密码"
|
||||
confirmLabel="确认重置"
|
||||
onCancel={() => setResetTarget(null)}
|
||||
onConfirm={confirmResetPassword}
|
||||
>
|
||||
<p>确认重置用户「{resetTarget.name}({resetTarget.username})」的登录密码吗?重置后该用户需要使用临时密码登录并立即修改密码。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
{deleteUserTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除用户确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteUserTarget(null)}
|
||||
onConfirm={() => void deleteUser(deleteUserTarget)}
|
||||
>
|
||||
<p>确认删除用户「{deleteUserTarget.name}({deleteUserTarget.username})」吗?删除后该用户将不能登录,也不再出现在用户列表中。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Checkbox, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { vendors, gateways } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows: setApiGatewayRows, vendorRows: apiVendorRows, apiLoading, apiError, refreshApi, can = () => true, onUpdateGateway, onToggleGatewayStatus, onDeleteGateway }) {
|
||||
const [localGatewayRows, setLocalGatewayRows] = useState(gateways);
|
||||
const gatewayRows = Array.isArray(apiGatewayRows) ? apiGatewayRows : localGatewayRows;
|
||||
const setGatewayRows = setApiGatewayRows || setLocalGatewayRows;
|
||||
const vendorOptions = apiVendorRows?.length ? apiVendorRows : vendors;
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [editingGateway, setEditingGateway] = useState(null);
|
||||
const [gatewayConfirm, setGatewayConfirm] = useState(null);
|
||||
const provinceOptions = ['北京', '上海', '广东', '浙江', '江苏', '新疆', '西藏', '港澳台', '海外'];
|
||||
const codecOptions = ['PCMA', 'PCMU', 'G729', 'G722', 'OPUS'];
|
||||
const canManage = can('vendor_gateways.manage');
|
||||
const splitList = (value) => (value && !['无', '-'].includes(value) ? value.split(/[,,、]/).map((item) => item.trim()).filter(Boolean) : []);
|
||||
const splitTimeRanges = (value) => {
|
||||
const ranges = String(value || '')
|
||||
.split(/[,,、\n]/)
|
||||
.map((item) => {
|
||||
const [start = '', end = ''] = item.split('-').map((part) => part.trim());
|
||||
return { start, end };
|
||||
})
|
||||
.filter((item) => item.start || item.end);
|
||||
return ranges.length ? ranges : [{ start: '', end: '' }];
|
||||
};
|
||||
const splitRate = (value) => {
|
||||
return String(value || '').trim();
|
||||
};
|
||||
const formatMinuteRate = (cycle, rate) => {
|
||||
const billingCycle = Number(cycle);
|
||||
const cycleRate = Number(rate);
|
||||
if (!billingCycle || !Number.isFinite(billingCycle) || !Number.isFinite(cycleRate)) return '-';
|
||||
return `¥${((cycleRate * 60) / billingCycle).toFixed(4)}/分钟`;
|
||||
};
|
||||
const rewritePoolRows = (value) => {
|
||||
const rows = Array.isArray(value) ? value.map((item) => ({ caller: item.caller || '', weight: String(item.weight || 1) })) : [];
|
||||
return rows.length ? rows : [{ caller: '', weight: '1' }];
|
||||
};
|
||||
const emptyGatewayForm = {
|
||||
vendor: vendorOptions[0]?.name || '',
|
||||
name: '',
|
||||
authMode: 'IP',
|
||||
ipAddress: '',
|
||||
sipAccount: '',
|
||||
sipPassword: '',
|
||||
concurrencyLimit: '',
|
||||
billingCycle: '60',
|
||||
cycleRate: '',
|
||||
requestRate: '',
|
||||
blockedProvinces: [],
|
||||
forbiddenPeriods: [{ start: '', end: '' }],
|
||||
codecs: [],
|
||||
landingCalleePrefix: '',
|
||||
callerRewritePool: [{ caller: '', weight: '1' }],
|
||||
};
|
||||
const [gatewayForm, setGatewayForm] = useState(emptyGatewayForm);
|
||||
const openEditGateway = (gateway) => {
|
||||
setEditingGateway(gateway);
|
||||
const rateValue = splitRate(gateway.requestRate);
|
||||
setGatewayForm({
|
||||
vendor: gateway.vendor,
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode,
|
||||
ipAddress: gateway.ipAddress || '',
|
||||
sipAccount: gateway.sipAccount || '',
|
||||
sipPassword: gateway.sipPassword || '',
|
||||
concurrencyLimit: String(gateway.concurrencyLimit),
|
||||
billingCycle: String(gateway.billingCycle || 60),
|
||||
cycleRate: String(gateway.cycleRate ?? ''),
|
||||
requestRate: rateValue,
|
||||
blockedProvinces: splitList(gateway.blockedProvinces),
|
||||
forbiddenPeriods: splitTimeRanges(gateway.callTimeLimit),
|
||||
codecs: splitList(gateway.codecs),
|
||||
landingCalleePrefix: gateway.landingCalleePrefix || '',
|
||||
callerRewritePool: rewritePoolRows(gateway.callerRewritePool),
|
||||
});
|
||||
};
|
||||
const closeEditGateway = () => {
|
||||
setEditingGateway(null);
|
||||
setGatewayForm(emptyGatewayForm);
|
||||
};
|
||||
const submitGateway = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate) return;
|
||||
const billingCycle = Number(gatewayForm.billingCycle);
|
||||
const cycleRate = Number(gatewayForm.cycleRate);
|
||||
if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) return;
|
||||
const ipAddress = gatewayForm.ipAddress.trim();
|
||||
const sipAccount = gatewayForm.sipAccount.trim();
|
||||
const sipPassword = gatewayForm.sipPassword.trim();
|
||||
const authValid = gatewayForm.authMode === 'IP' ? ipAddress : ipAddress && sipAccount;
|
||||
if (!authValid) return;
|
||||
const vendorId = vendorOptions.find((vendor) => vendor.name === gatewayForm.vendor)?.id || editingGateway.vendorId;
|
||||
const callerRewritePool = gatewayForm.callerRewritePool
|
||||
.map((item) => ({ caller: item.caller.trim(), weight: Number(item.weight || 1), status: 'ENABLED' }))
|
||||
.filter((item) => item.caller);
|
||||
const body = {
|
||||
vendorId,
|
||||
name: gatewayForm.name.trim(),
|
||||
authMode: gatewayForm.authMode === 'SIP注册' ? 'SIP_DIGEST' : gatewayForm.authMode,
|
||||
host: ipAddress,
|
||||
port: editingGateway.port || 5060,
|
||||
transport: editingGateway.transport || 'udp',
|
||||
sipUsername: sipAccount || undefined,
|
||||
cpsLimit: Number(String(gatewayForm.requestRate).match(/\d+/)?.[0] || 0),
|
||||
concurrencyLimit: Number(gatewayForm.concurrencyLimit),
|
||||
billingCycleSec: billingCycle,
|
||||
cycleRate: String(cycleRate),
|
||||
landingCalleePrefix: gatewayForm.landingCalleePrefix.trim() || null,
|
||||
callerRewritePool,
|
||||
forbiddenPeriods: gatewayForm.forbiddenPeriods
|
||||
.filter((period) => period.start || period.end)
|
||||
.map((period) => ({ weekdayMask: 127, startTime: `${period.start || '00:00'}:00`, endTime: `${period.end || '23:59'}:00` })),
|
||||
codecs: gatewayForm.codecs.map((codec, index) => ({ codec, priority: index + 1 })),
|
||||
prefixRules: [],
|
||||
};
|
||||
if (sipPassword) {
|
||||
body.sipPassword = sipPassword;
|
||||
}
|
||||
setActionError('');
|
||||
try {
|
||||
if (onUpdateGateway) {
|
||||
await onUpdateGateway(editingGateway.id, body);
|
||||
} else {
|
||||
setGatewayRows((rows) => rows.map((gateway) => (
|
||||
gateway.id === editingGateway.id
|
||||
? {
|
||||
...gateway,
|
||||
vendor: gatewayForm.vendor,
|
||||
vendorId,
|
||||
name: body.name,
|
||||
authMode: gatewayForm.authMode,
|
||||
ipAddress,
|
||||
sipAccount,
|
||||
sipPassword: sipPassword ? '******' : gateway.sipPassword,
|
||||
concurrencyLimit: body.concurrencyLimit,
|
||||
billingCycle,
|
||||
cycleRate,
|
||||
requestRate: gatewayForm.requestRate.trim() || '-',
|
||||
blockedProvinces: gatewayForm.blockedProvinces.length ? gatewayForm.blockedProvinces.join('、') : '无',
|
||||
callTimeLimit: gatewayForm.forbiddenPeriods
|
||||
.filter((period) => period.start || period.end)
|
||||
.map((period) => `${period.start || '00:00'}-${period.end || '23:59'}`)
|
||||
.join('、') || '无',
|
||||
codecs: gatewayForm.codecs.length ? gatewayForm.codecs.join(', ') : '-',
|
||||
landingCalleePrefix: body.landingCalleePrefix || '',
|
||||
callerRewritePool,
|
||||
calleePrefixTransform: '-',
|
||||
callerPrefixTransform: '-',
|
||||
}
|
||||
: gateway
|
||||
)));
|
||||
}
|
||||
closeEditGateway();
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const toggleArrayValue = (field, value) => {
|
||||
setGatewayForm((form) => ({
|
||||
...form,
|
||||
[field]: form[field].includes(value) ? form[field].filter((item) => item !== value) : [...form[field], value],
|
||||
}));
|
||||
};
|
||||
const updateCallerRewrite = (index, key, value) => {
|
||||
setGatewayForm((form) => ({
|
||||
...form,
|
||||
callerRewritePool: form.callerRewritePool.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, [key]: value } : rule)),
|
||||
}));
|
||||
};
|
||||
const addCallerRewrite = () => {
|
||||
setGatewayForm((form) => ({ ...form, callerRewritePool: [...form.callerRewritePool, { caller: '', weight: '1' }] }));
|
||||
};
|
||||
const removeCallerRewrite = (index) => {
|
||||
setGatewayForm((form) => ({
|
||||
...form,
|
||||
callerRewritePool: form.callerRewritePool.length > 1 ? form.callerRewritePool.filter((_, ruleIndex) => ruleIndex !== index) : [{ caller: '', weight: '1' }],
|
||||
}));
|
||||
};
|
||||
const updateForbiddenPeriod = (index, key, value) => {
|
||||
setGatewayForm((form) => ({
|
||||
...form,
|
||||
forbiddenPeriods: form.forbiddenPeriods.map((period, periodIndex) => (
|
||||
periodIndex === index ? { ...period, [key]: value } : period
|
||||
)),
|
||||
}));
|
||||
};
|
||||
const addForbiddenPeriod = () => {
|
||||
setGatewayForm((form) => ({ ...form, forbiddenPeriods: [...form.forbiddenPeriods, { start: '', end: '' }] }));
|
||||
};
|
||||
const removeForbiddenPeriod = (index) => {
|
||||
setGatewayForm((form) => ({
|
||||
...form,
|
||||
forbiddenPeriods: form.forbiddenPeriods.length > 1 ? form.forbiddenPeriods.filter((_, periodIndex) => periodIndex !== index) : [{ start: '', end: '' }],
|
||||
}));
|
||||
};
|
||||
const toggleVendorGatewayStatus = async (gateway) => {
|
||||
setActionError('');
|
||||
try {
|
||||
if (onToggleGatewayStatus) {
|
||||
await onToggleGatewayStatus(gateway);
|
||||
return;
|
||||
}
|
||||
setGatewayRows((rows) => rows.map((item) => (
|
||||
item.id === gateway.id ? { ...item, status: item.status === '启用' ? '禁用' : '启用' } : item
|
||||
)));
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const deleteVendorGateway = async (gateway) => {
|
||||
setActionError('');
|
||||
try {
|
||||
if (onDeleteGateway) {
|
||||
await onDeleteGateway(gateway.id);
|
||||
return;
|
||||
}
|
||||
setGatewayRows((rows) => rows.filter((item) => item.id !== gateway.id));
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="落地网关管理" desc="管理供应商落地网关认证、并发、价格和号码限制策略。" actions={canManage ? <Button icon={<Icon type="plus" />}>新增落地网关</Button> : null} />
|
||||
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label="供应商"><Select defaultValue="all"><option value="all">全部供应商</option>{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}</Select></Field>
|
||||
<Field label="落地网关名称"><Input placeholder="搜索网关名称" /></Field>
|
||||
<Button icon={<Icon type="search" />}>查询</Button>
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<Panel title="落地网关列表" className="wide-panel">
|
||||
<SimpleTable rows={gatewayRows} columns={[
|
||||
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'authMode', label: '认证方式' },
|
||||
{ key: 'authTarget', label: 'IP/账号', render: (row) => (row.authMode === 'IP' ? row.ipAddress : row.sipAccount) },
|
||||
{ key: 'concurrencyLimit', label: '并发上限' },
|
||||
{ key: 'minuteRate', label: '价格', render: (row) => formatMinuteRate(row.billingCycle, row.cycleRate) },
|
||||
{ key: 'landingCalleePrefix', label: '落地被叫前缀', render: (row) => row.landingCalleePrefix || '-' },
|
||||
{ key: 'callerRewriteCount', label: '指定主叫数', render: (row) => (row.callerRewritePool || []).length },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditGateway(row)}>编辑</Button> : null}
|
||||
{canManage ? (
|
||||
<Button size="sm" variant={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setGatewayConfirm({ type: 'toggle', row })}>
|
||||
{row.status === '启用' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? <Button size="sm" variant="danger" onClick={() => setGatewayConfirm({ type: 'delete', row })}>删除</Button> : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
{editingGateway ? (
|
||||
<Modal title="编辑落地网关" onClose={closeEditGateway} size="lg">
|
||||
<form className="modal-form" onSubmit={submitGateway}>
|
||||
<div className="match-grid">
|
||||
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
|
||||
<Select value={gatewayForm.vendor} onChange={(event) => setGatewayForm({ ...gatewayForm, vendor: event.target.value })} required>
|
||||
{vendorOptions.map((vendor) => <option key={vendor.id}>{vendor.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={<span>落地网关名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.name} onChange={(event) => setGatewayForm({ ...gatewayForm, name: event.target.value })} placeholder="请输入落地网关名称" required />
|
||||
</Field>
|
||||
<Field label={<span>认证方式 <span className="required-star">*</span></span>}>
|
||||
<Select value={gatewayForm.authMode} onChange={(event) => setGatewayForm({ ...gatewayForm, authMode: event.target.value, ipAddress: '', sipAccount: '', sipPassword: '' })} required>
|
||||
<option>IP</option>
|
||||
<option>SIP注册</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={<span>落地主机/IP <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.ipAddress} onChange={(event) => setGatewayForm({ ...gatewayForm, ipAddress: event.target.value })} placeholder="例如 203.0.113.18 或 sip.carrier.local" required />
|
||||
</Field>
|
||||
{gatewayForm.authMode !== 'IP' ? (
|
||||
<>
|
||||
<Field label={<span>SIP账号 <span className="required-star">*</span></span>}>
|
||||
<Input value={gatewayForm.sipAccount} onChange={(event) => setGatewayForm({ ...gatewayForm, sipAccount: event.target.value })} placeholder="请输入 SIP 账号" required />
|
||||
</Field>
|
||||
<Field label="SIP密码(留空不修改)">
|
||||
<Input type="password" value={gatewayForm.sipPassword} onChange={(event) => setGatewayForm({ ...gatewayForm, sipPassword: event.target.value })} placeholder="至少 12 位" />
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
<Field label={<span>并发上限 <span className="required-star">*</span></span>}>
|
||||
<Input type="number" min="1" value={gatewayForm.concurrencyLimit} onChange={(event) => setGatewayForm({ ...gatewayForm, concurrencyLimit: event.target.value })} placeholder="例如 800" required />
|
||||
</Field>
|
||||
<div className="rate-config-card">
|
||||
<div className="rate-config-head">
|
||||
<strong>费率配置</strong>
|
||||
<span>{formatMinuteRate(gatewayForm.billingCycle, gatewayForm.cycleRate)}</span>
|
||||
</div>
|
||||
<div className="rate-config-fields">
|
||||
<Field label={<span>计费周期 <span className="required-star">*</span></span>}>
|
||||
<Input type="number" min="1" max="60" value={gatewayForm.billingCycle} onChange={(event) => setGatewayForm({ ...gatewayForm, billingCycle: event.target.value })} placeholder="最大 60 秒" required />
|
||||
</Field>
|
||||
<Field label={<span>周期费率 <span className="required-star">*</span></span>}>
|
||||
<Input type="number" min="0" step="0.0001" value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="例如 0.02" required />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<strong>请求速率</strong>
|
||||
<Input value={gatewayForm.requestRate} onChange={(event) => setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="例如 120 CPS" />
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<strong>屏蔽省份</strong>
|
||||
<div className="option-grid">
|
||||
{provinceOptions.map((province) => (
|
||||
<Checkbox key={province} label={province} checked={gatewayForm.blockedProvinces.includes(province)} onChange={() => toggleArrayValue('blockedProvinces', province)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<div className="config-card-head">
|
||||
<strong>禁呼时段</strong>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addForbiddenPeriod}>添加时段</Button>
|
||||
</div>
|
||||
<div className="rule-list">
|
||||
{gatewayForm.forbiddenPeriods.map((period, index) => (
|
||||
<div className="time-range" key={`forbidden-${index}`}>
|
||||
<Input type="time" value={period.start} onChange={(event) => updateForbiddenPeriod(index, 'start', event.target.value)} />
|
||||
<span>至</span>
|
||||
<Input type="time" value={period.end} onChange={(event) => updateForbiddenPeriod(index, 'end', event.target.value)} />
|
||||
<Button type="button" size="sm" variant="danger" onClick={() => removeForbiddenPeriod(index)}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<strong>语音编码限制</strong>
|
||||
<div className="option-grid">
|
||||
{codecOptions.map((codec) => (
|
||||
<Checkbox key={codec} label={codec} checked={gatewayForm.codecs.includes(codec)} onChange={() => toggleArrayValue('codecs', codec)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card config-card-wide">
|
||||
<strong>落地要求被叫前缀</strong>
|
||||
<Input value={gatewayForm.landingCalleePrefix} onChange={(event) => setGatewayForm({ ...gatewayForm, landingCalleePrefix: event.target.value })} placeholder="可为空,例如 86 或 ABC" />
|
||||
</div>
|
||||
<div className="config-card config-card-wide">
|
||||
<div className="config-card-head">
|
||||
<strong>落地要求指定主叫</strong>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addCallerRewrite}>添加号码</Button>
|
||||
</div>
|
||||
<div className="rule-list">
|
||||
{gatewayForm.callerRewritePool.map((rule, index) => (
|
||||
<div className="prefix-rule" key={`caller-${index}`}>
|
||||
<Input value={rule.caller} onChange={(event) => updateCallerRewrite(index, 'caller', event.target.value)} placeholder="指定主叫,如 02160010001" />
|
||||
<span>权重</span>
|
||||
<Input type="number" min="1" value={rule.weight} onChange={(event) => updateCallerRewrite(index, 'weight', event.target.value)} />
|
||||
<Button type="button" size="sm" variant="danger" onClick={() => removeCallerRewrite(index)}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeEditGateway}>取消</Button>
|
||||
<Button type="submit">保存修改</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{gatewayConfirm ? (
|
||||
<ConfirmDialog
|
||||
title={gatewayConfirm.type === 'delete' ? '删除落地网关确认' : `${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关确认`}
|
||||
confirmLabel={gatewayConfirm.type === 'delete' ? '确认删除' : `确认${gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}`}
|
||||
confirmVariant={gatewayConfirm.type === 'delete' ? 'danger' : 'primary'}
|
||||
onCancel={() => setGatewayConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const { type, row } = gatewayConfirm;
|
||||
setGatewayConfirm(null);
|
||||
return type === 'delete' ? void deleteVendorGateway(row) : void toggleVendorGatewayStatus(row);
|
||||
}}
|
||||
>
|
||||
{gatewayConfirm.type === 'delete' ? (
|
||||
<p>确认删除落地网关「{gatewayConfirm.row.name}」吗?删除后该网关将不再参与落地。</p>
|
||||
) : (
|
||||
<p>确认{gatewayConfirm.row.status === '启用' ? '禁用' : '启用'}落地网关「{gatewayConfirm.row.name}」吗?</p>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDate, zhStatus } from '../utils/formatters.js';
|
||||
import { gateways, vendorLineGroups } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineGroupRows: setApiLineGroupRows, gatewayRows: apiGatewayRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteLineGroup }) {
|
||||
const [localLineGroupRows, setLocalLineGroupRows] = useState(vendorLineGroups);
|
||||
const lineGroupRows = Array.isArray(apiLineGroupRows) ? apiLineGroupRows : localLineGroupRows;
|
||||
const setLineGroupRows = setApiLineGroupRows || setLocalLineGroupRows;
|
||||
const availableGateways = apiGatewayRows?.length ? apiGatewayRows : gateways;
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [editingLineGroup, setEditingLineGroup] = useState(null);
|
||||
const [deleteLineGroupTarget, setDeleteLineGroupTarget] = useState(null);
|
||||
const [lineGroupForm, setLineGroupForm] = useState({ name: '', gatewayIds: [] });
|
||||
const [showAddGatewayModal, setShowAddGatewayModal] = useState(false);
|
||||
const [addGatewayForm, setAddGatewayForm] = useState({ gatewayId: '' });
|
||||
const canManage = can('line_groups.manage');
|
||||
const findGateway = (gatewayId) => availableGateways.find((gateway) => gateway.id === gatewayId);
|
||||
const getLineGroupConcurrency = (group) => group.gatewayIds.reduce((total, gatewayId) => total + (findGateway(gatewayId)?.concurrencyLimit || 0), 0);
|
||||
const openEditLineGroup = (group) => {
|
||||
setEditingLineGroup(group);
|
||||
setLineGroupForm({ name: group.name, gatewayIds: [...group.gatewayIds] });
|
||||
setShowAddGatewayModal(false);
|
||||
setAddGatewayForm({ gatewayId: '' });
|
||||
};
|
||||
const closeEditLineGroup = () => {
|
||||
setEditingLineGroup(null);
|
||||
setLineGroupForm({ name: '', gatewayIds: [] });
|
||||
setShowAddGatewayModal(false);
|
||||
setAddGatewayForm({ gatewayId: '' });
|
||||
};
|
||||
const addGatewayToLineGroup = () => {
|
||||
if (!addGatewayForm.gatewayId || lineGroupForm.gatewayIds.includes(addGatewayForm.gatewayId)) return;
|
||||
setLineGroupForm((form) => ({ ...form, gatewayIds: [...form.gatewayIds, addGatewayForm.gatewayId] }));
|
||||
setAddGatewayForm({ gatewayId: '' });
|
||||
setShowAddGatewayModal(false);
|
||||
};
|
||||
const removeGatewayFromLineGroup = (gatewayId) => {
|
||||
setLineGroupForm((form) => ({ ...form, gatewayIds: form.gatewayIds.filter((id) => id !== gatewayId) }));
|
||||
};
|
||||
const moveLineGroupGateway = (gatewayId, direction) => {
|
||||
setLineGroupForm((form) => {
|
||||
const ids = [...form.gatewayIds];
|
||||
const currentIndex = ids.indexOf(gatewayId);
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ids.length) return form;
|
||||
[ids[currentIndex], ids[nextIndex]] = [ids[nextIndex], ids[currentIndex]];
|
||||
return { ...form, gatewayIds: ids };
|
||||
});
|
||||
};
|
||||
const submitLineGroup = (event) => {
|
||||
event.preventDefault();
|
||||
if (!editingLineGroup || !lineGroupForm.name.trim()) return;
|
||||
setLineGroupRows((rows) => rows.map((group) => (
|
||||
group.id === editingLineGroup.id ? { ...group, name: lineGroupForm.name.trim(), gatewayIds: lineGroupForm.gatewayIds } : group
|
||||
)));
|
||||
closeEditLineGroup();
|
||||
};
|
||||
const deleteLineGroup = async (group) => {
|
||||
if ((group.customerGatewayCount ?? 0) > 0) {
|
||||
setActionError(`线路组「${group.name}」仍被 ${group.customerGatewayCount} 个客户网关使用,不能删除。`);
|
||||
return;
|
||||
}
|
||||
setActionError('');
|
||||
try {
|
||||
if (onDeleteLineGroup) {
|
||||
await onDeleteLineGroup(group.id);
|
||||
setDeleteLineGroupTarget(null);
|
||||
return;
|
||||
}
|
||||
setLineGroupRows((rows) => rows.filter((item) => item.id !== group.id));
|
||||
setDeleteLineGroupTarget(null);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="落地线路组" desc="配置落地线路组、组内线路优先级和汇总并发上限。" actions={canManage ? <Button icon={<Icon type="plus" />}>新增线路组</Button> : null} />
|
||||
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label="线路组名称"><Input placeholder="搜索线路组名称" /></Field>
|
||||
<Button icon={<Icon type="search" />}>查询</Button>
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<Panel title="落地线路组列表" className="wide-panel">
|
||||
<SimpleTable rows={lineGroupRows} columns={[
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
|
||||
{ key: 'customerGatewayCount', label: '使用客户网关数' },
|
||||
{ key: 'concurrencyLimit', label: '并发上限', render: (row) => getLineGroupConcurrency(row) },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManage ? <Button size="sm" variant="outline" onClick={() => openEditLineGroup(row)}>编辑</Button> : null}
|
||||
{canManage ? <Button size="sm" variant="danger" onClick={() => setDeleteLineGroupTarget(row)}>删除</Button> : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
{editingLineGroup ? (
|
||||
<Drawer title="编辑落地线路组" aside={<Badge tone="info">{editingLineGroup.id}</Badge>} onClose={closeEditLineGroup}>
|
||||
<form className="modal-form" onSubmit={submitLineGroup}>
|
||||
<Field label={<span>线路组名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={lineGroupForm.name} onChange={(event) => setLineGroupForm({ ...lineGroupForm, name: event.target.value })} placeholder="请输入线路组名称" required />
|
||||
</Field>
|
||||
<div className="drawer-toolbar">
|
||||
<div>
|
||||
<strong>{lineGroupForm.gatewayIds.length} 条线路</strong>
|
||||
<span>按优先级从小到大尝试落地网关。</span>
|
||||
</div>
|
||||
<Button type="button" icon={<Icon type="plus" />} onClick={() => setShowAddGatewayModal(true)}>添加网关</Button>
|
||||
</div>
|
||||
<SimpleTable rows={lineGroupForm.gatewayIds.map((gatewayId, index) => {
|
||||
const gateway = findGateway(gatewayId);
|
||||
return {
|
||||
id: gatewayId,
|
||||
priority: index + 1,
|
||||
vendor: gateway?.vendor || '-',
|
||||
name: gateway?.name || gatewayId,
|
||||
concurrencyLimit: gateway?.concurrencyLimit || 0,
|
||||
};
|
||||
})} columns={[
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'vendor', label: '供应商' },
|
||||
{ key: 'name', label: '落地网关' },
|
||||
{ key: 'concurrencyLimit', label: '并发上限' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, -1)}>上移</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => moveLineGroupGateway(row.id, 1)}>下移</Button>
|
||||
<Button type="button" size="sm" variant="danger" onClick={() => removeGatewayFromLineGroup(row.id)}>删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeEditLineGroup}>取消</Button>
|
||||
<Button type="submit">保存线路组</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Drawer>
|
||||
) : null}
|
||||
{showAddGatewayModal ? (
|
||||
<Modal title="添加落地网关" onClose={() => setShowAddGatewayModal(false)} size="sm">
|
||||
<form className="modal-form" onSubmit={(event) => { event.preventDefault(); addGatewayToLineGroup(); }}>
|
||||
<Field label={<span>落地网关 <span className="required-star">*</span></span>}>
|
||||
<Select value={addGatewayForm.gatewayId} onChange={(event) => setAddGatewayForm({ gatewayId: event.target.value })} required>
|
||||
<option value="">请选择落地网关</option>
|
||||
{availableGateways
|
||||
.filter((gateway) => !lineGroupForm.gatewayIds.includes(gateway.id))
|
||||
.map((gateway) => <option key={gateway.id} value={gateway.id}>{gateway.vendor} / {gateway.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={() => setShowAddGatewayModal(false)}>取消</Button>
|
||||
<Button type="submit">确认添加</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{deleteLineGroupTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除落地线路组确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteLineGroupTarget(null)}
|
||||
onConfirm={() => void deleteLineGroup(deleteLineGroupTarget)}
|
||||
>
|
||||
<p>确认删除落地线路组「{deleteLineGroupTarget.name}」吗?</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
|
||||
|
||||
function normalizeBusinessPrefix(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
prefix: item.prefix,
|
||||
name: item.name,
|
||||
description: item.description || '-',
|
||||
priority: item.priority ?? 100,
|
||||
status: zhStatus(item.status),
|
||||
gatewayCount: item.gatewayCount ?? 0,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Field, Input, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { gateways } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) {
|
||||
const [showCreateVendor, setShowCreateVendor] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState(null);
|
||||
const [rechargeVendor, setRechargeVendor] = useState(null);
|
||||
const [deleteVendorTarget, setDeleteVendorTarget] = useState(null);
|
||||
const [newVendor, setNewVendor] = useState({ name: '' });
|
||||
const [editVendorForm, setEditVendorForm] = useState({ name: '' });
|
||||
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
|
||||
const [actionError, setActionError] = useState('');
|
||||
const canManageVendors = can('vendors.manage');
|
||||
const canManageRecharges = can('recharges.manage');
|
||||
const parseMoney = (value) => Number(String(value).replace(/[^\d.-]/g, '')) || 0;
|
||||
const formatMoney = (value) => `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const openEditVendor = (vendor) => {
|
||||
setEditingVendor(vendor);
|
||||
setEditVendorForm({ name: vendor.name || '' });
|
||||
};
|
||||
const closeEditVendor = () => {
|
||||
setEditingVendor(null);
|
||||
setEditVendorForm({ name: '' });
|
||||
};
|
||||
const openRechargeVendor = (vendor) => {
|
||||
setRechargeVendor(vendor);
|
||||
setRechargeForm({ amount: '', remark: '' });
|
||||
};
|
||||
const closeRechargeVendor = () => {
|
||||
setRechargeVendor(null);
|
||||
setRechargeForm({ amount: '', remark: '' });
|
||||
};
|
||||
const submitVendor = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!newVendor.name.trim()) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onCreateVendor) {
|
||||
await onCreateVendor({ name: newVendor.name.trim(), creditLimit: '0.000000' });
|
||||
} else {
|
||||
const nextIndex = vendorRows.length + 1;
|
||||
setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, status: '启用', cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]);
|
||||
}
|
||||
setNewVendor({ name: '' });
|
||||
setShowCreateVendor(false);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const submitEditVendor = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!editVendorForm.name.trim() || !editingVendor) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onUpdateVendor) {
|
||||
await onUpdateVendor(editingVendor.id, { name: editVendorForm.name.trim() });
|
||||
} else {
|
||||
setVendorRows((rows) => rows.map((vendor) => (
|
||||
vendor.id === editingVendor.id ? { ...vendor, name: editVendorForm.name.trim() } : vendor
|
||||
)));
|
||||
}
|
||||
closeEditVendor();
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const submitRecharge = async (event) => {
|
||||
event.preventDefault();
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!rechargeVendor || !Number.isFinite(amount) || amount <= 0) return;
|
||||
setActionError('');
|
||||
try {
|
||||
if (onRechargeVendor) {
|
||||
await onRechargeVendor(rechargeVendor.id, { amount: amount.toFixed(2), remark: rechargeForm.remark.trim() || undefined });
|
||||
} else {
|
||||
const beforeBalance = parseMoney(rechargeVendor.balance);
|
||||
const afterBalance = beforeBalance + amount;
|
||||
setVendorRows((rows) => rows.map((vendor) => (
|
||||
vendor.id === rechargeVendor.id ? { ...vendor, balance: formatMoney(afterBalance) } : vendor
|
||||
)));
|
||||
addRechargeRecord({ id: `RCG-V-${Date.now()}`, type: 'vendor', owner: rechargeVendor.name, amount: formatMoney(amount), beforeBalance: formatMoney(beforeBalance), afterBalance: formatMoney(afterBalance), remark: rechargeForm.remark.trim() || '-', operator: '运营管理员', time: new Date().toLocaleString('zh-CN', { hour12: false }), status: '成功' });
|
||||
}
|
||||
closeRechargeVendor();
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
const deleteVendor = async (vendor) => {
|
||||
setActionError('');
|
||||
try {
|
||||
if (onDeleteVendor) {
|
||||
await onDeleteVendor(vendor.id);
|
||||
} else {
|
||||
if ((vendor.gateways ?? 0) > 0) {
|
||||
throw new Error('该供应商仍有关联落地网关,不能删除。');
|
||||
}
|
||||
setVendorRows((rows) => rows.filter((item) => item.id !== vendor.id));
|
||||
}
|
||||
setDeleteVendorTarget(null);
|
||||
} catch (error) {
|
||||
setActionError(explainApiError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="供应商管理" desc="管理供应商账户、余额、授信和充值记录。" actions={canManageVendors ? <Button icon={<Icon type="plus" />} onClick={() => setShowCreateVendor(true)}>新增供应商</Button> : null} />
|
||||
<ApiNotice loading={apiLoading} error={apiError || actionError} onRetry={refreshApi} />
|
||||
<Toolbar>
|
||||
<Field label="供应商名称"><Input placeholder="搜索供应商名称" /></Field>
|
||||
<Button icon={<Icon type="search" />}>查询</Button>
|
||||
</Toolbar>
|
||||
<section className="master-detail">
|
||||
<Panel title="供应商列表" className="wide-panel">
|
||||
<SimpleTable rows={vendorRows} columns={[
|
||||
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'balance', label: '余额' },
|
||||
{ key: 'credit', label: '授信额度' },
|
||||
{ key: 'gateways', label: '落地网关数' },
|
||||
{ key: 'status', label: '状态', status: true },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
render: (row) => (
|
||||
<div className="table-actions">
|
||||
{canManageVendors ? <Button size="sm" variant="outline" onClick={() => openEditVendor(row)}>编辑</Button> : null}
|
||||
{canManageRecharges ? <Button size="sm" variant="secondary" onClick={() => openRechargeVendor(row)}>充值</Button> : null}
|
||||
{canManageVendors ? <Button size="sm" variant="danger" onClick={() => setDeleteVendorTarget(row)}>删除</Button> : null}
|
||||
{!canManageVendors && !canManageRecharges ? '-' : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Panel>
|
||||
</section>
|
||||
{showCreateVendor ? (
|
||||
<Modal title="新增供应商" onClose={() => setShowCreateVendor(false)} size="sm">
|
||||
<form className="modal-form" onSubmit={submitVendor}>
|
||||
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={newVendor.name} onChange={(event) => setNewVendor({ ...newVendor, name: event.target.value })} placeholder="请输入供应商名称" required />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreateVendor(false)}>取消</Button>
|
||||
<Button type="submit">保存供应商</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{editingVendor ? (
|
||||
<Modal title="编辑供应商" onClose={closeEditVendor} size="sm">
|
||||
<form className="modal-form" onSubmit={submitEditVendor}>
|
||||
<Field label={<span>供应商名称 <span className="required-star">*</span></span>}>
|
||||
<Input value={editVendorForm.name} onChange={(event) => setEditVendorForm({ ...editVendorForm, name: event.target.value })} placeholder="请输入供应商名称" required />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeEditVendor}>取消</Button>
|
||||
<Button type="submit">保存修改</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{rechargeVendor ? (
|
||||
<Modal title={`${rechargeVendor.name} 充值`} onClose={closeRechargeVendor} size="sm">
|
||||
<form className="modal-form" onSubmit={submitRecharge}>
|
||||
<KeyValue label="当前余额" value={rechargeVendor.balance} />
|
||||
<Field label={<span>充值金额 <span className="required-star">*</span></span>}>
|
||||
<Input type="number" min="0.01" step="0.01" value={rechargeForm.amount} onChange={(event) => setRechargeForm({ ...rechargeForm, amount: event.target.value })} placeholder="请输入充值金额" required />
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Textarea rows="4" value={rechargeForm.remark} onChange={(event) => setRechargeForm({ ...rechargeForm, remark: event.target.value })} placeholder="请输入备注" />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={closeRechargeVendor}>取消</Button>
|
||||
<Button type="submit">确认充值</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
{deleteVendorTarget ? (
|
||||
<ConfirmDialog
|
||||
title="删除供应商确认"
|
||||
confirmLabel="确认删除"
|
||||
confirmVariant="danger"
|
||||
onCancel={() => setDeleteVendorTarget(null)}
|
||||
onConfirm={() => void deleteVendor(deleteVendorTarget)}
|
||||
>
|
||||
<p>确认删除供应商「{deleteVendorTarget.name}」吗?删除后该供应商将不再出现在供应商列表中。</p>
|
||||
</ConfirmDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const PAGE_PERMISSIONS = {
|
||||
dashboard: ['dashboard.view'],
|
||||
activeCalls: ['active_calls.view'],
|
||||
customers: ['customers.view'],
|
||||
customerGateways: ['customer_gateways.view'],
|
||||
businessPrefixes: ['customer_gateways.view'],
|
||||
rechargeRecords: ['recharges.view'],
|
||||
vendors: ['vendors.view'],
|
||||
vendorGateways: ['vendor_gateways.view'],
|
||||
vendorLineGroups: ['line_groups.view'],
|
||||
numberLibrary: ['number_library.view'],
|
||||
cdr: ['cdr.view'],
|
||||
quality: ['quality.view'],
|
||||
users: ['users.view'],
|
||||
roles: ['roles.view'],
|
||||
operationLogs: ['audit.view'],
|
||||
};
|
||||
|
||||
export function permissionSet(user) {
|
||||
return new Set(Array.isArray(user?.permissions) ? user.permissions : []);
|
||||
}
|
||||
|
||||
export function can(userOrPermissions, permission) {
|
||||
if (!permission) return true;
|
||||
const permissions = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
|
||||
return permissions.has(permission);
|
||||
}
|
||||
|
||||
export function canAll(userOrPermissions, permissions = []) {
|
||||
const granted = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
|
||||
return permissions.every((permission) => granted.has(permission));
|
||||
}
|
||||
|
||||
export function canAny(userOrPermissions, permissions = []) {
|
||||
if (!permissions.length) return true;
|
||||
const granted = userOrPermissions instanceof Set ? userOrPermissions : permissionSet(userOrPermissions);
|
||||
return permissions.some((permission) => granted.has(permission));
|
||||
}
|
||||
@@ -1144,6 +1144,11 @@ button:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recording-player {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
|
||||
const selectedBlue = '#2563EB';
|
||||
|
||||
function formatCurrency(value, digits = 2) {
|
||||
const numeric = Number(value || 0);
|
||||
return `¥${numeric.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatDurationText(seconds) {
|
||||
const value = Math.max(0, Number(seconds) || 0);
|
||||
const hours = Math.floor(value / 3600);
|
||||
const minutes = Math.floor((value % 3600) / 60);
|
||||
const remainSeconds = value % 60;
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}`
|
||||
: `${String(minutes).padStart(2, '0')}:${String(remainSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function zhStatus(status) {
|
||||
return status === 'ENABLED' ? '启用' : status === 'DISABLED' ? '停用' : status === 'SUCCEEDED' ? '成功' : status || '-';
|
||||
}
|
||||
|
||||
function carrierLabel(value) {
|
||||
const labels = {
|
||||
MOBILE: '移动',
|
||||
UNICOM: '联通',
|
||||
TELECOM: '电信',
|
||||
BROADCAST: '广电',
|
||||
MVNO: '虚拟运营商',
|
||||
UNKNOWN: '未知',
|
||||
};
|
||||
return labels[value] || value || '-';
|
||||
}
|
||||
|
||||
function enStatus(status) {
|
||||
return status === '启用' ? 'ENABLED' : status === '停用' || status === '禁用' ? 'DISABLED' : status;
|
||||
}
|
||||
|
||||
function billingModeLabel(value) {
|
||||
return value === 'POSTPAID' ? '后付费' : value === 'PREPAID' ? '预付费' : '-';
|
||||
}
|
||||
|
||||
function normalizeCustomer(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
contact: item.contactName || '-',
|
||||
phone: item.phone || '-',
|
||||
email: item.email || '-',
|
||||
domain: item.domain || '-',
|
||||
auth: '真实 API',
|
||||
status: zhStatus(item.status),
|
||||
balance: formatCurrency(item.balance),
|
||||
credit: formatCurrency(item.creditLimit),
|
||||
billing: billingModeLabel(item.billingMode),
|
||||
routeGroup: '-',
|
||||
gateways: item.gatewayCount ?? 0,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVendor(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
balance: formatCurrency(item.balance),
|
||||
credit: formatCurrency(item.creditLimit),
|
||||
gateways: item.gatewayCount ?? 0,
|
||||
status: zhStatus(item.status),
|
||||
cycle: item.settlement || '-',
|
||||
ratePlan: '-',
|
||||
contact: item.contactName || '-',
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function authModeLabel(value) {
|
||||
if (value === 'SIP_DIGEST') return 'SIP注册';
|
||||
if (value === 'MIXED') return '混合认证';
|
||||
return value || 'IP';
|
||||
}
|
||||
|
||||
function normalizeCustomerGateway(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
customerId: item.customerId,
|
||||
customer: item.customerName || '-',
|
||||
name: item.name,
|
||||
authMode: authModeLabel(item.authMode),
|
||||
ipAddress: item.sourceIp || '',
|
||||
sourceIps: item.sourceIps || (item.sourceIp ? [item.sourceIp] : []),
|
||||
sipAccount: item.sipUsername || '',
|
||||
sipDomain: item.sipDomain || '',
|
||||
sipPassword: item.hasSipCredential ? '******' : '',
|
||||
lineGroupId: item.lineGroupId || '',
|
||||
lineGroupName: item.lineGroupName || '-',
|
||||
billingCycleSec: item.billingCycleSec ?? 60,
|
||||
cycleRate: Number(item.cycleRate || 0),
|
||||
callerMatchMode: item.callerMatchMode || 'ANY',
|
||||
callerPrefixes: item.callerPrefixes || [],
|
||||
calleeMatchMode: item.calleeMatchMode || 'ANY',
|
||||
businessPrefixes: item.businessPrefixes || [],
|
||||
routePolicyCount: item.policyCount ?? 0,
|
||||
status: zhStatus(item.status),
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVendorGateway(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
vendorId: item.vendorId,
|
||||
vendor: item.vendorName || '-',
|
||||
name: item.name,
|
||||
authMode: authModeLabel(item.authMode),
|
||||
ipAddress: item.host || '',
|
||||
sipAccount: item.sipUsername || '',
|
||||
sipPassword: item.hasSipCredential ? '******' : '',
|
||||
concurrencyLimit: item.concurrencyLimit ?? 0,
|
||||
billingCycle: item.billingCycleSec ?? 60,
|
||||
cycleRate: Number(item.cycleRate || 0),
|
||||
requestRate: `${item.cpsLimit ?? 0} CPS`,
|
||||
blockedProvinces: '-',
|
||||
callTimeLimit: '-',
|
||||
codecs: (item.codecs || []).map((codec) => codec.codec).join(', ') || '-',
|
||||
landingCalleePrefix: item.landingCalleePrefix || '',
|
||||
callerRewritePool: item.callerRewritePool || [],
|
||||
calleePrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLEE').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-',
|
||||
callerPrefixTransform: (item.prefixRules || []).filter((rule) => rule.direction === 'CALLER').map((rule) => `${rule.matchPrefix} -> ${rule.replacePrefix || rule.matchPrefix}`).join('\n') || '-',
|
||||
status: zhStatus(item.status),
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandingLineGroup(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
status: zhStatus(item.status),
|
||||
customerGatewayCount: item.customerGatewayCount ?? 0,
|
||||
policyCount: item.policyCount ?? 0,
|
||||
gatewayIds: (item.items || []).map((line) => line.vendorGatewayId),
|
||||
items: item.items || [],
|
||||
createdAt: formatDate(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecharge(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.accountType === 'VENDOR' ? 'vendor' : 'customer',
|
||||
owner: item.accountName,
|
||||
amount: formatCurrency(item.amount),
|
||||
beforeBalance: formatCurrency(item.beforeBalance),
|
||||
afterBalance: formatCurrency(item.afterBalance),
|
||||
remark: item.remark || '-',
|
||||
operator: item.createdBy || '系统',
|
||||
time: formatDateTime(item.occurredAt),
|
||||
status: zhStatus(item.status),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
username: item.username,
|
||||
name: item.displayName,
|
||||
phone: item.phone || '-',
|
||||
email: item.email || '-',
|
||||
roleId: item.roleIds?.[0] || '',
|
||||
status: zhStatus(item.status),
|
||||
lastLogin: formatDateTime(item.lastLoginAt),
|
||||
lastIp: '-',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRole(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '-',
|
||||
status: zhStatus(item.status),
|
||||
builtIn: Boolean(item.builtIn),
|
||||
permissions: item.permissionIds || [],
|
||||
userCount: item.userCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuditLog(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
time: formatDateTime(item.createdAt),
|
||||
user: item.username || '系统',
|
||||
username: item.username || '-',
|
||||
module: item.module,
|
||||
action: item.action,
|
||||
object: item.objectId ? `${item.objectType}:${item.objectId}` : item.objectType,
|
||||
result: item.result === 'SUCCESS' ? '成功' : '失败',
|
||||
ip: item.ip || '-',
|
||||
summary: item.errorCode || `${item.module}.${item.action}`,
|
||||
userAgent: item.userAgent || '-',
|
||||
};
|
||||
}
|
||||
|
||||
function reviewResultLabel(value) {
|
||||
return value === 'PASS' ? '通过' : value === 'ISSUE' ? '有问题' : value === 'ESCALATED' ? '升级处理' : '-';
|
||||
}
|
||||
|
||||
function reviewResultValue(label) {
|
||||
if (label === '有问题') return 'ISSUE';
|
||||
if (label === '升级处理') return 'ESCALATED';
|
||||
return 'PASS';
|
||||
}
|
||||
|
||||
function normalizeQualityRule(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
customerId: item.customerId || '',
|
||||
customer: item.customerName || '全部客户',
|
||||
lineGroupId: item.lineGroupId || '',
|
||||
route: item.lineGroupName || '全部线路',
|
||||
ratio: Number(item.ratio || 0),
|
||||
status: zhStatus(item.status),
|
||||
start: item.effectiveAt ? new Date(item.effectiveAt).toISOString().slice(0, 10) : '',
|
||||
expiresAt: item.expiresAt ? new Date(item.expiresAt).toISOString().slice(0, 10) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecording(item) {
|
||||
const latestReview = item.latestReview || null;
|
||||
const reviewStatus = item.reviewStatus === 'REVIEWED' || latestReview ? '已完成' : item.sampling?.selected ? '待质检' : '未抽中';
|
||||
return {
|
||||
...item,
|
||||
id: item.id,
|
||||
callId: item.callId || item.rawCdrId || item.id,
|
||||
customer: item.customerName || item.customerId || '-',
|
||||
caller: item.caller || '-',
|
||||
callee: item.callee || '-',
|
||||
business: item.lineGroupName || '-',
|
||||
time: formatDateTime(item.startedAt || item.createdAt),
|
||||
duration: formatDurationText(item.durationSec),
|
||||
file: item.storageKey,
|
||||
play: item.status === 'READY' ? '可试听' : zhStatus(item.status),
|
||||
review: reviewStatus,
|
||||
score: latestReview?.score ?? '',
|
||||
issue: latestReview?.notes || '',
|
||||
result: reviewResultLabel(latestReview?.result),
|
||||
issueTagsText: Array.isArray(latestReview?.issueTags) ? latestReview.issueTags.join(', ') : '',
|
||||
samplingText: item.sampling?.selected ? '已抽中' : '未抽中',
|
||||
samplingRuleText: item.sampling?.matches?.length ? item.sampling.matches.map((match) => match.ruleName).join(', ') : '-',
|
||||
latestReview,
|
||||
reviews: item.reviews || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDurationText,
|
||||
zhStatus,
|
||||
carrierLabel,
|
||||
enStatus,
|
||||
billingModeLabel,
|
||||
authModeLabel,
|
||||
normalizeCustomer,
|
||||
normalizeVendor,
|
||||
normalizeCustomerGateway,
|
||||
normalizeVendorGateway,
|
||||
normalizeLandingLineGroup,
|
||||
normalizeRecharge,
|
||||
normalizeUser,
|
||||
normalizeRole,
|
||||
normalizeAuditLog,
|
||||
reviewResultLabel,
|
||||
reviewResultValue,
|
||||
normalizeQualityRule,
|
||||
normalizeRecording,
|
||||
};
|
||||
@@ -4,13 +4,14 @@ import { Prisma } from '@lisglosips/database';
|
||||
import type { ParsedCdrStreamEvent } from '@lisglosips/redis';
|
||||
|
||||
import { calculateCycleCharge } from './billing.js';
|
||||
import { CdrRatingService, type CdrRatingStore, type CdrRatingTransaction, type CreateRatedInput } from './rating.js';
|
||||
import { CdrRatingService, normalizeCdrNullableId, type CdrRatingStore, type CdrRatingTransaction, type CreateRatedInput } from './rating.js';
|
||||
|
||||
class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
rawByEventId = new Map<string, { id: string; eventId: string; ratingStatus: 'UNRATED' | 'RATED' | 'SKIPPED' | 'FAILED' }>();
|
||||
ratedByRawId = new Map<string, { id: string; rawCdrId: string; customerFee: Prisma.Decimal }>();
|
||||
vendorGateway = { id: 'vgw_1', vendorId: 'ven_1', billingCycleSec: 6, cycleRate: new Prisma.Decimal('0.012000') };
|
||||
customer = { id: 'cus_1', balance: new Prisma.Decimal('10.000000') };
|
||||
createdRawEvents: ParsedCdrStreamEvent[] = [];
|
||||
|
||||
async transaction<T>(operation: (tx: CdrRatingTransaction) => Promise<T>): Promise<T> {
|
||||
return operation(this);
|
||||
@@ -23,6 +24,7 @@ class MemoryStore implements CdrRatingStore, CdrRatingTransaction {
|
||||
async createRaw(event: ParsedCdrStreamEvent, rawCdrId: string) {
|
||||
const raw = { id: rawCdrId, eventId: event.event_id, ratingStatus: 'UNRATED' as const };
|
||||
this.rawByEventId.set(event.event_id, raw);
|
||||
this.createdRawEvents.push(event);
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -73,6 +75,12 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
||||
source_ip: '100.93.185.30',
|
||||
caller: '1001',
|
||||
callee: '13800138000',
|
||||
raw_callee: '67113800138000',
|
||||
business_prefix_id: 'bp_seed',
|
||||
business_prefix: '671',
|
||||
landing_caller: '02160010001',
|
||||
landing_callee: '8613800138000',
|
||||
normalized_callee: '13800138000',
|
||||
callee_city_code: '340100',
|
||||
callee_city_name: '合肥市',
|
||||
callee_province_name: '安徽省',
|
||||
@@ -97,6 +105,15 @@ function event(overrides: Partial<ParsedCdrStreamEvent> = {}): ParsedCdrStreamEv
|
||||
}
|
||||
|
||||
describe('S23 CDR rating', () => {
|
||||
it('normalizes OpenSIPS placeholder ids before writing nullable foreign keys', () => {
|
||||
expect(normalizeCdrNullableId('none')).toBeNull();
|
||||
expect(normalizeCdrNullableId('unknown')).toBeNull();
|
||||
expect(normalizeCdrNullableId('no_active_version')).toBeNull();
|
||||
expect(normalizeCdrNullableId('no_policy_match')).toBeNull();
|
||||
expect(normalizeCdrNullableId('single_gateway')).toBeNull();
|
||||
expect(normalizeCdrNullableId('cgp_1')).toBe('cgp_1');
|
||||
});
|
||||
|
||||
it('calculates Decimal cycle charges with ceiling billing seconds', () => {
|
||||
const charge = calculateCycleCharge({ durationSec: 28, billingCycleSec: 6, cycleRate: '0.012000' });
|
||||
|
||||
@@ -121,6 +138,15 @@ describe('S23 CDR rating', () => {
|
||||
});
|
||||
expect(duplicate.outcome).toBe('duplicate');
|
||||
expect(store.customer.balance.toFixed(6)).toBe('9.940000');
|
||||
expect(store.createdRawEvents[0]).toMatchObject({
|
||||
callee: '13800138000',
|
||||
raw_callee: '67113800138000',
|
||||
business_prefix_id: 'bp_seed',
|
||||
business_prefix: '671',
|
||||
landing_caller: '02160010001',
|
||||
landing_callee: '8613800138000',
|
||||
normalized_callee: '13800138000'
|
||||
});
|
||||
});
|
||||
|
||||
it('skips failed or zero-duration CDRs without balance changes', async () => {
|
||||
|
||||
@@ -176,20 +176,25 @@ class PrismaCdrRatingTransaction implements CdrRatingTransaction {
|
||||
id: rawCdrId,
|
||||
eventId: storageEventId(event.event_id),
|
||||
callId: event.call_id,
|
||||
customerId: nullableId(event.customer_id),
|
||||
customerGatewayId: nullableId(event.customer_gateway_id),
|
||||
customerGatewayPolicyId: nullableId(event.customer_gateway_policy_id),
|
||||
customerId: normalizeCdrNullableId(event.customer_id),
|
||||
customerGatewayId: normalizeCdrNullableId(event.customer_gateway_id),
|
||||
customerGatewayPolicyId: normalizeCdrNullableId(event.customer_gateway_policy_id),
|
||||
sourceIp: emptyToNull(event.source_ip),
|
||||
caller: event.caller || 'unknown',
|
||||
callee: event.callee || 'unknown',
|
||||
rawCallee: emptyToNull(event.raw_callee),
|
||||
businessPrefixId: normalizeCdrNullableId(event.business_prefix_id),
|
||||
businessPrefix: emptyToNull(event.business_prefix),
|
||||
calleeCityCode: emptyToNull(event.callee_city_code),
|
||||
calleeCityName: emptyToNull(event.callee_city_name),
|
||||
calleeProvinceName: emptyToNull(event.callee_province_name),
|
||||
calleeOperator: numberCarrier(event.callee_operator),
|
||||
calleeNumberType: phoneNumberType(event.callee_number_type),
|
||||
vendorId: nullableId(event.vendor_id),
|
||||
vendorGatewayId: nullableId(event.vendor_gateway_id),
|
||||
lineGroupId: nullableId(event.line_group_id),
|
||||
vendorId: normalizeCdrNullableId(event.vendor_id),
|
||||
vendorGatewayId: normalizeCdrNullableId(event.vendor_gateway_id),
|
||||
lineGroupId: normalizeCdrNullableId(event.line_group_id),
|
||||
landingCaller: emptyToNull(event.landing_caller),
|
||||
landingCallee: emptyToNull(event.landing_callee),
|
||||
startedAt: parseCdrDate(event.started_at, event.created_at),
|
||||
answeredAt: parseOptionalCdrDate(event.answered_at),
|
||||
endedAt: parseCdrDate(event.ended_at, event.created_at),
|
||||
@@ -274,12 +279,19 @@ function prefixedId(prefix: string): string {
|
||||
}
|
||||
|
||||
function billableId(value: string): boolean {
|
||||
return Boolean(nullableId(value));
|
||||
return Boolean(normalizeCdrNullableId(value));
|
||||
}
|
||||
|
||||
function nullableId(value: string): string | null {
|
||||
export function normalizeCdrNullableId(value: string): string | null {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === 'none' || normalized === 'unknown' || normalized === 'no_active_version') {
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === 'none' ||
|
||||
normalized === 'unknown' ||
|
||||
normalized === 'no_active_version' ||
|
||||
normalized === 'no_policy_match' ||
|
||||
normalized === 'single_gateway'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
@@ -330,6 +342,12 @@ function eventToJson(event: ParsedCdrStreamEvent): Prisma.InputJsonValue {
|
||||
source_ip: event.source_ip,
|
||||
caller: event.caller,
|
||||
callee: event.callee,
|
||||
raw_callee: event.raw_callee,
|
||||
business_prefix_id: event.business_prefix_id,
|
||||
business_prefix: event.business_prefix,
|
||||
landing_caller: event.landing_caller,
|
||||
landing_callee: event.landing_callee,
|
||||
normalized_callee: event.normalized_callee,
|
||||
callee_city_code: event.callee_city_code,
|
||||
callee_city_name: event.callee_city_name,
|
||||
callee_province_name: event.callee_province_name,
|
||||
|
||||
@@ -59,9 +59,38 @@ function prismaFixture(): PrismaClient {
|
||||
{ id: 'cus_1', status: 'ENABLED', balance: new Prisma.Decimal('10'), creditLimit: new Prisma.Decimal('0'), minBalance: new Prisma.Decimal('0') }
|
||||
]
|
||||
},
|
||||
businessPrefix: {
|
||||
findMany: async () => [
|
||||
{ id: 'bp_1', prefix: '671', name: '业务671', priority: 10, status: 'ENABLED' }
|
||||
]
|
||||
},
|
||||
customerGateway: {
|
||||
findMany: async () => [
|
||||
{ id: 'cgw_1', customerId: 'cus_1', authMode: 'IP', sourceIp: '100.93.185.30', sipUsername: null, sipDomain: null, sipHa1: null, status: 'ENABLED' }
|
||||
{
|
||||
id: 'cgw_1',
|
||||
customerId: 'cus_1',
|
||||
authMode: 'IP',
|
||||
sourceIp: '100.93.185.30',
|
||||
ips: [
|
||||
{ sourceIp: '100.93.185.30' },
|
||||
{ sourceIp: '100.93.185.31' }
|
||||
],
|
||||
sipUsername: null,
|
||||
sipDomain: null,
|
||||
sipHa1: null,
|
||||
lineGroupId: 'lg_1',
|
||||
billingCycleSec: 6,
|
||||
cycleRate: new Prisma.Decimal('0.008'),
|
||||
callerMatchMode: 'PREFIXES',
|
||||
callerPrefixes: [{ prefix: '021' }],
|
||||
calleeMatchMode: 'BUSINESS_PREFIXES',
|
||||
businessPrefixes: [
|
||||
{
|
||||
businessPrefix: { id: 'bp_1', prefix: '671', name: '业务671', priority: 10, status: 'ENABLED' }
|
||||
}
|
||||
],
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
},
|
||||
customerGatewayPolicy: {
|
||||
@@ -95,10 +124,15 @@ function prismaFixture(): PrismaClient {
|
||||
concurrencyLimit: 30,
|
||||
billingCycleSec: 6,
|
||||
cycleRate: new Prisma.Decimal('0.012'),
|
||||
landingCalleePrefix: '86',
|
||||
status: 'ENABLED',
|
||||
forbiddenPeriods: [],
|
||||
codecs: [],
|
||||
prefixRules: [],
|
||||
callerRewritePool: [
|
||||
{ caller: '02160010001', weight: 70, status: 'ENABLED' },
|
||||
{ caller: '02160010002', weight: 30, status: 'ENABLED' }
|
||||
],
|
||||
blockedRegions: [{ regionScope: 'CITY', provinceCode: '340000', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' }]
|
||||
}
|
||||
]
|
||||
@@ -141,6 +175,12 @@ describe('S42 config publisher number library snapshot', () => {
|
||||
|
||||
expect(result.published).toBe(true);
|
||||
expect(result.manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
businessPrefixCount: 1,
|
||||
customerGatewayIpCount: 2,
|
||||
customerGatewayBusinessPrefixCount: 1,
|
||||
customerGatewayCallerPrefixCount: 1,
|
||||
callerRewriteCount: 2,
|
||||
cityCount: 1,
|
||||
phoneSegmentCount: 1,
|
||||
areaCodeCount: 1,
|
||||
@@ -149,6 +189,14 @@ describe('S42 config publisher number library snapshot', () => {
|
||||
});
|
||||
expect(redis.writes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:business_prefix:bp_1` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:business_prefix_value:671`, value: 'bp_1' }),
|
||||
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:business_prefixes` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:auth:ip:100.93.185.30`, value: 'cgw_1' }),
|
||||
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:auth:ip:100.93.185.31:gateways`, value: 'cgw_1' }),
|
||||
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:customer_gateway:cgw_1:caller_prefixes`, value: '021' }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:customer_gateway:cgw_1:line_group`, value: 'lg_1' }),
|
||||
expect.objectContaining({ command: 'rpush', key: `cfg:v:${version}:vendor_gateway:vgw_1:caller_rewrite_pool` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:phone_segment:1380013` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:area_code:0551` }),
|
||||
expect.objectContaining({ command: 'set', key: `cfg:v:${version}:carrier_prefix:138` }),
|
||||
|
||||
@@ -17,12 +17,18 @@ export interface PublishConfigResult {
|
||||
}
|
||||
|
||||
export interface ConfigManifest {
|
||||
schemaVersion: number;
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
customerCount: number;
|
||||
businessPrefixCount: number;
|
||||
gatewayCount: number;
|
||||
customerGatewayIpCount: number;
|
||||
customerGatewayBusinessPrefixCount: number;
|
||||
customerGatewayCallerPrefixCount: number;
|
||||
policyCount: number;
|
||||
vendorGatewayCount: number;
|
||||
callerRewriteCount: number;
|
||||
lineGroupCount: number;
|
||||
cityCount: number;
|
||||
phoneSegmentCount: number;
|
||||
@@ -40,14 +46,29 @@ type ConfigSnapshot = {
|
||||
creditLimit: string;
|
||||
minBalance: string;
|
||||
}>;
|
||||
businessPrefixes: Array<{
|
||||
id: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
status: string;
|
||||
}>;
|
||||
gateways: Array<{
|
||||
id: string;
|
||||
customerId: string;
|
||||
authMode: string;
|
||||
sourceIp: string | null;
|
||||
sourceIps: string[];
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
sipHa1: string | null;
|
||||
lineGroupId: string | null;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
callerMatchMode: string;
|
||||
callerPrefixes: string[];
|
||||
calleeMatchMode: string;
|
||||
businessPrefixes: Array<{ id: string; prefix: string; name: string; priority: number; status: string }>;
|
||||
status: string;
|
||||
}>;
|
||||
policies: Array<{
|
||||
@@ -75,10 +96,12 @@ type ConfigSnapshot = {
|
||||
concurrencyLimit: number;
|
||||
billingCycleSec: number;
|
||||
cycleRate: string;
|
||||
landingCalleePrefix: string | null;
|
||||
status: string;
|
||||
forbiddenPeriods: Array<{ weekdayMask: number; startTime: string; endTime: string }>;
|
||||
codecs: Array<{ codec: string; priority: number }>;
|
||||
prefixRules: Array<{ direction: string; matchPrefix: string; replacePrefix: string; priority: number }>;
|
||||
callerRewritePool: Array<{ caller: string; weight: number; status: string }>;
|
||||
blockedRegions: Array<{
|
||||
regionScope: string;
|
||||
provinceCode: string | null;
|
||||
@@ -136,7 +159,7 @@ type ConfigSnapshot = {
|
||||
export async function publishPendingConfig(prisma: PrismaClient, redis: RedisClient, now = new Date()): Promise<PublishConfigResult> {
|
||||
const pending = await prisma.outboxEvent.findMany({
|
||||
where: {
|
||||
aggregateType: { in: ['customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
|
||||
aggregateType: { in: ['business_prefix_config', 'customer_gateway_config', 'vendor_gateway_config', 'line_group_config', 'number_library_config'] },
|
||||
status: 'PENDING',
|
||||
availableAt: { lte: now }
|
||||
},
|
||||
@@ -217,14 +240,30 @@ export async function rollbackActiveConfig(redis: RedisClient): Promise<{ rolled
|
||||
}
|
||||
|
||||
async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
const [customers, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
|
||||
const [customers, businessPrefixes, gateways, policies, vendorGateways, lineGroups, cities, phoneSegments, areaCodes, carrierPrefixRules] = await prisma.$transaction([
|
||||
prisma.customer.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
}),
|
||||
prisma.businessPrefix.findMany({
|
||||
where: { deletedAt: null, status: 'ENABLED' },
|
||||
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }]
|
||||
}),
|
||||
prisma.customerGateway.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ id: 'asc' }]
|
||||
orderBy: [{ id: 'asc' }],
|
||||
include: {
|
||||
ips: { where: { deletedAt: null, status: 'ENABLED' }, orderBy: [{ createdAt: 'asc' }] },
|
||||
callerPrefixes: { orderBy: [{ priority: 'asc' }, { prefix: 'asc' }] },
|
||||
businessPrefixes: {
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
include: {
|
||||
businessPrefix: {
|
||||
select: { id: true, prefix: true, name: true, priority: true, status: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.customerGatewayPolicy.findMany({
|
||||
where: { deletedAt: null },
|
||||
@@ -237,6 +276,7 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
|
||||
codecs: { orderBy: [{ priority: 'asc' }] },
|
||||
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
|
||||
callerRewritePool: { where: { deletedAt: null, status: 'ENABLED' }, orderBy: [{ weight: 'desc' }, { caller: 'asc' }] },
|
||||
blockedRegions: { orderBy: [{ regionScope: 'asc' }, { provinceCode: 'asc' }, { cityCode: 'asc' }] }
|
||||
}
|
||||
}),
|
||||
@@ -275,14 +315,35 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
creditLimit: customer.creditLimit.toFixed(6),
|
||||
minBalance: customer.minBalance.toFixed(6)
|
||||
})),
|
||||
businessPrefixes: businessPrefixes.map((prefixItem) => ({
|
||||
id: prefixItem.id,
|
||||
prefix: prefixItem.prefix,
|
||||
name: prefixItem.name,
|
||||
priority: prefixItem.priority,
|
||||
status: prefixItem.status
|
||||
})),
|
||||
gateways: gateways.map((gateway) => ({
|
||||
id: gateway.id,
|
||||
customerId: gateway.customerId,
|
||||
authMode: gateway.authMode,
|
||||
sourceIp: gateway.sourceIp,
|
||||
sourceIps: gateway.ips.length ? gateway.ips.map((ip) => ip.sourceIp) : gateway.sourceIp ? [gateway.sourceIp] : [],
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipDomain: gateway.sipDomain,
|
||||
sipHa1: gateway.sipHa1,
|
||||
lineGroupId: gateway.lineGroupId,
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
callerMatchMode: gateway.callerMatchMode,
|
||||
callerPrefixes: gateway.callerPrefixes.map((item) => item.prefix),
|
||||
calleeMatchMode: gateway.calleeMatchMode,
|
||||
businessPrefixes: gateway.businessPrefixes.map((item) => ({
|
||||
id: item.businessPrefix.id,
|
||||
prefix: item.businessPrefix.prefix,
|
||||
name: item.businessPrefix.name,
|
||||
priority: item.businessPrefix.priority,
|
||||
status: item.businessPrefix.status
|
||||
})),
|
||||
status: gateway.status
|
||||
})),
|
||||
policies: policies.map((policy) => ({
|
||||
@@ -310,6 +371,7 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
concurrencyLimit: gateway.concurrencyLimit,
|
||||
billingCycleSec: gateway.billingCycleSec,
|
||||
cycleRate: gateway.cycleRate.toFixed(6),
|
||||
landingCalleePrefix: gateway.landingCalleePrefix,
|
||||
status: gateway.status,
|
||||
forbiddenPeriods: gateway.forbiddenPeriods.map((period) => ({
|
||||
weekdayMask: period.weekdayMask,
|
||||
@@ -326,6 +388,11 @@ async function loadSnapshot(prisma: PrismaClient): Promise<ConfigSnapshot> {
|
||||
replacePrefix: rule.replacePrefix,
|
||||
priority: rule.priority
|
||||
})),
|
||||
callerRewritePool: gateway.callerRewritePool.map((caller) => ({
|
||||
caller: caller.caller,
|
||||
weight: caller.weight,
|
||||
status: caller.status
|
||||
})),
|
||||
blockedRegions: gateway.blockedRegions.map((region) => ({
|
||||
regionScope: region.regionScope,
|
||||
provinceCode: region.provinceCode,
|
||||
@@ -391,12 +458,18 @@ async function writeSnapshot(
|
||||
const prefix = configVersionPrefix(version);
|
||||
const checksum = crypto.createHash('sha256').update(stableJson(snapshot)).digest('hex');
|
||||
const manifest: ConfigManifest = {
|
||||
schemaVersion: 2,
|
||||
version,
|
||||
generatedAt: now.toISOString(),
|
||||
customerCount: snapshot.customers.length,
|
||||
businessPrefixCount: snapshot.businessPrefixes.length,
|
||||
gatewayCount: snapshot.gateways.length,
|
||||
customerGatewayIpCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.sourceIps.length, 0),
|
||||
customerGatewayBusinessPrefixCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.businessPrefixes.length, 0),
|
||||
customerGatewayCallerPrefixCount: snapshot.gateways.reduce((sum, gateway) => sum + gateway.callerPrefixes.length, 0),
|
||||
policyCount: snapshot.policies.length,
|
||||
vendorGatewayCount: snapshot.vendorGateways.length,
|
||||
callerRewriteCount: snapshot.vendorGateways.reduce((sum, gateway) => sum + gateway.callerRewritePool.length, 0),
|
||||
lineGroupCount: snapshot.lineGroups.length,
|
||||
cityCount: snapshot.cities.length,
|
||||
phoneSegmentCount: snapshot.phoneSegments.length,
|
||||
@@ -412,10 +485,31 @@ async function writeSnapshot(
|
||||
for (const customer of snapshot.customers) {
|
||||
multi.set(`${prefix}:customer:${customer.id}`, JSON.stringify(customer));
|
||||
}
|
||||
for (const businessPrefix of snapshot.businessPrefixes) {
|
||||
multi.set(`${prefix}:business_prefix:${businessPrefix.id}`, JSON.stringify(businessPrefix));
|
||||
multi.set(`${prefix}:business_prefix_value:${businessPrefix.prefix}`, businessPrefix.id);
|
||||
multi.rpush(`${prefix}:business_prefixes`, JSON.stringify(businessPrefix));
|
||||
}
|
||||
for (const gateway of snapshot.gateways) {
|
||||
multi.set(`${prefix}:customer_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
if ((gateway.authMode === 'IP' || gateway.authMode === 'MIXED') && gateway.sourceIp) {
|
||||
multi.set(`${prefix}:auth:ip:${gateway.sourceIp}`, gateway.id);
|
||||
if (gateway.callerMatchMode === 'PREFIXES') {
|
||||
for (const callerPrefix of gateway.callerPrefixes) {
|
||||
multi.rpush(`${prefix}:customer_gateway:${gateway.id}:caller_prefixes`, callerPrefix);
|
||||
}
|
||||
}
|
||||
if (gateway.calleeMatchMode === 'BUSINESS_PREFIXES') {
|
||||
for (const businessPrefix of gateway.businessPrefixes) {
|
||||
multi.rpush(`${prefix}:customer_gateway:${gateway.id}:business_prefixes`, JSON.stringify(businessPrefix));
|
||||
}
|
||||
}
|
||||
if (gateway.lineGroupId) {
|
||||
multi.set(`${prefix}:customer_gateway:${gateway.id}:line_group`, gateway.lineGroupId);
|
||||
}
|
||||
if (gateway.authMode === 'IP' || gateway.authMode === 'MIXED') {
|
||||
for (const sourceIp of gateway.sourceIps) {
|
||||
multi.set(`${prefix}:auth:ip:${sourceIp}`, gateway.id);
|
||||
multi.rpush(`${prefix}:auth:ip:${sourceIp}:gateways`, gateway.id);
|
||||
}
|
||||
}
|
||||
if ((gateway.authMode === 'SIP_DIGEST' || gateway.authMode === 'MIXED') && gateway.sipUsername && gateway.sipDomain) {
|
||||
multi.set(`${prefix}:auth:sip:${gateway.sipUsername}@${gateway.sipDomain}`, gateway.id);
|
||||
@@ -426,6 +520,9 @@ async function writeSnapshot(
|
||||
}
|
||||
for (const gateway of snapshot.vendorGateways) {
|
||||
multi.set(`${prefix}:vendor_gateway:${gateway.id}`, JSON.stringify(gateway));
|
||||
for (const callerRewrite of gateway.callerRewritePool) {
|
||||
multi.rpush(`${prefix}:vendor_gateway:${gateway.id}:caller_rewrite_pool`, JSON.stringify(callerRewrite));
|
||||
}
|
||||
for (const region of gateway.blockedRegions) {
|
||||
if (region.regionScope === 'CITY' && region.cityCode) {
|
||||
multi.sadd(`${prefix}:vendor_gateway:${gateway.id}:blocked_city_codes`, region.cityCode);
|
||||
|
||||
Reference in New Issue
Block a user