312 lines
18 KiB
TypeScript
312 lines
18 KiB
TypeScript
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, Input, 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';
|
||
|
||
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 content = document.querySelector<HTMLElement>('.v2-content');
|
||
if (!content) return;
|
||
const reset = () => {
|
||
content.scrollTop = 0;
|
||
content.scrollLeft = 0;
|
||
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
|
||
};
|
||
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 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><Title heading={4}>修改登录密码</Title><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 title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
|
||
<label><span>当前密码</span><Input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
|
||
<label><span>新密码</span><Input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
|
||
<div className="v2-password-rules" aria-label="新密码安全规则">
|
||
<span className={lengthValid ? 'is-valid' : ''}><IconTickCircle />10–128 位字符</span>
|
||
<span className={categoriesValid ? 'is-valid' : ''}><IconTickCircle />大小写字母、数字、特殊字符至少三类</span>
|
||
</div>
|
||
<label><span>确认新密码</span><Input required minLength={10} type="password" autoComplete="new-password" 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 mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], navigation[7], { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }];
|
||
|
||
function MobileNavigation() {
|
||
const location = useLocation();
|
||
const { session } = usePlatformSession();
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const primary = mobilePrimaryNavigation.filter((item) => hasMenu(session, item.menu));
|
||
const more = mobileMoreNavigation.filter((item) => hasMenu(session, item.menu));
|
||
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 navigation)[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)}><nav>{more.map(link)}</nav></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>
|
||
);
|
||
}
|