import { IconAlarm, IconBarChartHStroked, IconBox, IconChevronDown, IconChevronLeft, IconHelpCircle, IconHome, IconLock, IconMapPin, IconMore, IconSearch, IconSetting, IconTickCircle, IconUser, IconExit } from '@douyinfe/semi-icons'; import { Avatar, Banner, Button, Card, Dropdown, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui'; import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; import { usePlatformSession } from '../auth/AuthGate'; import { hasMenu } from '../auth/session'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; import { PasswordInput } from '../shared/PasswordInput'; const { Header, Sider, Content } = Layout; const { Text, Title } = Typography; const navigation = [ { to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome }, { to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch }, { to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin }, { to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked }, { to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked }, { to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm }, { to: '/access', menu: 'access', label: '接入管理', icon: IconBox }, { to: '/users', menu: 'users', label: '账号管理', icon: IconUser } ]; const pageNames: Record = { monitor: '全局监控', vehicles: '车辆查询', tracks: '轨迹回放', history: '历史数据', statistics: '里程查询', alerts: '告警中心', access: '接入管理', operations: '运维质量', users: '账号管理' }; const pageHelp: Record = { monitor: { summary: '查看全车队实时位置、状态和最新上报。', tips: ['车牌、协议和状态筛选会自动生效,无需再次提交。', '拖动地图会暂停选中车辆跟随,点击“跟随车辆”可恢复。', '文字地址按需解析,避免实时刷新持续消耗地图 API。'] }, vehicles: { summary: '按车牌或 VIN 查询车辆档案和实时遥测。', tips: ['先搜索车辆,再进入详情查看接入来源与最新数据。', '实时遥测按固定周期更新,离开页面后会自动停止。'] }, tracks: { summary: '按车辆和时间范围回放历史轨迹。', tips: ['先提交查询条件,再使用播放轴定位具体时刻。', '手动拖动地图会暂停跟随,避免操作与播放动画冲突。'] }, history: { summary: '查询车辆原始历史数据并导出证据。', tips: ['最多同时查询 5 台车辆,缩小时间范围可提高响应速度。', '导出任务在后台执行,完成前可继续使用其他页面。'] }, statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] }, alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] }, access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] }, operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] }, users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] } }; function ContextHelp({ section, visible, onClose }: { section: string; visible: boolean; onClose: () => void }) { const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] }; const title = pageNames[section] ?? '车辆数据中台'; useSideSheetA11y(visible, '.v2-help-sheet', 'v2-context-help', title, '关闭帮助'); return
当前页面帮助{title}
} onCancel={onClose} footer={} >
当前模块 {content.summary}
建议操作{content.tips.length} 项
    {content.tips.map((tip, index) =>
  1. {index + 1}{tip}
  2. )}
Esc关闭帮助
; } function AccountMenu({ session, roleLabel, onPassword, onLogout }: { session: ReturnType['session']; roleLabel?: string; onPassword: () => void; onLogout: () => void; }) { const accountLabel = session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号'; return {session.name.slice(0, 1)} 当前登录账号{session.name}{accountLabel} {roleLabel} } onClick={onPassword}>修改登录密码 } onClick={onLogout}>退出登录 ; } export function AppShell() { const location = useLocation(); const section = location.pathname.split('/')[1] || 'monitor'; const activeRoutePath = `/${section}`; const { session, logout } = usePlatformSession(); const [helpOpen, setHelpOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false); const [passwordOpen, setPasswordOpen] = useState(false); const mobileLayout = useMobileLayout(); const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role]; useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); useLayoutEffect(() => { const reset = () => { for (const content of document.querySelectorAll('.v2-content')) { content.scrollTop = 0; content.scrollLeft = 0; content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); const routeRoot = content.firstElementChild; if (routeRoot instanceof HTMLElement) { routeRoot.scrollTop = 0; routeRoot.scrollLeft = 0; } } }; reset(); let trailingFrame = 0; const frame = window.requestAnimationFrame(() => { reset(); trailingFrame = window.requestAnimationFrame(reset); }); const timer = window.setTimeout(reset, 120); return () => { window.cancelAnimationFrame(frame); window.cancelAnimationFrame(trailingFrame); window.clearTimeout(timer); }; }, [location.pathname]); return ( {mobileLayout ? : }
羚牛车辆数据中台{pageNames[section] ?? '车辆数据中台'}
{ setAccountOpen(false); setPasswordOpen(true); }} onLogout={() => { setAccountOpen(false); logout(); }} />} >
setHelpOpen(false)} /> {passwordOpen ? setPasswordOpen(false)} onChanged={logout} /> : null}
); } function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) { const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [pending, setPending] = useState(false); const [error, setError] = useState(''); const categoryCount = [ /[a-z]/.test(newPassword), /[A-Z]/.test(newPassword), /\d/.test(newPassword), /[^A-Za-z0-9]/.test(newPassword) ].filter(Boolean).length; const lengthValid = newPassword.length >= 10 && newPassword.length <= 128; const categoriesValid = categoryCount >= 3; const confirmationStarted = confirmPassword.length > 0; const passwordsMatch = confirmationStarted && newPassword === confirmPassword; const passwordValid = lengthValid && categoriesValid; const submit = async (event: FormEvent) => { event.preventDefault(); if (!passwordValid) { setError('新密码不符合安全规则'); return; } if (!passwordsMatch) { setError('两次输入的新密码不一致'); return; } setPending(true); setError(''); try { await api.changePassword({ currentPassword, newPassword }); onChanged(); } catch (reason) { setError(reason instanceof Error ? reason.message : '密码修改失败'); } finally { setPending(false); } }; return
修改登录密码
} aria-label="修改登录密码" onCancel={onClose} closeOnEsc={!pending} maskClosable={!pending} footer={null} >
10–128 位字符 大小写字母、数字、特殊字符至少三类
{confirmationStarted ? {passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'} : null} {error ? : null}
; } const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; const operationsNavigation = { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }; const mobileMoreGroups = [ { key: 'insight', title: '分析与处置', description: '历史证据与业务告警', items: [navigation[3], navigation[5]] }, { key: 'governance', title: '平台治理', description: '接入、账号与运维质量', items: [navigation[6], navigation[7], operationsNavigation] } ]; const mobileMoreNavigation = mobileMoreGroups.flatMap((group) => group.items); function MobileNavigation() { const location = useLocation(); const { session } = usePlatformSession(); const [moreOpen, setMoreOpen] = useState(false); const primary = mobilePrimaryNavigation.filter((item) => hasMenu(session, item.menu)); const moreGroups = mobileMoreGroups .map((group) => ({ ...group, items: group.items.filter((item) => hasMenu(session, item.menu)) })) .filter((group) => group.items.length > 0); const more = moreGroups.flatMap((group) => group.items); const moreActive = more.some((item) => location.pathname.startsWith(item.to)); const itemCount = primary.length + (more.length ? 1 : 0); const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; useEffect(() => setMoreOpen(false), [location.pathname]); useSideSheetA11y(moreOpen, '.v2-mobile-more-sidesheet', 'v2-mobile-more', '更多功能', '关闭更多功能'); const link = ({ to, label, icon: Icon }: (typeof mobileMoreNavigation)[number]) => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}>{label}; return <> {more.length ? 更多功能数据分析与系统管理} aria-label="更多功能" footer={null} onCancel={() => setMoreOpen(false)} >
{moreGroups.map((group) =>
{group.title}{group.description}
{group.items.length} 项
)}
: null} ; } function Sidebar({ activePath }: { activePath: string }) { const { session } = usePlatformSession(); const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(false); const compactLayout = useMobileLayout(900); const effectiveCollapsed = collapsed || compactLayout; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; const visibleNavigation = [ ...navigation.filter((item) => hasMenu(session, item.menu)), ...(hasMenu(session, 'operations') ? [{ to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }] : []) ]; const items = visibleNavigation.map(({ to, label, icon: Icon }) => ({ itemKey: to, text: label, icon: , link: to, linkOptions: { 'aria-label': label, title: effectiveCollapsed ? label : undefined, onClick: (event: MouseEvent) => { event.preventDefault(); navigate(to); }, onPointerEnter: () => warmRoute(to), onFocus: () => warmRoute(to), onPointerDown: () => warmRoute(to) } })); return (
灵牛智能