Commit 0b4bac68ef1e301a74bc316709ab34e82f2e55db

Authored by zichun
1 parent bab42193

feat(fe-shell): 全部导航总览浮层 NavOverlay REQ-USR-003

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 +});