From ecb567c0dacd749607808fbed09648267757be6f Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 27 Aug 2026 15:17:47 +0800 Subject: [PATCH] optimize ui loading and dashboard performance --- apps/api/src/modules/cdrs/cdrs.repository.ts | 4 +- .../api/src/modules/cdrs/cdrs.service.spec.ts | 9 + apps/api/src/modules/cdrs/cdrs.service.ts | 2 +- .../modules/dashboard/dashboard.controller.ts | 6 + .../dashboard/dashboard.service.spec.ts | 16 + .../modules/dashboard/dashboard.service.ts | 8 + apps/web/src/App.jsx | 125 ++- apps/web/src/api.js | 61 +- apps/web/src/components/layout.jsx | 41 +- apps/web/src/pages/ActiveCallsPage.jsx | 22 +- apps/web/src/pages/BusinessPrefixesPage.jsx | 5 +- apps/web/src/pages/CdrPage.jsx | 24 +- apps/web/src/pages/CustomerGatewaysPage.jsx | 2 +- apps/web/src/pages/CustomersPage.jsx | 1 + apps/web/src/pages/DashboardPage.jsx | 25 +- apps/web/src/pages/NumberLibraryPage.jsx | 14 +- apps/web/src/pages/OperationLogsPage.jsx | 2 +- apps/web/src/pages/QualityPage.jsx | 46 +- apps/web/src/pages/RechargeRecordsPage.jsx | 3 +- apps/web/src/pages/RolesPage.jsx | 2 +- apps/web/src/pages/UsersPage.jsx | 2 +- apps/web/src/pages/VendorGatewaysPage.jsx | 2 +- apps/web/src/pages/VendorLineGroupsPage.jsx | 2 +- apps/web/src/pages/VendorsPage.jsx | 2 +- apps/web/src/styles.css | 946 ++++++++++++++++++ ...gloSIPS_UI性能整改方案_20260827.docx | Bin 0 -> 50843 bytes docs/TEST_PLAN_AND_CASES.md | 36 + ...NCE_REMEDIATION_IMPLEMENTATION_20260827.md | 31 + 28 files changed, 1307 insertions(+), 132 deletions(-) create mode 100644 docs/LisgloSIPS_UI性能整改方案_20260827.docx create mode 100644 docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md diff --git a/apps/api/src/modules/cdrs/cdrs.repository.ts b/apps/api/src/modules/cdrs/cdrs.repository.ts index 904dff2..2616bdd 100644 --- a/apps/api/src/modules/cdrs/cdrs.repository.ts +++ b/apps/api/src/modules/cdrs/cdrs.repository.ts @@ -202,10 +202,10 @@ export class PrismaCdrsRepository implements CdrsRepository { async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> { const where = this.where(query); - const [items, total] = await this.prisma.$transaction([ + const [items, total] = await Promise.all([ this.prisma.rawCdr.findMany({ where, - orderBy: [{ startedAt: 'desc' }], + orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], take: query.take, skip: query.skip, include: this.includeCdr() diff --git a/apps/api/src/modules/cdrs/cdrs.service.spec.ts b/apps/api/src/modules/cdrs/cdrs.service.spec.ts index ac8f02c..5f41832 100644 --- a/apps/api/src/modules/cdrs/cdrs.service.spec.ts +++ b/apps/api/src/modules/cdrs/cdrs.service.spec.ts @@ -75,6 +75,15 @@ class MemoryCdrsRepository implements CdrsRepository { } describe('CDR service', () => { + it('uses a 25-row default page to keep the first list response small', async () => { + const repository = new MemoryCdrsRepository(); + const service = new CdrsService(repository); + + await service.list({}); + + expect(repository.lastQuery).toMatchObject({ take: 25, skip: 0 }); + }); + it('normalizes filters for city, carrier and time range queries', async () => { const repository = new MemoryCdrsRepository(); const service = new CdrsService(repository); diff --git a/apps/api/src/modules/cdrs/cdrs.service.ts b/apps/api/src/modules/cdrs/cdrs.service.ts index 9b5e02b..02873f3 100644 --- a/apps/api/src/modules/cdrs/cdrs.service.ts +++ b/apps/api/src/modules/cdrs/cdrs.service.ts @@ -15,7 +15,7 @@ export class CdrsService { carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier), startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from), startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to), - take: this.int(rawQuery.take, 100, 1, 500), + take: this.int(rawQuery.take, 25, 1, 500), skip: this.int(rawQuery.skip, 0, 0, 1_000_000) }; if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) { diff --git a/apps/api/src/modules/dashboard/dashboard.controller.ts b/apps/api/src/modules/dashboard/dashboard.controller.ts index f0ec895..893a5d2 100644 --- a/apps/api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/api/src/modules/dashboard/dashboard.controller.ts @@ -14,6 +14,12 @@ export class DashboardController { return this.dashboardService.summary(); } + @Get('overview') + @RequirePermissions('dashboard.view') + overview(@Query() query: { hours?: string; bucketMinutes?: string }) { + return this.dashboardService.overview(query); + } + @Get('trends') @RequirePermissions('dashboard.view') trends(@Query() query: { hours?: string; bucketMinutes?: string }) { diff --git a/apps/api/src/modules/dashboard/dashboard.service.spec.ts b/apps/api/src/modules/dashboard/dashboard.service.spec.ts index 8600d23..44cc436 100644 --- a/apps/api/src/modules/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/modules/dashboard/dashboard.service.spec.ts @@ -4,6 +4,22 @@ import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc import type { DashboardRepository } from './dashboard.repository.js'; describe('dashboard service', () => { + it('loads summary and trends together for the overview response', async () => { + const repository = { + summaryWindow: async () => ({ + cdrs: [], ratedCdrs: [], failureCodes: [], abnormalGateways: [], + activeCustomers: 0, activeCustomerGateways: 0, activeVendorGateways: 0, pendingQuality: 0 + }), + trendWindow: async () => ({ cdrs: [], ratedCdrs: [] }) + } as unknown as DashboardRepository; + const service = new DashboardService(repository); + + const result = await service.overview({ hours: 1, bucketMinutes: 60 }); + + expect(result.summary).toHaveProperty('calls.totalCalls', 0); + expect(result.trends.buckets).toHaveLength(1); + }); + it('uses Asia/Shanghai day boundary for today summary', async () => { const calls: Array<{ start: Date; end: Date }> = []; const repository = { diff --git a/apps/api/src/modules/dashboard/dashboard.service.ts b/apps/api/src/modules/dashboard/dashboard.service.ts index e3f682d..5234c39 100644 --- a/apps/api/src/modules/dashboard/dashboard.service.ts +++ b/apps/api/src/modules/dashboard/dashboard.service.ts @@ -36,6 +36,14 @@ export interface DashboardTrendBucket { export class DashboardService { constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {} + async overview(query: { hours?: unknown; bucketMinutes?: unknown } = {}) { + const [summary, trends] = await Promise.all([ + this.summary(), + this.trends(query) + ]); + return { summary, trends }; + } + async summary(now = new Date()) { const start = startOfShanghaiDayUtc(now); const snapshot = await this.dashboard.summaryWindow(start, now); diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 9607249..47d6ddb 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -1,6 +1,6 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Badge, Button, Field, Input } from './components/ui.jsx'; -import { Icon } from './components/layout.jsx'; +import { Icon, PageLoadingDialog } from './components/layout.jsx'; import { api, explainApiError } from './api.js'; import { formatCurrency, @@ -238,9 +238,13 @@ export default function App() { const [dashboardTrends, setDashboardTrends] = useState(null); const [activeCalls, setActiveCalls] = useState([]); const [activeCallsLoading, setActiveCallsLoading] = useState(false); + const [activeCallsBlockingLoading, setActiveCallsBlockingLoading] = useState(false); const [activeCallsError, setActiveCallsError] = useState(''); + const [activeCallsUpdatedAt, setActiveCallsUpdatedAt] = useState(null); const [apiLoading, setApiLoading] = useState(true); const [apiError, setApiError] = useState(''); + const loadedPagesRef = useRef(new Set()); + const pageRequestRef = useRef({ id: 0, controller: null }); const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]); const canPermission = (permission) => can(currentPermissions, permission); const allowedNavGroups = useMemo(() => navGroups @@ -249,55 +253,86 @@ export default function App() { const ActivePage = pages[active]; const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [active]); const canAccessPage = (key, permissions = currentPermissions) => canAll(permissions, PAGE_PERMISSIONS[key] || []); - const refreshApi = async (user = authUser) => { + const loadPageData = async (page = active, user = authUser, { force = false } = {}) => { const sourceUser = user && Array.isArray(user.permissions) ? user : authUser; + if (!sourceUser) return; + if (!force && loadedPagesRef.current.has(page)) { + setApiLoading(false); + setApiError(''); + return; + } const granted = permissionSet(sourceUser); - const skipped = Symbol('skipped'); - const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped)); + pageRequestRef.current.controller?.abort('PAGE_CHANGED'); + const controller = new AbortController(); + const requestId = pageRequestRef.current.id + 1; + pageRequestRef.current = { id: requestId, controller }; setApiLoading(true); setApiError(''); - const [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList] = await Promise.allSettled([ - loadIfAllowed('dashboard.view', api.dashboardSummary), - loadIfAllowed('dashboard.view', api.dashboardTrends), - loadIfAllowed('customers.view', api.customers), - loadIfAllowed('vendors.view', api.vendors), - loadIfAllowed('customer_gateways.view', api.customerGateways), - loadIfAllowed('vendor_gateways.view', api.vendorGateways), - loadIfAllowed('line_groups.view', api.landingLineGroups), - loadIfAllowed('recharges.view', api.recharges), - loadIfAllowed('users.view', api.users), - loadIfAllowed('roles.view', api.roles), - loadIfAllowed('audit.view', api.auditLogs), - ]); - const failures = [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList].filter((result) => result.status === 'rejected'); - if (summary.status === 'fulfilled' && summary.value !== skipped) setDashboardSummary(summary.value); - if (trends.status === 'fulfilled' && trends.value !== skipped) setDashboardTrends(trends.value); - if (customerList.status === 'fulfilled' && customerList.value !== skipped) setCustomerRows(customerList.value.map(normalizeCustomer)); - if (vendorList.status === 'fulfilled' && vendorList.value !== skipped) setVendorRows(vendorList.value.map(normalizeVendor)); - if (customerGatewayList.status === 'fulfilled' && customerGatewayList.value !== skipped) setCustomerGatewayRows(customerGatewayList.value.map(normalizeCustomerGateway)); - if (vendorGatewayList.status === 'fulfilled' && vendorGatewayList.value !== skipped) setVendorGatewayRows(vendorGatewayList.value.map(normalizeVendorGateway)); - if (lineGroupList.status === 'fulfilled' && lineGroupList.value !== skipped) setLandingLineGroupRows(lineGroupList.value.map(normalizeLandingLineGroup)); - if (rechargeList.status === 'fulfilled' && rechargeList.value !== skipped) setRechargeRows(rechargeList.value.items.map(normalizeRecharge)); - if (userList.status === 'fulfilled' && userList.value !== skipped) setUserRows(userList.value.map(normalizeUser)); - if (roleList.status === 'fulfilled' && roleList.value !== skipped) setRoleRows(roleList.value.map(normalizeRole)); - if (auditLogList.status === 'fulfilled' && auditLogList.value !== skipped) setLogRows(auditLogList.value.items.map(normalizeAuditLog)); - if (failures.length) { - setApiError(explainApiError(failures[0].reason)); + const signalOptions = { signal: controller.signal }; + const definitions = []; + const add = (permission, load, apply) => { + if (can(granted, permission)) definitions.push({ load, apply }); + }; + if (page === 'dashboard') { + add('dashboard.view', () => api.dashboardOverview(undefined, signalOptions), (value) => { + setDashboardSummary(value.summary); + setDashboardTrends(value.trends); + }); + } else if (page === 'customers') { + add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer))); + } else if (page === 'vendors') { + add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor))); + } else if (page === 'customerGateways') { + add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer))); + add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway))); + add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup))); + } else if (page === 'vendorGateways') { + add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor))); + add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway))); + } else if (page === 'vendorLineGroups') { + add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup))); + add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway))); + } else if (page === 'rechargeRecords') { + add('recharges.view', () => api.recharges(signalOptions), (value) => setRechargeRows(value.items.map(normalizeRecharge))); + } else if (page === 'users' || page === 'roles') { + 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))); + } 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))); + } else if (page === 'qualityRuleDependencies') { + add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer))); + add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup))); } + const results = await Promise.allSettled(definitions.map((definition) => definition.load())); + if (pageRequestRef.current.id !== requestId) return; + const failures = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') definitions[index].apply(result.value); + else if (!controller.signal.aborted) failures.push(result.reason); + }); + if (failures.length) setApiError(explainApiError(failures[0])); + else loadedPagesRef.current.add(page); setApiLoading(false); }; - const refreshActiveCalls = async () => { + const refreshApi = () => loadPageData(active, authUser, { force: true }); + const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => { setActiveCallsLoading(true); + if (!silent) setActiveCallsBlockingLoading(true); setActiveCallsError(''); try { const response = await api.activeCalls(); setActiveCalls(response.items || []); + setActiveCallsUpdatedAt(new Date()); } catch (error) { setActiveCallsError(explainApiError(error)); } finally { setActiveCallsLoading(false); + if (!silent) setActiveCallsBlockingLoading(false); } - }; + }, []); useEffect(() => { let mounted = true; @@ -305,7 +340,7 @@ export default function App() { .then((response) => { if (!mounted) return; setAuthUser(response.user); - void refreshApi(response.user); + void loadPageData('dashboard', response.user, { force: true }); }) .catch(() => { if (!mounted) return; @@ -343,7 +378,7 @@ export default function App() { if (firstAllowed) { setActive(firstAllowed.key); } - await refreshApi(response.user); + await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true }); }; const handleLogout = async () => { await api.logout(); @@ -363,10 +398,12 @@ export default function App() { setDashboardTrends(null); setActiveCalls([]); setActiveCallsError(''); + loadedPagesRef.current.clear(); + pageRequestRef.current.controller?.abort('LOGOUT'); }; const reloadAfterMutation = async (operation) => { await operation(); - await refreshApi(); + await loadPageData(active, authUser, { force: true }); }; const applyRechargeResult = (response) => { const normalized = normalizeRecharge(response); @@ -458,7 +495,7 @@ export default function App() { : active === 'rechargeRecords' ? { rechargeRows, apiLoading, apiError, refreshApi } : active === 'dashboard' - ? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows } + ? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi } : active === 'businessPrefixes' ? { can: canPermission } : active === 'numberLibrary' @@ -467,7 +504,9 @@ export default function App() { ? { activeCalls, activeCallsLoading, + activeCallsBlockingLoading, activeCallsError, + activeCallsUpdatedAt, refreshActiveCalls, can: canPermission, onHangupActiveCall: async (id) => { @@ -478,7 +517,12 @@ export default function App() { : active === 'cdr' ? { customerGatewayRows, vendorGatewayRows, can: canPermission } : active === 'quality' - ? { customerRows, lineGroupRows: landingLineGroupRows, can: canPermission } + ? { + customerRows, + lineGroupRows: landingLineGroupRows, + can: canPermission, + loadRuleDependencies: () => loadPageData('qualityRuleDependencies', authUser), + } : active === 'users' ? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) } : active === 'roles' @@ -527,7 +571,7 @@ export default function App() {

{group.title}

{group.items.filter((item) => !item.pending).map((item) => ( -
); diff --git a/apps/web/src/api.js b/apps/web/src/api.js index 084c6a0..bc0c6f1 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -22,6 +22,36 @@ export class ApiError extends Error { } } +const DEFAULT_TIMEOUT_MS = 10_000; + +async function fetchWithTimeout(path, options = {}) { + const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options; + const controller = new AbortController(); + const abortFromExternal = () => controller.abort(externalSignal?.reason); + if (externalSignal) { + if (externalSignal.aborted) { + abortFromExternal(); + } else { + externalSignal.addEventListener('abort', abortFromExternal, { once: true }); + } + } + const timeout = window.setTimeout(() => controller.abort('REQUEST_TIMEOUT'), timeoutMs); + try { + return await fetch(`${API_BASE}${path}`, { + ...fetchOptions, + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted && !externalSignal?.aborted) { + throw new ApiError('请求超过 10 秒未响应,请稍后重试。', { status: 408, code: 'REQUEST_TIMEOUT' }); + } + throw error; + } finally { + window.clearTimeout(timeout); + externalSignal?.removeEventListener('abort', abortFromExternal); + } +} + async function request(path, options = {}) { const token = window.localStorage.getItem(ACCESS_TOKEN_KEY); const headers = new Headers(options.headers || {}); @@ -33,7 +63,7 @@ async function request(path, options = {}) { headers.set('Authorization', `Bearer ${token}`); } - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetchWithTimeout(path, { ...options, headers, credentials: 'include', @@ -58,7 +88,7 @@ async function requestBlob(path, options = {}) { headers.set('Authorization', `Bearer ${token}`); } - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetchWithTimeout(path, { ...options, headers, credentials: 'include', @@ -116,11 +146,12 @@ export const api = { setAccessToken(''); } }, - dashboardSummary: () => request('/dashboard/summary'), - dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`), - activeCalls: () => request('/active-calls'), + dashboardSummary: (options) => request('/dashboard/summary', options), + dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options), + dashboardOverview: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/overview?${new URLSearchParams(params)}`, options), + activeCalls: (options) => request('/active-calls', options), hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }), - cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`), + cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`), cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`), recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`), recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`), @@ -132,7 +163,7 @@ export const api = { enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }), - customers: () => request('/customers'), + customers: (options) => request('/customers', options), createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }), updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }), @@ -143,7 +174,7 @@ export const api = { method: 'POST', body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }), }), - vendors: () => request('/vendors'), + vendors: (options) => request('/vendors', options), createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }), updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }), @@ -152,26 +183,26 @@ export const api = { method: 'POST', body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }), }), - customerGateways: () => request('/customer-gateways'), + customerGateways: (options) => request('/customer-gateways', options), createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }), updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), - vendorGateways: () => request('/vendor-gateways'), + vendorGateways: (options) => request('/vendor-gateways', options), createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }), updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }), enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }), disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }), deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }), - landingLineGroups: () => request('/landing-line-groups'), + landingLineGroups: (options) => request('/landing-line-groups', options), deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }), - recharges: () => request('/recharges?take=100'), - users: () => request('/users'), + recharges: (options) => request('/recharges?take=100', options), + users: (options) => request('/users', options), deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }), - roles: () => request('/roles'), + roles: (options) => request('/roles', options), deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }), - auditLogs: () => request('/audit-logs?take=100'), + auditLogs: (options) => request('/audit-logs?take=100', 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) }), diff --git a/apps/web/src/components/layout.jsx b/apps/web/src/components/layout.jsx index 246a825..27c9887 100644 --- a/apps/web/src/components/layout.jsx +++ b/apps/web/src/components/layout.jsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Alert, Badge, Button } from './ui.jsx'; const selectedBlue = '#2563EB'; @@ -55,7 +56,7 @@ export function Panel({ title, aside, children, className = '' }) { export function ApiNotice({ loading, error, onRetry }) { if (loading) { - return 正在从 LisgloSIPS API 拉取页面数据。; + return null; } if (error) { return ( @@ -68,6 +69,32 @@ export function ApiNotice({ loading, error, onRetry }) { return null; } +export function PageLoadingDialog({ loading, message = '页面加载中,请稍候…', delay = 2000 }) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!loading) { + setVisible(false); + return undefined; + } + const timer = window.setTimeout(() => setVisible(true), delay); + return () => window.clearTimeout(timer); + }, [delay, loading]); + + if (!visible) return null; + return ( +
+
+
+
+ ); +} + export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) { return (
@@ -139,10 +166,10 @@ export function StatusBadge({ children }) { return {children}; } -export function SimpleTable({ columns, rows, onRowClick, selectedKey }) { +export function SimpleTable({ columns, rows, onRowClick, selectedKey, loading = false, emptyTitle = '暂无数据', emptyDescription }) { return (
- +
{columns.some((column) => column.width) ? ( {columns.map((column) => )} @@ -154,10 +181,14 @@ export function SimpleTable({ columns, rows, onRowClick, selectedKey }) { - {rows.length === 0 ? ( + {loading && rows.length === 0 ? Array.from({ length: 4 }, (_, rowIndex) => ( + + {columns.map((column) => )} + + )) : rows.length === 0 ? ( ) : rows.map((row, index) => ( diff --git a/apps/web/src/pages/ActiveCallsPage.jsx b/apps/web/src/pages/ActiveCallsPage.jsx index 1b3dbb5..7f5bfe6 100644 --- a/apps/web/src/pages/ActiveCallsPage.jsx +++ b/apps/web/src/pages/ActiveCallsPage.jsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { Badge, Button } from '../components/ui.jsx'; -import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx'; +import { Icon, PageTitle, Panel, ApiNotice, PageLoadingDialog, ConfirmDialog, SimpleTable } from '../components/layout.jsx'; import { formatDateTime, formatDurationText } from '../utils/formatters.js'; -export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) { +export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsBlockingLoading, activeCallsError, activeCallsUpdatedAt, refreshActiveCalls, can = () => true, onHangupActiveCall }) { const [busyId, setBusyId] = useState(''); const [hangupTarget, setHangupTarget] = useState(null); const [autoRefresh, setAutoRefresh] = useState(true); @@ -36,32 +36,36 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr } const timer = window.setInterval(() => { if (!activeCallsLoading) { - void refreshActiveCalls(); + void refreshActiveCalls({ silent: true }); } }, 5000); return () => window.clearInterval(timer); }, [autoRefresh, activeCallsLoading, refreshActiveCalls]); + useEffect(() => { + if (activeCallsError) setAutoRefresh(false); + }, [activeCallsError]); return ( <> - + )} /> - + refreshActiveCalls()} /> +
当前通话数 {rows.length} - OpenSIPS MI + 实时交换机数据
最长通话 @@ -71,11 +75,11 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
控制面 {activeCallsError ? '异常' : '就绪'} - MI 受控访问 + {activeCallsUpdatedAt ? `更新于 ${activeCallsUpdatedAt.toLocaleTimeString('zh-CN', { hour12: false })}` : '受控接口访问'}
{rows.length} 路} className="wide-panel"> - true }) { actions={canManage ? : null} /> + {message ? {message} : null} true }) { - true }) { const [detailCdr, setDetailCdr] = useState(null); const [cdrRows, setCdrRows] = useState([]); - const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false }); + const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 25, skip: 0, hasMore: false }); const [cdrLoading, setCdrLoading] = useState(false); const [cdrError, setCdrError] = useState(''); const [detailLoading, setDetailLoading] = useState(false); @@ -26,7 +26,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can carrier: 'all', startedFrom: '', startedTo: '', - take: '50', + take: '25', skip: 0, }); const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6)); @@ -138,7 +138,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can void loadCdrs(nextFilters); }; const changePage = (direction) => { - const take = Number(filters.take) || 50; + const take = Number(filters.take) || 25; const nextSkip = Math.max(0, filters.skip + direction * take); const nextFilters = { ...filters, skip: nextSkip }; setFilters(nextFilters); @@ -202,6 +202,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can actions={} /> void loadCdrs()} /> + updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /> updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /> @@ -247,19 +248,14 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can {cdrMeta.total} 条}> - {localError} : null}
- ({ label, value: '--', delta: '等待真实数据', tone: 'neutral' })); + +const realtimeSourceLabel = (source) => source === 'not_configured' ? '实时数据暂未接入' : source || '实时数据'; function dashboardMetrics(summary) { if (!summary) { - return metrics; + return pendingMetrics; } return [ { label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' }, - { label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' }, + { label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' }, { label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' }, { label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' }, { label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' }, { label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' }, - { label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' }, + { label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' }, { label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' }, { label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' }, { label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' }, @@ -22,10 +28,10 @@ function dashboardMetrics(summary) { ]; } -export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) { +export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }) { const trendBuckets = dashboardTrends?.buckets || []; - const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend; - const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend; + const 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 ( <> @@ -53,9 +59,7 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
- {customerRows.length ? customerRows.slice(0, 3).map((item, index) => ( -
{index + 1}{item.name}{item.balance}
- )) : 客户 API 返回空列表。} + 当前 Dashboard 接口暂未提供客户消费排名,避免额外预加载客户列表。
@@ -69,4 +73,3 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a ); } - diff --git a/apps/web/src/pages/NumberLibraryPage.jsx b/apps/web/src/pages/NumberLibraryPage.jsx index c2c3e48..20d833a 100644 --- a/apps/web/src/pages/NumberLibraryPage.jsx +++ b/apps/web/src/pages/NumberLibraryPage.jsx @@ -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, Modal, SimpleTable } from '../components/layout.jsx'; +import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable } from '../components/layout.jsx'; import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js'; import { api, explainApiError } from '../api.js'; @@ -136,9 +136,6 @@ export function NumberLibraryPage({ can = () => true }) { useEffect(() => { void loadTab('cities'); - void loadTab('phoneSegments'); - void loadTab('areaCodes'); - void loadTab('carrierPrefixRules'); }, []); const updateFilter = (key, value) => { @@ -245,7 +242,7 @@ export function NumberLibraryPage({ can = () => true }) { const renderTable = (tab) => { if (tab === 'cities') { return ( - true }) { } if (tab === 'phoneSegments') { return ( - true }) { } if (tab === 'areaCodes') { return ( - true }) { ); } return ( - true }) { actions={canManage ? : null} /> void loadTab(activeTab)} /> + {message ? {message} : null} 重置 共 {visibleLogs.length} 条} className="wide-panel"> - true }) { +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: '100' }); + const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50' }); const [showRules, setShowRules] = useState(false); const [editingRule, setEditingRule] = useState(undefined); const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm); const [deleteRuleTarget, setDeleteRuleTarget] = useState(null); const [ruleBusy, setRuleBusy] = useState(false); + const [rulesLoading, setRulesLoading] = useState(false); const [ruleError, setRuleError] = useState(''); const [detailRecording, setDetailRecording] = useState(null); const [detailLoading, setDetailLoading] = useState(false); @@ -51,18 +52,31 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => limit: nextFilter.limit, reviewStatus: nextFilter.reviewStatus, }; - const [recordingList, ruleList] = await Promise.all([ - api.recordings(params), - api.qualityRules(), - ]); + const recordingList = await api.recordings(params); setRecordingRows((recordingList || []).map(normalizeRecording)); - setRuleRows((ruleList || []).map(normalizeQualityRule)); } catch (error) { setQualityError(explainApiError(error)); } finally { setQualityLoading(false); } }; + const refreshRules = async () => { + setRulesLoading(true); + setRuleError(''); + try { + const ruleList = await api.qualityRules(); + setRuleRows((ruleList || []).map(normalizeQualityRule)); + } catch (error) { + setRuleError(explainApiError(error)); + } finally { + setRulesLoading(false); + } + }; + const openRulesDrawer = () => { + setShowRules(true); + if (!ruleRows.length) void refreshRules(); + if (loadRuleDependencies) void loadRuleDependencies(); + }; useEffect(() => { void refreshQuality(); }, []); @@ -134,7 +148,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => } else { await api.createQualityRule(ruleBody()); } - await refreshQuality(); + await refreshRules(); closeRuleModal(); } catch (error) { setRuleError(explainApiError(error)); @@ -151,7 +165,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => } else { await api.enableQualityRule(rule.id); } - await refreshQuality(); + await refreshRules(); } catch (error) { setRuleError(explainApiError(error)); } finally { @@ -164,7 +178,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => setRuleError(''); try { await api.deleteQualityRule(deleteRuleTarget.id); - await refreshQuality(); + await refreshRules(); setDeleteRuleTarget(null); } catch (error) { setRuleError(explainApiError(error)); @@ -287,9 +301,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => } + actions={
} /> void refreshQuality()} /> + changeRecordingFilter('limit', event.target.value)}> + - - 共 {recordingRows.length} 条录音} className="wide-panel"> -
规则管理按客户和线路设置录音抽检比例。
{canManageQuality ? : null} - `${row.ratio}%` }, diff --git a/apps/web/src/pages/RechargeRecordsPage.jsx b/apps/web/src/pages/RechargeRecordsPage.jsx index 2db8351..970a77a 100644 --- a/apps/web/src/pages/RechargeRecordsPage.jsx +++ b/apps/web/src/pages/RechargeRecordsPage.jsx @@ -9,7 +9,7 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres const recordTable = (
- ); } - diff --git a/apps/web/src/pages/RolesPage.jsx b/apps/web/src/pages/RolesPage.jsx index 54d9f79..def8cc2 100644 --- a/apps/web/src/pages/RolesPage.jsx +++ b/apps/web/src/pages/RolesPage.jsx @@ -65,7 +65,7 @@ export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiErro {actionError ? {actionError} : null} {roleRows.length} 个角色} className="wide-panel"> - ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[ + ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[ { key: 'name', label: '角色名称' }, { key: 'type', label: '类型' }, { key: 'description', label: '角色说明' }, diff --git a/apps/web/src/pages/UsersPage.jsx b/apps/web/src/pages/UsersPage.jsx index 8d09f4b..9fe0dc6 100644 --- a/apps/web/src/pages/UsersPage.jsx +++ b/apps/web/src/pages/UsersPage.jsx @@ -81,7 +81,7 @@ export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiErro 共 {visibleUsers.length} 个用户} className="wide-panel"> - ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[ + ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[ { key: 'username', label: '用户名' }, { key: 'name', label: '姓名' }, { key: 'phone', label: '手机号' }, diff --git a/apps/web/src/pages/VendorGatewaysPage.jsx b/apps/web/src/pages/VendorGatewaysPage.jsx index ddbb981..ccef652 100644 --- a/apps/web/src/pages/VendorGatewaysPage.jsx +++ b/apps/web/src/pages/VendorGatewaysPage.jsx @@ -235,7 +235,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
-
- row.gatewayIds.length }, { key: 'customerGatewayCount', label: '使用客户网关数' }, diff --git a/apps/web/src/pages/VendorsPage.jsx b/apps/web/src/pages/VendorsPage.jsx index 6b470ff..a052da9 100644 --- a/apps/web/src/pages/VendorsPage.jsx +++ b/apps/web/src/pages/VendorsPage.jsx @@ -114,7 +114,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
- div:first-child { + flex-wrap: wrap; + justify-content: flex-start; + } + + .topbar-right { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px 12px; + align-items: center; + justify-content: stretch; + } + + .topbar-right > span:not(.ui-badge), + .topbar-right > strong { + min-width: 0; + white-space: normal; + } + + .topbar-right .ui-button { + justify-self: start; + } + + .page-content { + overflow-y: visible; + padding: 20px; + } + + .page-title { + padding: 20px; + } + + .modal-head, + .modal-body, + .drawer-head, + .drawer-body { + padding: 16px; + } + + .ui-slider { + grid-template-columns: minmax(0, 1fr); + } +} + +/* UI performance remediation: stable async states and readable wide tables. */ +.page-loading-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + padding: 24px; + background: rgb(15 23 42 / 38%); + backdrop-filter: blur(2px); +} + +.page-loading-dialog { + display: flex; + align-items: center; + gap: 16px; + width: min(420px, 100%); + padding: 22px 24px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: 0 24px 60px rgb(15 23 42 / 24%); +} + +.page-loading-dialog > div { + display: grid; + gap: 5px; +} + +.page-loading-dialog strong { + font-size: 16px; +} + +.page-loading-dialog span:not(.page-loading-spinner) { + color: var(--muted); + font-size: 13px; +} + +.page-loading-spinner { + flex: 0 0 auto; + width: 30px; + height: 30px; + border: 3px solid color-mix(in srgb, var(--selected) 22%, transparent); + border-top-color: var(--selected); + border-radius: 50%; + animation: page-loading-spin 0.8s linear infinite; +} + +@keyframes page-loading-spin { + to { transform: rotate(360deg); } +} + +.table-skeleton-cell { + display: block; + min-width: 72px; + height: 13px; + border-radius: 999px; + background: linear-gradient(90deg, var(--surface-muted) 20%, var(--border) 45%, var(--surface-muted) 70%); + background-size: 220% 100%; + animation: table-skeleton-shimmer 1.2s ease-in-out infinite; +} + +@keyframes table-skeleton-shimmer { + to { background-position-x: -220%; } +} + +.table-shell { + max-width: 100%; + overflow: auto; + overscroll-behavior-inline: contain; +} + +.wide-panel .proto-table { + min-width: 1040px; +} + +.wide-panel .proto-table th:last-child, +.wide-panel .proto-table td:last-child { + position: sticky; + right: 0; + z-index: 1; + background: var(--surface); + box-shadow: -10px 0 14px -14px rgb(15 23 42 / 55%); +} + +.wide-panel .proto-table th:last-child { + z-index: 2; + background: var(--surface-muted); +} + +.proto-table td, +.key-value dd, +.log-summary p { + overflow-wrap: anywhere; +} + +.proto-table tbody tr { + content-visibility: auto; + contain-intrinsic-size: 52px; +} + +@media (prefers-reduced-motion: reduce) { + .page-loading-spinner, + .table-skeleton-cell { + animation: none; + } +} diff --git a/docs/LisgloSIPS_UI性能整改方案_20260827.docx b/docs/LisgloSIPS_UI性能整改方案_20260827.docx new file mode 100644 index 0000000000000000000000000000000000000000..0f57e0418094f7984efcb5da74e0bc14da38f227 GIT binary patch literal 50843 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¬tl1#R3;5C~;<9T5dM= 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≠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

6R~{!e7hs4pm*>fhD6Tj3GGFl_Ppms3|r{k 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 literal 0 HcmV?d00001 diff --git a/docs/TEST_PLAN_AND_CASES.md b/docs/TEST_PLAN_AND_CASES.md index 874edd8..9eff225 100644 --- a/docs/TEST_PLAN_AND_CASES.md +++ b/docs/TEST_PLAN_AND_CASES.md @@ -1655,6 +1655,42 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts | 数据检查 | release 目录和 public 目录一致。 | | 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 | +#### WEB-007 慢页面统一加载反馈 + +| 字段 | 内容 | +| --- | --- | +| 优先级 | P0 | +| 目的 | 验证页面请求超过 2 秒时才显示加载弹窗,完成或失败后立即隐藏。 | +| 前置条件 | 管理员已登录;浏览器可将目标接口延迟到 1.5 秒、2.5 秒和失败三种状态。 | +| 步骤 | 1. 分别以三种延迟进入 Dashboard、话单、质检、号码库。2. 观察弹窗和列表状态。3. 在请求过程中切换菜单。 | +| 预期结果 | 2 秒内无弹窗;超过 2 秒出现“页面加载中”;请求完成、失败或被页面切换取消后弹窗立即消失;加载阶段只显示骨架,不出现“暂无数据”。 | +| 数据检查 | 页面切换会取消上一页面未完成请求,不产生旧响应覆盖新页面。 | +| 安全检查 | 弹窗和错误信息不显示 token、SQL 或内部堆栈。 | + +#### WEB-008 Dashboard 按页面取数 + +| 字段 | 内容 | +| --- | --- | +| 优先级 | P0 | +| 目的 | 验证登录首页只请求首页概览数据,不再预加载全站接口。 | +| 前置条件 | 管理员登录,浏览器 Network 保留日志。 | +| 步骤 | 1. 清空 Network。2. 登录或刷新首页。3. 检查 `/api/v2/dashboard/overview` 及其他业务接口。4. 切换到客户、供应商后复查。 | +| 预期结果 | 首页使用一个 overview 请求并行取得 summary/trends;未进入的客户、供应商、网关、用户、角色、日志等接口不请求;进入对应页面后才按需请求。 | +| 数据检查 | 首页指标全部来自真实 overview 响应,不使用 fixture 回退。 | +| 安全检查 | 只请求当前账号具备权限的数据。 | + +#### WEB-009 话单首屏性能与分页稳定性 + +| 字段 | 内容 | +| --- | --- | +| 优先级 | P0 | +| 目的 | 验证话单首屏缩小到 25 条、列表和总数并行查询、同时间记录分页顺序稳定。 | +| 前置条件 | 话单表存在超过 50 条记录,其中多条 `startedAt` 相同。 | +| 步骤 | 1. 首次进入话单中心。2. 检查请求 take。3. 连续翻页并返回。4. 记录接口耗时并观察重复/遗漏。 | +| 预期结果 | 默认 `take=25`;按 `startedAt desc, id desc` 稳定排序;列表与 count 并行;主列表仅展示核心字段,完整字段在详情抽屉。 | +| 数据检查 | 前后页无重复或遗漏,total/hasMore 正确。 | +| 安全检查 | 话单筛选和详情仍遵守 `cdrs.view` 权限。 | + ### 8.9 性能、故障与安全 本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。 diff --git a/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md b/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md new file mode 100644 index 0000000..2cc0267 --- /dev/null +++ b/docs/UI_PERFORMANCE_REMEDIATION_IMPLEMENTATION_20260827.md @@ -0,0 +1,31 @@ +# UI 与页面性能整改实施记录(2026-08-27) + +## 实施范围 + +本次按《LisgloSIPS UI 性能整改方案》实施前端加载体验、按页面取数、Dashboard 请求合并、话单首屏性能、质检与号码库懒加载、宽表可读性和中文状态文案。TLS 证书按要求不在本次代码整改范围内,未修改证书或生产环境配置。 + +## 已完成 + +- 全局请求增加 10 秒超时和主动取消;切换菜单时取消上一页面请求,避免旧响应覆盖当前页面。 +- 页面请求超过 2 秒才显示加载弹窗,完成、失败或取消后立即隐藏;表格加载中显示骨架,不再与空状态同时出现。 +- 登录首页由全站 11 组接口预加载改为按当前路由加载;Dashboard 新增 `/api/v2/dashboard/overview`,服务端并行取得 summary 与 trends,前端只发一个首页概览请求。 +- 话单默认页大小由 100/50 降为 25;列表与总数改为并行查询,并增加 `startedAt desc, id desc` 稳定排序;主表精简为核心字段,完整信息保留在详情抽屉。 +- 质检录音默认限制为 50;抽检规则及客户/线路依赖只在打开规则抽屉时加载。 +- 号码库由首次同时加载四个页签改为只加载当前页签,其他页签首次打开时再读取。 +- 当前通话自动刷新改为静默更新;失败后停止自动轮询,避免 5 秒间隔持续叠加失败请求;手动首次加载仍使用延迟弹窗。 +- 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。 +- Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。 + +## 暂未直接实施 + +- 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。 +- 未修改 TLS 证书、Nginx 或生产部署配置。 +- 未发布生产。当前改动仅在本地源码、测试和构建层验证。 + +## 本地验证 + +- ESLint:通过。 +- TypeScript project build/typecheck:通过。 +- Vitest:26 个测试文件、95 个测试全部通过。 +- Web production build:通过,生成 `dist/index.html`、CSS 和 JS hash 产物。 +- 浏览器验收:本地 Vite 页面 URL、标题、非空 DOM、登录页截图和浏览器 console 已检查,未发现 console warning/error 或框架错误覆盖层。本机未配置 API 所需的 MySQL/Redis/鉴权环境,因此登录页按预期显示本地 API 500;已登录 Dashboard、话单和 2 秒延迟交互仍需在接入可用后端后做最终浏览器回归。

- + {emptyDescription}