feat(web): refine Semi UI application shell

This commit is contained in:
lingniu
2026-07-20 01:49:16 +08:00
parent 275d453954
commit 6e9a170fbf
5 changed files with 168 additions and 52 deletions

View File

@@ -10,7 +10,7 @@
</style> </style>
</head> </head>
<body> <body>
<div id="root"><div id="platform-boot" role="status" aria-live="polite"><div><img class="platform-boot-logo" src="/brand-logo.svg" alt="牛智能" /><strong>车辆数据中台</strong><small>正在加载工作台…</small></div></div></div> <div id="root"><div id="platform-boot" role="status" aria-live="polite"><div><img class="platform-boot-logo" src="/brand-logo.svg" alt="牛智能" /><strong>车辆数据中台</strong><small>正在加载工作台…</small></div></div></div>
<script> <script>
(() => { (() => {
const readyEvent = 'vehicle-platform:ready'; const readyEvent = 'vehicle-platform:ready';

View File

@@ -1,4 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from '@douyinfe/semi-ui';
import zhCN from '@douyinfe/semi-ui/lib/es/locale/source/zh_CN';
import { useEffect, type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell'; import { AppShell } from './layout/AppShell';
@@ -50,28 +52,30 @@ function ProtectedRoute({ menu, children }: { menu: string; children: ReactNode
export function AppV2() { export function AppV2() {
return ( return (
<QueryClientProvider client={queryClient}> <ConfigProvider locale={zhCN} timeZone="Asia/Shanghai" direction="ltr">
<PlatformReadySignal /> <QueryClientProvider client={queryClient}>
<PlatformErrorBoundary> <PlatformReadySignal />
<AuthGate><BrowserRouter future={ROUTER_FUTURE}> <PlatformErrorBoundary>
<Routes> <AuthGate><BrowserRouter future={ROUTER_FUTURE}>
<Route element={<AppShell />}> <Routes>
<Route index element={<SessionIndex />} /> <Route element={<AppShell />}>
<Route path="/monitor" element={<ProtectedRoute menu="monitor"><RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" /></ProtectedRoute>} /> <Route index element={<SessionIndex />} />
<Route path="/vehicles/:vin?" element={<ProtectedRoute menu="vehicles"><RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" /></ProtectedRoute>} /> <Route path="/monitor" element={<ProtectedRoute menu="monitor"><RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" /></ProtectedRoute>} />
<Route path="/tracks" element={<ProtectedRoute menu="tracks"><RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" /></ProtectedRoute>} /> <Route path="/vehicles/:vin?" element={<ProtectedRoute menu="vehicles"><RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" /></ProtectedRoute>} />
<Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} /> <Route path="/tracks" element={<ProtectedRoute menu="tracks"><RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} /> <Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} />
<Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} /> <Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} />
<Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" /></ProtectedRoute>} /> <Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} />
<Route path="/operations" element={<ProtectedRoute menu="operations"><RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" /></ProtectedRoute>} /> <Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute menu="users"><RoutePage page={RoutePages.Users} recreatePage={RoutePageFactories.Users} label="账号管理" /></ProtectedRoute>} /> <Route path="/operations" element={<ProtectedRoute menu="operations"><RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/monitor" replace />} /> <Route path="/users" element={<ProtectedRoute menu="users"><RoutePage page={RoutePages.Users} recreatePage={RoutePageFactories.Users} label="账号管理" /></ProtectedRoute>} />
</Route> <Route path="*" element={<Navigate to="/monitor" replace />} />
</Routes> </Route>
</BrowserRouter></AuthGate> </Routes>
</PlatformErrorBoundary> </BrowserRouter></AuthGate>
</QueryClientProvider> </PlatformErrorBoundary>
</QueryClientProvider>
</ConfigProvider>
); );
} }

View File

@@ -49,6 +49,19 @@ test('renders only explicitly assigned customer menus', () => {
expect(screen.queryByRole('link', { name: '运维质量' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: '运维质量' })).not.toBeInTheDocument();
}); });
test('groups desktop workflows with distinct Semi navigation icons and the current brand name', () => {
const view = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/statistics']}>
<Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes>
</MemoryRouter>);
expect(screen.getByLabelText('羚牛智能车辆数据中台')).toContainElement(screen.getByAltText('羚牛智能'));
expect(view.container.querySelector('.v2-nav-group-start.is-workspace')).toHaveTextContent('全局监控');
expect(view.container.querySelector('.v2-nav-group-start.is-governance')).toHaveTextContent('告警中心');
expect(screen.getByRole('link', { name: '轨迹回放' })?.querySelector('.semi-icon-route')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '历史数据' })?.querySelector('.semi-icon-history')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '里程查询' })?.querySelector('.semi-icon-bar_chart_v_stroked')).toBeInTheDocument();
});
test('reschedules likely route preloads when the active module changes', async () => { test('reschedules likely route preloads when the active module changes', async () => {
const firstCleanup = vi.fn(); const firstCleanup = vi.fn();
const secondCleanup = vi.fn(); const secondCleanup = vi.fn();

View File

@@ -1,19 +1,21 @@
import { import {
IconAlarm, IconAlarm,
IconBarChartHStroked, IconBarChartVStroked,
IconBox,
IconChevronDown, IconChevronDown,
IconChevronLeft, IconChevronLeft,
IconChevronRight, IconChevronRight,
IconConnectionPoint1,
IconHelpCircle, IconHelpCircle,
IconHistory,
IconHome, IconHome,
IconLock, IconLock,
IconMapPin,
IconMore, IconMore,
IconRoute,
IconSearch, IconSearch,
IconSetting, IconSetting,
IconTickCircle, IconTickCircle,
IconUser, IconUser,
IconUserGroup,
IconExit IconExit
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { Avatar, Banner, Button, Card, Dropdown, Layout, Nav, Tag, Typography } from '@douyinfe/semi-ui'; import { Avatar, Banner, Button, Card, Dropdown, Layout, Nav, Tag, Typography } from '@douyinfe/semi-ui';
@@ -33,14 +35,14 @@ const { Header, Sider, Content } = Layout;
const { Text, Title } = Typography; const { Text, Title } = Typography;
const navigation = [ const navigation = [
{ to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome }, { to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome, group: 'workspace' },
{ to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch }, { to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch, group: 'workspace' },
{ to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin }, { to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconRoute, group: 'workspace' },
{ to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked }, { to: '/history', menu: 'history', label: '历史数据', icon: IconHistory, group: 'workspace' },
{ to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked }, { to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartVStroked, group: 'workspace' },
{ to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm }, { to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm, group: 'governance' },
{ to: '/access', menu: 'access', label: '接入管理', icon: IconBox }, { to: '/access', menu: 'access', label: '接入管理', icon: IconConnectionPoint1, group: 'governance' },
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUser } { to: '/users', menu: 'users', label: '账号管理', icon: IconUserGroup, group: 'governance' }
]; ];
const pageNames: Record<string, string> = { const pageNames: Record<string, string> = {
@@ -270,7 +272,7 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
} }
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];
const operationsNavigation = { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }; const operationsNavigation = { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting, group: 'governance' };
const mobileMoreGroups = [ const mobileMoreGroups = [
{ {
key: 'insight', key: 'insight',
@@ -354,27 +356,33 @@ function Sidebar({ activePath }: { activePath: string }) {
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
const visibleNavigation = [ const visibleNavigation = [
...navigation.filter((item) => hasMenu(session, item.menu)), ...navigation.filter((item) => hasMenu(session, item.menu)),
...(hasMenu(session, 'operations') ? [{ to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }] : []) ...(hasMenu(session, 'operations') ? [operationsNavigation] : [])
]; ];
const items = visibleNavigation.map(({ to, label, icon: Icon }) => ({ let previousGroup = '';
itemKey: to, const items = visibleNavigation.map(({ to, label, icon: Icon, group }) => {
text: label, const startsGroup = group !== previousGroup;
icon: <Icon size="large" />, previousGroup = group;
link: to, return {
linkOptions: { itemKey: to,
'aria-label': label, text: label,
title: effectiveCollapsed ? label : undefined, icon: <Icon size="large" />,
onClick: (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); navigate(to); }, className: startsGroup ? `v2-nav-group-start is-${group}` : undefined,
onPointerEnter: () => warmRoute(to), link: to,
onFocus: () => warmRoute(to), linkOptions: {
onPointerDown: () => warmRoute(to) '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 ( return (
<Sider className={`v2-sidebar${effectiveCollapsed ? ' is-collapsed' : ''}`} aria-label="主导航"> <Sider className={`v2-sidebar${effectiveCollapsed ? ' is-collapsed' : ''}`} aria-label="主导航">
<div className="v2-brand" aria-label="牛智能车辆数据中台"> <div className="v2-brand" aria-label="牛智能车辆数据中台">
<img className="v2-brand-logo" src="/brand-logo.svg" alt="牛智能" /> <img className="v2-brand-logo" src="/brand-logo.svg" alt="牛智能" />
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" /> <img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
</div> </div>
<Nav className="v2-navigation" aria-label="主导航" items={items} selectedKeys={[activePath]} isCollapsed={effectiveCollapsed} tooltipShowDelay={300} tooltipHideDelay={300} /> <Nav className="v2-navigation" aria-label="主导航" items={items} selectedKeys={[activePath]} isCollapsed={effectiveCollapsed} tooltipShowDelay={300} tooltipHideDelay={300} />

View File

@@ -165,6 +165,97 @@
} }
} }
/*
* Semi UI application navigation convergence.
* Group the primary driving workflows separately from governance tools and
* give every destination its own semantic icon without changing route order.
*/
.v2-navigation.semi-navigation {
padding: 10px 9px 12px;
}
.v2-navigation.semi-navigation .semi-navigation-item {
position: relative;
transition: background-color .16s ease, color .16s ease, box-shadow .16s ease;
}
.v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start {
margin-top: 29px;
overflow: visible;
}
.v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start::before {
position: absolute;
z-index: 1;
top: -22px;
right: 10px;
left: 12px;
overflow: hidden;
color: #9aa5b4;
content: "";
font-size: 9px;
font-weight: 700;
letter-spacing: .08em;
line-height: 16px;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start.is-workspace::before {
content: "车辆工作台";
}
.v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start.is-governance::before {
content: "平台治理";
}
.v2-navigation.semi-navigation .semi-navigation-item-icon {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
border-radius: 8px;
background: #f3f6fa;
color: #758397;
transition: background-color .16s ease, color .16s ease, box-shadow .16s ease;
}
.v2-navigation.semi-navigation .semi-navigation-item:hover {
background: linear-gradient(90deg, #f7f9fc 0%, #fbfcfe 100%);
}
.v2-navigation.semi-navigation .semi-navigation-item:hover .semi-navigation-item-icon {
background: #eaf2fd;
color: #3a6fae;
}
.v2-navigation.semi-navigation .semi-navigation-item-selected {
background: linear-gradient(90deg, #eaf3ff 0%, #f4f8ff 100%);
box-shadow: inset 3px 0 var(--v2-blue), 0 3px 10px rgba(18, 104, 243, .045);
}
.v2-navigation.semi-navigation .semi-navigation-item-selected .semi-navigation-item-icon {
background: #fff;
color: var(--v2-blue);
box-shadow: 0 2px 8px rgba(18, 104, 243, .09), inset 0 0 0 1px rgba(18, 104, 243, .08);
}
.v2-sidebar.is-collapsed .v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start {
margin-top: 2px;
}
.v2-sidebar.is-collapsed .v2-navigation.semi-navigation .semi-navigation-item.v2-nav-group-start::before {
display: none;
}
.v2-sidebar.is-collapsed .v2-navigation.semi-navigation .semi-navigation-item-icon {
width: 32px;
height: 32px;
flex-basis: 32px;
}
@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { @media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) {
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-inner { .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-inner {
height: 100dvh !important; height: 100dvh !important;