diff --git a/frontend/src/layouts/AppLayout/AppLayout.module.css b/frontend/src/layouts/AppLayout/AppLayout.module.css new file mode 100644 index 0000000..18d56a9 --- /dev/null +++ b/frontend/src/layouts/AppLayout/AppLayout.module.css @@ -0,0 +1,208 @@ +/* REQ-USR-003: 应用外壳 scoped 样式。语义色用 var(--color-*); + * 顶栏 #1f1f23 / overlay #2b3137 等品牌深色底为外壳局部装饰,scoped 保留(D9)。 */ + +/* ===== app shell ===== */ +.app { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.stage { + flex: 1; + position: relative; + overflow: auto; + background: var(--color-bg-base); +} + +/* ===== TOP BAR(深色装饰 scoped) ===== */ +.topbar { + display: flex; + align-items: stretch; + height: 44px; + background: #1f1f23; /* 外壳局部装饰底色(D9:非语义 token,scoped 保留) */ + color: #ffffff; + position: relative; + z-index: 30; + flex-shrink: 0; +} +.logo { + width: 54px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: #0e1216; + cursor: pointer; +} +.logo svg { + width: 30px; + height: 30px; +} +.navBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 0 18px; + color: #fff; + cursor: pointer; + font-size: 14px; + border: none; + background: transparent; + height: 100%; +} +.navBtn:hover { + background: #33363d; +} +.navBtnActive { + background: var(--color-primary); +} +.tabs { + display: flex; + align-items: stretch; + flex: 1; + min-width: 0; +} +.tab { + display: flex; + align-items: center; + gap: 8px; + padding: 0 18px; + cursor: pointer; + color: #cfd2d8; + font-size: 14px; + height: 100%; + border: none; + background: transparent; +} +.tabActive { + color: var(--color-primary); +} +.tabClose { + margin-left: 6px; + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: #9aa0a8; + border: none; + background: transparent; + cursor: pointer; +} +.tabClose:hover { + background: #3a3d44; + color: #fff; +} +.right { + display: flex; + align-items: center; + gap: 18px; + padding-right: 14px; +} +.rightIcon { + width: 18px; + height: 18px; + opacity: 0.9; + cursor: pointer; + display: inline-flex; + align-items: center; +} +.user { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + cursor: pointer; + color: #fff; + border: none; + background: transparent; +} +.more { + font-size: 18px; + letter-spacing: 2px; + cursor: pointer; + padding: 0 4px; +} + +/* ===== NAV OVERLAY(深色装饰 scoped,D9) ===== */ +.navOverlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + color: #cfd3da; +} +.navOverlayMask { + position: absolute; + inset: 0; + background: transparent; +} +.navOverlayBody { + position: relative; + display: flex; + flex: 1; + background: #2b3137; /* overlay 深色底(scoped 装饰) */ +} +.navSide { + width: 200px; + background: #2b3137; + padding: 8px 0; + border-right: 1px solid #1e2226; + overflow: auto; +} +.navSideItem { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 18px; + font-size: 14px; + color: #d3d6db; + cursor: pointer; +} +.navSideItem:hover { + background: #34393f; +} +.navSideItemActive { + color: var(--color-primary); + background: #34393f; +} +.navGrid { + flex: 1; + padding: 30px 40px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 30px 40px; + align-content: start; + overflow: auto; +} +.navColTitle { + font-size: 15px; + color: #e8eaee; + font-weight: 500; + margin: 0 0 18px; + border-bottom: 1px solid #4a4f57; + padding-bottom: 10px; +} +.navLeaf { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 0; + color: #cfd3da; + font-size: 14px; + cursor: pointer; + border: none; + background: transparent; + text-align: left; + width: 100%; +} +.navLeaf:hover { + color: #fff; +} +.navStar { + color: var(--color-warning); +} diff --git a/frontend/src/layouts/AppLayout/NavOverlay.tsx b/frontend/src/layouts/AppLayout/NavOverlay.tsx new file mode 100644 index 0000000..3e8dbb0 --- /dev/null +++ b/frontend/src/layouts/AppLayout/NavOverlay.tsx @@ -0,0 +1,80 @@ +// REQ-USR-003: 全部导航总览浮层(BR7/BR8/D4)。受控 open;左 NAV_SIDE 列 + 右 NAV_COLS 网格。 +// 叶子据 routePath 分流:有 → onNavigate;无 → onPlaceholder(占位「功能开发中」由调用方提示)。 +import { useEffect } from 'react'; +import { NAV_SIDE, NAV_COLS } from './navConfig'; +import type { NavLeaf } from './navConfig'; +import styles from './AppLayout.module.css'; + +interface NavOverlayProps { + open: boolean; + onClose: () => void; + onNavigate: (routePath: string) => void; + onPlaceholder: () => void; +} + +export default function NavOverlay({ open, onClose, onNavigate, onPlaceholder }: NavOverlayProps) { + // Esc 关闭 + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + const handleLeaf = (leaf: NavLeaf) => { + if (leaf.routePath) onNavigate(leaf.routePath); + else onPlaceholder(); + }; + + return ( +
+ {/* 遮罩:点击空白处关闭(仅自身触发,不冒泡子项) */} + + ); +} diff --git a/frontend/tests/unit/NavOverlay.test.tsx b/frontend/tests/unit/NavOverlay.test.tsx new file mode 100644 index 0000000..9ca6b71 --- /dev/null +++ b/frontend/tests/unit/NavOverlay.test.tsx @@ -0,0 +1,74 @@ +// REQ-USR-003: NavOverlay 全部导航总览(开关 / 分组渲染 / 路由项与占位项,BR7/BR8/D4) +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfigProvider, App as AntdApp } from 'antd'; +import NavOverlay from '../../src/layouts/AppLayout/NavOverlay'; + +function renderOverlay(props: Partial> = {}) { + const onClose = props.onClose ?? vi.fn(); + const onNavigate = props.onNavigate ?? vi.fn(); + const onPlaceholder = props.onPlaceholder ?? vi.fn(); + const open = props.open ?? true; + render( + + + + + , + ); + return { onClose, onNavigate, onPlaceholder }; +} + +describe('NavOverlay', () => { + it('hidden when open is false / visible when true', () => { + const { unmount } = render( + + + + + , + ); + expect(screen.queryByText('期初设置')).not.toBeInTheDocument(); + unmount(); + + renderOverlay({ open: true }); + expect(screen.getByText('期初设置')).toBeInTheDocument(); + expect(screen.getByText('API对接管理')).toBeInTheDocument(); + }); + + it('side has 系统设置 active', () => { + renderOverlay({ open: true }); + const side = screen.getByTestId('nav-side'); + const sys = within(side).getByText('系统设置'); + expect(sys.closest('[aria-current]')?.getAttribute('aria-current')).toBe('true'); + }); + + it("clicking 用户列表 calls onNavigate('/usr/users')", async () => { + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); + expect(onNavigate).toHaveBeenCalledWith('/usr/users'); + expect(onPlaceholder).not.toHaveBeenCalled(); + }); + + it('clicking placeholder leaf calls onPlaceholder (no navigate)', async () => { + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); + await userEvent.click(screen.getByRole('button', { name: '系统权限' })); + expect(onPlaceholder).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it('Esc / mask click calls onClose', async () => { + const { onClose } = renderOverlay({ open: true }); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalled(); + + await userEvent.click(screen.getByTestId('nav-overlay-mask')); + expect(onClose).toHaveBeenCalledTimes(2); + }); +});