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 0000000..0f57e04 Binary files /dev/null and b/docs/LisgloSIPS_UI性能整改方案_20260827.docx differ 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}