fix vendor status customer toggles and cps

This commit is contained in:
hectorzhao
2026-06-29 11:53:59 +08:00
parent 4d6d0a8546
commit 581026460d
20 changed files with 192 additions and 112 deletions
@@ -31,10 +31,31 @@ describe('active calls service', () => {
toTag: 'to-b',
caller: 'sip:1001@s21.lisglosips.test',
callee: 'sip:2001@s21.lisglosips.test',
state: 'confirmed'
state: 'confirmed',
sipState: '200 OK',
sipStatusCode: 200,
sipStatusText: 'OK'
});
});
it('maps OpenSIPS dialog states to SIP signaling states', () => {
const calls = normalizeDialogs({
Dialogs: [
{ ID: 'early-ring', callid: 'call-early', state: 2 },
{ ID: 'early-progress', callid: 'call-progress', state: 'early', to_tag: 'tag-b' },
{ ID: 'inviting', callid: 'call-invite', state: 1 },
{ ID: 'terminating', callid: 'call-bye', state: 5 }
]
});
expect(calls.map((call) => [call.id, call.sipState, call.sipStatusCode, call.sipStatusText])).toEqual([
['early-ring', '180 Ringing', 180, 'Ringing'],
['early-progress', '183 Session Progress', 183, 'Session Progress'],
['inviting', 'INVITE', null, 'INVITE'],
['terminating', 'BYE / Terminating', null, 'Terminating']
]);
});
it('extracts caller and landing IPs from dialog contacts and SDP', () => {
const calls = normalizeDialogs({
Dialogs: [
@@ -9,6 +9,9 @@ export interface ActiveCallSummary {
caller: string | null;
callee: string | null;
state: string | null;
sipState: string;
sipStatusCode: number | null;
sipStatusText: string;
startedAt: string | null;
durationSec: number | null;
lifetimeSec: number | null;
@@ -149,6 +152,9 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
const calleeSdp =
stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null);
const dialogState = stringField(record, ['state', 'status']);
const sipState = sipStateFromDialog(dialogState, stringField(record, ['to_tag', 'totag', 'toTag']));
return {
id,
callId,
@@ -156,7 +162,10 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
toTag: stringField(record, ['to_tag', 'totag', 'toTag']),
caller: stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri']),
callee: stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri']),
state: stringField(record, ['state', 'status']),
state: dialogState,
sipState: sipState.label,
sipStatusCode: sipState.code,
sipStatusText: sipState.text,
startedAt,
durationSec,
lifetimeSec,
@@ -178,6 +187,26 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
};
}
function sipStateFromDialog(state: string | null, toTag: string | null): { label: string; code: number | null; text: string } {
const normalized = (state || '').trim().toLowerCase();
if (normalized === 'confirmed' || normalized === '3') {
return { label: '200 OK', code: 200, text: 'OK' };
}
if (normalized === 'early' || normalized === '2') {
return { label: toTag ? '183 Session Progress' : '180 Ringing', code: toTag ? 183 : 180, text: toTag ? 'Session Progress' : 'Ringing' };
}
if (normalized === 'unconfirmed' || normalized === '1') {
return { label: 'INVITE', code: null, text: 'INVITE' };
}
if (normalized === 'deleted' || normalized === '4') {
return { label: 'BYE', code: null, text: 'BYE' };
}
if (normalized === 'terminating' || normalized === '5') {
return { label: 'BYE / Terminating', code: null, text: 'Terminating' };
}
return { label: state || 'UNKNOWN', code: null, text: state || 'UNKNOWN' };
}
function stringField(record: Record<string, unknown>, names: string[]): string | null {
for (const name of names) {
const value = findCaseInsensitive(record, name);
@@ -143,7 +143,7 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
transport: input.transport ?? 'udp',
sipUsername: input.sipUsername ?? null,
hasSipCredential: input.hasSipCredential ?? false,
cpsLimit: input.cpsLimit ?? 0,
cpsLimit: input.cpsLimit ?? 1,
concurrencyLimit: input.concurrencyLimit ?? 0,
billingCycleSec,
cycleRate,
@@ -325,7 +325,24 @@ describe('S16 vendor gateways API', () => {
expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong');
});
it('validates child configuration and supports enable/disable', async () => {
it('validates CPS and child configuration and supports enable/disable', async () => {
for (const cpsLimit of [0, 10001, '20 CPS']) {
await request(app.getHttpServer())
.post('/api/v2/vendor-gateways')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({
vendorId: 'ven_seed',
name: `Invalid CPS ${cpsLimit}`,
authMode: 'IP',
host: '203.0.113.21',
cpsLimit
})
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('INTEGER_INVALID');
});
}
await request(app.getHttpServer())
.post('/api/v2/vendor-gateways')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
@@ -64,7 +64,7 @@ export class VendorGatewaysService {
transport: body.transport === undefined ? 'udp' : this.transport(body.transport),
sipUsername,
sipHa1: sipPassword ? this.sipHa1(sipUsername, host, sipPassword) : undefined,
cpsLimit: body.cpsLimit === undefined ? 0 : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
cpsLimit: body.cpsLimit === undefined ? 1 : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000),
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'),
@@ -105,7 +105,7 @@ export class VendorGatewaysService {
transport: body.transport === undefined ? undefined : this.transport(body.transport),
sipUsername: this.requiresSip(authMode) ? sipUsername : null,
sipHa1: password ? this.sipHa1(sipUsername, host, password) : this.requiresSip(authMode) ? undefined : null,
cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000),
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'),
-14
View File
@@ -35,20 +35,6 @@ export class VendorsController {
return this.vendorsService.update(id, body as never, currentUser?.id);
}
@Post(':id/enable')
@RequirePermissions('vendors.manage')
@AuditAction({ module: 'vendors', action: 'enable', objectType: 'vendor', objectIdParam: 'id' })
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.vendorsService.enable(id, currentUser?.id);
}
@Post(':id/disable')
@RequirePermissions('vendors.manage')
@AuditAction({ module: 'vendors', action: 'disable', objectType: 'vendor', objectIdParam: 'id' })
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.vendorsService.disable(id, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('vendors.manage')
@AuditAction({ module: 'vendors', action: 'delete', objectType: 'vendor', objectIdParam: 'id' })
+7 -14
View File
@@ -11,7 +11,6 @@ import {
VENDORS_REPOSITORY,
type CreateVendorInput,
type UpdateVendorInput,
type VendorStatus,
type VendorSummary,
type VendorsRepository
} from './vendors.repository.js';
@@ -62,7 +61,6 @@ class MemoryVendorsRepository implements VendorsRepository {
contactName: input.contactName ?? null,
phone: input.phone ?? null,
email: input.email ?? null,
status: input.status ?? 'ENABLED',
creditLimit: input.creditLimit,
settlement: input.settlement ?? null,
notes: input.notes ?? null
@@ -79,7 +77,6 @@ class MemoryVendorsRepository implements VendorsRepository {
contactName: input.contactName === undefined ? current.contactName : input.contactName,
phone: input.phone === undefined ? current.phone : input.phone,
email: input.email === undefined ? current.email : input.email,
status: input.status ?? current.status,
creditLimit: input.creditLimit ?? current.creditLimit,
availableBalance: (Number(current.balance) + Number(input.creditLimit ?? current.creditLimit)).toFixed(6),
settlement: input.settlement === undefined ? current.settlement : input.settlement,
@@ -90,12 +87,10 @@ class MemoryVendorsRepository implements VendorsRepository {
return updated;
}
async setStatus(vendorId: string, status: VendorStatus): Promise<VendorSummary> {
return this.update(vendorId, { status });
}
async softDelete(vendorId: string): Promise<VendorSummary> {
return this.update(vendorId, { status: 'DISABLED' });
const current = this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Deleted Vendor' });
this.vendors.set(vendorId, { ...current, updatedAt: new Date('2026-06-21T05:00:00.000Z') });
return this.vendors.get(vendorId) as VendorSummary;
}
private summary(input: {
@@ -104,7 +99,6 @@ class MemoryVendorsRepository implements VendorsRepository {
contactName?: string | null;
phone?: string | null;
email?: string | null;
status?: VendorStatus;
creditLimit?: string;
settlement?: string | null;
notes?: string | null;
@@ -118,7 +112,6 @@ class MemoryVendorsRepository implements VendorsRepository {
contactName: input.contactName ?? null,
phone: input.phone ?? null,
email: input.email ?? null,
status: input.status ?? 'ENABLED',
balance,
creditLimit,
availableBalance: (Number(balance) + Number(creditLimit)).toFixed(6),
@@ -215,7 +208,7 @@ describe('S15 vendors API', () => {
.expect(403);
});
it('creates, updates, disables, enables, and soft deletes vendors with audit entries', async () => {
it('creates, updates, and soft deletes vendors with audit entries', async () => {
await request(app.getHttpServer())
.post('/api/v2/vendors')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
@@ -248,12 +241,12 @@ describe('S15 vendors API', () => {
});
});
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404);
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404);
await request(app.getHttpServer()).delete('/api/v2/vendors/ven_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true);
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'disable' && entry.objectId === 'ven_created')).toBe(true);
expect(audit.entries.some((entry) => entry.module === 'vendors' && (entry.action === 'disable' || entry.action === 'enable'))).toBe(false);
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'delete' && entry.objectId === 'ven_created')).toBe(true);
});
});
-14
View File
@@ -3,15 +3,12 @@ import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type VendorStatus = 'ENABLED' | 'DISABLED';
export interface VendorSummary {
id: string;
name: string;
contactName: string | null;
phone: string | null;
email: string | null;
status: VendorStatus;
balance: string;
creditLimit: string;
availableBalance: string;
@@ -27,7 +24,6 @@ export interface CreateVendorInput {
contactName?: string;
phone?: string;
email?: string;
status?: VendorStatus;
creditLimit: string;
settlement?: string;
notes?: string;
@@ -39,7 +35,6 @@ export interface UpdateVendorInput {
contactName?: string | null;
phone?: string | null;
email?: string | null;
status?: VendorStatus;
creditLimit?: string;
settlement?: string | null;
notes?: string | null;
@@ -51,7 +46,6 @@ export interface VendorsRepository {
get(vendorId: string): Promise<VendorSummary>;
create(input: CreateVendorInput): Promise<VendorSummary>;
update(vendorId: string, input: UpdateVendorInput): Promise<VendorSummary>;
setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise<VendorSummary>;
softDelete(vendorId: string, actorId?: string): Promise<VendorSummary>;
}
@@ -87,7 +81,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
contactName: input.contactName,
phone: input.phone,
email: input.email,
status: input.status ?? 'ENABLED',
creditLimit: new Prisma.Decimal(input.creditLimit),
settlement: input.settlement,
notes: input.notes,
@@ -110,7 +103,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
contactName: input.contactName,
phone: input.phone,
email: input.email,
status: input.status,
creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit),
settlement: input.settlement,
notes: input.notes,
@@ -123,10 +115,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
return this.toSummary(vendor);
}
async setStatus(vendorIdValue: string, status: VendorStatus, actorId?: string): Promise<VendorSummary> {
return this.update(vendorIdValue, { status, actorId });
}
async softDelete(vendorIdValue: string, actorId?: string): Promise<VendorSummary> {
const existing = await this.findActiveOrThrow(vendorIdValue);
const linkedGateways = await this.prisma.vendorGateway.count({
@@ -188,7 +176,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
contactName: string | null;
phone: string | null;
email: string | null;
status: VendorStatus;
balance: Prisma.Decimal;
creditLimit: Prisma.Decimal;
settlement: string | null;
@@ -203,7 +190,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
status: vendor.status,
balance: vendor.balance.toFixed(6),
creditLimit: vendor.creditLimit.toFixed(6),
availableBalance: vendor.balance.plus(vendor.creditLimit).toFixed(6),
-21
View File
@@ -3,7 +3,6 @@ import {
VENDORS_REPOSITORY,
type CreateVendorInput,
type UpdateVendorInput,
type VendorStatus,
type VendorSummary,
type VendorsRepository
} from './vendors.repository.js';
@@ -13,7 +12,6 @@ interface CreateVendorDto {
contactName?: unknown;
phone?: unknown;
email?: unknown;
status?: unknown;
creditLimit?: unknown;
settlement?: unknown;
notes?: unknown;
@@ -24,7 +22,6 @@ interface UpdateVendorDto {
contactName?: unknown;
phone?: unknown;
email?: unknown;
status?: unknown;
creditLimit?: unknown;
settlement?: unknown;
notes?: unknown;
@@ -48,7 +45,6 @@ export class VendorsService {
contactName: this.optionalString(body.contactName, 'contactName', 80),
phone: this.optionalString(body.phone, 'phone', 32),
email: this.optionalString(body.email, 'email', 160),
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'),
settlement: this.optionalString(body.settlement, 'settlement', 80),
notes: this.optionalString(body.notes, 'notes', 500),
@@ -64,7 +60,6 @@ export class VendorsService {
contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80),
phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32),
email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160),
status: body.status === undefined ? undefined : this.status(body.status),
creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'),
settlement: body.settlement === undefined ? undefined : this.nullableString(body.settlement, 'settlement', 80),
notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500),
@@ -74,14 +69,6 @@ export class VendorsService {
return this.vendors.update(vendorId, input);
}
enable(vendorId: string, actorId?: string): Promise<VendorSummary> {
return this.vendors.setStatus(vendorId, 'ENABLED', actorId);
}
disable(vendorId: string, actorId?: string): Promise<VendorSummary> {
return this.vendors.setStatus(vendorId, 'DISABLED', actorId);
}
remove(vendorId: string, actorId?: string): Promise<VendorSummary> {
return this.vendors.softDelete(vendorId, actorId);
}
@@ -115,14 +102,6 @@ export class VendorsService {
return this.limitedString(value, field, maxLength);
}
private status(value: unknown): VendorStatus {
if (value !== 'ENABLED' && value !== 'DISABLED') {
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
}
return value;
}
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)) {
+1
View File
@@ -398,6 +398,7 @@ export default function App() {
can: canPermission,
onCreateCustomer: (body) => reloadAfterMutation(() => api.createCustomer(body)),
onUpdateCustomer: (id, body) => reloadAfterMutation(() => api.updateCustomer(id, body)),
onToggleCustomerStatus: (row) => reloadAfterMutation(() => (row.status === '启用' ? api.disableCustomer(row.id) : api.enableCustomer(row.id))),
onDeleteCustomer: (id) => reloadAfterMutation(() => api.deleteCustomer(id)),
onRechargeCustomer: (id, body) => rechargeAndApply(() => api.rechargeCustomer(id, body)),
}
+2
View File
@@ -135,6 +135,8 @@ export const api = {
customers: () => request('/customers'),
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
disableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
deleteCustomer: (id) => request(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
rechargeCustomer: (id, body) =>
request(`/customers/${encodeURIComponent(id)}/recharges`, {
+5 -5
View File
@@ -29,15 +29,15 @@ export const sipAccounts = [
];
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: 'V2001', name: '供应商 A', balance: '¥42,860.00', credit: '¥300,000', gateways: 8, cycle: '月结', ratePlan: 'CN-Mobile-2026', contact: '陈经理', createdAt: '2026-04-02' },
{ id: 'V2002', name: '供应商 B', balance: '¥18,200.00', credit: '¥120,000', gateways: 5, 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: '禁用' },
{ 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, 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, 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, blockedProvinces: '北京', callTimeLimit: '09:00-21:00', codecs: 'PCMA, G729', calleePrefixTransform: '0571 -> 0571', callerPrefixTransform: '0571888* -> 0571888*', status: '禁用' },
];
export const vendorLineGroups = [
+2 -2
View File
@@ -16,7 +16,7 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
calleeText: call.callee || '-',
callerIpText: call.callerIp || '-',
landingIpText: call.landingIp || '-',
stateText: call.state || '未知',
stateText: call.sipState || call.sipStatusText || call.state || '未知',
startedText: formatDateTime(call.startedAt),
durationText: call.durationSec === null || call.durationSec === undefined ? '-' : formatDurationText(call.durationSec),
}));
@@ -82,7 +82,7 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
{ key: 'calleeText', label: '被叫' },
{ key: 'callerIpText', label: '呼叫方 IP' },
{ key: 'landingIpText', label: '落地 IP' },
{ key: 'stateText', label: '状态', status: true },
{ key: 'stateText', label: 'SIP状态', status: true },
{ key: 'durationText', label: '持续时长' },
{ key: 'startedText', label: '开始时间' },
{
+34 -2
View File
@@ -4,11 +4,12 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Simpl
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 }) {
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onToggleCustomerStatus, onDeleteCustomer, onRechargeCustomer }) {
const [showCreateCustomer, setShowCreateCustomer] = useState(false);
const [editingCustomer, setEditingCustomer] = useState(null);
const [rechargeCustomer, setRechargeCustomer] = useState(null);
const [deleteCustomerTarget, setDeleteCustomerTarget] = useState(null);
const [statusCustomerTarget, setStatusCustomerTarget] = useState(null);
const [newCustomer, setNewCustomer] = useState({ name: '', contact: '', phone: '', email: '' });
const [editCustomerForm, setEditCustomerForm] = useState({ name: '', contact: '', phone: '', email: '' });
const [rechargeForm, setRechargeForm] = useState({ amount: '', remark: '' });
@@ -125,6 +126,21 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
setActionError(explainApiError(error));
}
};
const toggleCustomerStatus = async (customer) => {
setActionError('');
try {
if (onToggleCustomerStatus) {
await onToggleCustomerStatus(customer);
} else {
setCustomerRows((rows) => rows.map((item) => (
item.id === customer.id ? { ...item, status: item.status === '启用' ? '停用' : '启用' } : item
)));
}
setStatusCustomerTarget(null);
} catch (error) {
setActionError(explainApiError(error));
}
};
return (
<>
@@ -136,7 +152,7 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
<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>
<Field label="状态"><Select defaultValue="all"><option value="all">全部状态</option><option>启用</option><option>停用</option></Select></Field>
<Button icon={<Icon type="search" />}>查询</Button>
</Toolbar>
<section className="master-detail">
@@ -157,6 +173,11 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
<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={row.status === '启用' ? 'ghost' : 'secondary'} onClick={() => setStatusCustomerTarget(row)}>
{row.status === '启用' ? '禁用' : '启用'}
</Button>
) : null}
{canManageCustomers ? <Button size="sm" variant="danger" onClick={() => setDeleteCustomerTarget(row)}>删除</Button> : null}
{!canManageCustomers && !canManageRecharges ? '-' : null}
</div>
@@ -238,6 +259,17 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
<p>确认删除客户{deleteCustomerTarget.name}删除后该客户将不再出现在客户列表中</p>
</ConfirmDialog>
) : null}
{statusCustomerTarget ? (
<ConfirmDialog
title={`${statusCustomerTarget.status === '启用' ? '禁用' : '启用'}客户确认`}
confirmLabel={`确认${statusCustomerTarget.status === '启用' ? '禁用' : '启用'}`}
confirmVariant="primary"
onCancel={() => setStatusCustomerTarget(null)}
onConfirm={() => void toggleCustomerStatus(statusCustomerTarget)}
>
<p>确认{statusCustomerTarget.status === '启用' ? '禁用' : '启用'}客户{statusCustomerTarget.name}</p>
</ConfirmDialog>
) : null}
</>
);
}
+10 -10
View File
@@ -26,9 +26,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
.filter((item) => item.start || item.end);
return ranges.length ? ranges : [{ start: '', end: '' }];
};
const splitRate = (value) => {
return String(value || '').trim();
};
const splitRate = (value) => String(value || '').replace(/[^\d]/g, '');
const formatMinuteRate = (cycle, rate) => {
const billingCycle = Number(cycle);
const cycleRate = Number(rate);
@@ -84,10 +82,12 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
};
const submitGateway = async (event) => {
event.preventDefault();
if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate) return;
if (!editingGateway || !gatewayForm.name.trim() || !gatewayForm.concurrencyLimit || !gatewayForm.billingCycle || !gatewayForm.cycleRate || !gatewayForm.requestRate) return;
const billingCycle = Number(gatewayForm.billingCycle);
const cycleRate = Number(gatewayForm.cycleRate);
const cpsLimit = Number(gatewayForm.requestRate);
if (!Number.isFinite(billingCycle) || billingCycle <= 0 || billingCycle > 60 || !Number.isFinite(cycleRate) || cycleRate < 0) return;
if (!Number.isInteger(cpsLimit) || cpsLimit < 1 || cpsLimit > 10000) return;
const ipAddress = gatewayForm.ipAddress.trim();
const sipAccount = gatewayForm.sipAccount.trim();
const sipPassword = gatewayForm.sipPassword.trim();
@@ -105,7 +105,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
port: editingGateway.port || 5060,
transport: editingGateway.transport || 'udp',
sipUsername: sipAccount || undefined,
cpsLimit: Number(String(gatewayForm.requestRate).match(/\d+/)?.[0] || 0),
cpsLimit,
concurrencyLimit: Number(gatewayForm.concurrencyLimit),
billingCycleSec: billingCycle,
cycleRate: String(cycleRate),
@@ -139,7 +139,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
concurrencyLimit: body.concurrencyLimit,
billingCycle,
cycleRate,
requestRate: gatewayForm.requestRate.trim() || '-',
requestRate: cpsLimit,
blockedProvinces: gatewayForm.blockedProvinces.length ? gatewayForm.blockedProvinces.join('、') : '无',
callTimeLimit: gatewayForm.forbiddenPeriods
.filter((period) => period.start || period.end)
@@ -240,6 +240,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
{ 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: 'requestRate', label: 'CPS' },
{ key: 'concurrencyLimit', label: '并发上限' },
{ key: 'minuteRate', label: '价格', render: (row) => formatMinuteRate(row.billingCycle, row.cycleRate) },
{ key: 'landingCalleePrefix', label: '落地被叫前缀', render: (row) => row.landingCalleePrefix || '-' },
@@ -311,10 +312,9 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
</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>
<Field label={<span>CPS <span className="required-star">*</span></span>}>
<Input type="number" min="1" max="10000" step="1" value={gatewayForm.requestRate} onChange={(event) => setGatewayForm({ ...gatewayForm, requestRate: event.target.value })} placeholder="1-10000" required />
</Field>
<div className="config-card">
<strong>屏蔽省份</strong>
<div className="option-grid">
+1 -2
View File
@@ -42,7 +42,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
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' }]);
setVendorRows((rows) => [...rows, { id: `V${String(2000 + nextIndex)}`, name: newVendor.name.trim(), balance: '¥0.00', credit: '¥0', gateways: 0, cycle: '待配置', ratePlan: '待配置', contact: '-', createdAt: '2026-06-19' }]);
}
setNewVendor({ name: '' });
setShowCreateVendor(false);
@@ -121,7 +121,6 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
{ key: 'balance', label: '余额' },
{ key: 'credit', label: '授信额度' },
{ key: 'gateways', label: '落地网关数' },
{ key: 'status', label: '状态', status: true },
{
key: 'actions',
label: '操作',
+1 -2
View File
@@ -80,7 +80,6 @@ function normalizeVendor(item) {
balance: formatCurrency(item.balance),
credit: formatCurrency(item.creditLimit),
gateways: item.gatewayCount ?? 0,
status: zhStatus(item.status),
cycle: item.settlement || '-',
ratePlan: '-',
contact: item.contactName || '-',
@@ -133,7 +132,7 @@ function normalizeVendorGateway(item) {
concurrencyLimit: item.concurrencyLimit ?? 0,
billingCycle: item.billingCycleSec ?? 60,
cycleRate: Number(item.cycleRate || 0),
requestRate: `${item.cpsLimit ?? 0} CPS`,
requestRate: item.cpsLimit ?? 1,
blockedProvinces: '-',
callTimeLimit: '-',
codecs: (item.codecs || []).map((codec) => codec.codec).join(', ') || '-',