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 }> {
|
||||
const where = this.where(query);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: 'desc' }],
|
||||
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
include: this.includeCdr()
|
||||
|
||||
@@ -75,6 +75,15 @@ class MemoryCdrsRepository implements CdrsRepository {
|
||||
}
|
||||
|
||||
describe('CDR service', () => {
|
||||
it('uses a 25-row default page to keep the first list response small', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
await service.list({});
|
||||
|
||||
expect(repository.lastQuery).toMatchObject({ take: 25, skip: 0 });
|
||||
});
|
||||
|
||||
it('normalizes filters for city, carrier and time range queries', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
@@ -15,7 +15,7 @@ export class CdrsService {
|
||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
|
||||
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
|
||||
take: this.int(rawQuery.take, 100, 1, 500),
|
||||
take: this.int(rawQuery.take, 25, 1, 500),
|
||||
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||
};
|
||||
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
|
||||
|
||||
@@ -14,6 +14,12 @@ export class DashboardController {
|
||||
return this.dashboardService.summary();
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@RequirePermissions('dashboard.view')
|
||||
overview(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||
return this.dashboardService.overview(query);
|
||||
}
|
||||
|
||||
@Get('trends')
|
||||
@RequirePermissions('dashboard.view')
|
||||
trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||
|
||||
@@ -4,6 +4,22 @@ import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc
|
||||
import type { DashboardRepository } from './dashboard.repository.js';
|
||||
|
||||
describe('dashboard service', () => {
|
||||
it('loads summary and trends together for the overview response', async () => {
|
||||
const repository = {
|
||||
summaryWindow: async () => ({
|
||||
cdrs: [], ratedCdrs: [], failureCodes: [], abnormalGateways: [],
|
||||
activeCustomers: 0, activeCustomerGateways: 0, activeVendorGateways: 0, pendingQuality: 0
|
||||
}),
|
||||
trendWindow: async () => ({ cdrs: [], ratedCdrs: [] })
|
||||
} as unknown as DashboardRepository;
|
||||
const service = new DashboardService(repository);
|
||||
|
||||
const result = await service.overview({ hours: 1, bucketMinutes: 60 });
|
||||
|
||||
expect(result.summary).toHaveProperty('calls.totalCalls', 0);
|
||||
expect(result.trends.buckets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses Asia/Shanghai day boundary for today summary', async () => {
|
||||
const calls: Array<{ start: Date; end: Date }> = [];
|
||||
const repository = {
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface DashboardTrendBucket {
|
||||
export class DashboardService {
|
||||
constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {}
|
||||
|
||||
async overview(query: { hours?: unknown; bucketMinutes?: unknown } = {}) {
|
||||
const [summary, trends] = await Promise.all([
|
||||
this.summary(),
|
||||
this.trends(query)
|
||||
]);
|
||||
return { summary, trends };
|
||||
}
|
||||
|
||||
async summary(now = new Date()) {
|
||||
const start = startOfShanghaiDayUtc(now);
|
||||
const snapshot = await this.dashboard.summaryWindow(start, now);
|
||||
|
||||
+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 { Icon } from './components/layout.jsx';
|
||||
import { Icon, PageLoadingDialog } from './components/layout.jsx';
|
||||
import { api, explainApiError } from './api.js';
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -238,9 +238,13 @@ export default function App() {
|
||||
const [dashboardTrends, setDashboardTrends] = useState(null);
|
||||
const [activeCalls, setActiveCalls] = useState([]);
|
||||
const [activeCallsLoading, setActiveCallsLoading] = useState(false);
|
||||
const [activeCallsBlockingLoading, setActiveCallsBlockingLoading] = useState(false);
|
||||
const [activeCallsError, setActiveCallsError] = useState('');
|
||||
const [activeCallsUpdatedAt, setActiveCallsUpdatedAt] = useState(null);
|
||||
const [apiLoading, setApiLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState('');
|
||||
const loadedPagesRef = useRef(new Set());
|
||||
const pageRequestRef = useRef({ id: 0, controller: null });
|
||||
const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]);
|
||||
const canPermission = (permission) => can(currentPermissions, permission);
|
||||
const allowedNavGroups = useMemo(() => navGroups
|
||||
@@ -249,55 +253,86 @@ export default function App() {
|
||||
const ActivePage = pages[active];
|
||||
const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [active]);
|
||||
const canAccessPage = (key, permissions = currentPermissions) => canAll(permissions, PAGE_PERMISSIONS[key] || []);
|
||||
const refreshApi = async (user = authUser) => {
|
||||
const loadPageData = async (page = active, user = authUser, { force = false } = {}) => {
|
||||
const sourceUser = user && Array.isArray(user.permissions) ? user : authUser;
|
||||
if (!sourceUser) return;
|
||||
if (!force && loadedPagesRef.current.has(page)) {
|
||||
setApiLoading(false);
|
||||
setApiError('');
|
||||
return;
|
||||
}
|
||||
const granted = permissionSet(sourceUser);
|
||||
const skipped = Symbol('skipped');
|
||||
const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped));
|
||||
pageRequestRef.current.controller?.abort('PAGE_CHANGED');
|
||||
const controller = new AbortController();
|
||||
const requestId = pageRequestRef.current.id + 1;
|
||||
pageRequestRef.current = { id: requestId, controller };
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
const [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList] = await Promise.allSettled([
|
||||
loadIfAllowed('dashboard.view', api.dashboardSummary),
|
||||
loadIfAllowed('dashboard.view', api.dashboardTrends),
|
||||
loadIfAllowed('customers.view', api.customers),
|
||||
loadIfAllowed('vendors.view', api.vendors),
|
||||
loadIfAllowed('customer_gateways.view', api.customerGateways),
|
||||
loadIfAllowed('vendor_gateways.view', api.vendorGateways),
|
||||
loadIfAllowed('line_groups.view', api.landingLineGroups),
|
||||
loadIfAllowed('recharges.view', api.recharges),
|
||||
loadIfAllowed('users.view', api.users),
|
||||
loadIfAllowed('roles.view', api.roles),
|
||||
loadIfAllowed('audit.view', api.auditLogs),
|
||||
]);
|
||||
const failures = [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList].filter((result) => result.status === 'rejected');
|
||||
if (summary.status === 'fulfilled' && summary.value !== skipped) setDashboardSummary(summary.value);
|
||||
if (trends.status === 'fulfilled' && trends.value !== skipped) setDashboardTrends(trends.value);
|
||||
if (customerList.status === 'fulfilled' && customerList.value !== skipped) setCustomerRows(customerList.value.map(normalizeCustomer));
|
||||
if (vendorList.status === 'fulfilled' && vendorList.value !== skipped) setVendorRows(vendorList.value.map(normalizeVendor));
|
||||
if (customerGatewayList.status === 'fulfilled' && customerGatewayList.value !== skipped) setCustomerGatewayRows(customerGatewayList.value.map(normalizeCustomerGateway));
|
||||
if (vendorGatewayList.status === 'fulfilled' && vendorGatewayList.value !== skipped) setVendorGatewayRows(vendorGatewayList.value.map(normalizeVendorGateway));
|
||||
if (lineGroupList.status === 'fulfilled' && lineGroupList.value !== skipped) setLandingLineGroupRows(lineGroupList.value.map(normalizeLandingLineGroup));
|
||||
if (rechargeList.status === 'fulfilled' && rechargeList.value !== skipped) setRechargeRows(rechargeList.value.items.map(normalizeRecharge));
|
||||
if (userList.status === 'fulfilled' && userList.value !== skipped) setUserRows(userList.value.map(normalizeUser));
|
||||
if (roleList.status === 'fulfilled' && roleList.value !== skipped) setRoleRows(roleList.value.map(normalizeRole));
|
||||
if (auditLogList.status === 'fulfilled' && auditLogList.value !== skipped) setLogRows(auditLogList.value.items.map(normalizeAuditLog));
|
||||
if (failures.length) {
|
||||
setApiError(explainApiError(failures[0].reason));
|
||||
const signalOptions = { signal: controller.signal };
|
||||
const definitions = [];
|
||||
const add = (permission, load, apply) => {
|
||||
if (can(granted, permission)) definitions.push({ load, apply });
|
||||
};
|
||||
if (page === 'dashboard') {
|
||||
add('dashboard.view', () => api.dashboardOverview(undefined, signalOptions), (value) => {
|
||||
setDashboardSummary(value.summary);
|
||||
setDashboardTrends(value.trends);
|
||||
});
|
||||
} else if (page === 'customers') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
} else if (page === 'vendors') {
|
||||
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||
} else if (page === 'customerGateways') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
} else if (page === 'vendorGateways') {
|
||||
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'vendorLineGroups') {
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'rechargeRecords') {
|
||||
add('recharges.view', () => api.recharges(signalOptions), (value) => setRechargeRows(value.items.map(normalizeRecharge)));
|
||||
} else if (page === 'users' || page === 'roles') {
|
||||
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
|
||||
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
|
||||
} else if (page === 'operationLogs') {
|
||||
add('audit.view', () => api.auditLogs(signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
|
||||
} else if (page === 'cdr') {
|
||||
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'qualityRuleDependencies') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
}
|
||||
const results = await Promise.allSettled(definitions.map((definition) => definition.load()));
|
||||
if (pageRequestRef.current.id !== requestId) return;
|
||||
const failures = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') definitions[index].apply(result.value);
|
||||
else if (!controller.signal.aborted) failures.push(result.reason);
|
||||
});
|
||||
if (failures.length) setApiError(explainApiError(failures[0]));
|
||||
else loadedPagesRef.current.add(page);
|
||||
setApiLoading(false);
|
||||
};
|
||||
const refreshActiveCalls = async () => {
|
||||
const refreshApi = () => loadPageData(active, authUser, { force: true });
|
||||
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
|
||||
setActiveCallsLoading(true);
|
||||
if (!silent) setActiveCallsBlockingLoading(true);
|
||||
setActiveCallsError('');
|
||||
try {
|
||||
const response = await api.activeCalls();
|
||||
setActiveCalls(response.items || []);
|
||||
setActiveCallsUpdatedAt(new Date());
|
||||
} catch (error) {
|
||||
setActiveCallsError(explainApiError(error));
|
||||
} finally {
|
||||
setActiveCallsLoading(false);
|
||||
if (!silent) setActiveCallsBlockingLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -305,7 +340,7 @@ export default function App() {
|
||||
.then((response) => {
|
||||
if (!mounted) return;
|
||||
setAuthUser(response.user);
|
||||
void refreshApi(response.user);
|
||||
void loadPageData('dashboard', response.user, { force: true });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
@@ -343,7 +378,7 @@ export default function App() {
|
||||
if (firstAllowed) {
|
||||
setActive(firstAllowed.key);
|
||||
}
|
||||
await refreshApi(response.user);
|
||||
await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true });
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
await api.logout();
|
||||
@@ -363,10 +398,12 @@ export default function App() {
|
||||
setDashboardTrends(null);
|
||||
setActiveCalls([]);
|
||||
setActiveCallsError('');
|
||||
loadedPagesRef.current.clear();
|
||||
pageRequestRef.current.controller?.abort('LOGOUT');
|
||||
};
|
||||
const reloadAfterMutation = async (operation) => {
|
||||
await operation();
|
||||
await refreshApi();
|
||||
await loadPageData(active, authUser, { force: true });
|
||||
};
|
||||
const applyRechargeResult = (response) => {
|
||||
const normalized = normalizeRecharge(response);
|
||||
@@ -458,7 +495,7 @@ export default function App() {
|
||||
: active === 'rechargeRecords'
|
||||
? { rechargeRows, apiLoading, apiError, refreshApi }
|
||||
: active === 'dashboard'
|
||||
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }
|
||||
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }
|
||||
: active === 'businessPrefixes'
|
||||
? { can: canPermission }
|
||||
: active === 'numberLibrary'
|
||||
@@ -467,7 +504,9 @@ export default function App() {
|
||||
? {
|
||||
activeCalls,
|
||||
activeCallsLoading,
|
||||
activeCallsBlockingLoading,
|
||||
activeCallsError,
|
||||
activeCallsUpdatedAt,
|
||||
refreshActiveCalls,
|
||||
can: canPermission,
|
||||
onHangupActiveCall: async (id) => {
|
||||
@@ -478,7 +517,12 @@ export default function App() {
|
||||
: active === 'cdr'
|
||||
? { customerGatewayRows, vendorGatewayRows, can: canPermission }
|
||||
: active === 'quality'
|
||||
? { customerRows, lineGroupRows: landingLineGroupRows, can: canPermission }
|
||||
? {
|
||||
customerRows,
|
||||
lineGroupRows: landingLineGroupRows,
|
||||
can: canPermission,
|
||||
loadRuleDependencies: () => loadPageData('qualityRuleDependencies', authUser),
|
||||
}
|
||||
: active === 'users'
|
||||
? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) }
|
||||
: active === 'roles'
|
||||
@@ -527,7 +571,7 @@ export default function App() {
|
||||
<div className="nav-group" key={group.title}>
|
||||
<p>{group.title}</p>
|
||||
{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-label">{item.label}</span>
|
||||
{item.pending ? <span className="nav-status">待设计</span> : null}
|
||||
@@ -553,6 +597,7 @@ export default function App() {
|
||||
<main className="page-content">
|
||||
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
|
||||
</main>
|
||||
<PageLoadingDialog loading={apiLoading && active !== 'activeCalls'} />
|
||||
</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 = {}) {
|
||||
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const headers = new Headers(options.headers || {});
|
||||
@@ -33,7 +63,7 @@ async function request(path, options = {}) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
@@ -58,7 +88,7 @@ async function requestBlob(path, options = {}) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
@@ -116,11 +146,12 @@ export const api = {
|
||||
setAccessToken('');
|
||||
}
|
||||
},
|
||||
dashboardSummary: () => request('/dashboard/summary'),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
||||
activeCalls: () => request('/active-calls'),
|
||||
dashboardSummary: (options) => request('/dashboard/summary', options),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options),
|
||||
dashboardOverview: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/overview?${new URLSearchParams(params)}`, options),
|
||||
activeCalls: (options) => request('/active-calls', options),
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
|
||||
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
||||
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
||||
@@ -132,7 +163,7 @@ export const api = {
|
||||
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
customers: () => request('/customers'),
|
||||
customers: (options) => request('/customers', options),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
@@ -143,7 +174,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
|
||||
}),
|
||||
vendors: () => request('/vendors'),
|
||||
vendors: (options) => request('/vendors', options),
|
||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
@@ -152,26 +183,26 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
customerGateways: () => request('/customer-gateways'),
|
||||
customerGateways: (options) => request('/customer-gateways', options),
|
||||
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
vendorGateways: () => request('/vendor-gateways'),
|
||||
vendorGateways: (options) => request('/vendor-gateways', options),
|
||||
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
landingLineGroups: () => request('/landing-line-groups'),
|
||||
landingLineGroups: (options) => request('/landing-line-groups', options),
|
||||
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
recharges: () => request('/recharges?take=100'),
|
||||
users: () => request('/users'),
|
||||
recharges: (options) => request('/recharges?take=100', options),
|
||||
users: (options) => request('/users', options),
|
||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
roles: () => request('/roles'),
|
||||
roles: (options) => request('/roles', options),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
auditLogs: (options) => request('/audit-logs?take=100', options),
|
||||
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
|
||||
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
||||
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button } from './ui.jsx';
|
||||
|
||||
const selectedBlue = '#2563EB';
|
||||
@@ -55,7 +56,7 @@ export function Panel({ title, aside, children, className = '' }) {
|
||||
|
||||
export function ApiNotice({ loading, error, onRetry }) {
|
||||
if (loading) {
|
||||
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据。</Alert>;
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
@@ -68,6 +69,32 @@ export function ApiNotice({ loading, error, onRetry }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PageLoadingDialog({ loading, message = '页面加载中,请稍候…', delay = 2000 }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setVisible(true), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delay, loading]);
|
||||
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<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 = '当前筛选条件下没有可展示的数据。' }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
@@ -139,10 +166,10 @@ export function StatusBadge({ children }) {
|
||||
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 (
|
||||
<div className="table-shell">
|
||||
<table className="proto-table">
|
||||
<table className="proto-table" aria-busy={loading}>
|
||||
{columns.some((column) => column.width) ? (
|
||||
<colgroup>
|
||||
{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>
|
||||
</thead>
|
||||
<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>
|
||||
<td colSpan={columns.length}>
|
||||
<EmptyState />
|
||||
<EmptyState title={emptyTitle}>{emptyDescription}</EmptyState>
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.map((row, index) => (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, PageLoadingDialog, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
|
||||
|
||||
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
|
||||
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsBlockingLoading, activeCallsError, activeCallsUpdatedAt, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
|
||||
const [busyId, setBusyId] = useState('');
|
||||
const [hangupTarget, setHangupTarget] = useState(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
@@ -36,32 +36,36 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
if (!activeCallsLoading) {
|
||||
void refreshActiveCalls();
|
||||
void refreshActiveCalls({ silent: true });
|
||||
}
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
|
||||
useEffect(() => {
|
||||
if (activeCallsError) setAutoRefresh(false);
|
||||
}, [activeCallsError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="当前通话"
|
||||
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||
desc="查看交换机当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||
actions={(
|
||||
<div className="table-actions">
|
||||
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
|
||||
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
|
||||
</Button>
|
||||
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
|
||||
<Button icon={<Icon type="reload" />} onClick={() => refreshActiveCalls()} disabled={activeCallsLoading}>刷新通话</Button>
|
||||
</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">
|
||||
<div className="metric-card">
|
||||
<span>当前通话数</span>
|
||||
<strong>{rows.length}</strong>
|
||||
<em className="metric-neutral">OpenSIPS MI</em>
|
||||
<em className="metric-neutral">实时交换机数据</em>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span>最长通话</span>
|
||||
@@ -71,11 +75,11 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
||||
<div className="metric-card">
|
||||
<span>控制面</span>
|
||||
<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>
|
||||
</section>
|
||||
<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: 'callerText', label: '主叫' },
|
||||
{ key: 'calleeText', label: '被叫' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { 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}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
|
||||
<PageLoadingDialog loading={loading} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Panel
|
||||
title="业务前缀"
|
||||
@@ -164,7 +165,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
|
||||
</Toolbar>
|
||||
<SimpleTable rows={rows} columns={[
|
||||
<SimpleTable loading={loading} rows={rows} columns={[
|
||||
{ key: 'prefix', label: '业务前缀', width: '120px' },
|
||||
{ key: 'name', label: '名称', width: '160px' },
|
||||
{ key: 'priority', label: '优先级', width: '90px' },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { api, explainApiError } from '../api.js';
|
||||
|
||||
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
||||
const [detailCdr, setDetailCdr] = useState(null);
|
||||
const [cdrRows, setCdrRows] = useState([]);
|
||||
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false });
|
||||
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 25, skip: 0, hasMore: false });
|
||||
const [cdrLoading, setCdrLoading] = useState(false);
|
||||
const [cdrError, setCdrError] = useState('');
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -26,7 +26,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
carrier: 'all',
|
||||
startedFrom: '',
|
||||
startedTo: '',
|
||||
take: '50',
|
||||
take: '25',
|
||||
skip: 0,
|
||||
});
|
||||
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
|
||||
@@ -138,7 +138,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const changePage = (direction) => {
|
||||
const take = Number(filters.take) || 50;
|
||||
const take = Number(filters.take) || 25;
|
||||
const nextSkip = Math.max(0, filters.skip + direction * take);
|
||||
const nextFilters = { ...filters, skip: nextSkip };
|
||||
setFilters(nextFilters);
|
||||
@@ -202,6 +202,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
|
||||
/>
|
||||
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
|
||||
<PageLoadingDialog loading={cdrLoading} />
|
||||
<Toolbar>
|
||||
<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>
|
||||
@@ -247,19 +248,14 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<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: 'callee', label: '被叫号码' },
|
||||
{ key: 'location', label: '地级市' },
|
||||
{ key: 'operatorText', label: '运营商' },
|
||||
{ key: 'customerGatewayName', label: '客户网关名称' },
|
||||
{ key: 'callIp', label: '呼叫IP地址' },
|
||||
{ key: 'vendorGatewayName', label: '落地网关名称' },
|
||||
{ key: 'lineIp', label: '线路IP地址' },
|
||||
{ key: 'callTime', label: '呼叫时间' },
|
||||
{ key: 'durationText', label: '通话时长' },
|
||||
{ key: 'ratingStatusText', label: '计费状态', status: true },
|
||||
{ key: 'callIp', label: '呼叫 IP' },
|
||||
{ key: 'lineIp', label: '线路 IP' },
|
||||
{ key: 'customerFee', label: '客户费用' },
|
||||
{ key: 'recordingText', label: '录音', status: true },
|
||||
{ key: 'callTime', label: '呼叫时间' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
|
||||
@@ -296,7 +296,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
|
||||
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
|
||||
<section className="content-grid">
|
||||
<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: 'name', label: '名称', width: '240px', 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">
|
||||
<Panel title="客户列表" className="main-list wide-panel">
|
||||
<SimpleTable
|
||||
loading={apiLoading}
|
||||
rows={customerRows}
|
||||
columns={[
|
||||
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
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) {
|
||||
if (!summary) {
|
||||
return metrics;
|
||||
return pendingMetrics;
|
||||
}
|
||||
return [
|
||||
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
|
||||
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
|
||||
{ label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' },
|
||||
{ label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
|
||||
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
|
||||
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
|
||||
{ label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' },
|
||||
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' },
|
||||
{ label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' },
|
||||
@@ -22,10 +28,10 @@ function dashboardMetrics(summary) {
|
||||
];
|
||||
}
|
||||
|
||||
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) {
|
||||
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }) {
|
||||
const trendBuckets = dashboardTrends?.buckets || [];
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend;
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
|
||||
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
||||
return (
|
||||
<>
|
||||
@@ -53,9 +59,7 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
||||
</Panel>
|
||||
<Panel title="客户消费 TOP 10">
|
||||
<div className="rank-list">
|
||||
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
|
||||
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
|
||||
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表。</EmptyState>}
|
||||
<EmptyState title="消费排名待接入">当前 Dashboard 接口暂未提供客户消费排名,避免额外预加载客户列表。</EmptyState>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="失败响应码分布">
|
||||
@@ -69,4 +73,3 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
@@ -136,9 +136,6 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
|
||||
useEffect(() => {
|
||||
void loadTab('cities');
|
||||
void loadTab('phoneSegments');
|
||||
void loadTab('areaCodes');
|
||||
void loadTab('carrierPrefixRules');
|
||||
}, []);
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
@@ -245,7 +242,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const renderTable = (tab) => {
|
||||
if (tab === 'cities') {
|
||||
return (
|
||||
<SimpleTable rows={rows.cities} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.cities} columns={[
|
||||
{ key: 'code', label: '地级市代码', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||
@@ -258,7 +255,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}
|
||||
if (tab === 'phoneSegments') {
|
||||
return (
|
||||
<SimpleTable rows={rows.phoneSegments} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.phoneSegments} columns={[
|
||||
{ key: 'segment7', label: '前 7 位', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
@@ -272,7 +269,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}
|
||||
if (tab === 'areaCodes') {
|
||||
return (
|
||||
<SimpleTable rows={rows.areaCodes} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.areaCodes} columns={[
|
||||
{ key: 'areaCode', label: '区号', width: '100px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
@@ -283,7 +280,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SimpleTable rows={rows.carrierPrefixRules} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.carrierPrefixRules} columns={[
|
||||
{ key: 'prefix', label: '前缀', width: '100px' },
|
||||
{ key: 'carrier', label: '运营商', width: '110px' },
|
||||
{ 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}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
|
||||
<PageLoadingDialog loading={loading} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
||||
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<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: 'user', label: '操作用户' },
|
||||
{ key: 'module', label: '功能模块' },
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 { api, explainApiError } from '../api.js';
|
||||
|
||||
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 [recordingRows, setRecordingRows] = useState([]);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const [qualityError, setQualityError] = useState('');
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' });
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50' });
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(undefined);
|
||||
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
||||
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
|
||||
const [ruleBusy, setRuleBusy] = useState(false);
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
const [ruleError, setRuleError] = useState('');
|
||||
const [detailRecording, setDetailRecording] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -51,18 +52,31 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
limit: nextFilter.limit,
|
||||
reviewStatus: nextFilter.reviewStatus,
|
||||
};
|
||||
const [recordingList, ruleList] = await Promise.all([
|
||||
api.recordings(params),
|
||||
api.qualityRules(),
|
||||
]);
|
||||
const recordingList = await api.recordings(params);
|
||||
setRecordingRows((recordingList || []).map(normalizeRecording));
|
||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
||||
} catch (error) {
|
||||
setQualityError(explainApiError(error));
|
||||
} finally {
|
||||
setQualityLoading(false);
|
||||
}
|
||||
};
|
||||
const refreshRules = async () => {
|
||||
setRulesLoading(true);
|
||||
setRuleError('');
|
||||
try {
|
||||
const ruleList = await api.qualityRules();
|
||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
setRulesLoading(false);
|
||||
}
|
||||
};
|
||||
const openRulesDrawer = () => {
|
||||
setShowRules(true);
|
||||
if (!ruleRows.length) void refreshRules();
|
||||
if (loadRuleDependencies) void loadRuleDependencies();
|
||||
};
|
||||
useEffect(() => {
|
||||
void refreshQuality();
|
||||
}, []);
|
||||
@@ -134,7 +148,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
} else {
|
||||
await api.createQualityRule(ruleBody());
|
||||
}
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
closeRuleModal();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
@@ -151,7 +165,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
} else {
|
||||
await api.enableQualityRule(rule.id);
|
||||
}
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
@@ -164,7 +178,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
setRuleError('');
|
||||
try {
|
||||
await api.deleteQualityRule(deleteRuleTarget.id);
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
setDeleteRuleTarget(null);
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
@@ -287,9 +301,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<PageTitle
|
||||
title="质检中心"
|
||||
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()} />
|
||||
<PageLoadingDialog loading={qualityLoading} />
|
||||
<Toolbar>
|
||||
<Field label="质检状态">
|
||||
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
|
||||
@@ -300,15 +315,14 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
</Field>
|
||||
<Field label="读取条数">
|
||||
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</Toolbar>
|
||||
<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: 'customer', label: '客户' },
|
||||
{ key: 'caller', label: '主叫' },
|
||||
@@ -329,7 +343,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例。</span></div>
|
||||
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
|
||||
</div>
|
||||
<SimpleTable rows={ruleRows} columns={[
|
||||
<SimpleTable loading={rulesLoading} rows={ruleRows} columns={[
|
||||
{ key: 'name', label: '规则名称' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
|
||||
|
||||
@@ -9,7 +9,7 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
|
||||
const recordTable = (
|
||||
<section className="content-grid">
|
||||
<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: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
|
||||
{ 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} />
|
||||
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
||||
<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: 'type', 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>
|
||||
</Toolbar>
|
||||
<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: 'name', label: '姓名' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
|
||||
@@ -235,7 +235,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<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: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'authMode', label: '认证方式' },
|
||||
|
||||
@@ -86,7 +86,7 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<Panel title="落地线路组列表" className="wide-panel">
|
||||
<SimpleTable rows={lineGroupRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={lineGroupRows} columns={[
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
|
||||
{ key: 'customerGatewayCount', label: '使用客户网关数' },
|
||||
|
||||
@@ -114,7 +114,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
|
||||
</Toolbar>
|
||||
<section className="master-detail">
|
||||
<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: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'balance', label: '余额' },
|
||||
|
||||
@@ -1919,3 +1919,949 @@ button:disabled {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user