Files
lisglosips/apps/web/src/App.jsx
T

622 lines
26 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
import { Icon, PageLoadingDialog } from './components/layout.jsx';
import { api, explainApiError } from './api.js';
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
import {
formatCurrency,
normalizeAuditLog,
normalizeCustomer,
normalizeCustomerGateway,
normalizeLandingLineGroup,
normalizeRecharge,
normalizeRole,
normalizeUser,
normalizeVendor,
normalizeVendorGateway,
} from './utils/formatters.js';
import { DashboardPage } from './pages/DashboardPage.jsx';
import { CallerAnalyticsPage } from './pages/CallerAnalyticsPage.jsx';
import { ActiveCallsPage } from './pages/ActiveCallsPage.jsx';
import { CustomersPage } from './pages/CustomersPage.jsx';
import { RechargeRecordsPage } from './pages/RechargeRecordsPage.jsx';
import { CustomerGatewaysPage } from './pages/CustomerGatewaysPage.jsx';
import { VendorsPage } from './pages/VendorsPage.jsx';
import { VendorGatewaysPage } from './pages/VendorGatewaysPage.jsx';
import { VendorLineGroupsPage } from './pages/VendorLineGroupsPage.jsx';
import { BusinessPrefixesPage } from './pages/BusinessPrefixesPage.jsx';
import { NumberLibraryPage } from './pages/NumberLibraryPage.jsx';
import { RoutesPage } from './pages/RoutesPage.jsx';
import { BillingPage } from './pages/BillingPage.jsx';
import { CdrPage } from './pages/CdrPage.jsx';
import { QualityPage } from './pages/QualityPage.jsx';
import { SipOpsPage } from './pages/SipOpsPage.jsx';
import { MonitoringPage } from './pages/MonitoringPage.jsx';
import { SettingsPage } from './pages/SettingsPage.jsx';
import { UsersPage } from './pages/UsersPage.jsx';
import { RolesPage } from './pages/RolesPage.jsx';
import { OperationLogsPage } from './pages/OperationLogsPage.jsx';
import { PAGE_PERMISSIONS, can, canAll, permissionSet } from './permissions.js';
const navGroups = [
{
title: '运营',
items: [
{ key: 'dashboard', label: '概览 Dashboard', permissions: PAGE_PERMISSIONS.dashboard },
{ key: 'customers', label: '客户管理', permissions: PAGE_PERMISSIONS.customers },
{ key: 'customerGateways', label: '客户网关管理', permissions: PAGE_PERMISSIONS.customerGateways },
{ key: 'businessPrefixes', label: '业务前缀管理', permissions: PAGE_PERMISSIONS.businessPrefixes },
{ key: 'rechargeRecords', label: '充值记录', permissions: PAGE_PERMISSIONS.rechargeRecords },
{ key: 'vendors', label: '供应商管理', permissions: PAGE_PERMISSIONS.vendors },
{ key: 'vendorGateways', label: '落地网关管理', permissions: PAGE_PERMISSIONS.vendorGateways },
{ key: 'vendorLineGroups', label: '落地线路组', permissions: PAGE_PERMISSIONS.vendorLineGroups },
{ key: 'numberLibrary', label: '号码库', permissions: PAGE_PERMISSIONS.numberLibrary },
{ key: 'billing', label: '费率与计费', pending: true },
],
},
{
title: '业务',
items: [
{ key: 'activeCalls', label: '当前通话', permissions: PAGE_PERMISSIONS.activeCalls },
{ key: 'cdr', label: '话单中心', permissions: PAGE_PERMISSIONS.cdr },
{ key: 'callerAnalytics', label: '主叫号码分析', permissions: PAGE_PERMISSIONS.callerAnalytics },
{ key: 'quality', label: '质检中心', permissions: PAGE_PERMISSIONS.quality },
{ key: 'sipops', label: 'SIP 运维', pending: true },
{ key: 'monitoring', label: '监控告警', pending: true },
],
},
{
title: '系统',
items: [
{ key: 'settings', label: '系统设置', pending: true },
{ key: 'users', label: '用户管理', permissions: PAGE_PERMISSIONS.users },
{ key: 'roles', label: '角色与权限', permissions: PAGE_PERMISSIONS.roles },
{ key: 'operationLogs', label: '操作日志', permissions: PAGE_PERMISSIONS.operationLogs },
],
},
];
const pages = {
callerAnalytics: CallerAnalyticsPage,
dashboard: DashboardPage,
activeCalls: ActiveCallsPage,
customers: CustomersPage,
customerGateways: CustomerGatewaysPage,
businessPrefixes: BusinessPrefixesPage,
rechargeRecords: RechargeRecordsPage,
vendors: VendorsPage,
vendorGateways: VendorGatewaysPage,
vendorLineGroups: VendorLineGroupsPage,
numberLibrary: NumberLibraryPage,
routes: RoutesPage,
billing: BillingPage,
cdr: CdrPage,
quality: QualityPage,
sipops: SipOpsPage,
monitoring: MonitoringPage,
settings: SettingsPage,
users: UsersPage,
roles: RolesPage,
operationLogs: OperationLogsPage,
};
function NoPermissionPage({ label }) {
return (
<Alert title="无权限访问" tone="warning">
当前账号没有访问{label || '该页面'}所需的权限请联系管理员调整角色权限
</Alert>
);
}
function LoginPage({ checkingSession, onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [captcha, setCaptcha] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const refreshCaptcha = async () => {
setCaptchaLoading(true);
try {
const nextCaptcha = await api.captcha();
setCaptcha(nextCaptcha);
setCaptchaCode('');
} catch (captchaError) {
setError(explainApiError(captchaError));
} finally {
setCaptchaLoading(false);
}
};
useEffect(() => {
if (!checkingSession) {
void refreshCaptcha();
}
}, [checkingSession]);
const submitLogin = async (event) => {
event.preventDefault();
if (!username.trim() || !password || !captcha?.captchaId || !captchaCode.trim() || submitting) {
return;
}
setSubmitting(true);
setError('');
try {
await onLogin({ username, password, captchaId: captcha.captchaId, captchaCode });
} catch (loginError) {
setError(explainApiError(loginError));
setPassword('');
await refreshCaptcha();
} finally {
setSubmitting(false);
}
};
return (
<main className="login-shell">
<section className="login-panel" aria-labelledby="login-title">
<div className="login-brand">
<div className="brand-mark"></div>
<div>
<h1 id="login-title">聆界SIP管理平台</h1>
<span>LisgloSIPS V2</span>
</div>
</div>
{checkingSession ? (
<Alert title="正在恢复会话">正在校验当前浏览器的安全会话</Alert>
) : (
<form className="login-form" onSubmit={submitLogin}>
{error ? <Alert title="登录失败" tone="danger">{error}</Alert> : null}
<Field label="用户名">
<Input
autoComplete="username"
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
required
/>
</Field>
<Field label="密码">
<Input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
required
/>
</Field>
<Field label="图形验证码">
<div className="captcha-row">
<Input
autoComplete="off"
inputMode="text"
value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value.toUpperCase())}
placeholder="请输入验证码"
maxLength="5"
required
/>
<button
type="button"
className="captcha-image"
onClick={refreshCaptcha}
disabled={captchaLoading}
title="刷新验证码"
aria-label="刷新验证码"
>
{captcha?.imageDataUrl ? <img src={captcha.imageDataUrl} alt="图形验证码" /> : <span>{captchaLoading ? '加载中' : '刷新'}</span>}
</button>
</div>
</Field>
<Button type="submit" disabled={submitting || captchaLoading || !username.trim() || !password || !captchaCode.trim()}>
{submitting ? '正在登录' : '登录'}
</Button>
</form>
)}
</section>
</main>
);
}
export default function App() {
const [active, setActive] = useState('dashboard');
const [collapsed, setCollapsed] = useState(false);
const [authUser, setAuthUser] = useState(null);
const [authChecking, setAuthChecking] = useState(true);
const [customerRows, setCustomerRows] = useState([]);
const [vendorRows, setVendorRows] = useState([]);
const [customerGatewayRows, setCustomerGatewayRows] = useState([]);
const [vendorGatewayRows, setVendorGatewayRows] = useState([]);
const [landingLineGroupRows, setLandingLineGroupRows] = useState([]);
const [rechargeRows, setRechargeRows] = useState([]);
const [userRows, setUserRows] = useState([]);
const [roleRows, setRoleRows] = useState([]);
const [logRows, setLogRows] = useState([]);
const [dashboardSummary, setDashboardSummary] = useState(null);
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
.map((group) => ({ ...group, items: group.items.filter((item) => !item.permissions || canAll(currentPermissions, item.permissions)) }))
.filter((group) => group.items.some((item) => !item.pending)), [currentPermissions]);
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 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);
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 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') {
const range = lastSevenDays();
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), 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 refreshApi = () => loadPageData(active, authUser, { force: true });
const refreshAuditLogs = async (params = {}) => {
setApiLoading(true);
setApiError('');
try {
const value = await api.auditLogs(params);
setLogRows((value.items || []).map(normalizeAuditLog));
} catch (error) {
setApiError(explainApiError(error));
} finally {
setApiLoading(false);
}
};
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;
api.refresh()
.then((response) => {
if (!mounted) return;
setAuthUser(response.user);
void loadPageData('dashboard', response.user, { force: true });
})
.catch(() => {
if (!mounted) return;
setAuthUser(null);
setApiLoading(false);
})
.finally(() => {
if (mounted) {
setAuthChecking(false);
}
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (authUser && active === 'activeCalls' && can(authUser, 'active_calls.view')) {
void refreshActiveCalls();
}
}, [authUser, active]);
useEffect(() => {
if (!authUser || canAccessPage(active)) {
return;
}
const firstAllowed = allowedNavGroups.flatMap((group) => group.items).find((item) => !item.pending);
if (firstAllowed) {
setActive(firstAllowed.key);
}
}, [active, allowedNavGroups, authUser]);
const handleLogin = async (credentials) => {
const response = await api.login(credentials);
setAuthUser(response.user);
const firstAllowed = navGroups.flatMap((group) => group.items).find((item) => !item.pending && canAll(response.user, item.permissions || []));
if (firstAllowed) {
setActive(firstAllowed.key);
}
await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true });
};
const handleLogout = async () => {
await api.logout();
setAuthUser(null);
setActive('dashboard');
setApiError('');
setCustomerRows([]);
setVendorRows([]);
setCustomerGatewayRows([]);
setVendorGatewayRows([]);
setLandingLineGroupRows([]);
setRechargeRows([]);
setUserRows([]);
setRoleRows([]);
setLogRows([]);
setDashboardSummary(null);
setDashboardTrends(null);
setActiveCalls([]);
setActiveCallsError('');
loadedPagesRef.current.clear();
pageRequestRef.current.controller?.abort('LOGOUT');
};
const reloadAfterMutation = async (operation) => {
await operation();
await loadPageData(active, authUser, { force: true });
};
const applyRechargeResult = (response) => {
const normalized = normalizeRecharge(response);
setRechargeRows((rows) => [normalized, ...rows.filter((row) => row.id !== normalized.id)]);
if (response.accountType === 'CUSTOMER') {
setCustomerRows((rows) =>
rows.map((row) => (row.id === response.accountId ? { ...row, balance: formatCurrency(response.afterBalance) } : row))
);
}
if (response.accountType === 'VENDOR') {
setVendorRows((rows) =>
rows.map((row) => (row.id === response.accountId ? { ...row, balance: formatCurrency(response.afterBalance) } : row))
);
}
return response;
};
const rechargeAndApply = async (operation) => {
const response = await operation();
return applyRechargeResult(response);
};
const activePageProps = active === 'customers'
? {
customerRows,
setCustomerRows,
addRechargeRecord: (record) => setRechargeRows((rows) => [record, ...rows]),
apiLoading,
apiError,
refreshApi,
can: canPermission,
onCreateCustomer: (body) => reloadAfterMutation(() => api.createCustomer(body)),
onUpdateCustomer: (id, body) => reloadAfterMutation(() => api.updateCustomer(id, body)),
onToggleCustomerStatus: (row) => reloadAfterMutation(() => (row.status === '启用' ? api.disableCustomer(row.id) : api.enableCustomer(row.id))),
onDeleteCustomer: (id) => reloadAfterMutation(() => api.deleteCustomer(id)),
onRechargeCustomer: (id, body) => rechargeAndApply(() => api.rechargeCustomer(id, body)),
}
: active === 'vendors'
? {
vendorRows,
setVendorRows,
addRechargeRecord: (record) => setRechargeRows((rows) => [record, ...rows]),
apiLoading,
apiError,
refreshApi,
can: canPermission,
onCreateVendor: (body) => reloadAfterMutation(() => api.createVendor(body)),
onUpdateVendor: (id, body) => reloadAfterMutation(() => api.updateVendor(id, body)),
onDeleteVendor: (id) => reloadAfterMutation(() => api.deleteVendor(id)),
onRechargeVendor: (id, body) => rechargeAndApply(() => api.rechargeVendor(id, body)),
}
: active === 'customerGateways'
? {
gatewayRows: customerGatewayRows,
setGatewayRows: setCustomerGatewayRows,
customerRows,
lineGroupRows: landingLineGroupRows,
apiLoading,
apiError,
refreshApi,
can: canPermission,
onCreateGateway: (body) => reloadAfterMutation(() => api.createCustomerGateway(body)),
onUpdateGateway: (id, body) => reloadAfterMutation(() => api.updateCustomerGateway(id, body)),
onToggleGatewayStatus: (row) => reloadAfterMutation(() => (row.status === '启用' ? api.disableCustomerGateway(row.id) : api.enableCustomerGateway(row.id))),
onDeleteGateway: (id) => reloadAfterMutation(() => api.deleteCustomerGateway(id)),
}
: active === 'vendorGateways'
? {
gatewayRows: vendorGatewayRows,
setGatewayRows: setVendorGatewayRows,
vendorRows,
apiLoading,
apiError,
refreshApi,
can: canPermission,
onUpdateGateway: (id, body) => reloadAfterMutation(() => api.updateVendorGateway(id, body)),
onToggleGatewayStatus: (row) => reloadAfterMutation(() => (row.status === '启用' ? api.disableVendorGateway(row.id) : api.enableVendorGateway(row.id))),
onDeleteGateway: (id) => reloadAfterMutation(() => api.deleteVendorGateway(id)),
}
: active === 'vendorLineGroups'
? {
lineGroupRows: landingLineGroupRows,
setLineGroupRows: setLandingLineGroupRows,
gatewayRows: vendorGatewayRows,
apiLoading,
apiError,
refreshApi,
can: canPermission,
onDeleteLineGroup: (id) => reloadAfterMutation(() => api.deleteLandingLineGroup(id)),
}
: active === 'rechargeRecords'
? { rechargeRows, apiLoading, apiError, refreshApi }
: active === 'dashboard'
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }
: active === 'businessPrefixes'
? { can: canPermission }
: active === 'numberLibrary'
? { can: canPermission }
: active === 'activeCalls'
? {
activeCalls,
activeCallsLoading,
activeCallsBlockingLoading,
activeCallsError,
activeCallsUpdatedAt,
refreshActiveCalls,
can: canPermission,
onHangupActiveCall: async (id) => {
await api.hangupActiveCall(id);
await refreshActiveCalls();
},
}
: active === 'cdr'
? { customerGatewayRows, vendorGatewayRows, can: canPermission }
: active === 'quality'
? {
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'
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
: active === 'operationLogs'
? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
: {};
if (!authUser) {
return <LoginPage checkingSession={authChecking} onLogin={handleLogin} />;
}
return (
<div className={`prototype-app ${collapsed ? 'sidebar-collapsed' : ''}`}>
<aside className="sidebar">
<div className="brand-block">
<div className="brand-expanded">
<img
className="brand-logo brand-logo-expanded"
src="/brand/logo1.png"
alt="聆界SIP管理平台"
/>
<span className="brand-sip-text">SIP</span>
</div>
<img
className="brand-logo brand-logo-collapsed"
src="/brand/logo2.png"
alt="聆界SIP管理平台"
/>
<div className="brand-copy">
<strong>聆界SIP管理平台</strong>
<span>LisgloSIPS</span>
</div>
<button
type="button"
className="sidebar-toggle"
title={collapsed ? '展开菜单' : '收起菜单'}
aria-label={collapsed ? '展开菜单' : '收起菜单'}
onClick={() => setCollapsed((value) => !value)}
>
<Icon type={collapsed ? 'expand' : 'collapse'} />
</button>
</div>
<nav>
{allowedNavGroups.map((group) => (
<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); 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}
</button>
))}
</div>
))}
</nav>
</aside>
<div className="main-area">
<header className="topbar">
<div>
<span>当前页面</span>
<strong>{activeLabel}</strong>
</div>
<div className="topbar-right">
<Badge tone={apiError ? 'warning' : 'success'}>{apiError ? 'API 待登录' : '真实 API'}</Badge>
<span>{new Date().toLocaleString('zh-CN', { hour12: false })}</span>
<strong>{authUser.displayName || authUser.username}</strong>
<Button variant="ghost" size="sm" icon={<Icon type="logout" />} onClick={handleLogout}>退出</Button>
</div>
</header>
<main className="page-content">
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
</main>
<PageLoadingDialog loading={apiLoading && active !== 'activeCalls'} />
</div>
</div>
);
}