optimize ui loading and dashboard performance

This commit is contained in:
hectorzhao
2026-08-27 15:17:47 +08:00
parent 7ff019e829
commit ecb567c0da
28 changed files with 1307 additions and 132 deletions
+85 -40
View File
@@ -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
View File
@@ -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) }),
+36 -5
View File
@@ -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) => (
+13 -9
View File
@@ -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: '被叫' },
+3 -2
View File
@@ -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' },
+10 -14
View File
@@ -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: '操作',
+1 -1
View File
@@ -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' },
+1
View File
@@ -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' },
+14 -11
View File
@@ -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
</>
);
}
+6 -8
View File
@@ -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}
+1 -1
View File
@@ -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: '功能模块' },
+30 -16
View File
@@ -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}%` },
+1 -2
View File
@@ -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
</>
);
}
+1 -1
View File
@@ -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: '角色说明' },
+1 -1
View File
@@ -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: '手机号' },
+1 -1
View File
@@ -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: '认证方式' },
+1 -1
View File
@@ -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: '使用客户网关数' },
+1 -1
View File
@@ -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: '余额' },
+946
View File
@@ -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;
}
}