Commit db8754051921352fb64a3ae03d10def9dd0ba054
1 parent
0b4bac68
feat(fe-shell): 顶栏与当前用户/退出登录 REQ-USR-004
Showing
4 changed files
with
298 additions
and
0 deletions
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 | +}); | ... | ... |