Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/demo/role-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 项)

Expand Down
12 changes: 12 additions & 0 deletions e2e/flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
36 changes: 31 additions & 5 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<AppInitialState | undefined> {
Expand Down Expand Up @@ -156,20 +160,42 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
actionsRender: () => [<RightContent key="right-content" />],
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 ? (
<span
onClick={(event) => {
if (window.matchMedia('(max-width: 768px)').matches) return;
event.preventDefault();
event.stopPropagation();
history.push(firstAccessibleLeaf(item as NavRoute) ?? item.path!);
}}
>
{defaultDom}
</span>
) : (
defaultDom
),
// 主题与 antd 运行时算法同源(localStorage),避免首帧闪烁
navTheme: themeMode === 'realDark' ? 'realDark' : 'light',
splitMenus: true,
fixSiderbar: true,
fixHeader: true,
unAccessible: <AccessFallback />,
footerRender: () => <BuildFooter />,
childrenRender: (children) => (
<ErrorBoundary>
<MessageBridge />
<AuthGuard>
{children}
<GroupNav />
<PageTransition>{children}</PageTransition>
<GuideTour />
<CommandPalette />
</AuthGuard>
</ErrorBoundary>
),
Expand Down
46 changes: 46 additions & 0 deletions src/components/CommandPalette/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => (
<input {...rest} />
),
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(<CommandPalette />);
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);
});
});
97 changes: 97 additions & 0 deletions src/components/CommandPalette/index.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading