feat: optimize UI queries and seed number library

This commit is contained in:
hectorzhao
2026-08-27 20:24:18 +08:00
parent 138297d191
commit 564234b769
22 changed files with 631 additions and 84 deletions
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConfigService } from '@nestjs/config';
import type { RuntimeConfig } from '../../shared/config.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
function client() {
const config = {
get: () => ({
mode: 'http',
httpUrl: 'http://127.0.0.1:8888/mi',
sshHost: 'unused',
remoteCommand: 'unused',
timeoutMs: 3000
})
} as unknown as ConfigService<RuntimeConfig, true>;
return new OpenSipsMiClient(config);
}
describe('OpenSipsMiClient HTTP MI', () => {
afterEach(() => vi.unstubAllGlobals());
it('lists dialogs through the local JSON-RPC endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
});
it('preserves OpenSIPS MI errors for the API layer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
});
});
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
private async call(method: string, params: string[] = []): Promise<unknown> {
const config = this.configService.get('activeCalls', { infer: true });
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args = [];
if (config.sshConfig) {
args.push('-F', config.sshConfig);
}
args.push(config.sshHost, remoteCommand);
let output: string;
let response: MiResponse;
try {
output = await this.executor.run(args, config.timeoutMs);
response = config.mode === 'http'
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
: await this.callSsh(config, method, params);
} catch (error) {
if (error instanceof BadGatewayException) throw error;
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
message: 'OpenSIPS control plane is unavailable.',
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
});
}
let response: MiResponse;
try {
response = JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
if (response.error) {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_ERROR',
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
return response.result;
}
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
if (!response.ok) {
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
}
try {
return await response.json() as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args: string[] = [];
if (config.sshConfig) args.push('-F', config.sshConfig);
args.push(config.sshHost, remoteCommand);
const output = await this.executor.run(args, config.timeoutMs);
try {
return JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
}
function shellQuote(value: string): string {
@@ -1,4 +1,5 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export interface AuditLogQuery {
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
objectType?: string;
objectId?: string;
result?: 'SUCCESS' | 'FAILURE';
createdFrom?: Date;
createdTo?: Date;
take: number;
skip: number;
}
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
const where = {
const where: Prisma.AuditLogWhereInput = {
module: query.module,
action: query.action,
userId: query.userId,
objectType: query.objectType,
objectId: query.objectId,
result: query.result
result: query.result,
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
};
const [items, total] = await this.prisma.$transaction([
@@ -13,6 +13,8 @@ export class AuditLogsService {
objectType: this.optionalString(rawQuery.objectType),
objectId: this.optionalString(rawQuery.objectId),
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
take: this.positiveInt(rawQuery.take, 50, 100),
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
};
@@ -56,4 +58,16 @@ export class AuditLogsService {
return parsed;
}
private optionalDate(value: unknown, field: string): Date | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
}
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
export interface RecordingListQuery {
status?: string;
reviewStatus?: 'PENDING' | 'REVIEWED';
limit?: number;
startedFrom?: Date;
startedTo?: Date;
}
export interface RecordingsRepository {
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
getDetail(id: string): Promise<RecordingDetail>;
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
export class PrismaRecordingsRepository implements RecordingsRepository {
constructor(private readonly prisma: PrismaService) {}
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
const take = Math.min(query.limit ?? 100, 500);
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
const recordings = await this.prisma.recording.findMany({
where: {
status: query.status ? (query.status as never) : 'READY',
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
OR: startedAt ? [
{ rawCdr: { is: { startedAt } } },
{ rawCdrId: null, createdAt: startedAt }
] : undefined
},
orderBy: [{ createdAt: 'desc' }],
take,
@@ -26,11 +26,13 @@ export class RecordingsService {
@Inject(QualityService) private readonly qualityService: QualityService
) {}
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
const recordings = await this.recordings.list({
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
});
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
}
@@ -123,6 +125,17 @@ export class RecordingsService {
}
return parsed;
}
private date(value: unknown, field: string): Date {
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
}
export function internalRecordingPath(storageKey: string): string {
+6
View File
@@ -19,6 +19,8 @@ export interface RuntimeConfig {
url: string;
};
activeCalls: {
mode: 'http' | 'ssh';
httpUrl: string;
sshHost: string;
sshConfig?: string;
remoteCommand: string;
@@ -47,6 +49,8 @@ export const validationSchema = Joi.object({
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
ACTIVE_CALLS_MODE: Joi.string().valid('http', 'ssh').default('http'),
ACTIVE_CALLS_HTTP_URL: Joi.string().uri({ scheme: ['http', 'https'] }).default('http://127.0.0.1:8888/mi'),
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
@@ -89,6 +93,8 @@ export function appConfig(): RuntimeConfig {
url: process.env.REDIS_URL ?? ''
},
activeCalls: {
mode: process.env.ACTIVE_CALLS_MODE === 'ssh' ? 'ssh' : 'http',
httpUrl: process.env.ACTIVE_CALLS_HTTP_URL ?? 'http://127.0.0.1:8888/mi',
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
+16 -2
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
import { Icon, PageLoadingDialog } from './components/layout.jsx';
import { api, explainApiError } from './api.js';
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
import {
formatCurrency,
normalizeAuditLog,
@@ -298,7 +299,8 @@ export default function App() {
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
} else if (page === 'operationLogs') {
add('audit.view', () => api.auditLogs(signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
const range = lastSevenDays();
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
} else if (page === 'cdr') {
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
@@ -318,6 +320,18 @@ export default function App() {
setApiLoading(false);
};
const refreshApi = () => loadPageData(active, authUser, { force: true });
const refreshAuditLogs = async (params = {}) => {
setApiLoading(true);
setApiError('');
try {
const value = await api.auditLogs(params);
setLogRows((value.items || []).map(normalizeAuditLog));
} catch (error) {
setApiError(explainApiError(error));
} finally {
setApiLoading(false);
}
};
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
setActiveCallsLoading(true);
if (!silent) setActiveCallsBlockingLoading(true);
@@ -528,7 +542,7 @@ export default function App() {
: active === 'roles'
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
: active === 'operationLogs'
? { logRows, apiLoading, apiError, refreshApi }
? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
: {};
if (!authUser) {
+6 -6
View File
@@ -153,7 +153,7 @@ export const api = {
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...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) }),
@@ -202,20 +202,20 @@ export const api = {
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
roles: (options) => request('/roles', options),
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
auditLogs: (options) => request('/audit-logs?take=100', options),
auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options),
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 })}`),
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`),
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`),
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`),
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`),
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`),
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`),
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
};
+31 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button } from './ui.jsx';
import { Alert, Badge, Button, Field, Input } from './ui.jsx';
const selectedBlue = '#2563EB';
@@ -42,6 +42,36 @@ export function Toolbar({ children }) {
return <div className="toolbar">{children}</div>;
}
export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) {
return (
<>
<Field label="开始日期"><Input type="date" value={startDate} max={endDate || undefined} onChange={(event) => onStartChange(event.target.value)} /></Field>
<Field label="结束日期"><Input type="date" value={endDate} min={startDate || undefined} onChange={(event) => onEndChange(event.target.value)} /></Field>
</>
);
}
export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) {
const start = total ? skip + 1 : 0;
const end = Math.min(total, skip + count);
return (
<div className="pagination-bar" aria-label="分页">
<span>显示 {start}-{end} {total} </span>
<div className="pagination-actions">
<label>每页
<select value={take} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<Button size="sm" variant="outline" disabled={loading || skip === 0} onClick={() => onPageChange(Math.max(0, skip - take))}>上一页</Button>
<Button size="sm" variant="outline" disabled={loading || skip + count >= total} onClick={() => onPageChange(skip + take)}>下一页</Button>
</div>
</div>
);
}
export function Panel({ title, aside, children, className = '' }) {
return (
<section className={`prototype-panel ${className}`}>
+9 -8
View File
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
const [detailCdr, setDetailCdr] = useState(null);
@@ -24,8 +27,8 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
vendorGatewayId: 'all',
cityCode: '',
carrier: 'all',
startedFrom: '',
startedTo: '',
startedFrom: defaultRange.startDate,
startedTo: defaultRange.endDate,
take: '25',
skip: 0,
});
@@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
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() : '',
...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo),
take: nextFilters.take,
skip: String(nextFilters.skip),
});
@@ -133,7 +135,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
void loadCdrs(nextFilters);
};
const resetFilters = () => {
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: defaultRange.startDate, startedTo: defaultRange.endDate, take: filters.take, skip: 0 };
setFilters(nextFilters);
void loadCdrs(nextFilters);
};
@@ -230,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
<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>
<DateRangeFields startDate={filters.startedFrom} endDate={filters.startedTo} onStartChange={(value) => updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} />
<Field label="每页">
<Select value={filters.take} onChange={(event) => {
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
+1 -14
View File
@@ -1,5 +1,5 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
import { Icon, PageTitle, Panel, ApiNotice, MiniBarChart, LineChart } from '../components/layout.jsx';
import { formatCurrency } from '../utils/formatters.js';
const pendingMetrics = [
@@ -32,7 +32,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
const trendBuckets = dashboardTrends?.buckets || [];
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
return (
<>
<PageTitle
@@ -57,18 +56,6 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
<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">
<EmptyState title="消费排名待接入">当前 Dashboard 接口暂未提供客户消费排名避免额外预加载客户列表</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>
</>
);
+36 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable } from '../components/layout.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable, Pagination } from '../components/layout.jsx';
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
@@ -53,10 +53,10 @@ export function NumberLibraryPage({ can = () => true }) {
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' },
cities: { keyword: '', take: 25, skip: 0 },
phoneSegments: { segment7: '', cityCode: '', carrier: 'all', take: 25, skip: 0 },
areaCodes: { areaCode: '', cityCode: '', take: 25, skip: 0 },
carrierPrefixRules: { prefix: '', carrier: 'all', take: 25, skip: 0 },
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -66,8 +66,7 @@ export function NumberLibraryPage({ can = () => true }) {
const [importing, setImporting] = useState(false);
const canManage = can('number_library.manage');
const readTab = async (tab) => {
const params = filters[tab] || {};
const readTab = async (tab, params = filters[tab] || {}) => {
if (tab === 'cities') {
const payload = await api.numberLibraryCities(params);
return normalizeNumberLibraryList(payload, (item) => ({
@@ -120,11 +119,11 @@ export function NumberLibraryPage({ can = () => true }) {
}));
};
const loadTab = async (tab = activeTab) => {
const loadTab = async (tab = activeTab, params = filters[tab]) => {
setLoading(true);
setError('');
try {
const result = await readTab(tab);
const result = await readTab(tab, params);
setRows((current) => ({ ...current, [tab]: result.rows }));
setTotals((current) => ({ ...current, [tab]: result.total }));
} catch (loadError) {
@@ -141,10 +140,22 @@ export function NumberLibraryPage({ can = () => true }) {
const updateFilter = (key, value) => {
setFilters((current) => ({
...current,
[activeTab]: { ...current[activeTab], [key]: value },
[activeTab]: { ...current[activeTab], [key]: value, skip: 0 },
}));
};
const searchActiveTab = () => {
const next = { ...filters[activeTab], skip: 0 };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const changePage = (skip, take = filters[activeTab].take) => {
const next = { ...filters[activeTab], skip, take };
setFilters((current) => ({ ...current, [activeTab]: next }));
void loadTab(activeTab, next);
};
const openImport = (tab) => {
setImportTarget(tab);
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
@@ -187,7 +198,7 @@ export function NumberLibraryPage({ can = () => true }) {
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>
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</>
);
}
@@ -207,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</>
);
}
@@ -216,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) {
<>
<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>
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</>
);
}
@@ -234,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) {
<option value="UNKNOWN">未知</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
</>
);
};
@@ -327,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) {
</Toolbar>
) : null}
{renderTable(tab.value)}
{tab.value === activeTab ? (
<Pagination
total={totals[tab.value]}
take={filters[tab.value].take}
skip={filters[tab.value].skip}
count={rows[tab.value].length}
loading={loading}
onPageChange={(skip) => changePage(skip)}
onPageSizeChange={(take) => changePage(0, take)}
/>
) : null}
</Panel>
),
}))}
+26 -7
View File
@@ -1,7 +1,10 @@
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 { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const defaultRange = lastSevenDays();
function toneForStatus(status) {
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
@@ -11,8 +14,8 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all');
const [resultFilter, setResultFilter] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [detailLog, setDetailLog] = useState(null);
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
const visibleLogs = useMemo(() => {
@@ -23,18 +26,34 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
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(''); };
const queryLogs = (overrides = {}) => {
const next = { moduleFilter, resultFilter, startDate, endDate, ...overrides };
return refreshApi({
module: next.moduleFilter,
result: next.resultFilter === '成功' ? 'SUCCESS' : next.resultFilter === '失败' ? 'FAILURE' : 'all',
...dateRangeParams(next.startDate, next.endDate, 'createdFrom', 'createdTo'),
});
};
const resetFilters = () => {
setKeyword('');
setModuleFilter('all');
setResultFilter('all');
setStartDate(defaultRange.startDate);
setEndDate(defaultRange.endDate);
void queryLogs({ moduleFilter: 'all', resultFilter: 'all', ...defaultRange });
};
return (
<>
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
<ApiNotice loading={apiLoading} error={apiError} onRetry={() => void queryLogs()} />
<PageLoadingDialog loading={apiLoading} />
<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>
<DateRangeFields startDate={startDate} endDate={endDate} onStartChange={setStartDate} onEndChange={setEndDate} />
<Button icon={<Icon type="search" />} disabled={apiLoading} onClick={() => void queryLogs()}>查询</Button>
<Button variant="outline" onClick={resetFilters}>重置</Button>
</Toolbar>
<Panel title="日志列表" aside={<Badge tone="neutral"> {visibleLogs.length} </Badge>} className="wide-panel">
+12 -2
View File
@@ -1,17 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
const defaultRange = lastSevenDays();
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
const [ruleRows, setRuleRows] = useState([]);
const [recordingRows, setRecordingRows] = useState([]);
const [qualityLoading, setQualityLoading] = useState(false);
const [qualityError, setQualityError] = useState('');
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50' });
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50', ...defaultRange });
const [showRules, setShowRules] = useState(false);
const [editingRule, setEditingRule] = useState(undefined);
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
@@ -51,6 +53,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const params = {
limit: nextFilter.limit,
reviewStatus: nextFilter.reviewStatus,
...dateRangeParams(nextFilter.startDate, nextFilter.endDate),
};
const recordingList = await api.recordings(params);
setRecordingRows((recordingList || []).map(normalizeRecording));
@@ -293,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
const changeRecordingFilter = (key, value) => {
const nextFilter = { ...recordingFilter, [key]: value };
setRecordingFilter(nextFilter);
};
const resetRecordingFilter = () => {
const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange };
setRecordingFilter(nextFilter);
void refreshQuality(nextFilter);
};
@@ -313,6 +320,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<option value="REVIEWED">已完成</option>
</Select>
</Field>
<DateRangeFields startDate={recordingFilter.startDate} endDate={recordingFilter.endDate} onStartChange={(value) => changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} />
<Field label="读取条数">
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
<option value="25">25</option>
@@ -320,6 +328,8 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
<option value="100">100</option>
</Select>
</Field>
<Button icon={<Icon type="search" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>查询</Button>
<Button variant="outline" disabled={qualityLoading} onClick={resetRecordingFilter}>重置</Button>
</Toolbar>
<Panel title="录音列表" aside={<Badge tone="neutral"> {recordingRows.length} 条录音</Badge>} className="wide-panel">
<SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
+28
View File
@@ -2196,11 +2196,39 @@ a {
width: 100%;
max-width: var(--content-max-width);
gap: 20px;
align-content: start;
grid-auto-rows: max-content;
min-height: 0;
padding: 28px;
overflow-y: auto;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 16px;
color: var(--muted);
font-size: 13px;
}
.pagination-actions,
.pagination-actions label {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-actions select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
padding: 0 28px 0 10px;
}
.login-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
+19
View File
@@ -0,0 +1,19 @@
const pad = (value) => String(value).padStart(2, '0');
export function formatDateInput(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function lastSevenDays(reference = new Date()) {
const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const start = new Date(end);
start.setDate(start.getDate() - 6);
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}
export function dateRangeParams(startDate, endDate, fromKey = 'startedFrom', toKey = 'startedTo') {
const params = {};
if (startDate) params[fromKey] = new Date(`${startDate}T00:00:00`).toISOString();
if (endDate) params[toKey] = new Date(`${endDate}T23:59:59.999`).toISOString();
return params;
}