Commit 0b4bac68ef1e301a74bc316709ab34e82f2e55db
1 parent
bab42193
feat(fe-shell): 全部导航总览浮层 NavOverlay REQ-USR-003
Showing
3 changed files
with
362 additions
and
0 deletions
frontend/src/layouts/AppLayout/AppLayout.module.css
0 → 100644
| 1 | +/* REQ-USR-003: 应用外壳 scoped 样式。语义色用 var(--color-*); | |
| 2 | + * 顶栏 #1f1f23 / overlay #2b3137 等品牌深色底为外壳局部装饰,scoped 保留(D9)。 */ | |
| 3 | + | |
| 4 | +/* ===== app shell ===== */ | |
| 5 | +.app { | |
| 6 | + height: 100vh; | |
| 7 | + display: flex; | |
| 8 | + flex-direction: column; | |
| 9 | + overflow: hidden; | |
| 10 | +} | |
| 11 | +.stage { | |
| 12 | + flex: 1; | |
| 13 | + position: relative; | |
| 14 | + overflow: auto; | |
| 15 | + background: var(--color-bg-base); | |
| 16 | +} | |
| 17 | + | |
| 18 | +/* ===== TOP BAR(深色装饰 scoped) ===== */ | |
| 19 | +.topbar { | |
| 20 | + display: flex; | |
| 21 | + align-items: stretch; | |
| 22 | + height: 44px; | |
| 23 | + background: #1f1f23; /* 外壳局部装饰底色(D9:非语义 token,scoped 保留) */ | |
| 24 | + color: #ffffff; | |
| 25 | + position: relative; | |
| 26 | + z-index: 30; | |
| 27 | + flex-shrink: 0; | |
| 28 | +} | |
| 29 | +.logo { | |
| 30 | + width: 54px; | |
| 31 | + display: flex; | |
| 32 | + align-items: center; | |
| 33 | + justify-content: center; | |
| 34 | + border: none; | |
| 35 | + background: transparent; | |
| 36 | + color: #0e1216; | |
| 37 | + cursor: pointer; | |
| 38 | +} | |
| 39 | +.logo svg { | |
| 40 | + width: 30px; | |
| 41 | + height: 30px; | |
| 42 | +} | |
| 43 | +.navBtn { | |
| 44 | + display: flex; | |
| 45 | + align-items: center; | |
| 46 | + gap: 6px; | |
| 47 | + padding: 0 18px; | |
| 48 | + color: #fff; | |
| 49 | + cursor: pointer; | |
| 50 | + font-size: 14px; | |
| 51 | + border: none; | |
| 52 | + background: transparent; | |
| 53 | + height: 100%; | |
| 54 | +} | |
| 55 | +.navBtn:hover { | |
| 56 | + background: #33363d; | |
| 57 | +} | |
| 58 | +.navBtnActive { | |
| 59 | + background: var(--color-primary); | |
| 60 | +} | |
| 61 | +.tabs { | |
| 62 | + display: flex; | |
| 63 | + align-items: stretch; | |
| 64 | + flex: 1; | |
| 65 | + min-width: 0; | |
| 66 | +} | |
| 67 | +.tab { | |
| 68 | + display: flex; | |
| 69 | + align-items: center; | |
| 70 | + gap: 8px; | |
| 71 | + padding: 0 18px; | |
| 72 | + cursor: pointer; | |
| 73 | + color: #cfd2d8; | |
| 74 | + font-size: 14px; | |
| 75 | + height: 100%; | |
| 76 | + border: none; | |
| 77 | + background: transparent; | |
| 78 | +} | |
| 79 | +.tabActive { | |
| 80 | + color: var(--color-primary); | |
| 81 | +} | |
| 82 | +.tabClose { | |
| 83 | + margin-left: 6px; | |
| 84 | + width: 16px; | |
| 85 | + height: 16px; | |
| 86 | + border-radius: 50%; | |
| 87 | + display: inline-flex; | |
| 88 | + align-items: center; | |
| 89 | + justify-content: center; | |
| 90 | + font-size: 11px; | |
| 91 | + color: #9aa0a8; | |
| 92 | + border: none; | |
| 93 | + background: transparent; | |
| 94 | + cursor: pointer; | |
| 95 | +} | |
| 96 | +.tabClose:hover { | |
| 97 | + background: #3a3d44; | |
| 98 | + color: #fff; | |
| 99 | +} | |
| 100 | +.right { | |
| 101 | + display: flex; | |
| 102 | + align-items: center; | |
| 103 | + gap: 18px; | |
| 104 | + padding-right: 14px; | |
| 105 | +} | |
| 106 | +.rightIcon { | |
| 107 | + width: 18px; | |
| 108 | + height: 18px; | |
| 109 | + opacity: 0.9; | |
| 110 | + cursor: pointer; | |
| 111 | + display: inline-flex; | |
| 112 | + align-items: center; | |
| 113 | +} | |
| 114 | +.user { | |
| 115 | + display: flex; | |
| 116 | + align-items: center; | |
| 117 | + gap: 6px; | |
| 118 | + font-size: 14px; | |
| 119 | + cursor: pointer; | |
| 120 | + color: #fff; | |
| 121 | + border: none; | |
| 122 | + background: transparent; | |
| 123 | +} | |
| 124 | +.more { | |
| 125 | + font-size: 18px; | |
| 126 | + letter-spacing: 2px; | |
| 127 | + cursor: pointer; | |
| 128 | + padding: 0 4px; | |
| 129 | +} | |
| 130 | + | |
| 131 | +/* ===== NAV OVERLAY(深色装饰 scoped,D9) ===== */ | |
| 132 | +.navOverlay { | |
| 133 | + position: absolute; | |
| 134 | + inset: 0; | |
| 135 | + z-index: 20; | |
| 136 | + display: flex; | |
| 137 | + color: #cfd3da; | |
| 138 | +} | |
| 139 | +.navOverlayMask { | |
| 140 | + position: absolute; | |
| 141 | + inset: 0; | |
| 142 | + background: transparent; | |
| 143 | +} | |
| 144 | +.navOverlayBody { | |
| 145 | + position: relative; | |
| 146 | + display: flex; | |
| 147 | + flex: 1; | |
| 148 | + background: #2b3137; /* overlay 深色底(scoped 装饰) */ | |
| 149 | +} | |
| 150 | +.navSide { | |
| 151 | + width: 200px; | |
| 152 | + background: #2b3137; | |
| 153 | + padding: 8px 0; | |
| 154 | + border-right: 1px solid #1e2226; | |
| 155 | + overflow: auto; | |
| 156 | +} | |
| 157 | +.navSideItem { | |
| 158 | + display: flex; | |
| 159 | + align-items: center; | |
| 160 | + gap: 10px; | |
| 161 | + padding: 11px 18px; | |
| 162 | + font-size: 14px; | |
| 163 | + color: #d3d6db; | |
| 164 | + cursor: pointer; | |
| 165 | +} | |
| 166 | +.navSideItem:hover { | |
| 167 | + background: #34393f; | |
| 168 | +} | |
| 169 | +.navSideItemActive { | |
| 170 | + color: var(--color-primary); | |
| 171 | + background: #34393f; | |
| 172 | +} | |
| 173 | +.navGrid { | |
| 174 | + flex: 1; | |
| 175 | + padding: 30px 40px; | |
| 176 | + display: grid; | |
| 177 | + grid-template-columns: repeat(7, 1fr); | |
| 178 | + gap: 30px 40px; | |
| 179 | + align-content: start; | |
| 180 | + overflow: auto; | |
| 181 | +} | |
| 182 | +.navColTitle { | |
| 183 | + font-size: 15px; | |
| 184 | + color: #e8eaee; | |
| 185 | + font-weight: 500; | |
| 186 | + margin: 0 0 18px; | |
| 187 | + border-bottom: 1px solid #4a4f57; | |
| 188 | + padding-bottom: 10px; | |
| 189 | +} | |
| 190 | +.navLeaf { | |
| 191 | + display: flex; | |
| 192 | + align-items: center; | |
| 193 | + gap: 6px; | |
| 194 | + padding: 7px 0; | |
| 195 | + color: #cfd3da; | |
| 196 | + font-size: 14px; | |
| 197 | + cursor: pointer; | |
| 198 | + border: none; | |
| 199 | + background: transparent; | |
| 200 | + text-align: left; | |
| 201 | + width: 100%; | |
| 202 | +} | |
| 203 | +.navLeaf:hover { | |
| 204 | + color: #fff; | |
| 205 | +} | |
| 206 | +.navStar { | |
| 207 | + color: var(--color-warning); | |
| 208 | +} | ... | ... |
frontend/src/layouts/AppLayout/NavOverlay.tsx
0 → 100644
| 1 | +// REQ-USR-003: 全部导航总览浮层(BR7/BR8/D4)。受控 open;左 NAV_SIDE 列 + 右 NAV_COLS 网格。 | |
| 2 | +// 叶子据 routePath 分流:有 → onNavigate;无 → onPlaceholder(占位「功能开发中」由调用方提示)。 | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | +import { NAV_SIDE, NAV_COLS } from './navConfig'; | |
| 5 | +import type { NavLeaf } from './navConfig'; | |
| 6 | +import styles from './AppLayout.module.css'; | |
| 7 | + | |
| 8 | +interface NavOverlayProps { | |
| 9 | + open: boolean; | |
| 10 | + onClose: () => void; | |
| 11 | + onNavigate: (routePath: string) => void; | |
| 12 | + onPlaceholder: () => void; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export default function NavOverlay({ open, onClose, onNavigate, onPlaceholder }: NavOverlayProps) { | |
| 16 | + // Esc 关闭 | |
| 17 | + useEffect(() => { | |
| 18 | + if (!open) return; | |
| 19 | + const handler = (e: KeyboardEvent) => { | |
| 20 | + if (e.key === 'Escape') onClose(); | |
| 21 | + }; | |
| 22 | + window.addEventListener('keydown', handler); | |
| 23 | + return () => window.removeEventListener('keydown', handler); | |
| 24 | + }, [open, onClose]); | |
| 25 | + | |
| 26 | + if (!open) return null; | |
| 27 | + | |
| 28 | + const handleLeaf = (leaf: NavLeaf) => { | |
| 29 | + if (leaf.routePath) onNavigate(leaf.routePath); | |
| 30 | + else onPlaceholder(); | |
| 31 | + }; | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <div | |
| 35 | + className={styles.navOverlay} | |
| 36 | + data-testid="nav-overlay" | |
| 37 | + role="dialog" | |
| 38 | + aria-modal="true" | |
| 39 | + > | |
| 40 | + {/* 遮罩:点击空白处关闭(仅自身触发,不冒泡子项) */} | |
| 41 | + <div | |
| 42 | + className={styles.navOverlayMask} | |
| 43 | + data-testid="nav-overlay-mask" | |
| 44 | + onClick={onClose} | |
| 45 | + aria-hidden="true" | |
| 46 | + /> | |
| 47 | + <div className={styles.navOverlayBody}> | |
| 48 | + <div className={styles.navSide} data-testid="nav-side"> | |
| 49 | + {NAV_SIDE.map((s) => ( | |
| 50 | + <div | |
| 51 | + key={s.key} | |
| 52 | + className={`${styles.navSideItem} ${s.active ? styles.navSideItemActive : ''}`} | |
| 53 | + aria-current={s.active ? 'true' : undefined} | |
| 54 | + > | |
| 55 | + {s.label} | |
| 56 | + </div> | |
| 57 | + ))} | |
| 58 | + </div> | |
| 59 | + <div className={styles.navGrid}> | |
| 60 | + {NAV_COLS.map((col) => ( | |
| 61 | + <div key={col.title} className={styles.navCol}> | |
| 62 | + <h3 className={styles.navColTitle}>{col.title}</h3> | |
| 63 | + {col.items.map((leaf) => ( | |
| 64 | + <button | |
| 65 | + type="button" | |
| 66 | + key={leaf.label} | |
| 67 | + className={styles.navLeaf} | |
| 68 | + onClick={() => handleLeaf(leaf)} | |
| 69 | + > | |
| 70 | + {leaf.label} | |
| 71 | + {leaf.star && <span className={styles.navStar}>★</span>} | |
| 72 | + </button> | |
| 73 | + ))} | |
| 74 | + </div> | |
| 75 | + ))} | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + ); | |
| 80 | +} | ... | ... |
frontend/tests/unit/NavOverlay.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: NavOverlay 全部导航总览(开关 / 分组渲染 / 路由项与占位项,BR7/BR8/D4) | |
| 2 | +import { describe, it, expect, vi } from 'vitest'; | |
| 3 | +import { render, screen, within } from '@testing-library/react'; | |
| 4 | +import userEvent from '@testing-library/user-event'; | |
| 5 | +import { ConfigProvider, App as AntdApp } from 'antd'; | |
| 6 | +import NavOverlay from '../../src/layouts/AppLayout/NavOverlay'; | |
| 7 | + | |
| 8 | +function renderOverlay(props: Partial<React.ComponentProps<typeof NavOverlay>> = {}) { | |
| 9 | + const onClose = props.onClose ?? vi.fn(); | |
| 10 | + const onNavigate = props.onNavigate ?? vi.fn(); | |
| 11 | + const onPlaceholder = props.onPlaceholder ?? vi.fn(); | |
| 12 | + const open = props.open ?? true; | |
| 13 | + render( | |
| 14 | + <ConfigProvider> | |
| 15 | + <AntdApp> | |
| 16 | + <NavOverlay | |
| 17 | + open={open} | |
| 18 | + onClose={onClose} | |
| 19 | + onNavigate={onNavigate} | |
| 20 | + onPlaceholder={onPlaceholder} | |
| 21 | + /> | |
| 22 | + </AntdApp> | |
| 23 | + </ConfigProvider>, | |
| 24 | + ); | |
| 25 | + return { onClose, onNavigate, onPlaceholder }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +describe('NavOverlay', () => { | |
| 29 | + it('hidden when open is false / visible when true', () => { | |
| 30 | + const { unmount } = render( | |
| 31 | + <ConfigProvider> | |
| 32 | + <AntdApp> | |
| 33 | + <NavOverlay open={false} onClose={vi.fn()} onNavigate={vi.fn()} onPlaceholder={vi.fn()} /> | |
| 34 | + </AntdApp> | |
| 35 | + </ConfigProvider>, | |
| 36 | + ); | |
| 37 | + expect(screen.queryByText('期初设置')).not.toBeInTheDocument(); | |
| 38 | + unmount(); | |
| 39 | + | |
| 40 | + renderOverlay({ open: true }); | |
| 41 | + expect(screen.getByText('期初设置')).toBeInTheDocument(); | |
| 42 | + expect(screen.getByText('API对接管理')).toBeInTheDocument(); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it('side has 系统设置 active', () => { | |
| 46 | + renderOverlay({ open: true }); | |
| 47 | + const side = screen.getByTestId('nav-side'); | |
| 48 | + const sys = within(side).getByText('系统设置'); | |
| 49 | + expect(sys.closest('[aria-current]')?.getAttribute('aria-current')).toBe('true'); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it("clicking 用户列表 calls onNavigate('/usr/users')", async () => { | |
| 53 | + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); | |
| 54 | + await userEvent.click(screen.getByRole('button', { name: /用户列表/ })); | |
| 55 | + expect(onNavigate).toHaveBeenCalledWith('/usr/users'); | |
| 56 | + expect(onPlaceholder).not.toHaveBeenCalled(); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it('clicking placeholder leaf calls onPlaceholder (no navigate)', async () => { | |
| 60 | + const { onNavigate, onPlaceholder } = renderOverlay({ open: true }); | |
| 61 | + await userEvent.click(screen.getByRole('button', { name: '系统权限' })); | |
| 62 | + expect(onPlaceholder).toHaveBeenCalledTimes(1); | |
| 63 | + expect(onNavigate).not.toHaveBeenCalled(); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('Esc / mask click calls onClose', async () => { | |
| 67 | + const { onClose } = renderOverlay({ open: true }); | |
| 68 | + await userEvent.keyboard('{Escape}'); | |
| 69 | + expect(onClose).toHaveBeenCalled(); | |
| 70 | + | |
| 71 | + await userEvent.click(screen.getByTestId('nav-overlay-mask')); | |
| 72 | + expect(onClose).toHaveBeenCalledTimes(2); | |
| 73 | + }); | |
| 74 | +}); | ... | ... |