From 44201b57265c365ca4c5be0d22ba183f6520a5b0 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:19:04 +0800 Subject: [PATCH 1/7] refactor(navigation): centralize labels with existing route characterization --- frontend/src/App.test.tsx | 57 ++++++- frontend/src/App.tsx | 105 +----------- .../navigation/navigation-config.test.tsx | 36 ++++ .../features/navigation/navigation-config.tsx | 160 ++++++++++++++++++ 4 files changed, 255 insertions(+), 103 deletions(-) create mode 100644 frontend/src/features/navigation/navigation-config.test.tsx create mode 100644 frontend/src/features/navigation/navigation-config.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0d230507..1b456ef2 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { App as AntdApp } from 'antd' import { http, HttpResponse } from 'msw' @@ -26,6 +26,61 @@ describe('App authentication', () => { afterEach(() => vi.restoreAllMocks()) + it.each([true, false])( + 'preserves every existing navigation URL for admin=%s', + async (isAdmin) => { + authenticateExistingUser() + useAuthStore.setState({ user: { ...user, is_system_admin: isAdmin } }) + renderApp(`/projects/${project.id}/dashboard`) + await screen.findByRole('heading', { name: '质量指挥中心' }) + const navigation = within(document.querySelector('.sidebar') as HTMLElement) + const sections = { + dashboard: '质量总览', + settings: '项目管理', + services: '服务目录', + 'request-targets': '请求目标', + apis: '接口管理', + protocols: '多协议工作台', + assets: '测试资产', + workflows: '流程编排', + data: '数据与 Mock', + tasks: '任务执行', + performance: '性能实验室', + environments: '环境实验室', + contracts: '契约中心', + 'test-engineering': '测试工程', + contexts: '上下文检查器', + impact: '影响分析', + 'change-regression': '变更回归', + quality: '质量中心', + release: '发布门禁', + ai: 'AI 助手', + 'ai-changes': 'AI 变更集', + 'mcp-changes': 'MCP 变更集', + reports: '测试报告', + organization: '组织治理', + fabric: '分布式执行面', + platform: '平台管理', + } + const globalPaths: Record = { + organization: '/organization', + fabric: '/execution-fabric', + platform: '/platform', + } + for (const [section, label] of Object.entries(sections)) { + if (!isAdmin && ['fabric', 'platform'].includes(section)) { + expect(navigation.queryByRole('link', { name: label })).not.toBeInTheDocument() + continue + } + expect(navigation.getByRole('link', { name: label, exact: true })).toHaveAttribute( + 'href', + globalPaths[section] ?? `/projects/${project.id}/${section}`, + ) + } + expect(navigation.getAllByRole('link')).toHaveLength(isAdmin ? 26 : 24) + }, + ) + it('logs in, shows the lazy dashboard, and logs out', async () => { server.use( http.post('/api/v1/auth/refresh', () => HttpResponse.json({}, { status: 401 })), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e05f2e4a..860e722e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,5 @@ import { DraftSessionProvider } from './features/drafts/DraftSessionProvider' -import { - ApiOutlined, - AppstoreOutlined, - AuditOutlined, - ApartmentOutlined, - BarChartOutlined, - BranchesOutlined, - CodeOutlined, - CloudServerOutlined, - DashboardOutlined, - DatabaseOutlined, - FolderOpenOutlined, - FundProjectionScreenOutlined, - FileSearchOutlined, - ExperimentOutlined, - LogoutOutlined, - ScheduleOutlined, - SafetyCertificateOutlined, - ToolOutlined, - RobotOutlined, - ShareAltOutlined, - TeamOutlined, - UserOutlined, -} from '@ant-design/icons' +import { ApiOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons' import { Avatar, Breadcrumb, @@ -36,7 +13,7 @@ import { Tag, Typography, } from 'antd' -import { lazy, Suspense, useEffect, useState, type ReactNode } from 'react' +import { lazy, Suspense, useEffect, useState } from 'react' import { Link, Navigate, Route, Routes, useNavigate } from 'react-router-dom' import LoginPage from './features/auth/LoginPage' @@ -46,6 +23,7 @@ import ProjectProvider from './features/projects/ProjectProvider' import ProjectEmptyState from './features/projects/ProjectEmptyState' import { projectPath, type ProjectSection } from './features/projects/project-routing' import { useProjectContext } from './features/projects/use-project-context' +import { sectionLabels, shellNavigationItems } from './features/navigation/navigation-config' import GlobalSearch from './features/search/GlobalSearch' const DashboardPage = lazy(() => import('./pages/DashboardPage')) @@ -77,35 +55,6 @@ const OrganizationGovernancePage = lazy(() => import('./pages/OrganizationGovern const { Header, Content, Sider } = Layout -const sectionLabels: Record = { - dashboard: '质量总览', - settings: '项目管理', - services: '服务目录', - 'request-targets': '请求目标', - apis: '接口管理', - protocols: '多协议工作台', - assets: '测试资产', - workflows: '流程编排', - data: '数据与 Mock', - tasks: '任务执行', - performance: '性能实验室', - environments: '环境实验室', - contracts: '契约中心', - 'test-engineering': '测试工程', - contexts: '上下文检查器', - impact: '影响分析', - 'change-regression': '变更回归', - quality: '质量中心', - release: '发布门禁', - ai: 'AI 助手', - 'ai-changes': 'AI 变更集', - 'mcp-changes': 'MCP 变更集', - reports: '测试报告', - platform: '平台管理', - fabric: '分布式执行面', - organization: '组织治理', -} - export default function App() { const initialized = useAuthStore((state) => state.initialized) const token = useAuthStore((state) => state.token) @@ -204,50 +153,6 @@ function authenticatedUserId(user: { id: string } | null | undefined): string { return user?.id ?? 'anonymous' } -function shellNavigationItems( - isSystemAdmin: boolean, - pathFor: (section: ProjectSection) => string, -) { - const projectItems = (Object.keys(sectionLabels) as ProjectSection[]) - .filter((section) => !['organization', 'fabric', 'platform'].includes(section)) - .map((section) => navigationItem(section, navigationIcon(section), pathFor(section))) - const globalItems = [navigationItem('organization', , '/organization')] - if (isSystemAdmin) { - globalItems.push(navigationItem('fabric', , '/execution-fabric')) - globalItems.push(navigationItem('platform', , '/platform')) - } - return [...projectItems, ...globalItems] -} - -function navigationIcon(section: ProjectSection): ReactNode { - const icons: Partial> = { - dashboard: , - settings: , - services: , - 'request-targets': , - apis: , - protocols: , - assets: , - workflows: , - data: , - tasks: , - performance: , - environments: , - contracts: , - 'test-engineering': , - contexts: , - impact: , - 'change-regression': , - quality: , - release: , - ai: , - 'ai-changes': , - 'mcp-changes': , - reports: , - } - return icons[section] ?? -} - function ProjectWorkspaceTabs(props: { visible: boolean userId: string @@ -442,10 +347,6 @@ function ProjectIndexRedirect() { return } -function navigationItem(section: ProjectSection, icon: ReactNode, path: string) { - return { key: section, icon, label: {sectionLabels[section]} } -} - function breadcrumbItems(projectName: string | null, section: ProjectSection) { const items = [{ title: FlowTest }] if (projectName) items.push({ title: {projectName} }) diff --git a/frontend/src/features/navigation/navigation-config.test.tsx b/frontend/src/features/navigation/navigation-config.test.tsx new file mode 100644 index 00000000..f9ce58b3 --- /dev/null +++ b/frontend/src/features/navigation/navigation-config.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { globalPath, projectPath } from '../projects/project-routing' +import { + groupForSection, + navigationGroups, + sectionLabels, + visibleSections, +} from './navigation-config' + +describe('navigation configuration', () => { + it('maps the 26 original leaves exactly once into dashboard and six groups', () => { + const sections = ['dashboard', ...navigationGroups.flatMap((group) => group.sections)] + expect(navigationGroups).toHaveLength(6) + expect(new Set(sections).size).toBe(26) + expect([...sections].sort()).toEqual(Object.keys(sectionLabels).sort()) + expect(navigationGroups.every((group) => group.key.startsWith('nav:'))).toBe(true) + expect(groupForSection('dashboard')).toBeUndefined() + expect(groupForSection('workflows')?.label).toBe('测试设计') + }) + + it('keeps organization available to members while filtering only the two admin leaves', () => { + expect(visibleSections(true)).toHaveLength(26) + expect(visibleSections(false)).toHaveLength(24) + expect(visibleSections(false)).toContain('organization') + expect(visibleSections(false)).not.toContain('fabric') + expect(visibleSections(false)).not.toContain('platform') + }) + + it('keeps existing global redirects and project URLs for every section', () => { + for (const section of visibleSections(true)) { + expect(globalPath(section)).toBe(section === 'fabric' ? '/execution-fabric' : `/${section}`) + expect(projectPath('project-1', section)).toBe(`/projects/project-1/${section}`) + } + }) +}) diff --git a/frontend/src/features/navigation/navigation-config.tsx b/frontend/src/features/navigation/navigation-config.tsx new file mode 100644 index 00000000..791ee46a --- /dev/null +++ b/frontend/src/features/navigation/navigation-config.tsx @@ -0,0 +1,160 @@ +import { + ApiOutlined, + AppstoreOutlined, + AuditOutlined, + ApartmentOutlined, + BarChartOutlined, + BranchesOutlined, + CodeOutlined, + CloudServerOutlined, + DashboardOutlined, + DatabaseOutlined, + FolderOpenOutlined, + FundProjectionScreenOutlined, + FileSearchOutlined, + ExperimentOutlined, + ScheduleOutlined, + SafetyCertificateOutlined, + ToolOutlined, + RobotOutlined, + ShareAltOutlined, + TeamOutlined, +} from '@ant-design/icons' +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import type { ProjectSection } from '../projects/project-routing' + +export const sectionLabels: Record = { + dashboard: '质量总览', + settings: '项目管理', + services: '服务目录', + 'request-targets': '请求目标', + apis: '接口管理', + protocols: '多协议工作台', + assets: '测试资产', + workflows: '流程编排', + data: '数据与 Mock', + tasks: '任务执行', + performance: '性能实验室', + environments: '环境实验室', + contracts: '契约中心', + 'test-engineering': '测试工程', + contexts: '上下文检查器', + impact: '影响分析', + 'change-regression': '变更回归', + quality: '质量中心', + release: '发布门禁', + ai: 'AI 助手', + 'ai-changes': 'AI 变更集', + 'mcp-changes': 'MCP 变更集', + reports: '测试报告', + platform: '平台管理', + fabric: '分布式执行面', + organization: '组织治理', +} + +export function shellNavigationItems( + isSystemAdmin: boolean, + pathFor: (section: ProjectSection) => string, +) { + const projectItems = (Object.keys(sectionLabels) as ProjectSection[]) + .filter((section) => !['organization', 'fabric', 'platform'].includes(section)) + .map((section) => navigationItem(section, navigationIcon(section), pathFor(section))) + const globalItems = [navigationItem('organization', , '/organization')] + if (isSystemAdmin) { + globalItems.push(navigationItem('fabric', , '/execution-fabric')) + globalItems.push(navigationItem('platform', , '/platform')) + } + return [...projectItems, ...globalItems] +} + +function navigationIcon(section: ProjectSection): ReactNode { + const icons: Partial> = { + dashboard: , + settings: , + services: , + 'request-targets': , + apis: , + protocols: , + assets: , + workflows: , + data: , + tasks: , + performance: , + environments: , + contracts: , + 'test-engineering': , + contexts: , + impact: , + 'change-regression': , + quality: , + release: , + ai: , + 'ai-changes': , + 'mcp-changes': , + reports: , + } + return icons[section] ?? +} + +function navigationItem(section: ProjectSection, icon: ReactNode, path: string) { + return { key: section, icon, label: {sectionLabels[section]} } +} + +export const navigationGroups = [ + { + key: 'nav:project-api', + label: '项目与接口', + sections: ['apis', 'services', 'request-targets', 'protocols', 'settings'], + icon: , + }, + { + key: 'nav:test-design', + label: '测试设计', + sections: ['workflows', 'assets', 'data', 'contracts', 'test-engineering'], + icon: , + }, + { + key: 'nav:execution', + label: '执行与环境', + sections: ['tasks', 'environments', 'performance'], + icon: , + }, + { + key: 'nav:quality', + label: '质量分析', + sections: ['reports', 'impact', 'change-regression', 'quality', 'release'], + icon: , + }, + { + key: 'nav:ai', + label: 'AI 与集成', + sections: ['ai', 'contexts', 'ai-changes', 'mcp-changes'], + icon: , + }, + { + key: 'nav:system', + label: '系统管理', + sections: ['organization', 'fabric', 'platform'], + icon: , + }, +] as const satisfies readonly { + key: string + label: string + sections: readonly ProjectSection[] + icon: ReactNode +}[] + +export type NavigationGroupKey = (typeof navigationGroups)[number]['key'] + +export function groupForSection(section: ProjectSection) { + return navigationGroups.find((group) => + (group.sections as readonly ProjectSection[]).includes(section), + ) +} + +export function visibleSections(isSystemAdmin: boolean): ProjectSection[] { + return (Object.keys(sectionLabels) as ProjectSection[]).filter( + (section) => isSystemAdmin || (section !== 'fabric' && section !== 'platform'), + ) +} From 61d1bbe711327c425d0f031c07aef1cf19fffad8 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:24:30 +0800 Subject: [PATCH 2/7] fix(navigation): select project dashboard from global administration pages --- .../projects/ProjectProvider.test.tsx | 19 +++++++++++++++++++ .../src/features/projects/ProjectProvider.tsx | 14 +++++++++++--- .../src/features/projects/project-routing.ts | 10 ++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/projects/ProjectProvider.test.tsx b/frontend/src/features/projects/ProjectProvider.test.tsx index 0edc578e..2d51f25c 100644 --- a/frontend/src/features/projects/ProjectProvider.test.tsx +++ b/frontend/src/features/projects/ProjectProvider.test.tsx @@ -11,6 +11,24 @@ import ProjectProvider from './ProjectProvider' import { useProjectContext } from './use-project-context' describe('ProjectProvider', () => { + it.each(['/organization', '/platform', '/execution-fabric'])( + 'selects a project from %s without constructing a project-scoped global route', + async (route) => { + renderProvider(route) + await screen.findByText('全部项目') + await userEvent.setup().click(screen.getByRole('button', { name: '选择项目' })) + expect(screen.getByTestId('location')).toHaveTextContent(`/projects/${project.id}/dashboard`) + expect(screen.getByTestId('global-path')).toHaveTextContent('/organization') + }, + ) + + it('preserves the business section when selecting a project', async () => { + renderProvider('/reports') + await screen.findByText('全部项目') + await userEvent.setup().click(screen.getByRole('button', { name: '选择项目' })) + expect(screen.getByTestId('location')).toHaveTextContent(`/projects/${project.id}/reports`) + }) + it('selects and clears the project through the URL', async () => { const browser = userEvent.setup() renderProvider('/dashboard') @@ -65,6 +83,7 @@ function ContextProbe() { {context.projects.data?.items.map((item) => item.name).join(',')} + {context.pathFor('organization')} {context.pathFor('reports')} + + + + ) +} + +function renderWorkspace(route = '/workflows?focus=node&proposal=p#draft', admin = true) { + return render( + + + , + ) +} + +function mockViewport(width: number) { + const listeners = new Set<() => void>() + let current = width + vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ + get matches() { + return current <= Number(query.match(/max-width: (\d+)/)?.[1] ?? 0) + }, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: () => true, + addEventListener: (_event: string, listener: EventListenerOrEventListenerObject) => + listeners.add(listener as () => void), + removeEventListener: (_event: string, listener: EventListenerOrEventListenerObject) => + listeners.delete(listener as () => void), + })) + return (next: number) => + act(() => { + current = next + listeners.forEach((listener) => listener()) + }) +} + +describe('grouped shell navigation', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => vi.restoreAllMocks()) + + it('keeps URL, local draft and manually closed active group stable across ordinary renders', async () => { + const browser = userEvent.setup() + localStorage.setItem( + 'flowtest:navigation:v1:alice', + JSON.stringify({ openKeys: ['nav:system'] }), + ) + renderWorkspace() + const group = screen.getByRole('menuitem', { name: '测试设计' }) + expect(group).toHaveAttribute('aria-expanded', 'true') + await browser.click(group) + await browser.click(screen.getByRole('button', { name: '重新渲染' })) + expect(group).toHaveAttribute('aria-expanded', 'false') + await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) + expect(screen.getByTestId('location')).toHaveTextContent( + '/workflows?focus=node&proposal=p#draft', + ) + expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + await browser.click(screen.getByRole('button', { name: '展开侧栏' })) + expect(group).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') + }) + + it('navigates via row and collapsed dashboard icon activation without a text Link', async () => { + const browser = userEvent.setup() + renderWorkspace() + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + await browser.click(screen.getByRole('menuitem', { name: '质量总览' })) + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + }) + + it('opens the matching parent on Tab navigation and browser history, and uses an accordion', async () => { + const browser = userEvent.setup() + renderWorkspace() + await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) + expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await browser.click(screen.getByRole('button', { name: '打开报告 Tab' })) + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + await browser.click(screen.getByRole('button', { name: '后退' })) + expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + }) + + it('defaults the dashboard to closed parents and filters admin leaves after role changes', async () => { + const browser = userEvent.setup() + const view = renderWorkspace('/dashboard') + await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) + await waitFor(() => expect(screen.getByRole('link', { name: '平台管理' })).toBeVisible()) + view.rerender( + + + , + ) + await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) + await waitFor(() => expect(screen.getByRole('link', { name: '组织治理' })).toBeVisible()) + expect(screen.queryByRole('link', { name: '平台管理' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: '分布式执行面' })).not.toBeInTheDocument() + }) + + it('defaults compact screens to collapsed without overwriting the explicit desktop preference', async () => { + const resize = mockViewport(1100) + const browser = userEvent.setup() + renderWorkspace('/dashboard') + expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() + expect(localStorage.getItem('flowtest:navigation:v1:alice')).toBeNull() + resize(1440) + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + resize(1100) + resize(1920) + expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() + expect(JSON.parse(localStorage.getItem('flowtest:navigation:v1:alice')!).collapsed).toBe(true) + }) + + it('uses a narrow-screen drawer, closes after choosing a leaf and restores trigger focus', async () => { + mockViewport(390) + const browser = userEvent.setup() + renderWorkspace('/dashboard') + const trigger = screen.getByRole('button', { name: '打开导航' }) + await browser.click(trigger) + const drawer = await screen.findByRole('dialog') + await browser.click(within(drawer).getByRole('menuitem', { name: '测试设计' })) + await browser.click(within(drawer).getByRole('link', { name: '流程编排' })) + expect(screen.getByTestId('location')).toHaveTextContent('/workflows') + await waitFor(() => expect(trigger).toHaveFocus()) + await browser.click(trigger) + fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Escape', keyCode: 27 }) + await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false')) + expect(localStorage.getItem('flowtest:navigation:v1:alice')).toEqual(expect.any(String)) + }) +}) diff --git a/frontend/src/features/navigation/ShellSidebar.tsx b/frontend/src/features/navigation/ShellSidebar.tsx new file mode 100644 index 00000000..ea19516f --- /dev/null +++ b/frontend/src/features/navigation/ShellSidebar.tsx @@ -0,0 +1,106 @@ +import { ApiOutlined, MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined } from '@ant-design/icons' +import { Button, ConfigProvider, Drawer, Layout, Menu, type MenuProps } from 'antd' +import { useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +import type { ProjectSection } from '../projects/project-routing' +import { navigationPath, shellNavigationItems, visibleSections } from './navigation-config' +import { useNavigationState } from './use-navigation-state' + +interface ShellSidebarProps { + userId: string + isSystemAdmin: boolean + section: ProjectSection + pathFor: (section: ProjectSection) => string +} + +export default function ShellSidebar({ + userId, + isSystemAdmin, + section, + pathFor, +}: ShellSidebarProps) { + const state = useNavigationState(userId, section, isSystemAdmin) + const navigate = useNavigate() + const handleLeafClick: NonNullable = ({ key, domEvent }) => { + if (state.mobile) state.setDrawerOpen(false) + const target = visibleSections(isSystemAdmin).find((item) => item === key) + const clickedLink = domEvent.target instanceof Element && domEvent.target.closest('a') + // Links keep native browser behavior; row/icon/keyboard activation uses the same router guard. + if (target && !clickedLink) navigate(navigationPath(target, pathFor)) + } + const drawerTrigger = useRef(null) + const navigation = ( +
+
+ + {!state.collapsed && FlowTest} +
+ +
+ +
+
+ ) + if (state.mobile) { + return ( + <> +