Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx
2026-07-19 04:29:58 +08:00

355 lines
19 KiB
TypeScript
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 {
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<string, string> = {
monitor: '全局监控',
vehicles: '车辆查询',
tracks: '轨迹回放',
history: '历史数据',
statistics: '里程查询',
alerts: '告警中心',
access: '接入管理',
operations: '运维质量',
users: '账号管理'
};
const pageHelp: Record<string, { summary: string; tips: string[] }> = {
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 <SideSheet
className="v2-help-sheet"
visible={visible}
aria-label={title}
width={410}
title={<div className="v2-help-sheet-title"><span><IconHelpCircle /></span><div><Text type="tertiary" size="small"></Text><Title heading={4}>{title}</Title></div></div>}
onCancel={onClose}
footer={<Button block theme="light" type="primary" onClick={onClose}></Button>}
>
<div className="v2-help-panel">
<Card className="v2-help-overview" bodyStyle={{ padding: 0 }}>
<Tag color="blue"></Tag>
<Text type="secondary">{content.summary}</Text>
</Card>
<div className="v2-help-section-label"><Text strong></Text><Text type="tertiary" size="small">{content.tips.length} </Text></div>
<ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol>
<footer><kbd>Esc</kbd><span></span></footer>
</div>
</SideSheet>;
}
function AccountMenu({
session,
roleLabel,
onPassword,
onLogout
}: {
session: ReturnType<typeof usePlatformSession>['session'];
roleLabel?: string;
onPassword: () => void;
onLogout: () => void;
}) {
const accountLabel = session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号';
return <Dropdown.Menu>
<Dropdown.Title className="v2-account-dropdown-profile">
<Avatar size="small" color="blue">{session.name.slice(0, 1)}</Avatar>
<span><small></small><strong>{session.name}</strong><em>{accountLabel}</em></span>
<Tag color="blue" size="small">{roleLabel}</Tag>
</Dropdown.Title>
<Dropdown.Divider />
<Dropdown.Item icon={<IconLock />} onClick={onPassword}></Dropdown.Item>
<Dropdown.Item type="danger" icon={<IconExit />} onClick={onLogout}>退</Dropdown.Item>
</Dropdown.Menu>;
}
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<HTMLElement>('.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 (
<Layout className="v2-shell">
{mobileLayout ? <MobileNavigation /> : <Sidebar activePath={activeRoutePath} />}
<Layout className="v2-main">
<Header className="v2-topbar">
<div className="v2-topbar-title"><Text className="v2-topbar-product" type="tertiary" size="small"></Text><Title className="v2-topbar-page-title" heading={4}>{pageNames[section] ?? '车辆数据中台'}</Title></div>
<div className="v2-topbar-actions">
<Button className="v2-help-trigger" theme="light" type="tertiary" icon={<IconHelpCircle />} aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><span className="v2-help-trigger-label"></span></Button>
<Dropdown
trigger="click"
position="bottomRight"
visible={accountOpen}
onVisibleChange={setAccountOpen}
contentClassName="v2-account-dropdown"
render={<AccountMenu
session={session}
roleLabel={roleLabel}
onPassword={() => { setAccountOpen(false); setPasswordOpen(true); }}
onLogout={() => { setAccountOpen(false); logout(); }}
/>}
>
<Button
theme="borderless"
className="v2-current-user"
aria-label={`账号菜单,${session.name}`}
aria-expanded={accountOpen}
aria-haspopup="menu"
>
<Avatar size="extra-small" color="blue">{session.name.slice(0, 1)}</Avatar>
<b>{session.name}</b>
<Tag color="blue" size="small">{roleLabel}</Tag>
<IconChevronDown className="v2-account-chevron" />
</Button>
</Dropdown>
</div>
</Header>
<Content key={location.pathname} className={`v2-content${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></Content>
</Layout>
<ContextHelp section={section} visible={helpOpen} onClose={() => setHelpOpen(false)} />
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
</Layout>
);
}
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 <Modal
className="v2-password-modal"
visible
title={<div className="v2-password-title"><span aria-hidden="true"><IconLock /></span><div><strong></strong><Text aria-hidden="true" type="tertiary" size="small"></Text></div></div>}
aria-label="修改登录密码"
onCancel={onClose}
closeOnEsc={!pending}
maskClosable={!pending}
footer={null}
>
<form className="v2-password-dialog" onSubmit={submit}>
<Banner type="warning" bordered closeIcon={null} title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
<label><span></span><PasswordInput aria-label="当前密码" autoFocus required autoComplete="current-password" visibilityLabel="当前密码" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
<label><span></span><PasswordInput aria-label="新密码" required minLength={10} autoComplete="new-password" visibilityLabel="新密码" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
<div className="v2-password-rules" aria-label="新密码安全规则">
<span className={lengthValid ? 'is-valid' : ''}><IconTickCircle />10128 </span>
<span className={categoriesValid ? 'is-valid' : ''}><IconTickCircle /></span>
</div>
<label><span></span><PasswordInput aria-label="确认新密码" required minLength={10} autoComplete="new-password" visibilityLabel="确认新密码" value={confirmPassword} onChange={setConfirmPassword} size="large" /></label>
{confirmationStarted ? <Text className={`v2-password-match${passwordsMatch ? ' is-valid' : ' is-error'}`} role="status">{passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'}</Text> : null}
{error ? <Banner className="v2-password-error" type="danger" bordered title="无法修改密码" description={error} /> : null}
<footer><Button type="tertiary" onClick={onClose} disabled={pending}></Button><Button htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !passwordValid || !passwordsMatch}>{pending ? '正在修改…' : '确认修改'}</Button></footer>
</form>
</Modal>;
}
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]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
return <>
{more.length ? <SideSheet
className="v2-mobile-more-sidesheet"
visible={moreOpen}
placement="bottom"
height="auto"
title={<div><strong></strong><span></span></div>}
aria-label="更多功能"
footer={null}
onCancel={() => setMoreOpen(false)}
>
<div className="v2-mobile-more-groups">
{moreGroups.map((group) => <section className="v2-mobile-more-section" key={group.key} aria-labelledby={`v2-mobile-more-${group.key}`}>
<header>
<div><strong id={`v2-mobile-more-${group.key}`}>{group.title}</strong><span>{group.description}</span></div>
<Tag size="small" color="blue">{group.items.length} </Tag>
</header>
<nav aria-label={group.title}>{group.items.map(link)}</nav>
</section>)}
</div>
</SideSheet> : null}
<nav className="v2-mobile-navigation" aria-label="主导航" style={{ '--v2-mobile-nav-count': Math.max(itemCount, 1) } as CSSProperties}>
{primary.map(link)}
{more.length ? <Button theme="borderless" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} aria-controls="v2-mobile-more" icon={<IconMore size="large" />} onClick={() => setMoreOpen((value) => !value)}><span></span></Button> : null}
</nav>
</>;
}
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: <Icon size="large" />,
link: to,
linkOptions: {
'aria-label': label,
title: effectiveCollapsed ? label : undefined,
onClick: (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); navigate(to); },
onPointerEnter: () => warmRoute(to),
onFocus: () => warmRoute(to),
onPointerDown: () => warmRoute(to)
}
}));
return (
<Sider className={`v2-sidebar${effectiveCollapsed ? ' is-collapsed' : ''}`} aria-label="主导航">
<div className="v2-brand" aria-label="灵牛智能车辆数据中台">
<img className="v2-brand-logo" src="/brand-logo.svg" alt="灵牛智能" />
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
</div>
<Nav className="v2-navigation" aria-label="主导航" items={items} selectedKeys={[activePath]} isCollapsed={effectiveCollapsed} tooltipShowDelay={300} tooltipHideDelay={300} />
{!compactLayout ? <Button className="v2-collapse" theme="borderless" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
<IconChevronLeft />
<span></span>
</Button> : null}
</Sider>
);
}