feat: complete phase2 baseline cdr quality rbac

This commit is contained in:
hectorzhao
2026-06-24 18:40:21 +08:00
parent 7057fd3c42
commit a86de6545f
63 changed files with 7853 additions and 3678 deletions
+2
View File
@@ -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,
+6 -3
View File
@@ -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);
+58 -39
View File
@@ -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
}
};
+1
View File
@@ -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;
}
}
+277 -10
View File
@@ -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
};
}
}
+45 -8
View File
@@ -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);
});
});
+19
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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 '登录状态已失效,请重新登录。';
}
+209
View File
@@ -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>
);
}
+153
View File
@@ -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.chenU1005', 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' },
];
+123
View File
@@ -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}
</>
);
}
+56
View File
@@ -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>
</>
);
}
+271
View File
@@ -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,
};
}
+392
View File
@@ -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}
</>
);
}
+529
View File
@@ -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}
</>
);
}
+243
View File
@@ -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}
</>
);
}
+72
View File
@@ -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>
</>
);
}
+27
View File
@@ -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>
</>
);
}
+307
View File
@@ -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}
</>
);
}
+67
View File
@@ -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}
</>
);
}
+418
View File
@@ -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 },
]}
/>
</>
);
}
+117
View File
@@ -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}
</>
);
}
+60
View File
@@ -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>
</>
);
}
+39
View File
@@ -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: '启用' };
+25
View File
@@ -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>
</>
);
}
+131
View File
@@ -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}
</>
);
}
+399
View File
@@ -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}
</>
);
}
+200
View File
@@ -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),
};
}
+196
View File
@@ -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}
</>
);
}
+38
View File
@@ -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));
}
+5
View File
@@ -1144,6 +1144,11 @@ button:disabled {
white-space: nowrap;
}
.recording-player {
width: 100%;
min-height: 40px;
}
.transcript {
display: grid;
gap: 8px;
+295
View File
@@ -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,
};
+27 -1
View File
@@ -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 () => {
+27 -9
View File
@@ -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` }),
+102 -5
View File
@@ -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);