From 879d66d77653aa5fbc235b74d200216db1ec6a54 Mon Sep 17 00:00:00 2001 From: Parker Date: Mon, 19 Oct 2026 14:22:36 +0800 Subject: [PATCH] refactor(layout): top nav shell with group tabs and command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace the mix layout with layout: 'top' carrying only the eight first-level groups; the second level moves into GroupNav, a sticky horizontal tab row on top of the content area (longest-prefix selection, access filtering, horizontal scroll instead of «···») - command palette (Cmd/Ctrl+K) over every accessible leaf page, traversing umi's unnamed layout wrappers via routeNav; combobox semantics, IME-safe Enter, hideInMenu and wildcard routes excluded - clicking a group title enters the first page the role can access (firstAccessibleLeaf) instead of the static redirect that sent no-permission roles to a 403; phones keep the drawer's native expand - DemoPage titles from the matched route chain; 150 ms page fade under prefers-reduced-motion; scroll reset on tab/palette navigation - tests: routeNav (unnamed wrappers, longest prefix), GroupNav access filtering and null cases, palette filter; e2e tab-row smoke --- docs/demo/role-matrix.md | 2 +- e2e/flows.spec.ts | 12 ++ src/app.tsx | 36 +++- src/components/CommandPalette/a11y.test.tsx | 46 +++++ src/components/CommandPalette/index.test.ts | 97 ++++++++++ src/components/CommandPalette/index.tsx | 191 ++++++++++++++++++++ src/components/DemoPage/index.tsx | 60 ++++-- src/components/GroupNav/index.test.tsx | 117 ++++++++++++ src/components/GroupNav/index.tsx | 101 +++++++++++ src/components/GuideTour/index.tsx | 10 + src/components/PageTransition/index.tsx | 15 ++ src/layouts/RightContent.tsx | 12 ++ src/locales/en-US/framework.ts | 9 +- src/locales/en-US/pages.ts | 16 +- src/locales/zh-CN/framework.ts | 8 +- src/locales/zh-CN/pages.ts | 18 +- src/utils/routeNav.test.ts | 63 +++++++ src/utils/routeNav.ts | 77 ++++++++ tailwind.css | 38 ++++ 19 files changed, 894 insertions(+), 34 deletions(-) create mode 100644 src/components/CommandPalette/a11y.test.tsx create mode 100644 src/components/CommandPalette/index.test.ts create mode 100644 src/components/CommandPalette/index.tsx create mode 100644 src/components/GroupNav/index.test.tsx create mode 100644 src/components/GroupNav/index.tsx create mode 100644 src/components/PageTransition/index.tsx create mode 100644 src/utils/routeNav.test.ts create mode 100644 src/utils/routeNav.ts diff --git a/docs/demo/role-matrix.md b/docs/demo/role-matrix.md index bc1962c2..b25ac1cb 100644 --- a/docs/demo/role-matrix.md +++ b/docs/demo/role-matrix.md @@ -94,7 +94,7 @@ ### operator 运维工程师(7 项) - 能:工作台;设备/告警/工单查看;精确设备坐标(`ops.device.coordinate.view`);告警处置(`ops.alarm.handle`);工单开工与完成(`ops.ticket.close`)。 -- 不能:派工 / 改派 / 退回 / 返工 / 验收签字(`ops.ticket.assign`、`ops.ticket.accept`);日程管理;报表导出;全部系统管理页面(左侧菜单不出现「系统管理」分组)。 +- 不能:派工 / 改派 / 退回 / 返工 / 验收签字(`ops.ticket.assign`、`ops.ticket.accept`);日程管理;报表导出;全部系统管理页面(顶部菜单不出现「系统管理」分组)。 ### analyst 数据分析员(5 项) diff --git a/e2e/flows.spec.ts b/e2e/flows.spec.ts index f08cc07b..4d7a00e2 100644 --- a/e2e/flows.spec.ts +++ b/e2e/flows.spec.ts @@ -15,6 +15,18 @@ test('旧版路径重定向到新地址', async ({ page }) => { await expect(page).toHaveURL(/\/map\/cesium\/heatmap$/); }); +test('组内导航条切换二级页面(顶栏一级 + 内容区页签二级) @smoke', async ({ page }) => { + await signIn(page); + await navigate(page, '/system/users'); + const nav = page.locator('[data-tour="groupnav"]'); + await expect(nav).toBeVisible(); + // 当前页在导航条上高亮 + await expect(nav.locator('.ant-menu-item-selected')).toContainText(/用户管理|Users/); + // 点击页签切换到操作日志(不再依赖左侧菜单) + await nav.getByRole('menuitem', { name: /操作日志|Audit log/ }).click(); + await expect(page).toHaveURL(/\/system\/logs$/); +}); + test('访客访问系统管理得到 403,权限演示页显示服务端粗化后的坐标', async ({ page }) => { await signIn(page, { user: 'guest' }); await navigate(page, '/system/users'); diff --git a/src/app.tsx b/src/app.tsx index 16a1f8d4..46caaedd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,8 +3,11 @@ import 'antd/dist/reset.css'; import BuildFooter from '@/components/BuildFooter'; +import CommandPalette from '@/components/CommandPalette'; import ErrorBoundary from '@/components/ErrorBoundary'; +import GroupNav from '@/components/GroupNav'; import GuideTour from '@/components/GuideTour'; +import PageTransition from '@/components/PageTransition'; import RightContent from '@/layouts/RightContent'; import Forbidden from '@/pages/Exception/403'; import { a11yThemeTokens } from '@/styles/themeTokens'; @@ -18,13 +21,14 @@ import { ThunderboltOutlined, } from '@ant-design/icons'; import type { RequestConfig, RuntimeAntdConfig, RunTimeLayoutConfig } from '@umijs/max'; -import { getIntl, getLocale, matchRoutes, Navigate, useLocation, useModel } from '@umijs/max'; +import { getIntl, getLocale, history, matchRoutes, Navigate, useLocation, useModel } from '@umijs/max'; import { App as AntdApp, theme as antdTheme } from 'antd'; import { useEffect } from 'react'; import { getInitialState as libGetInitialState, readTheme, type AppInitialState } from './utils/Auth/initialState'; import { loginPath } from './utils/Auth/loginUrl'; import { APP_TITLE, menuKeyOf, pageTitle } from './utils/pageTitle'; import { requestConfig } from './utils/requestConfig'; +import { firstAccessibleLeaf, type NavRoute } from './utils/routeNav'; // 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate export async function getInitialState(): Promise { @@ -156,11 +160,31 @@ export const layout: RunTimeLayoutConfig = (initialState) => { actionsRender: () => [], menuHeaderRender: undefined, appList: appList(), - layout: 'mix', + // 顶栏只承载一级分组,二级由内容区顶部的 GroupNav 承载,内容区回收原侧栏宽度; + // 分组带子项(hover/点击弹层直达任意二级页) + layout: 'top', + // 点分组标题(顶栏一级、下拉里的 Cesium 分组)= 进入该组第一个可见且可访问的子页 + // (firstAccessibleLeaf;分组路径自身的 redirect 是静态的,不看权限,直接 push 会让无权角色落进 403)。不走 postMenuData/onTitleClick——pro-components 3.1.14 的内部构建把 + // BaseMenu 的该属性名混淆成了 n,外部注入不生效;subMenuItemRender 是明文名且经 + // ProLayout→Header→TopNavHeader→BaseMenu 全程 {...props} 透传(实测可达)。 + // 移动端抽屉里点组名应展开子菜单而非跳转,按视口放行(桌面弹层 hover 触发,点标题不弹) + subMenuItemRender: (item, defaultDom) => + item.path ? ( + { + if (window.matchMedia('(max-width: 768px)').matches) return; + event.preventDefault(); + event.stopPropagation(); + history.push(firstAccessibleLeaf(item as NavRoute) ?? item.path!); + }} + > + {defaultDom} + + ) : ( + defaultDom + ), // 主题与 antd 运行时算法同源(localStorage),避免首帧闪烁 navTheme: themeMode === 'realDark' ? 'realDark' : 'light', - splitMenus: true, - fixSiderbar: true, fixHeader: true, unAccessible: , footerRender: () => , @@ -168,8 +192,10 @@ export const layout: RunTimeLayoutConfig = (initialState) => { - {children} + + {children} + ), diff --git a/src/components/CommandPalette/a11y.test.tsx b/src/components/CommandPalette/a11y.test.tsx new file mode 100644 index 00000000..d59c9849 --- /dev/null +++ b/src/components/CommandPalette/a11y.test.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@umijs/max', () => ({ + history: { push: vi.fn() }, + useAccess: () => ({ systemUserView: true }), + useAppData: () => ({ + clientRoutes: [ + { name: 'dashboard', path: '/dashboard' }, + { name: 'system', path: '/system', routes: [{ name: 'users', path: '/system/users', access: 'systemUserView' }] }, + ], + }), + useIntl: () => ({ formatMessage: ({ id }: { id: string }) => id }), +})); +// Modal 直接渲染内容;Input 渲染成原生 input,只透传 DOM 属性(allowClear/variant/prefix 是 antd 专有) +vi.mock('antd', () => ({ + Modal: ({ children }: { children: unknown }) => children, + Input: ({ allowClear: _a, variant: _v, prefix: _p, ...rest }: ComponentProps<'input'> & Record) => ( + + ), + theme: { useToken: () => ({ token: {} }) }, +})); + +import CommandPalette from './index'; + +const attr = (html: string, pattern: RegExp) => pattern.exec(html)?.[1]; + +describe('CommandPalette combobox semantics', () => { + it('输入框是 combobox:aria-controls 指向 listbox,aria-activedescendant 指向当前活动项', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('role="combobox"'); + expect(html).toContain('aria-autocomplete="list"'); + expect(html).toContain('aria-expanded="true"'); + + const listId = attr(html, /aria-controls="([^"]+)"/); + expect(listId).toBeTruthy(); + expect(html).toContain(`id="${listId}" role="listbox"`); + + const activeId = attr(html, /aria-activedescendant="([^"]+)"/); + expect(activeId).toBeTruthy(); + // 活动项(默认第一项)就是带该 id、且 aria-selected 的 option + expect(html).toMatch(new RegExp(`id="${activeId}" role="option" aria-selected="true"`)); + expect(html.match(/role="option"/g)).toHaveLength(2); + }); +}); diff --git a/src/components/CommandPalette/index.test.ts b/src/components/CommandPalette/index.test.ts new file mode 100644 index 00000000..f1cfa10c --- /dev/null +++ b/src/components/CommandPalette/index.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { NavRoute } from '@/utils/routeNav'; + +vi.mock('@umijs/max', () => ({ + history: { push: vi.fn() }, + useAccess: () => ({}), + useAppData: () => ({}), + useIntl: () => ({ formatMessage: ({ id }: { id: string }) => id }), +})); +vi.mock('antd', () => ({ Input: () => null, Modal: () => null, theme: { useToken: () => ({ token: {} }) } })); + +import { filterEntries, flattenNavEntries, type NavEntry } from './index'; + +const routes: NavRoute[] = [ + { name: 'dashboard', path: '/dashboard' }, + // umi clientRoutes 的布局包裹层没有 name,其子树必须继续遍历(system 组挂在这里验证) + { + routes: [ + { + name: 'system', + path: '/system', + routes: [ + { path: '/system', redirect: '/system/users' }, + { name: 'users', path: '/system/users', access: 'systemUserView' }, + { name: 'roles', path: '/system/roles' }, + ], + }, + ], + }, + { + name: 'map', + path: '/map', + routes: [ + { name: 'amap', path: '/map/amap' }, + { + name: 'cesium', + path: '/map/cesium', + routes: [ + { path: '/map/cesium', redirect: '/map/cesium/geohash' }, + { name: 'geohash', path: '/map/cesium/geohash' }, + { name: 'situation', path: '/map/cesium/situation' }, + ], + }, + ], + }, +]; + +const labelOf = (entry: NavEntry) => entry.keyChain.join('/'); + +describe('flattenNavEntries', () => { + it('只取叶子页:分组与场景壳不进面板,重定向占位被跳过', () => { + const entries = flattenNavEntries(routes); + expect(entries.map((entry) => entry.path)).toEqual([ + '/dashboard', + '/system/users', + '/system/roles', + '/map/amap', + '/map/cesium/geohash', + '/map/cesium/situation', + ]); + }); + + it('keyChain 携带完整 menu.* 键链,access 透传', () => { + const entries = flattenNavEntries(routes); + expect(entries.find((entry) => entry.path === '/map/cesium/geohash')?.keyChain).toEqual([ + 'menu.map', + 'menu.map.cesium', + 'menu.map.cesium.geohash', + ]); + expect(entries.find((entry) => entry.path === '/system/users')?.access).toBe('systemUserView'); + }); + + it('与菜单同口径排除 hideInMenu 与通配路径(登录页、* 兜底 404 不是可直达页面)', () => { + const entries = flattenNavEntries([ + { name: 'login', path: '/login', hideInMenu: true }, + ...routes, + { name: 'notFound', path: '*', hideInMenu: true }, + { name: 'detail', path: '/ops/devices/:id' }, + ]); + expect(entries.map((entry) => entry.path)).not.toEqual(expect.arrayContaining(['/login'])); + expect(entries.map((entry) => entry.path)).toHaveLength(6); + }); +}); + +describe('filterEntries', () => { + it('空查询返回全部;按文案与路径大小写不敏感匹配', () => { + const entries = flattenNavEntries(routes); + expect(filterEntries(entries, '', labelOf)).toHaveLength(6); + expect(filterEntries(entries, 'CESIUM', labelOf).map((entry) => entry.path)).toEqual([ + '/map/cesium/geohash', + '/map/cesium/situation', + ]); + expect(filterEntries(entries, '/system/roles', labelOf).map((entry) => entry.path)).toEqual(['/system/roles']); + expect(filterEntries(entries, '不存在', labelOf)).toEqual([]); + }); +}); diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx new file mode 100644 index 00000000..5f3e55b9 --- /dev/null +++ b/src/components/CommandPalette/index.tsx @@ -0,0 +1,191 @@ +import { canAccessRoute, type NavRoute } from '@/utils/routeNav'; +import { SearchOutlined } from '@ant-design/icons'; +import { history, useAccess, useAppData, useIntl } from '@umijs/max'; +import { Input, Modal, theme } from 'antd'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; + +/** 头像区搜索按钮派发这个事件打开面板(与 GuideTour 的 OPEN_TOUR_EVENT 同一模式) */ +export const OPEN_COMMAND_PALETTE_EVENT = 'umi-react-admin:open-command-palette'; + +export type NavEntry = { path: string; keyChain: string[]; access?: string }; + +/** 展平命名路由树为可搜索条目:只取叶子页(有名字、无命名子路由,含 dashboard 这类无分组单页)。 + * 与菜单同口径排除 hideInMenu(登录页、`*` 兜底 404)及带通配/参数的路径(push 过去没有意义) */ +export const flattenNavEntries = (routes: NavRoute[]): NavEntry[] => { + const walk = (list: NavRoute[], names: string[]): NavEntry[] => + list.flatMap((route) => { + const children = route.routes ?? []; + if (route.hideInMenu || /[*:]/.test(route.path ?? '')) return []; + // 无名路由(umi 的布局包裹层)自身不进面板,但它的子树要继续走 + if (!route.name || !route.path) return walk(children, names); + const next = [...names, route.name]; + // 文案键与菜单/页面标题同一套规则:menu.<父级>.<名>(见 src/utils/pageTitle.ts) + const keyChain = next.map((_, index) => `menu.${next.slice(0, index + 1).join('.')}`); + return children.some((child) => child.name) + ? walk(children, next) + : [{ path: route.path, keyChain, access: route.access }]; + }); + return walk(routes, []); +}; + +/** 过滤:按当前语言文案与路径做小写包含匹配;空查询返回全部 */ +export const filterEntries = (entries: NavEntry[], query: string, labelOf: (entry: NavEntry) => string) => { + const q = query.trim().toLowerCase(); + if (!q) return entries; + return entries.filter((entry) => labelOf(entry).toLowerCase().includes(q) || entry.path.toLowerCase().includes(q)); +}; + +const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform); + +/** + * ⌘K / Ctrl+K 命令面板:全站页面按名直达,弥补宽信息架构下「目标页在哪个分组」的记忆成本, + * 也是窄屏页签横滚之外的补救路径。键盘优先(↑↓ 选、Enter 开、Esc 关),权限过滤与菜单同口径。 + */ +export default function CommandPalette() { + const { clientRoutes } = useAppData(); + const access = useAccess() as Record; + const intl = useIntl(); + const { token } = theme.useToken(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const listRef = useRef(null); + // combobox 语义:焦点始终留在输入框,活动项经 aria-activedescendant 告诉读屏器(listbox 本身不获焦) + const baseId = useId(); + const listId = `${baseId}-list`; + const optionId = (index: number) => `${baseId}-option-${index}`; + + const entries = useMemo( + () => + // 与菜单、GroupNav 同口径(canAccessRoute):access 键为 false 或缺失的页面不进面板 + flattenNavEntries(clientRoutes as unknown as NavRoute[]).filter((entry) => canAccessRoute(access, entry)), + [clientRoutes, access], + ); + + const labelOf = (entry: NavEntry) => entry.keyChain.map((key) => intl.formatMessage({ id: key })).join(' / '); + const visible = useMemo( + () => + filterEntries(entries, query, (entry) => + entry.keyChain.map((key) => intl.formatMessage({ id: key })).join(' / '), + ).slice(0, 15), + [entries, query, intl], + ); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setOpen((value) => !value); + } + }; + const onOpen = () => setOpen(true); + window.addEventListener('keydown', onKey); + window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen); + return () => { + window.removeEventListener('keydown', onKey); + window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen); + }; + }, []); + + useEffect(() => { + setActive(0); + }, [query, open]); + + // 键盘活动项滚回视野 + useEffect(() => { + listRef.current?.querySelectorAll('[role="option"]')[active]?.scrollIntoView({ block: 'nearest' }); + }, [active]); + + const choose = (path: string) => { + setOpen(false); + setQuery(''); + history.push(path); + window.scrollTo(0, 0); + }; + + return ( + setOpen(false)} + footer={null} + closable={false} + width={520} + centered + styles={{ body: { padding: 0, overflow: 'hidden' } }} + > + } + placeholder={intl.formatMessage({ id: 'palette.placeholder' })} + aria-label={intl.formatMessage({ id: 'palette.placeholder' })} + role="combobox" + aria-autocomplete="list" + aria-expanded={visible.length > 0} + aria-controls={listId} + aria-activedescendant={visible[active] ? optionId(active) : undefined} + value={query} + onChange={({ target }) => setQuery(target.value)} + onKeyDown={(event) => { + const { key, nativeEvent } = event; + // 输入法组字中(拼音选词/上屏的 Enter、方向键翻候选)交给输入法;Safari 组字结束的那次 + // keydown 已不带 isComposing,只能靠 keyCode 229 识别 + if (nativeEvent.isComposing || nativeEvent.keyCode === 229) return; + if (key === 'ArrowDown') { + event.preventDefault(); // 不让光标跳到行尾 + setActive((value) => Math.min(value + 1, visible.length - 1)); + } else if (key === 'ArrowUp') { + event.preventDefault(); + setActive((value) => Math.max(value - 1, 0)); + } else if (key === 'Enter' && visible[active]) { + choose(visible[active].path); + } + }} + style={{ paddingBlock: 12, paddingInline: 16, fontSize: 15 }} + /> +
+ {visible.map((entry, index) => ( +
setActive(index)} + onClick={() => choose(entry.path)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingInline: 16, + paddingBlock: 8, + cursor: 'pointer', + backgroundColor: index === active ? token.colorFillSecondary : undefined, + }} + > + {labelOf(entry)} + {entry.path} +
+ ))} +
+ {/* 空态放在 listbox 之外(listbox 只能含 option),以 status 播报给读屏器 */} + {visible.length === 0 && ( +
+ {intl.formatMessage({ id: 'palette.empty' })} +
+ )} +
+ {intl.formatMessage({ id: 'palette.hint' }, { mod: isMac() ? '⌘K' : 'Ctrl+K' })} +
+
+ ); +} diff --git a/src/components/DemoPage/index.tsx b/src/components/DemoPage/index.tsx index 224404a2..69c0a45d 100644 --- a/src/components/DemoPage/index.tsx +++ b/src/components/DemoPage/index.tsx @@ -4,6 +4,8 @@ import { useIntl } from '@umijs/max'; import { Button, Space, Typography } from 'antd'; import { repoBlob } from '@/constants/repo'; +import { menuKeyOf } from '@/utils/pageTitle'; +import { groupOf, useNamedChain } from '@/utils/routeNav'; type DemoPageProps = Omit & { /** 页面说明的 i18n 键(显示在标题下方) */ @@ -13,11 +15,22 @@ type DemoPageProps = Omit & { }; /** - * 演示页统一外壳:标题与面包屑取自路由菜单,下方一句话说明,右上角「查看源码」直达仓库对应文件。 + * 演示页统一外壳。两种形态: + * - 组内页(绝大多数):GroupNav 选中页签已标明页面名,头部降级为紧凑说明行 + * (14px secondary 描述 + 右侧 extra),避免「页签 + 大标题」双重强调,页面头 ~80px → ~36px; + * - 无分组单页(dashboard):保留完整标题 + 说明 + extra 的页头。 + * 标题由路由链自算(menu.<父级>.<名>,与 document.title 同源)——二级菜单藏进 GroupNav 后 + * ProLayout 的菜单上下文不再含当前页;面包屑同理关闭(组内页签已表达分组语境)。 * 各页不再各自包 ProCard + shadow-2xl,间距与层级统一由 PageContainer 承担。 */ export default function DemoPage({ descriptionId, source, extra, children, ...rest }: DemoPageProps) { const intl = useIntl(); + const chain = useNamedChain(); + const key = menuKeyOf(chain); + const title = key && intl.messages[key] ? intl.formatMessage({ id: key }) : undefined; + const inGroup = !!groupOf(chain); + + const description = descriptionId ? intl.formatMessage({ id: descriptionId }) : undefined; const sourceButton = source ? (