547 lines
23 KiB
React
547 lines
23 KiB
React
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
|
||
import { Icon } from './components/layout.jsx';
|
||
import { api, explainApiError } from './api.js';
|
||
import {
|
||
formatCurrency,
|
||
normalizeAuditLog,
|
||
normalizeCustomer,
|
||
normalizeCustomerGateway,
|
||
normalizeLandingLineGroup,
|
||
normalizeRecharge,
|
||
normalizeRole,
|
||
normalizeUser,
|
||
normalizeVendor,
|
||
normalizeVendorGateway,
|
||
} from './utils/formatters.js';
|
||
import { DashboardPage } from './pages/DashboardPage.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: '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 = {
|
||
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 [activeCallsError, setActiveCallsError] = useState('');
|
||
const [apiLoading, setApiLoading] = useState(true);
|
||
const [apiError, setApiError] = useState('');
|
||
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 refreshApi = async (user = authUser) => {
|
||
const sourceUser = user && Array.isArray(user.permissions) ? user : authUser;
|
||
const granted = permissionSet(sourceUser);
|
||
const skipped = Symbol('skipped');
|
||
const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped));
|
||
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));
|
||
}
|
||
setApiLoading(false);
|
||
};
|
||
const refreshActiveCalls = async () => {
|
||
setActiveCallsLoading(true);
|
||
setActiveCallsError('');
|
||
try {
|
||
const response = await api.activeCalls();
|
||
setActiveCalls(response.items || []);
|
||
} catch (error) {
|
||
setActiveCallsError(explainApiError(error));
|
||
} finally {
|
||
setActiveCallsLoading(false);
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
|
||
api.refresh()
|
||
.then((response) => {
|
||
if (!mounted) return;
|
||
setAuthUser(response.user);
|
||
void refreshApi(response.user);
|
||
})
|
||
.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 refreshApi(response.user);
|
||
};
|
||
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('');
|
||
};
|
||
const reloadAfterMutation = async (operation) => {
|
||
await operation();
|
||
await refreshApi();
|
||
};
|
||
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)),
|
||
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, customerRows }
|
||
: active === 'businessPrefixes'
|
||
? { can: canPermission }
|
||
: active === 'numberLibrary'
|
||
? { can: canPermission }
|
||
: active === 'activeCalls'
|
||
? {
|
||
activeCalls,
|
||
activeCallsLoading,
|
||
activeCallsError,
|
||
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 }
|
||
: 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 }
|
||
: {};
|
||
|
||
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-mark">聆</div>
|
||
<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)} 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>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|