feat: optimize UI queries and seed number library
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }) }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1941,6 +1941,40 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
| 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 |
|
||||
| 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 |
|
||||
|
||||
### 8.10 S57 页面布局、日期范围与号码库
|
||||
|
||||
#### WEB-010 页面内容顶部对齐
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P1 |
|
||||
| 步骤 | 登录后依次打开客户网关、用户、号码库以及数据量较少的配置页,检查标题、筛选区和列表面板。 |
|
||||
| 预期结果 | 所有区块按内容高度从顶部连续排列,不因视口剩余高度产生大面积空白或组件纵向拉伸。 |
|
||||
|
||||
#### ACT-004 B 本机 OpenSIPS MI
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 步骤 | 1. 在 B 调用 OpenSIPS HTTP MI `dlg_list`。2. 打开当前通话。3. 发起测试呼叫后刷新。 |
|
||||
| 预期结果 | 无 A 服务器 SSH 依赖;空闲时快速返回空列表,呼叫中显示真实 dialog;MI 不可用时 3 秒内失败并显示受控提示。 |
|
||||
|
||||
#### DATE-001 三中心默认最近一周
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P1 |
|
||||
| 步骤 | 依次打开话单、质检、操作日志,检查日期输入和请求参数;修改范围查询,再点击重置。 |
|
||||
| 预期结果 | 首次和重置均为今天至前 6 天;开始日按 00:00:00、结束日按 23:59:59.999 查询;后端只返回范围内数据。 |
|
||||
|
||||
#### NUM-004 号码库全量与分页性能
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
|
||||
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
|
||||
|
||||
## 9. 缺陷分级
|
||||
|
||||
| 级别 | 定义 | 示例 |
|
||||
@@ -1965,7 +1999,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
## 11. 当前已知风险与补充建议
|
||||
|
||||
- Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。
|
||||
- 当前真实 80 万手机号段尚未导入。号码库性能、导入耗时、批量校验和 CDR 归属地准确性需单独做数据量级测试。
|
||||
- S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验。
|
||||
- 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。
|
||||
- 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。
|
||||
- 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。
|
||||
|
||||
@@ -16,11 +16,28 @@
|
||||
- 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。
|
||||
- Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。
|
||||
|
||||
## 暂未直接实施
|
||||
## S57 补充整改
|
||||
|
||||
- 全局 `.page-content` 固定为顶部对齐和内容高度网格行,消除数据较少时标题、筛选区和列表面板被拉伸形成的大面积空白;分页和日期范围控件收敛为全局组件和样式。
|
||||
- 当前通话不再依赖 B 到 A 的 SSH 包装命令,API 改为请求 B 本机 `http://127.0.0.1:8888/mi` 的 OpenSIPS JSON-RPC;保留 SSH 模式作为显式回滚选项。
|
||||
- 话单中心、质检中心、操作日志统一默认查询最近 7 个自然日(含今天),结束日期覆盖至当天 23:59:59.999;重置后仍恢复最近一周,不再回到无限时间范围。
|
||||
- 质检录音和操作日志补齐后端日期过滤,操作日志查询由“只对首批 100 条做浏览器过滤”改为日期、模块、结果的服务端过滤。
|
||||
- Dashboard 删除“客户消费 TOP 10”和“失败响应码分布”两个模块。
|
||||
- 号码库默认每页 25 条,四个页签分别维护分页游标、页大小和总数,只请求当前页;增加可重复执行的数据导入脚本,导入时保存来源和批次并拒绝部分匹配。
|
||||
|
||||
## 号码库数据口径
|
||||
|
||||
- 行政区划:国家统计局 2023-06-30 区划代码的 2024 整理版本;包含地级行政区和省/自治区直辖县级市,共 371 条。
|
||||
- 手机号段:`phone.dat` 2502 版本,共 517,258 条有效七位号段;运营商信息受携号转网影响,只表示号段原始分配归属,不作为当前在网运营商的强校验。
|
||||
- 城市区号:由号段库归属记录按城市多数匹配生成 321 条;当前表结构一个区号只能关联一个城市,共享区号按数据中出现最多的城市保存。
|
||||
- 运营商前缀:由七位号段按前三位多数归属生成 70 条,业务判定仍应优先使用七位号段。
|
||||
- 导入器要求所有有效号段均能映射到行政区代码;本批次干跑结果为未匹配 0 条。生产导入前必须先运行 MySQL/Redis 备份。
|
||||
|
||||
## S56 时暂未直接实施(现状更新)
|
||||
|
||||
- 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。
|
||||
- 未修改 TLS 证书、Nginx 或生产部署配置。
|
||||
- 未发布生产。当前改动仅在本地源码、测试和构建层验证。
|
||||
- S57 发布结果、生产数量和浏览器证据记录在对应发布报告中。
|
||||
|
||||
## 本地验证
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
commit=${1:?commit is required}
|
||||
archive=${2:?archive path is required}
|
||||
expected_sha=${3:?archive sha256 is required}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup=/var/backups/lisglosips-s57/$stamp
|
||||
old=$(readlink -f /opt/lisglosips/current)
|
||||
new=/opt/lisglosips/releases/s57-ui-number-library-$stamp
|
||||
|
||||
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
|
||||
test -d "$old"
|
||||
test ! -e "$new"
|
||||
|
||||
echo "phase=data-backup"
|
||||
systemctl start lisglosips-backup.service
|
||||
if systemctl --quiet is-failed lisglosips-backup.service; then
|
||||
systemctl status --no-pager lisglosips-backup.service
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "phase=config-backup"
|
||||
install -d -m 0700 "$backup"
|
||||
cp -a /etc/lisglosips/api.env "$backup/api.env"
|
||||
printf '%s\n' "$old" > "$backup/previous-release"
|
||||
|
||||
changed=0
|
||||
rollback() {
|
||||
rc=$?
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
echo "phase=rollback rc=$rc"
|
||||
cp -a "$backup/api.env" /etc/lisglosips/api.env
|
||||
ln -sfn "$old" /opt/lisglosips/current
|
||||
systemctl restart lisglosips@api || true
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
echo "phase=stage-release"
|
||||
cp -a --reflink=auto "$old" "$new"
|
||||
tar -xzf "$archive" -C "$new"
|
||||
printf '%s\n' "$commit" > "$new/.deployed-commit"
|
||||
printf '%s\n' "$old" > "$new/.delta-base-release"
|
||||
chown -R root:lisglosips "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
|
||||
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
|
||||
chmod 0755 "$new/infra/server-b/s57/deploy-ui-number-library.sh"
|
||||
|
||||
tmp_env=$(mktemp)
|
||||
grep -Ev '^(ACTIVE_CALLS_MODE|ACTIVE_CALLS_HTTP_URL|ACTIVE_CALLS_TIMEOUT_MS)=' /etc/lisglosips/api.env > "$tmp_env"
|
||||
printf '%s\n' 'ACTIVE_CALLS_MODE=http' 'ACTIVE_CALLS_HTTP_URL=http://127.0.0.1:8888/mi' 'ACTIVE_CALLS_TIMEOUT_MS=3000' >> "$tmp_env"
|
||||
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/api.env
|
||||
rm -f "$tmp_env"
|
||||
|
||||
changed=1
|
||||
ln -sfn "$new" /opt/lisglosips/current
|
||||
systemctl restart lisglosips@api
|
||||
sleep 4
|
||||
|
||||
echo "phase=verify"
|
||||
test "$(readlink -f /opt/lisglosips/current)" = "$new"
|
||||
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
|
||||
test "$(systemctl is-active lisglosips@api)" = active
|
||||
curl --fail --silent --show-error http://127.0.0.1:3000/api/v2/health >/dev/null
|
||||
curl --fail --silent --show-error -H 'content-type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' \
|
||||
http://127.0.0.1:8888/mi >/dev/null
|
||||
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
|
||||
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
|
||||
|
||||
changed=0
|
||||
trap - ERR
|
||||
@@ -72,8 +72,10 @@ const copyEntries = [
|
||||
['packages/redis/node_modules', 'packages/redis/node_modules', true],
|
||||
['infra/server-b/s30', 'infra/server-b/s30', true],
|
||||
['infra/server-b/s56', 'infra/server-b/s56', true],
|
||||
['infra/server-b/s57', 'infra/server-b/s57', true],
|
||||
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
|
||||
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true]
|
||||
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
|
||||
['scripts/import-number-library.mjs', 'scripts/import-number-library.mjs', true]
|
||||
];
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { PrismaClient } from '../packages/database/dist/index.js';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map((entry) => {
|
||||
const [key, ...value] = entry.replace(/^--/, '').split('=');
|
||||
return [key, value.length ? value.join('=') : true];
|
||||
}));
|
||||
|
||||
if (!args.admin || !args.phone) {
|
||||
throw new Error('Usage: node scripts/import-number-library.mjs --admin=/path/area.json.gz --phone=/path/phone.dat [--dry-run]');
|
||||
}
|
||||
|
||||
const batchId = String(args.batch || 'number-library-20260827');
|
||||
const adminSource = '国家统计局区划代码整理库 2024 (2023-06-30)';
|
||||
const phoneSource = 'pangongzi/phone phone.dat 2025-02';
|
||||
const adminTree = JSON.parse(zlib.gunzipSync(fs.readFileSync(String(args.admin))).toString('utf8'));
|
||||
|
||||
const sixDigitCode = (code) => String(code).slice(0, 6);
|
||||
const normalize = (value) => String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|土家族苗族自治州|苗族侗族自治州|藏族自治州|蒙古族藏族自治州|柯尔克孜自治州|哈萨克自治州|自治州|省|市|地区|盟|县$/g, '');
|
||||
|
||||
const cities = [];
|
||||
for (const province of adminTree) {
|
||||
for (const city of province.children || []) {
|
||||
const base = {
|
||||
code: sixDigitCode(city.code),
|
||||
provinceCode: sixDigitCode(province.code),
|
||||
provinceName: province.name,
|
||||
cityCode: sixDigitCode(city.code),
|
||||
cityName: city.name === '市辖区' ? province.name : city.name,
|
||||
cityLevel: 'PREFECTURE',
|
||||
status: 'ENABLED'
|
||||
};
|
||||
cities.push(base);
|
||||
if (/直辖县级行政区划/.test(city.name)) {
|
||||
cities.pop();
|
||||
for (const countyCity of city.children || []) {
|
||||
cities.push({ ...base, code: sixDigitCode(countyCity.code), cityCode: sixDigitCode(countyCity.code), cityName: countyCity.name, cityLevel: 'COUNTY_DIRECT' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phoneBuffer = fs.readFileSync(String(args.phone));
|
||||
const indexOffset = phoneBuffer.readUInt32LE(4);
|
||||
const phoneRecords = [];
|
||||
for (let offset = indexOffset; offset + 9 <= phoneBuffer.length; offset += 9) {
|
||||
const segment7 = String(phoneBuffer.readUInt32LE(offset)).padStart(7, '0');
|
||||
const dataOffset = phoneBuffer.readUInt32LE(offset + 4);
|
||||
const end = phoneBuffer.indexOf(0, dataOffset);
|
||||
const [provinceName, cityName, zipCode, areaCode] = phoneBuffer.subarray(dataOffset, end).toString('utf8').split('|');
|
||||
phoneRecords.push({ segment7, provinceName, cityName, zipCode, areaCode, type: phoneBuffer[offset + 8] });
|
||||
}
|
||||
|
||||
const byProvince = new Map();
|
||||
for (const city of cities) {
|
||||
const key = normalize(city.provinceName);
|
||||
const values = byProvince.get(key) || [];
|
||||
values.push(city);
|
||||
byProvince.set(key, values);
|
||||
}
|
||||
|
||||
function findCity(record) {
|
||||
const provinceCities = byProvince.get(normalize(record.provinceName)) || [];
|
||||
const aliasKey = `${normalize(record.provinceName)}|${normalize(record.cityName)}`;
|
||||
const cityAliases = {
|
||||
'山东|莱芜': '济南',
|
||||
'新疆|巴州': '巴音郭楞',
|
||||
'新疆|博州': '博尔塔拉',
|
||||
'新疆|克州': '克孜勒苏',
|
||||
'新疆|奎屯': '伊犁',
|
||||
'青海|格尔木': '海西'
|
||||
};
|
||||
const cityName = cityAliases[aliasKey] || normalize(record.cityName || record.provinceName);
|
||||
const exact = provinceCities.find((city) => normalize(city.cityName) === cityName);
|
||||
if (exact) return exact;
|
||||
const partial = provinceCities.filter((city) => normalize(city.cityName).startsWith(cityName) || cityName.startsWith(normalize(city.cityName)));
|
||||
return partial.length === 1 ? partial[0] : provinceCities.length === 1 ? provinceCities[0] : null;
|
||||
}
|
||||
|
||||
const directMatches = phoneRecords.map((record) => ({ record, city: findCity(record) }));
|
||||
const areaVotes = new Map();
|
||||
for (const { record, city } of directMatches) {
|
||||
if (!city || !record.areaCode) continue;
|
||||
const key = `${record.areaCode}|${city.cityCode}`;
|
||||
areaVotes.set(key, (areaVotes.get(key) || 0) + 1);
|
||||
}
|
||||
const areaCity = new Map();
|
||||
for (const [key, count] of areaVotes) {
|
||||
const [areaCode, cityCode] = key.split('|');
|
||||
const current = areaCity.get(areaCode);
|
||||
if (!current || count > current.count) areaCity.set(areaCode, { cityCode, count });
|
||||
}
|
||||
const cityByCode = new Map(cities.map((city) => [city.cityCode, city]));
|
||||
const unmatched = [];
|
||||
const carrierMap = { 1: 'MOBILE', 2: 'UNICOM', 3: 'TELECOM', 4: 'MVNO', 5: 'MVNO', 6: 'MVNO', 7: 'BROADCAST', 8: 'MVNO' };
|
||||
const segments = [];
|
||||
for (const match of directMatches) {
|
||||
if (!/^1\d{6}$/.test(match.record.segment7)) continue;
|
||||
const city = match.city || cityByCode.get(areaCity.get(match.record.areaCode)?.cityCode);
|
||||
if (!city) {
|
||||
unmatched.push(match.record);
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
segment7: match.record.segment7,
|
||||
cityCode: city.cityCode,
|
||||
provinceName: city.provinceName,
|
||||
cityName: city.cityName,
|
||||
carrier: carrierMap[match.record.type] || 'UNKNOWN',
|
||||
source: phoneSource,
|
||||
batchId
|
||||
});
|
||||
}
|
||||
|
||||
const areaCodes = [...areaCity.entries()].map(([areaCode, vote]) => {
|
||||
const city = cityByCode.get(vote.cityCode);
|
||||
return { areaCode, cityCode: city.cityCode, provinceName: city.provinceName, cityName: city.cityName, source: phoneSource, batchId };
|
||||
}).filter((item) => /^0\d{2,3}$/.test(item.areaCode));
|
||||
|
||||
const prefixVotes = new Map();
|
||||
for (const segment of segments) {
|
||||
const prefix = segment.segment7.slice(0, 3);
|
||||
const key = `${prefix}|${segment.carrier}`;
|
||||
prefixVotes.set(key, (prefixVotes.get(key) || 0) + 1);
|
||||
}
|
||||
const prefixWinners = new Map();
|
||||
for (const [key, count] of prefixVotes) {
|
||||
const [prefix, carrier] = key.split('|');
|
||||
const current = prefixWinners.get(prefix);
|
||||
if (!current || count > current.count) prefixWinners.set(prefix, { carrier, count });
|
||||
}
|
||||
const prefixes = [...prefixWinners.entries()].map(([prefix, value]) => ({ prefix, carrier: value.carrier, priority: 100, source: phoneSource, batchId }));
|
||||
|
||||
const report = {
|
||||
batchId,
|
||||
adminSource,
|
||||
phoneSource,
|
||||
phoneVersion: phoneBuffer.subarray(0, 4).toString('utf8'),
|
||||
cities: cities.length,
|
||||
sourcePhoneRecords: phoneRecords.length,
|
||||
segments: segments.length,
|
||||
unmatched: unmatched.length,
|
||||
areaCodes: areaCodes.length,
|
||||
prefixes: prefixes.length,
|
||||
unmatchedLocations: [...new Map(unmatched.map(({ provinceName, cityName, areaCode }) => [`${provinceName}|${cityName}|${areaCode}`, { provinceName, cityName, areaCode }])).values()]
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
if (args['dry-run']) process.exit(0);
|
||||
if (unmatched.length) throw new Error(`Refusing partial import: ${unmatched.length} phone segments could not be mapped to an administrative city.`);
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const current = {
|
||||
cities: await prisma.geoCity.count(),
|
||||
segments: await prisma.phoneNumberSegment.count(),
|
||||
areaCodes: await prisma.phoneAreaCode.count(),
|
||||
prefixes: await prisma.carrierPrefixRule.count()
|
||||
};
|
||||
if (Object.values(current).some(Boolean)) throw new Error(`Number library is not empty: ${JSON.stringify(current)}`);
|
||||
|
||||
await prisma.geoCity.createMany({ data: cities, skipDuplicates: true });
|
||||
for (let offset = 0; offset < segments.length; offset += 1000) {
|
||||
await prisma.phoneNumberSegment.createMany({ data: segments.slice(offset, offset + 1000), skipDuplicates: true });
|
||||
}
|
||||
await prisma.phoneAreaCode.createMany({ data: areaCodes, skipDuplicates: true });
|
||||
await prisma.carrierPrefixRule.createMany({ data: prefixes, skipDuplicates: true });
|
||||
await prisma.outboxEvent.create({
|
||||
data: {
|
||||
id: `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`,
|
||||
aggregateType: 'number_library_config',
|
||||
aggregateId: 'number-library',
|
||||
eventType: 'number_library.seeded',
|
||||
payload: report
|
||||
}
|
||||
});
|
||||
console.log(JSON.stringify({ imported: report }, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user