optimize ui loading and dashboard performance
This commit is contained in:
@@ -202,10 +202,10 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
|||||||
|
|
||||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
|
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
|
||||||
const where = this.where(query);
|
const where = this.where(query);
|
||||||
const [items, total] = await this.prisma.$transaction([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.rawCdr.findMany({
|
this.prisma.rawCdr.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ startedAt: 'desc' }],
|
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
|
||||||
take: query.take,
|
take: query.take,
|
||||||
skip: query.skip,
|
skip: query.skip,
|
||||||
include: this.includeCdr()
|
include: this.includeCdr()
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class MemoryCdrsRepository implements CdrsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('CDR service', () => {
|
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 () => {
|
it('normalizes filters for city, carrier and time range queries', async () => {
|
||||||
const repository = new MemoryCdrsRepository();
|
const repository = new MemoryCdrsRepository();
|
||||||
const service = new CdrsService(repository);
|
const service = new CdrsService(repository);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class CdrsService {
|
|||||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||||
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
|
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
|
||||||
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
|
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)
|
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||||
};
|
};
|
||||||
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
|
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export class DashboardController {
|
|||||||
return this.dashboardService.summary();
|
return this.dashboardService.summary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('overview')
|
||||||
|
@RequirePermissions('dashboard.view')
|
||||||
|
overview(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||||
|
return this.dashboardService.overview(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('trends')
|
@Get('trends')
|
||||||
@RequirePermissions('dashboard.view')
|
@RequirePermissions('dashboard.view')
|
||||||
trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc
|
|||||||
import type { DashboardRepository } from './dashboard.repository.js';
|
import type { DashboardRepository } from './dashboard.repository.js';
|
||||||
|
|
||||||
describe('dashboard service', () => {
|
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 () => {
|
it('uses Asia/Shanghai day boundary for today summary', async () => {
|
||||||
const calls: Array<{ start: Date; end: Date }> = [];
|
const calls: Array<{ start: Date; end: Date }> = [];
|
||||||
const repository = {
|
const repository = {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export interface DashboardTrendBucket {
|
|||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {}
|
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()) {
|
async summary(now = new Date()) {
|
||||||
const start = startOfShanghaiDayUtc(now);
|
const start = startOfShanghaiDayUtc(now);
|
||||||
const snapshot = await this.dashboard.summaryWindow(start, now);
|
const snapshot = await this.dashboard.summaryWindow(start, now);
|
||||||
|
|||||||
+85
-40
@@ -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 { 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 { api, explainApiError } from './api.js';
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -238,9 +238,13 @@ export default function App() {
|
|||||||
const [dashboardTrends, setDashboardTrends] = useState(null);
|
const [dashboardTrends, setDashboardTrends] = useState(null);
|
||||||
const [activeCalls, setActiveCalls] = useState([]);
|
const [activeCalls, setActiveCalls] = useState([]);
|
||||||
const [activeCallsLoading, setActiveCallsLoading] = useState(false);
|
const [activeCallsLoading, setActiveCallsLoading] = useState(false);
|
||||||
|
const [activeCallsBlockingLoading, setActiveCallsBlockingLoading] = useState(false);
|
||||||
const [activeCallsError, setActiveCallsError] = useState('');
|
const [activeCallsError, setActiveCallsError] = useState('');
|
||||||
|
const [activeCallsUpdatedAt, setActiveCallsUpdatedAt] = useState(null);
|
||||||
const [apiLoading, setApiLoading] = useState(true);
|
const [apiLoading, setApiLoading] = useState(true);
|
||||||
const [apiError, setApiError] = useState('');
|
const [apiError, setApiError] = useState('');
|
||||||
|
const loadedPagesRef = useRef(new Set());
|
||||||
|
const pageRequestRef = useRef({ id: 0, controller: null });
|
||||||
const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]);
|
const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]);
|
||||||
const canPermission = (permission) => can(currentPermissions, permission);
|
const canPermission = (permission) => can(currentPermissions, permission);
|
||||||
const allowedNavGroups = useMemo(() => navGroups
|
const allowedNavGroups = useMemo(() => navGroups
|
||||||
@@ -249,55 +253,86 @@ export default function App() {
|
|||||||
const ActivePage = pages[active];
|
const ActivePage = pages[active];
|
||||||
const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [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 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;
|
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 granted = permissionSet(sourceUser);
|
||||||
const skipped = Symbol('skipped');
|
pageRequestRef.current.controller?.abort('PAGE_CHANGED');
|
||||||
const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped));
|
const controller = new AbortController();
|
||||||
|
const requestId = pageRequestRef.current.id + 1;
|
||||||
|
pageRequestRef.current = { id: requestId, controller };
|
||||||
setApiLoading(true);
|
setApiLoading(true);
|
||||||
setApiError('');
|
setApiError('');
|
||||||
const [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList] = await Promise.allSettled([
|
const signalOptions = { signal: controller.signal };
|
||||||
loadIfAllowed('dashboard.view', api.dashboardSummary),
|
const definitions = [];
|
||||||
loadIfAllowed('dashboard.view', api.dashboardTrends),
|
const add = (permission, load, apply) => {
|
||||||
loadIfAllowed('customers.view', api.customers),
|
if (can(granted, permission)) definitions.push({ load, apply });
|
||||||
loadIfAllowed('vendors.view', api.vendors),
|
};
|
||||||
loadIfAllowed('customer_gateways.view', api.customerGateways),
|
if (page === 'dashboard') {
|
||||||
loadIfAllowed('vendor_gateways.view', api.vendorGateways),
|
add('dashboard.view', () => api.dashboardOverview(undefined, signalOptions), (value) => {
|
||||||
loadIfAllowed('line_groups.view', api.landingLineGroups),
|
setDashboardSummary(value.summary);
|
||||||
loadIfAllowed('recharges.view', api.recharges),
|
setDashboardTrends(value.trends);
|
||||||
loadIfAllowed('users.view', api.users),
|
});
|
||||||
loadIfAllowed('roles.view', api.roles),
|
} else if (page === 'customers') {
|
||||||
loadIfAllowed('audit.view', api.auditLogs),
|
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||||
]);
|
} else if (page === 'vendors') {
|
||||||
const failures = [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList].filter((result) => result.status === 'rejected');
|
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||||
if (summary.status === 'fulfilled' && summary.value !== skipped) setDashboardSummary(summary.value);
|
} else if (page === 'customerGateways') {
|
||||||
if (trends.status === 'fulfilled' && trends.value !== skipped) setDashboardTrends(trends.value);
|
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||||
if (customerList.status === 'fulfilled' && customerList.value !== skipped) setCustomerRows(customerList.value.map(normalizeCustomer));
|
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||||
if (vendorList.status === 'fulfilled' && vendorList.value !== skipped) setVendorRows(vendorList.value.map(normalizeVendor));
|
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||||
if (customerGatewayList.status === 'fulfilled' && customerGatewayList.value !== skipped) setCustomerGatewayRows(customerGatewayList.value.map(normalizeCustomerGateway));
|
} else if (page === 'vendorGateways') {
|
||||||
if (vendorGatewayList.status === 'fulfilled' && vendorGatewayList.value !== skipped) setVendorGatewayRows(vendorGatewayList.value.map(normalizeVendorGateway));
|
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||||
if (lineGroupList.status === 'fulfilled' && lineGroupList.value !== skipped) setLandingLineGroupRows(lineGroupList.value.map(normalizeLandingLineGroup));
|
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||||
if (rechargeList.status === 'fulfilled' && rechargeList.value !== skipped) setRechargeRows(rechargeList.value.items.map(normalizeRecharge));
|
} else if (page === 'vendorLineGroups') {
|
||||||
if (userList.status === 'fulfilled' && userList.value !== skipped) setUserRows(userList.value.map(normalizeUser));
|
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||||
if (roleList.status === 'fulfilled' && roleList.value !== skipped) setRoleRows(roleList.value.map(normalizeRole));
|
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||||
if (auditLogList.status === 'fulfilled' && auditLogList.value !== skipped) setLogRows(auditLogList.value.items.map(normalizeAuditLog));
|
} else if (page === 'rechargeRecords') {
|
||||||
if (failures.length) {
|
add('recharges.view', () => api.recharges(signalOptions), (value) => setRechargeRows(value.items.map(normalizeRecharge)));
|
||||||
setApiError(explainApiError(failures[0].reason));
|
} 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);
|
setApiLoading(false);
|
||||||
};
|
};
|
||||||
const refreshActiveCalls = async () => {
|
const refreshApi = () => loadPageData(active, authUser, { force: true });
|
||||||
|
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
|
||||||
setActiveCallsLoading(true);
|
setActiveCallsLoading(true);
|
||||||
|
if (!silent) setActiveCallsBlockingLoading(true);
|
||||||
setActiveCallsError('');
|
setActiveCallsError('');
|
||||||
try {
|
try {
|
||||||
const response = await api.activeCalls();
|
const response = await api.activeCalls();
|
||||||
setActiveCalls(response.items || []);
|
setActiveCalls(response.items || []);
|
||||||
|
setActiveCallsUpdatedAt(new Date());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setActiveCallsError(explainApiError(error));
|
setActiveCallsError(explainApiError(error));
|
||||||
} finally {
|
} finally {
|
||||||
setActiveCallsLoading(false);
|
setActiveCallsLoading(false);
|
||||||
|
if (!silent) setActiveCallsBlockingLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
@@ -305,7 +340,7 @@ export default function App() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setAuthUser(response.user);
|
setAuthUser(response.user);
|
||||||
void refreshApi(response.user);
|
void loadPageData('dashboard', response.user, { force: true });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -343,7 +378,7 @@ export default function App() {
|
|||||||
if (firstAllowed) {
|
if (firstAllowed) {
|
||||||
setActive(firstAllowed.key);
|
setActive(firstAllowed.key);
|
||||||
}
|
}
|
||||||
await refreshApi(response.user);
|
await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true });
|
||||||
};
|
};
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
@@ -363,10 +398,12 @@ export default function App() {
|
|||||||
setDashboardTrends(null);
|
setDashboardTrends(null);
|
||||||
setActiveCalls([]);
|
setActiveCalls([]);
|
||||||
setActiveCallsError('');
|
setActiveCallsError('');
|
||||||
|
loadedPagesRef.current.clear();
|
||||||
|
pageRequestRef.current.controller?.abort('LOGOUT');
|
||||||
};
|
};
|
||||||
const reloadAfterMutation = async (operation) => {
|
const reloadAfterMutation = async (operation) => {
|
||||||
await operation();
|
await operation();
|
||||||
await refreshApi();
|
await loadPageData(active, authUser, { force: true });
|
||||||
};
|
};
|
||||||
const applyRechargeResult = (response) => {
|
const applyRechargeResult = (response) => {
|
||||||
const normalized = normalizeRecharge(response);
|
const normalized = normalizeRecharge(response);
|
||||||
@@ -458,7 +495,7 @@ export default function App() {
|
|||||||
: active === 'rechargeRecords'
|
: active === 'rechargeRecords'
|
||||||
? { rechargeRows, apiLoading, apiError, refreshApi }
|
? { rechargeRows, apiLoading, apiError, refreshApi }
|
||||||
: active === 'dashboard'
|
: active === 'dashboard'
|
||||||
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }
|
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }
|
||||||
: active === 'businessPrefixes'
|
: active === 'businessPrefixes'
|
||||||
? { can: canPermission }
|
? { can: canPermission }
|
||||||
: active === 'numberLibrary'
|
: active === 'numberLibrary'
|
||||||
@@ -467,7 +504,9 @@ export default function App() {
|
|||||||
? {
|
? {
|
||||||
activeCalls,
|
activeCalls,
|
||||||
activeCallsLoading,
|
activeCallsLoading,
|
||||||
|
activeCallsBlockingLoading,
|
||||||
activeCallsError,
|
activeCallsError,
|
||||||
|
activeCallsUpdatedAt,
|
||||||
refreshActiveCalls,
|
refreshActiveCalls,
|
||||||
can: canPermission,
|
can: canPermission,
|
||||||
onHangupActiveCall: async (id) => {
|
onHangupActiveCall: async (id) => {
|
||||||
@@ -478,7 +517,12 @@ export default function App() {
|
|||||||
: active === 'cdr'
|
: active === 'cdr'
|
||||||
? { customerGatewayRows, vendorGatewayRows, can: canPermission }
|
? { customerGatewayRows, vendorGatewayRows, can: canPermission }
|
||||||
: active === 'quality'
|
: active === 'quality'
|
||||||
? { customerRows, lineGroupRows: landingLineGroupRows, can: canPermission }
|
? {
|
||||||
|
customerRows,
|
||||||
|
lineGroupRows: landingLineGroupRows,
|
||||||
|
can: canPermission,
|
||||||
|
loadRuleDependencies: () => loadPageData('qualityRuleDependencies', authUser),
|
||||||
|
}
|
||||||
: active === 'users'
|
: active === 'users'
|
||||||
? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) }
|
? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) }
|
||||||
: active === 'roles'
|
: active === 'roles'
|
||||||
@@ -527,7 +571,7 @@ export default function App() {
|
|||||||
<div className="nav-group" key={group.title}>
|
<div className="nav-group" key={group.title}>
|
||||||
<p>{group.title}</p>
|
<p>{group.title}</p>
|
||||||
{group.items.filter((item) => !item.pending).map((item) => (
|
{group.items.filter((item) => !item.pending).map((item) => (
|
||||||
<button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => setActive(item.key)} title={`${item.label}${item.pending ? '(待设计)' : ''}`}>
|
<button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => { setActive(item.key); void loadPageData(item.key); }} title={`${item.label}${item.pending ? '(待设计)' : ''}`}>
|
||||||
<span className="nav-short" aria-hidden="true">{item.label.slice(0, 1)}</span>
|
<span className="nav-short" aria-hidden="true">{item.label.slice(0, 1)}</span>
|
||||||
<span className="nav-label">{item.label}</span>
|
<span className="nav-label">{item.label}</span>
|
||||||
{item.pending ? <span className="nav-status">待设计</span> : null}
|
{item.pending ? <span className="nav-status">待设计</span> : null}
|
||||||
@@ -553,6 +597,7 @@ export default function App() {
|
|||||||
<main className="page-content">
|
<main className="page-content">
|
||||||
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
|
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
|
||||||
</main>
|
</main>
|
||||||
|
<PageLoadingDialog loading={apiLoading && active !== 'activeCalls'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+46
-15
@@ -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 = {}) {
|
async function request(path, options = {}) {
|
||||||
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
@@ -33,7 +63,7 @@ async function request(path, options = {}) {
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetchWithTimeout(path, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -58,7 +88,7 @@ async function requestBlob(path, options = {}) {
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetchWithTimeout(path, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -116,11 +146,12 @@ export const api = {
|
|||||||
setAccessToken('');
|
setAccessToken('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dashboardSummary: () => request('/dashboard/summary'),
|
dashboardSummary: (options) => request('/dashboard/summary', options),
|
||||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options),
|
||||||
activeCalls: () => request('/active-calls'),
|
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' }),
|
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)}`),
|
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
|
||||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
||||||
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
||||||
@@ -132,7 +163,7 @@ export const api = {
|
|||||||
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||||
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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) }),
|
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||||
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
@@ -143,7 +174,7 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
|
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
|
||||||
}),
|
}),
|
||||||
vendors: () => request('/vendors'),
|
vendors: (options) => request('/vendors', options),
|
||||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||||
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
@@ -152,26 +183,26 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
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) }),
|
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', 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' }),
|
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||||
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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) }),
|
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', 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' }),
|
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||||
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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' }),
|
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
recharges: () => request('/recharges?take=100'),
|
recharges: (options) => request('/recharges?take=100', options),
|
||||||
users: () => request('/users'),
|
users: (options) => request('/users', options),
|
||||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
roles: () => request('/roles'),
|
roles: (options) => request('/roles', options),
|
||||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
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)}`),
|
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
|
||||||
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
||||||
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Badge, Button } from './ui.jsx';
|
import { Alert, Badge, Button } from './ui.jsx';
|
||||||
|
|
||||||
const selectedBlue = '#2563EB';
|
const selectedBlue = '#2563EB';
|
||||||
@@ -55,7 +56,7 @@ export function Panel({ title, aside, children, className = '' }) {
|
|||||||
|
|
||||||
export function ApiNotice({ loading, error, onRetry }) {
|
export function ApiNotice({ loading, error, onRetry }) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据。</Alert>;
|
return null;
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +69,32 @@ export function ApiNotice({ loading, error, onRetry }) {
|
|||||||
return null;
|
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 (
|
||||||
|
<div className="page-loading-backdrop" role="status" aria-live="polite" aria-label={message}>
|
||||||
|
<div className="page-loading-dialog">
|
||||||
|
<span className="page-loading-spinner" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{message}</strong>
|
||||||
|
<span>正在读取真实业务数据,请不要重复刷新。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
|
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
@@ -139,10 +166,10 @@ export function StatusBadge({ children }) {
|
|||||||
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
|
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
|
export function SimpleTable({ columns, rows, onRowClick, selectedKey, loading = false, emptyTitle = '暂无数据', emptyDescription }) {
|
||||||
return (
|
return (
|
||||||
<div className="table-shell">
|
<div className="table-shell">
|
||||||
<table className="proto-table">
|
<table className="proto-table" aria-busy={loading}>
|
||||||
{columns.some((column) => column.width) ? (
|
{columns.some((column) => column.width) ? (
|
||||||
<colgroup>
|
<colgroup>
|
||||||
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
|
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
|
||||||
@@ -154,10 +181,14 @@ export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 ? (
|
{loading && rows.length === 0 ? Array.from({ length: 4 }, (_, rowIndex) => (
|
||||||
|
<tr className="table-skeleton-row" key={`skeleton-${rowIndex}`} aria-hidden="true">
|
||||||
|
{columns.map((column) => <td key={column.key}><span className="table-skeleton-cell" /></td>)}
|
||||||
|
</tr>
|
||||||
|
)) : rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length}>
|
<td colSpan={columns.length}>
|
||||||
<EmptyState />
|
<EmptyState title={emptyTitle}>{emptyDescription}</EmptyState>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : rows.map((row, index) => (
|
) : rows.map((row, index) => (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge, Button } from '../components/ui.jsx';
|
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';
|
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 [busyId, setBusyId] = useState('');
|
||||||
const [hangupTarget, setHangupTarget] = useState(null);
|
const [hangupTarget, setHangupTarget] = useState(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
@@ -36,32 +36,36 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
|||||||
}
|
}
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (!activeCallsLoading) {
|
if (!activeCallsLoading) {
|
||||||
void refreshActiveCalls();
|
void refreshActiveCalls({ silent: true });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
|
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeCallsError) setAutoRefresh(false);
|
||||||
|
}, [activeCallsError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title="当前通话"
|
title="当前通话"
|
||||||
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
desc="查看交换机当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||||
actions={(
|
actions={(
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
|
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
|
||||||
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
|
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
|
<Button icon={<Icon type="reload" />} onClick={() => refreshActiveCalls()} disabled={activeCallsLoading}>刷新通话</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} />
|
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={() => refreshActiveCalls()} />
|
||||||
|
<PageLoadingDialog loading={activeCallsBlockingLoading} />
|
||||||
<section className="metric-grid active-call-metrics">
|
<section className="metric-grid active-call-metrics">
|
||||||
<div className="metric-card">
|
<div className="metric-card">
|
||||||
<span>当前通话数</span>
|
<span>当前通话数</span>
|
||||||
<strong>{rows.length}</strong>
|
<strong>{rows.length}</strong>
|
||||||
<em className="metric-neutral">OpenSIPS MI</em>
|
<em className="metric-neutral">实时交换机数据</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-card">
|
<div className="metric-card">
|
||||||
<span>最长通话</span>
|
<span>最长通话</span>
|
||||||
@@ -71,11 +75,11 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
|||||||
<div className="metric-card">
|
<div className="metric-card">
|
||||||
<span>控制面</span>
|
<span>控制面</span>
|
||||||
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
|
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
|
||||||
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em>
|
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>{activeCallsUpdatedAt ? `更新于 ${activeCallsUpdatedAt.toLocaleTimeString('zh-CN', { hour12: false })}` : '受控接口访问'}</em>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} 路</Badge>} className="wide-panel">
|
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} 路</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={rows} columns={[
|
<SimpleTable loading={activeCallsBlockingLoading} rows={rows} columns={[
|
||||||
{ key: 'callId', label: 'Call-ID' },
|
{ key: 'callId', label: 'Call-ID' },
|
||||||
{ key: 'callerText', label: '主叫' },
|
{ key: 'callerText', label: '主叫' },
|
||||||
{ key: 'calleeText', label: '被叫' },
|
{ key: 'calleeText', label: '被叫' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||||
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
|
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
|
||||||
import { api, explainApiError } from '../api.js';
|
import { api, explainApiError } from '../api.js';
|
||||||
|
|
||||||
@@ -145,6 +145,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
|
|||||||
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
|
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
|
||||||
/>
|
/>
|
||||||
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
|
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
|
||||||
|
<PageLoadingDialog loading={loading} />
|
||||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||||
<Panel
|
<Panel
|
||||||
title="业务前缀"
|
title="业务前缀"
|
||||||
@@ -164,7 +165,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
|
|||||||
</Field>
|
</Field>
|
||||||
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
|
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<SimpleTable rows={rows} columns={[
|
<SimpleTable loading={loading} rows={rows} columns={[
|
||||||
{ key: 'prefix', label: '业务前缀', width: '120px' },
|
{ key: 'prefix', label: '业务前缀', width: '120px' },
|
||||||
{ key: 'name', label: '名称', width: '160px' },
|
{ key: 'name', label: '名称', width: '160px' },
|
||||||
{ key: 'priority', label: '优先级', width: '90px' },
|
{ key: 'priority', label: '优先级', width: '90px' },
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||||
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
|
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||||
import { api, explainApiError } from '../api.js';
|
import { api, explainApiError } from '../api.js';
|
||||||
|
|
||||||
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
||||||
const [detailCdr, setDetailCdr] = useState(null);
|
const [detailCdr, setDetailCdr] = useState(null);
|
||||||
const [cdrRows, setCdrRows] = useState([]);
|
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 [cdrLoading, setCdrLoading] = useState(false);
|
||||||
const [cdrError, setCdrError] = useState('');
|
const [cdrError, setCdrError] = useState('');
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -26,7 +26,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
|||||||
carrier: 'all',
|
carrier: 'all',
|
||||||
startedFrom: '',
|
startedFrom: '',
|
||||||
startedTo: '',
|
startedTo: '',
|
||||||
take: '50',
|
take: '25',
|
||||||
skip: 0,
|
skip: 0,
|
||||||
});
|
});
|
||||||
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
|
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);
|
void loadCdrs(nextFilters);
|
||||||
};
|
};
|
||||||
const changePage = (direction) => {
|
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 nextSkip = Math.max(0, filters.skip + direction * take);
|
||||||
const nextFilters = { ...filters, skip: nextSkip };
|
const nextFilters = { ...filters, skip: nextSkip };
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
@@ -202,6 +202,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
|||||||
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
|
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
|
||||||
/>
|
/>
|
||||||
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
|
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
|
||||||
|
<PageLoadingDialog loading={cdrLoading} />
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
|
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
|
||||||
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
|
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
|
||||||
@@ -247,19 +248,14 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
|||||||
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
|
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} 条</Badge>}>
|
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} 条</Badge>}>
|
||||||
<SimpleTable rows={cdrRows} columns={[
|
<SimpleTable loading={cdrLoading} rows={cdrRows} columns={[
|
||||||
{ key: 'caller', label: '主叫号码' },
|
{ key: 'caller', label: '主叫号码' },
|
||||||
{ key: 'callee', label: '被叫号码' },
|
{ key: 'callee', label: '被叫号码' },
|
||||||
{ key: 'location', label: '地级市' },
|
{ key: 'callIp', label: '呼叫 IP' },
|
||||||
{ key: 'operatorText', label: '运营商' },
|
{ key: 'lineIp', label: '线路 IP' },
|
||||||
{ key: 'customerGatewayName', label: '客户网关名称' },
|
{ key: 'customerFee', label: '客户费用' },
|
||||||
{ key: 'callIp', label: '呼叫IP地址' },
|
|
||||||
{ key: 'vendorGatewayName', label: '落地网关名称' },
|
|
||||||
{ key: 'lineIp', label: '线路IP地址' },
|
|
||||||
{ key: 'callTime', label: '呼叫时间' },
|
|
||||||
{ key: 'durationText', label: '通话时长' },
|
|
||||||
{ key: 'ratingStatusText', label: '计费状态', status: true },
|
|
||||||
{ key: 'recordingText', label: '录音', status: true },
|
{ key: 'recordingText', label: '录音', status: true },
|
||||||
|
{ key: 'callTime', label: '呼叫时间' },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '操作',
|
label: '操作',
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
|
|||||||
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
|
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<Panel title="客户网关列表" className="wide-panel">
|
<Panel title="客户网关列表" className="wide-panel">
|
||||||
<SimpleTable rows={gatewayRows} columns={[
|
<SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
|
||||||
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
|
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
|
||||||
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
|
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
|
||||||
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
|
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
|
|||||||
<section className="master-detail">
|
<section className="master-detail">
|
||||||
<Panel title="客户列表" className="main-list wide-panel">
|
<Panel title="客户列表" className="main-list wide-panel">
|
||||||
<SimpleTable
|
<SimpleTable
|
||||||
|
loading={apiLoading}
|
||||||
rows={customerRows}
|
rows={customerRows}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
|
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Badge, Button } from '../components/ui.jsx';
|
import { Badge, Button } from '../components/ui.jsx';
|
||||||
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||||
import { formatCurrency } from '../utils/formatters.js';
|
import { formatCurrency } from '../utils/formatters.js';
|
||||||
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
|
|
||||||
|
const pendingMetrics = [
|
||||||
|
'今日通话数', '当前在线通话', '今日接通率', '客户消费', '供应商成本', '今日毛利',
|
||||||
|
'在线注册用户', '活跃客户', '活跃落地网关', '异常网关', '质检待处理',
|
||||||
|
].map((label) => ({ label, value: '--', delta: '等待真实数据', tone: 'neutral' }));
|
||||||
|
|
||||||
|
const realtimeSourceLabel = (source) => source === 'not_configured' ? '实时数据暂未接入' : source || '实时数据';
|
||||||
|
|
||||||
function dashboardMetrics(summary) {
|
function dashboardMetrics(summary) {
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return metrics;
|
return pendingMetrics;
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
|
{ 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: `${(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.customerFee), delta: '今日', tone: 'neutral' },
|
||||||
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
|
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
|
||||||
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), 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.activeCustomers), delta: '启用', tone: 'neutral' },
|
||||||
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), 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' },
|
{ 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 trendBuckets = dashboardTrends?.buckets || [];
|
||||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
|
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)) : answerTrend;
|
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
|
||||||
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -53,9 +59,7 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
|||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="客户消费 TOP 10">
|
<Panel title="客户消费 TOP 10">
|
||||||
<div className="rank-list">
|
<div className="rank-list">
|
||||||
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
|
<EmptyState title="消费排名待接入">当前 Dashboard 接口暂未提供客户消费排名,避免额外预加载客户列表。</EmptyState>
|
||||||
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
|
|
||||||
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表。</EmptyState>}
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="失败响应码分布">
|
<Panel title="失败响应码分布">
|
||||||
@@ -69,4 +73,3 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
|
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 { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||||
import { api, explainApiError } from '../api.js';
|
import { api, explainApiError } from '../api.js';
|
||||||
|
|
||||||
@@ -136,9 +136,6 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTab('cities');
|
void loadTab('cities');
|
||||||
void loadTab('phoneSegments');
|
|
||||||
void loadTab('areaCodes');
|
|
||||||
void loadTab('carrierPrefixRules');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateFilter = (key, value) => {
|
const updateFilter = (key, value) => {
|
||||||
@@ -245,7 +242,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
const renderTable = (tab) => {
|
const renderTable = (tab) => {
|
||||||
if (tab === 'cities') {
|
if (tab === 'cities') {
|
||||||
return (
|
return (
|
||||||
<SimpleTable rows={rows.cities} columns={[
|
<SimpleTable loading={loading} rows={rows.cities} columns={[
|
||||||
{ key: 'code', label: '地级市代码', width: '110px' },
|
{ key: 'code', label: '地级市代码', width: '110px' },
|
||||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||||
@@ -258,7 +255,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
}
|
}
|
||||||
if (tab === 'phoneSegments') {
|
if (tab === 'phoneSegments') {
|
||||||
return (
|
return (
|
||||||
<SimpleTable rows={rows.phoneSegments} columns={[
|
<SimpleTable loading={loading} rows={rows.phoneSegments} columns={[
|
||||||
{ key: 'segment7', label: '前 7 位', width: '110px' },
|
{ key: 'segment7', label: '前 7 位', width: '110px' },
|
||||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||||
@@ -272,7 +269,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
}
|
}
|
||||||
if (tab === 'areaCodes') {
|
if (tab === 'areaCodes') {
|
||||||
return (
|
return (
|
||||||
<SimpleTable rows={rows.areaCodes} columns={[
|
<SimpleTable loading={loading} rows={rows.areaCodes} columns={[
|
||||||
{ key: 'areaCode', label: '区号', width: '100px' },
|
{ key: 'areaCode', label: '区号', width: '100px' },
|
||||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||||
@@ -283,7 +280,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SimpleTable rows={rows.carrierPrefixRules} columns={[
|
<SimpleTable loading={loading} rows={rows.carrierPrefixRules} columns={[
|
||||||
{ key: 'prefix', label: '前缀', width: '100px' },
|
{ key: 'prefix', label: '前缀', width: '100px' },
|
||||||
{ key: 'carrier', label: '运营商', width: '110px' },
|
{ key: 'carrier', label: '运营商', width: '110px' },
|
||||||
{ key: 'priority', label: '优先级', width: '90px' },
|
{ key: 'priority', label: '优先级', width: '90px' },
|
||||||
@@ -305,6 +302,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
|||||||
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
|
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
|
||||||
/>
|
/>
|
||||||
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
|
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
|
||||||
|
<PageLoadingDialog loading={loading} />
|
||||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
|||||||
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Panel title="日志列表" aside={<Badge tone="neutral">共 {visibleLogs.length} 条</Badge>} className="wide-panel">
|
<Panel title="日志列表" aside={<Badge tone="neutral">共 {visibleLogs.length} 条</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={visibleLogs} columns={[
|
<SimpleTable loading={apiLoading} rows={visibleLogs} columns={[
|
||||||
{ key: 'time', label: '操作时间' },
|
{ key: 'time', label: '操作时间' },
|
||||||
{ key: 'user', label: '操作用户' },
|
{ key: 'user', label: '操作用户' },
|
||||||
{ key: 'module', label: '功能模块' },
|
{ key: 'module', label: '功能模块' },
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
|
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||||
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
|
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
|
||||||
import { api, explainApiError } from '../api.js';
|
import { api, explainApiError } from '../api.js';
|
||||||
|
|
||||||
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
|
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
|
||||||
|
|
||||||
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) {
|
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
|
||||||
const [ruleRows, setRuleRows] = useState([]);
|
const [ruleRows, setRuleRows] = useState([]);
|
||||||
const [recordingRows, setRecordingRows] = useState([]);
|
const [recordingRows, setRecordingRows] = useState([]);
|
||||||
const [qualityLoading, setQualityLoading] = useState(false);
|
const [qualityLoading, setQualityLoading] = useState(false);
|
||||||
const [qualityError, setQualityError] = useState('');
|
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 [showRules, setShowRules] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState(undefined);
|
const [editingRule, setEditingRule] = useState(undefined);
|
||||||
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
||||||
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
|
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
|
||||||
const [ruleBusy, setRuleBusy] = useState(false);
|
const [ruleBusy, setRuleBusy] = useState(false);
|
||||||
|
const [rulesLoading, setRulesLoading] = useState(false);
|
||||||
const [ruleError, setRuleError] = useState('');
|
const [ruleError, setRuleError] = useState('');
|
||||||
const [detailRecording, setDetailRecording] = useState(null);
|
const [detailRecording, setDetailRecording] = useState(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -51,18 +52,31 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
limit: nextFilter.limit,
|
limit: nextFilter.limit,
|
||||||
reviewStatus: nextFilter.reviewStatus,
|
reviewStatus: nextFilter.reviewStatus,
|
||||||
};
|
};
|
||||||
const [recordingList, ruleList] = await Promise.all([
|
const recordingList = await api.recordings(params);
|
||||||
api.recordings(params),
|
|
||||||
api.qualityRules(),
|
|
||||||
]);
|
|
||||||
setRecordingRows((recordingList || []).map(normalizeRecording));
|
setRecordingRows((recordingList || []).map(normalizeRecording));
|
||||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setQualityError(explainApiError(error));
|
setQualityError(explainApiError(error));
|
||||||
} finally {
|
} finally {
|
||||||
setQualityLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
void refreshQuality();
|
void refreshQuality();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -134,7 +148,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
} else {
|
} else {
|
||||||
await api.createQualityRule(ruleBody());
|
await api.createQualityRule(ruleBody());
|
||||||
}
|
}
|
||||||
await refreshQuality();
|
await refreshRules();
|
||||||
closeRuleModal();
|
closeRuleModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRuleError(explainApiError(error));
|
setRuleError(explainApiError(error));
|
||||||
@@ -151,7 +165,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
} else {
|
} else {
|
||||||
await api.enableQualityRule(rule.id);
|
await api.enableQualityRule(rule.id);
|
||||||
}
|
}
|
||||||
await refreshQuality();
|
await refreshRules();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRuleError(explainApiError(error));
|
setRuleError(explainApiError(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -164,7 +178,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
setRuleError('');
|
setRuleError('');
|
||||||
try {
|
try {
|
||||||
await api.deleteQualityRule(deleteRuleTarget.id);
|
await api.deleteQualityRule(deleteRuleTarget.id);
|
||||||
await refreshQuality();
|
await refreshRules();
|
||||||
setDeleteRuleTarget(null);
|
setDeleteRuleTarget(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRuleError(explainApiError(error));
|
setRuleError(explainApiError(error));
|
||||||
@@ -287,9 +301,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
<PageTitle
|
<PageTitle
|
||||||
title="质检中心"
|
title="质检中心"
|
||||||
desc="集中查看录音、完成试听、问题标注和人工质检评分。"
|
desc="集中查看录音、完成试听、问题标注和人工质检评分。"
|
||||||
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={() => setShowRules(true)}>抽检规则</Button></div>}
|
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={openRulesDrawer}>抽检规则</Button></div>}
|
||||||
/>
|
/>
|
||||||
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
|
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
|
||||||
|
<PageLoadingDialog loading={qualityLoading} />
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Field label="质检状态">
|
<Field label="质检状态">
|
||||||
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
|
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
|
||||||
@@ -300,15 +315,14 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
</Field>
|
</Field>
|
||||||
<Field label="读取条数">
|
<Field label="读取条数">
|
||||||
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
||||||
|
<option value="25">25</option>
|
||||||
<option value="50">50</option>
|
<option value="50">50</option>
|
||||||
<option value="100">100</option>
|
<option value="100">100</option>
|
||||||
<option value="200">200</option>
|
|
||||||
<option value="500">500</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Panel title="录音列表" aside={<Badge tone="neutral">共 {recordingRows.length} 条录音</Badge>} className="wide-panel">
|
<Panel title="录音列表" aside={<Badge tone="neutral">共 {recordingRows.length} 条录音</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={recordingRows} columns={[
|
<SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
|
||||||
{ key: 'callId', label: 'Call-ID' },
|
{ key: 'callId', label: 'Call-ID' },
|
||||||
{ key: 'customer', label: '客户' },
|
{ key: 'customer', label: '客户' },
|
||||||
{ key: 'caller', label: '主叫' },
|
{ key: 'caller', label: '主叫' },
|
||||||
@@ -329,7 +343,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
|||||||
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例。</span></div>
|
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例。</span></div>
|
||||||
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
|
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
<SimpleTable rows={ruleRows} columns={[
|
<SimpleTable loading={rulesLoading} rows={ruleRows} columns={[
|
||||||
{ key: 'name', label: '规则名称' },
|
{ key: 'name', label: '规则名称' },
|
||||||
{ key: 'customer', label: '客户' },
|
{ key: 'customer', label: '客户' },
|
||||||
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
|
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
|
|||||||
const recordTable = (
|
const recordTable = (
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
|
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
|
||||||
<SimpleTable rows={visibleRows} columns={[
|
<SimpleTable loading={apiLoading} rows={visibleRows} columns={[
|
||||||
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
|
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
|
||||||
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
|
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
|
||||||
{ key: 'amount', label: '充值金额' },
|
{ key: 'amount', label: '充值金额' },
|
||||||
@@ -53,4 +53,3 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiErro
|
|||||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||||
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
||||||
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
|
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
|
<SimpleTable loading={apiLoading} rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
|
||||||
{ key: 'name', label: '角色名称' },
|
{ key: 'name', label: '角色名称' },
|
||||||
{ key: 'type', label: '类型' },
|
{ key: 'type', label: '类型' },
|
||||||
{ key: 'description', label: '角色说明' },
|
{ key: 'description', label: '角色说明' },
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiErro
|
|||||||
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
|
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Panel title="用户列表" aside={<Badge tone="neutral">共 {visibleUsers.length} 个用户</Badge>} className="wide-panel">
|
<Panel title="用户列表" aside={<Badge tone="neutral">共 {visibleUsers.length} 个用户</Badge>} className="wide-panel">
|
||||||
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
|
<SimpleTable loading={apiLoading} rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
|
||||||
{ key: 'username', label: '用户名' },
|
{ key: 'username', label: '用户名' },
|
||||||
{ key: 'name', label: '姓名' },
|
{ key: 'name', label: '姓名' },
|
||||||
{ key: 'phone', label: '手机号' },
|
{ key: 'phone', label: '手机号' },
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<Panel title="落地网关列表" className="wide-panel">
|
<Panel title="落地网关列表" className="wide-panel">
|
||||||
<SimpleTable rows={gatewayRows} columns={[
|
<SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
|
||||||
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
|
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
|
||||||
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
|
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
|
||||||
{ key: 'authMode', label: '认证方式' },
|
{ key: 'authMode', label: '认证方式' },
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<Panel title="落地线路组列表" className="wide-panel">
|
<Panel title="落地线路组列表" className="wide-panel">
|
||||||
<SimpleTable rows={lineGroupRows} columns={[
|
<SimpleTable loading={apiLoading} rows={lineGroupRows} columns={[
|
||||||
{ key: 'name', label: '名称' },
|
{ key: 'name', label: '名称' },
|
||||||
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
|
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
|
||||||
{ key: 'customerGatewayCount', label: '使用客户网关数' },
|
{ key: 'customerGatewayCount', label: '使用客户网关数' },
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
<section className="master-detail">
|
<section className="master-detail">
|
||||||
<Panel title="供应商列表" className="wide-panel">
|
<Panel title="供应商列表" className="wide-panel">
|
||||||
<SimpleTable rows={vendorRows} columns={[
|
<SimpleTable loading={apiLoading} rows={vendorRows} columns={[
|
||||||
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
|
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
|
||||||
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
||||||
{ key: 'balance', label: '余额' },
|
{ key: 'balance', label: '余额' },
|
||||||
|
|||||||
@@ -1919,3 +1919,949 @@ button:disabled {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CMPP platform design alignment */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--brand: #12121a;
|
||||||
|
--brand-2: #1c1c27;
|
||||||
|
--accent: #d9c3a0;
|
||||||
|
--accent-soft: #f6efe4;
|
||||||
|
--selected: #2563eb;
|
||||||
|
--selected-hover: #1d4ed8;
|
||||||
|
--selected-soft: #eff6ff;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-soft: #fffbeb;
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-soft: #ecfdf3;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-muted: #f3f4f6;
|
||||||
|
--page: #f6f7f9;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--line-strong: #d1d5db;
|
||||||
|
--text: #1f2937;
|
||||||
|
--ink: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--subtle: #9ca3af;
|
||||||
|
--shadow: 0 1px 2px rgba(18, 18, 26, 0.08);
|
||||||
|
--shadow-hover: 0 8px 24px rgba(18, 18, 26, 0.08);
|
||||||
|
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--sidebar-width: 248px;
|
||||||
|
--sidebar-collapsed-width: 84px;
|
||||||
|
--topbar-height: 64px;
|
||||||
|
--content-max-width: 1440px;
|
||||||
|
--control-height-sm: 32px;
|
||||||
|
--control-height-md: 38px;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--text);
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prototype-app {
|
||||||
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prototype-app.sidebar-collapsed {
|
||||||
|
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 24px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-expanded {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo-expanded {
|
||||||
|
width: min(100%, 132px);
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sip-text {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark,
|
||||||
|
.brand-logo-collapsed {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
color: var(--selected);
|
||||||
|
background: var(--selected-soft);
|
||||||
|
border-color: rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 0;
|
||||||
|
margin-right: -16px;
|
||||||
|
padding-right: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav::-webkit-scrollbar-track,
|
||||||
|
.sidebar nav::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:hover nav {
|
||||||
|
scrollbar-color: rgba(37, 99, 235, 0.24) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:hover nav::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group button {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group button:hover,
|
||||||
|
.nav-group button.active {
|
||||||
|
color: var(--selected);
|
||||||
|
background: var(--selected-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group button.active {
|
||||||
|
box-shadow: inset 3px 0 0 var(--selected);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-short {
|
||||||
|
background: var(--selected-soft);
|
||||||
|
color: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-block {
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar nav {
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-group {
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-group button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-group button.active {
|
||||||
|
box-shadow: inset 0 -3px 0 var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-group button.active .nav-short {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
min-height: var(--topbar-height);
|
||||||
|
padding: 0 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
|
||||||
|
var(--page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel,
|
||||||
|
.page-title,
|
||||||
|
.toolbar,
|
||||||
|
.prototype-panel,
|
||||||
|
.metric-card,
|
||||||
|
.modal-dialog,
|
||||||
|
.drawer-panel,
|
||||||
|
.match-card,
|
||||||
|
.config-card,
|
||||||
|
.prefix-picker,
|
||||||
|
.permission-group,
|
||||||
|
.log-summary {
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel,
|
||||||
|
.page-title,
|
||||||
|
.toolbar,
|
||||||
|
.prototype-panel,
|
||||||
|
.metric-card {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title h1 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.sub-toolbar {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-toolbar,
|
||||||
|
.match-card,
|
||||||
|
.config-card,
|
||||||
|
.rank-list div,
|
||||||
|
.code-grid div,
|
||||||
|
.kv,
|
||||||
|
.ops-row,
|
||||||
|
.scope-list span,
|
||||||
|
.role-grid div,
|
||||||
|
.drawer-toolbar,
|
||||||
|
.log-summary {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prototype-panel {
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid,
|
||||||
|
.master-detail {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell,
|
||||||
|
.ui-table-wrap {
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-table,
|
||||||
|
.ui-table {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-table th,
|
||||||
|
.ui-table th {
|
||||||
|
height: 48px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #fbfbfc;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-table td,
|
||||||
|
.ui-table td {
|
||||||
|
height: 64px;
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-table tbody tr:hover,
|
||||||
|
.proto-table tbody tr.is-selected,
|
||||||
|
.ui-table tbody tr:hover {
|
||||||
|
background: #fbfbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button {
|
||||||
|
height: var(--control-height-md);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary:hover {
|
||||||
|
background: var(--brand-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--selected);
|
||||||
|
border-color: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary:hover {
|
||||||
|
background: var(--selected-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-outline,
|
||||||
|
.ui-button-ghost {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-outline:hover,
|
||||||
|
.ui-button-ghost:hover {
|
||||||
|
color: var(--selected);
|
||||||
|
background: var(--selected-soft);
|
||||||
|
border-color: rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-sm {
|
||||||
|
height: var(--control-height-sm);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-icon-only {
|
||||||
|
width: var(--control-height-md);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-label {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-hint,
|
||||||
|
.ui-field-error {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input {
|
||||||
|
min-height: var(--control-height-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input::placeholder {
|
||||||
|
color: var(--subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input:focus {
|
||||||
|
border-color: var(--selected);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-textarea {
|
||||||
|
min-height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-check,
|
||||||
|
.ui-switch {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-check-box,
|
||||||
|
.ui-radio-dot {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-switch-track {
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tabs {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab-list {
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab-list button {
|
||||||
|
min-height: 38px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab-list button.is-active {
|
||||||
|
color: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-neutral {
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-brand {
|
||||||
|
color: #b99763;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-info {
|
||||||
|
color: var(--selected);
|
||||||
|
background: var(--selected-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-success {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert {
|
||||||
|
color: #1e40af;
|
||||||
|
background: var(--selected-soft);
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
border-color: #fed7aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert-success {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-soft);
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-alert-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
max-height: min(760px, calc(100vh - 48px));
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 48px rgba(18, 18, 26, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head,
|
||||||
|
.drawer-head {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head h2,
|
||||||
|
.drawer-head h2 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body,
|
||||||
|
.drawer-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions,
|
||||||
|
.drawer-actions {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart,
|
||||||
|
.line-chart {
|
||||||
|
background: linear-gradient(to top, #eef1f5 1px, transparent 1px);
|
||||||
|
background-size: 100% 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-segmented button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-segmented button.is-active {
|
||||||
|
color: var(--selected);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-slider {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-slider input {
|
||||||
|
accent-color: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-slider output {
|
||||||
|
color: var(--selected);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.prototype-app,
|
||||||
|
.prototype-app.sidebar-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
align-items: stretch;
|
||||||
|
height: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 260px;
|
||||||
|
margin-right: -16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group p {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group button,
|
||||||
|
.sidebar-collapsed .nav-group button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 38px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-block {
|
||||||
|
justify-items: stretch;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-expanded {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-logo-expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-logo-collapsed,
|
||||||
|
.sidebar-collapsed .nav-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1655,6 +1655,42 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
|||||||
| 数据检查 | release 目录和 public 目录一致。 |
|
| 数据检查 | release 目录和 public 目录一致。 |
|
||||||
| 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 |
|
| 安全检查 | 发布不覆盖后端 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 性能、故障与安全
|
### 8.9 性能、故障与安全
|
||||||
|
|
||||||
本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。
|
本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。
|
||||||
|
|||||||
@@ -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 秒延迟交互仍需在接入可用后端后做最终浏览器回归。
|
||||||
Reference in New Issue
Block a user