Commit db8754051921352fb64a3ae03d10def9dd0ba054

Authored by zichun
1 parent 0b4bac68

feat(fe-shell): 顶栏与当前用户/退出登录 REQ-USR-004

frontend/src/layouts/AppLayout/CurrentUserMenu.tsx 0 → 100644
  1 +// REQ-USR-004: 当前用户下拉(展示 sUserName(sUserType) + 退出登录,BR3/BR9)。
  2 +import { Dropdown } from 'antd';
  3 +import { DownOutlined, LogoutOutlined } from '@ant-design/icons';
  4 +import type { MenuProps } from 'antd';
  5 +import type { AuthUser } from '../../api/types';
  6 +import { formatCurrentUser, LOGOUT_MENU_TEXT } from './shellMessages';
  7 +import styles from './AppLayout.module.css';
  8 +
  9 +interface CurrentUserMenuProps {
  10 + user: AuthUser | null;
  11 + onLogout: () => void;
  12 +}
  13 +
  14 +export default function CurrentUserMenu({ user, onLogout }: CurrentUserMenuProps) {
  15 + const items: MenuProps['items'] = [
  16 + {
  17 + key: 'logout',
  18 + icon: <LogoutOutlined />,
  19 + label: LOGOUT_MENU_TEXT,
  20 + },
  21 + ];
  22 +
  23 + const onMenuClick: MenuProps['onClick'] = ({ key }) => {
  24 + if (key === 'logout') onLogout();
  25 + };
  26 +
  27 + return (
  28 + <Dropdown menu={{ items, onClick: onMenuClick }} trigger={['click']}>
  29 + <button type="button" className={styles.user}>
  30 + {formatCurrentUser(user)}
  31 + <DownOutlined aria-hidden style={{ fontSize: 10 }} />
  32 + </button>
  33 + </Dropdown>
  34 + );
  35 +}
frontend/src/layouts/AppLayout/TopBar.tsx 0 → 100644
  1 +// REQ-USR-003 / REQ-USR-004: 顶栏(Logo + 全部导航按钮 + 标签条 + 右侧搜索/通知/当前用户/更多)。
  2 +import { MenuOutlined, SearchOutlined, BellOutlined, HomeOutlined } from '@ant-design/icons';
  3 +import type { AuthUser } from '../../api/types';
  4 +import type { TabItem } from './useTabStack';
  5 +import CurrentUserMenu from './CurrentUserMenu';
  6 +import styles from './AppLayout.module.css';
  7 +
  8 +interface TopBarProps {
  9 + user: AuthUser | null;
  10 + tabs: TabItem[];
  11 + activeKey: string;
  12 + navOverlayOpen: boolean;
  13 + onToggleNav: () => void;
  14 + onSelectTab: (key: string) => void;
  15 + onCloseTab: (key: string) => void;
  16 + onLogout: () => void;
  17 + onLogoHome: () => void;
  18 +}
  19 +
  20 +export default function TopBar({
  21 + user,
  22 + tabs,
  23 + activeKey,
  24 + navOverlayOpen,
  25 + onToggleNav,
  26 + onSelectTab,
  27 + onCloseTab,
  28 + onLogout,
  29 + onLogoHome,
  30 +}: TopBarProps) {
  31 + return (
  32 + <div className={styles.topbar}>
  33 + {/* 品牌 Logo(鹿角 SVG),点击回主页 */}
  34 + <button type="button" className={styles.logo} title="主页" onClick={onLogoHome} aria-label="品牌Logo 回到主页">
  35 + <svg viewBox="0 0 64 64" fill="currentColor" aria-hidden="true">
  36 + <path d="M14 10c2 4 1 8-1 11 3-1 7 0 10 3 1-4 4-7 8-7-3 3-4 7-3 11l4 1c-1 3 0 6 3 8-3 0-6 1-8 4-1-3-4-5-8-5 2-3 2-7 0-10-3 1-7 0-10-3 3 0 5-2 6-5l-1-8z" />
  37 + <path d="M48 14c-2 3-2 6-1 9-2-2-5-2-8-1 1 3 1 6-1 9 3 0 5 2 6 5 1-3 4-5 7-5-2-3-2-6 0-9 2 1 5 1 7-1-2 0-4-1-5-3-1-2-3-4-5-4z" />
  38 + <path d="M28 38c2 3 5 5 9 5 1 4 4 7 8 8-3 2-5 5-5 9-3-2-7-3-11-2 1-3 1-7-1-10-3 0-6-1-8-4 3-1 6-3 8-6z" />
  39 + </svg>
  40 + </button>
  41 +
  42 + <div className={styles.tabs}>
  43 + <button
  44 + type="button"
  45 + className={`${styles.navBtn} ${navOverlayOpen ? styles.navBtnActive : ''}`}
  46 + aria-pressed={navOverlayOpen}
  47 + onClick={onToggleNav}
  48 + >
  49 + <MenuOutlined aria-hidden />
  50 + 全部导航
  51 + </button>
  52 +
  53 + {tabs.map((tab) => (
  54 + <button
  55 + type="button"
  56 + key={tab.key}
  57 + data-testid={`tab-${tab.key}`}
  58 + className={`${styles.tab} ${activeKey === tab.key ? styles.tabActive : ''}`}
  59 + aria-pressed={activeKey === tab.key}
  60 + onClick={() => onSelectTab(tab.key)}
  61 + >
  62 + {tab.key === 'home' && <HomeOutlined aria-hidden />}
  63 + {tab.title}
  64 + {tab.closable && (
  65 + <span
  66 + role="button"
  67 + tabIndex={0}
  68 + aria-label={`关闭${tab.title}`}
  69 + data-testid={`tab-close-${tab.key}`}
  70 + className={styles.tabClose}
  71 + onClick={(e) => {
  72 + e.stopPropagation();
  73 + onCloseTab(tab.key);
  74 + }}
  75 + onKeyDown={(e) => {
  76 + if (e.key === 'Enter' || e.key === ' ') {
  77 + e.stopPropagation();
  78 + onCloseTab(tab.key);
  79 + }
  80 + }}
  81 + >
  82 + ✕
  83 + </span>
  84 + )}
  85 + </button>
  86 + ))}
  87 + </div>
  88 +
  89 + <div className={styles.right}>
  90 + <span className={styles.rightIcon} title="搜索" aria-hidden="true">
  91 + <SearchOutlined />
  92 + </span>
  93 + <span className={styles.rightIcon} title="通知" aria-hidden="true">
  94 + <BellOutlined />
  95 + </span>
  96 + <CurrentUserMenu user={user} onLogout={onLogout} />
  97 + <span className={styles.more} aria-hidden="true">
  98 + ⋯
  99 + </span>
  100 + </div>
  101 + </div>
  102 + );
  103 +}
frontend/src/layouts/AppLayout/shellMessages.ts 0 → 100644
  1 +// REQ-USR-003 / REQ-USR-004: 外壳层文案常量(单一来源,跨组件复用,逐字复刻 spec / 原型)。
  2 +
  3 +/** 退出登录成功提示(message.success,BR9) */
  4 +export const LOGOUT_SUCCESS_TEXT = '已退出登录';
  5 +
  6 +/** 被动 401 提示(message.warning,BR10) */
  7 +export const SESSION_EXPIRED_TEXT = '登录已失效,请重新登录';
  8 +
  9 +/** 导航占位项点击提示(message.info,BR7/D4) */
  10 +export const FEATURE_WIP_TEXT = '功能开发中';
  11 +
  12 +/** 当前用户为空时的占位用户名(BR3/D10:user 缺失时退化展示) */
  13 +export const FALLBACK_USER_NAME = '未登录用户';
  14 +
  15 +/** 退出登录菜单项文案 */
  16 +export const LOGOUT_MENU_TEXT = '退出登录';
  17 +
  18 +/**
  19 + * 当前用户区文案规则(BR3/D10):`${sUserName}(${sUserType})`。
  20 + * user 为 null 时退化为占位用户名。sUserType 已是中文,不再映射。
  21 + */
  22 +export function formatCurrentUser(
  23 + user: { sUserName: string; sUserType: string } | null,
  24 +): string {
  25 + if (!user) return FALLBACK_USER_NAME;
  26 + return `${user.sUserName}(${user.sUserType})`;
  27 +}
frontend/tests/unit/AppLayout.topbar.test.tsx 0 → 100644
  1 +// REQ-USR-004: 顶栏结构 + 当前用户 + 退出登录(BR3/BR9)
  2 +import { describe, it, expect, vi } from 'vitest';
  3 +import { screen, within } from '@testing-library/react';
  4 +import userEvent from '@testing-library/user-event';
  5 +import { useNavigate, useLocation, Routes, Route } from 'react-router-dom';
  6 +import { App as AntdApp } from 'antd';
  7 +import { renderShell } from './renderShell';
  8 +import TopBar from '../../src/layouts/AppLayout/TopBar';
  9 +import { useAppDispatch } from '../../src/store/hooks';
  10 +import { clearCredentials } from '../../src/store/slices/authSlice';
  11 +import { LOGOUT_SUCCESS_TEXT } from '../../src/layouts/AppLayout/shellMessages';
  12 +import { HOME_TAB, BIZ_TABS, type TabItem } from '../../src/layouts/AppLayout/useTabStack';
  13 +import type { AuthUser } from '../../src/api/types';
  14 +
  15 +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' };
  16 +
  17 +// 顶栏测试宿主:以真实 onLogout(dispatch + message + navigate)驱动,模拟 AppLayout 提供的回调
  18 +function TopBarHost({
  19 + user,
  20 + tabs,
  21 + activeKey = 'home',
  22 + navOverlayOpen = false,
  23 + onToggleNav = vi.fn(),
  24 + onSelectTab = vi.fn(),
  25 + onCloseTab = vi.fn(),
  26 + onLogoHome = vi.fn(),
  27 +}: {
  28 + user: AuthUser | null;
  29 + tabs: TabItem[];
  30 + activeKey?: string;
  31 + navOverlayOpen?: boolean;
  32 + onToggleNav?: () => void;
  33 + onSelectTab?: (k: string) => void;
  34 + onCloseTab?: (k: string) => void;
  35 + onLogoHome?: () => void;
  36 +}) {
  37 + const dispatch = useAppDispatch();
  38 + const navigate = useNavigate();
  39 + const { message } = AntdApp.useApp();
  40 + const handleLogout = () => {
  41 + dispatch(clearCredentials());
  42 + message.success(LOGOUT_SUCCESS_TEXT);
  43 + navigate('/login', { replace: true });
  44 + };
  45 + return (
  46 + <TopBar
  47 + user={user}
  48 + tabs={tabs}
  49 + activeKey={activeKey}
  50 + navOverlayOpen={navOverlayOpen}
  51 + onToggleNav={onToggleNav}
  52 + onSelectTab={onSelectTab}
  53 + onCloseTab={onCloseTab}
  54 + onLogout={handleLogout}
  55 + onLogoHome={onLogoHome}
  56 + />
  57 + );
  58 +}
  59 +
  60 +function LoginProbe() {
  61 + const loc = useLocation();
  62 + return <div data-testid="login-probe">{loc.pathname}</div>;
  63 +}
  64 +
  65 +describe('TopBar', () => {
  66 + it('renders brand logo / 全部导航 button / 主页 tab', () => {
  67 + renderShell(<TopBarHost user={ADMIN} tabs={[HOME_TAB]} />, {
  68 + preloadedAuth: { token: 't', user: ADMIN },
  69 + });
  70 + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument();
  71 + expect(screen.getByRole('button', { name: '品牌Logo 回到主页' })).toBeInTheDocument();
  72 + const homeTab = screen.getByTestId('tab-home');
  73 + expect(homeTab).toHaveTextContent('主页');
  74 + // 主页 tab 无关闭按钮
  75 + expect(within(homeTab).queryByText('✕')).not.toBeInTheDocument();
  76 + });
  77 +
  78 + it('renders current user as sUserName(sUserType)', () => {
  79 + renderShell(<TopBarHost user={ADMIN} tabs={[HOME_TAB]} />, {
  80 + preloadedAuth: { token: 't', user: ADMIN },
  81 + });
  82 + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument();
  83 + });
  84 +
  85 + it('user fallback when user is null', () => {
  86 + renderShell(<TopBarHost user={null} tabs={[HOME_TAB]} />, {
  87 + preloadedAuth: { token: 't', user: null },
  88 + });
  89 + expect(screen.getByText('未登录用户')).toBeInTheDocument();
  90 + });
  91 +
  92 + it('logout menu dispatches clearCredentials, shows success, navigates /login', async () => {
  93 + localStorage.setItem('xly_erp_token', 't');
  94 + const { getState } = renderShell(
  95 + <Routes>
  96 + <Route path="/" element={<TopBarHost user={ADMIN} tabs={[HOME_TAB]} />} />
  97 + <Route path="/login" element={<LoginProbe />} />
  98 + </Routes>,
  99 + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } },
  100 + );
  101 + // 展开当前用户下拉
  102 + await userEvent.click(screen.getByText('朱子纯(超级管理员)'));
  103 + const logout = await screen.findByText('退出登录');
  104 + await userEvent.click(logout);
  105 + expect(getState().auth.token).toBeNull();
  106 + expect(localStorage.getItem('xly_erp_token')).toBeNull();
  107 + expect(await screen.findByText(LOGOUT_SUCCESS_TEXT)).toBeInTheDocument();
  108 + expect(screen.getByTestId('login-probe').textContent).toBe('/login');
  109 + });
  110 +
  111 + it('nav toggle button highlights when navOverlayOpen', () => {
  112 + renderShell(<TopBarHost user={ADMIN} tabs={[HOME_TAB]} navOverlayOpen />, {
  113 + preloadedAuth: { token: 't', user: ADMIN },
  114 + });
  115 + const navBtn = screen.getByRole('button', { name: '全部导航' });
  116 + expect(navBtn.getAttribute('aria-pressed')).toBe('true');
  117 + });
  118 +
  119 + it('clicking business tab close calls onCloseTab', async () => {
  120 + const onCloseTab = vi.fn();
  121 + renderShell(
  122 + <TopBarHost
  123 + user={ADMIN}
  124 + tabs={[HOME_TAB, BIZ_TABS.userlist]}
  125 + activeKey="userlist"
  126 + onCloseTab={onCloseTab}
  127 + />,
  128 + { preloadedAuth: { token: 't', user: ADMIN } },
  129 + );
  130 + await userEvent.click(screen.getByTestId('tab-close-userlist'));
  131 + expect(onCloseTab).toHaveBeenCalledWith('userlist');
  132 + });
  133 +});