diff --git a/frontend/src/layouts/AppLayout/CurrentUserMenu.tsx b/frontend/src/layouts/AppLayout/CurrentUserMenu.tsx new file mode 100644 index 0000000..fd6ec33 --- /dev/null +++ b/frontend/src/layouts/AppLayout/CurrentUserMenu.tsx @@ -0,0 +1,35 @@ +// REQ-USR-004: 当前用户下拉(展示 sUserName(sUserType) + 退出登录,BR3/BR9)。 +import { Dropdown } from 'antd'; +import { DownOutlined, LogoutOutlined } from '@ant-design/icons'; +import type { MenuProps } from 'antd'; +import type { AuthUser } from '../../api/types'; +import { formatCurrentUser, LOGOUT_MENU_TEXT } from './shellMessages'; +import styles from './AppLayout.module.css'; + +interface CurrentUserMenuProps { + user: AuthUser | null; + onLogout: () => void; +} + +export default function CurrentUserMenu({ user, onLogout }: CurrentUserMenuProps) { + const items: MenuProps['items'] = [ + { + key: 'logout', + icon: , + label: LOGOUT_MENU_TEXT, + }, + ]; + + const onMenuClick: MenuProps['onClick'] = ({ key }) => { + if (key === 'logout') onLogout(); + }; + + return ( + + + + ); +} diff --git a/frontend/src/layouts/AppLayout/TopBar.tsx b/frontend/src/layouts/AppLayout/TopBar.tsx new file mode 100644 index 0000000..1707f0d --- /dev/null +++ b/frontend/src/layouts/AppLayout/TopBar.tsx @@ -0,0 +1,103 @@ +// REQ-USR-003 / REQ-USR-004: 顶栏(Logo + 全部导航按钮 + 标签条 + 右侧搜索/通知/当前用户/更多)。 +import { MenuOutlined, SearchOutlined, BellOutlined, HomeOutlined } from '@ant-design/icons'; +import type { AuthUser } from '../../api/types'; +import type { TabItem } from './useTabStack'; +import CurrentUserMenu from './CurrentUserMenu'; +import styles from './AppLayout.module.css'; + +interface TopBarProps { + user: AuthUser | null; + tabs: TabItem[]; + activeKey: string; + navOverlayOpen: boolean; + onToggleNav: () => void; + onSelectTab: (key: string) => void; + onCloseTab: (key: string) => void; + onLogout: () => void; + onLogoHome: () => void; +} + +export default function TopBar({ + user, + tabs, + activeKey, + navOverlayOpen, + onToggleNav, + onSelectTab, + onCloseTab, + onLogout, + onLogoHome, +}: TopBarProps) { + return ( +
+ {/* 品牌 Logo(鹿角 SVG),点击回主页 */} + + +
+ + + {tabs.map((tab) => ( + + ))} +
+ +
+ + + + +
+
+ ); +} diff --git a/frontend/src/layouts/AppLayout/shellMessages.ts b/frontend/src/layouts/AppLayout/shellMessages.ts new file mode 100644 index 0000000..82b598f --- /dev/null +++ b/frontend/src/layouts/AppLayout/shellMessages.ts @@ -0,0 +1,27 @@ +// REQ-USR-003 / REQ-USR-004: 外壳层文案常量(单一来源,跨组件复用,逐字复刻 spec / 原型)。 + +/** 退出登录成功提示(message.success,BR9) */ +export const LOGOUT_SUCCESS_TEXT = '已退出登录'; + +/** 被动 401 提示(message.warning,BR10) */ +export const SESSION_EXPIRED_TEXT = '登录已失效,请重新登录'; + +/** 导航占位项点击提示(message.info,BR7/D4) */ +export const FEATURE_WIP_TEXT = '功能开发中'; + +/** 当前用户为空时的占位用户名(BR3/D10:user 缺失时退化展示) */ +export const FALLBACK_USER_NAME = '未登录用户'; + +/** 退出登录菜单项文案 */ +export const LOGOUT_MENU_TEXT = '退出登录'; + +/** + * 当前用户区文案规则(BR3/D10):`${sUserName}(${sUserType})`。 + * user 为 null 时退化为占位用户名。sUserType 已是中文,不再映射。 + */ +export function formatCurrentUser( + user: { sUserName: string; sUserType: string } | null, +): string { + if (!user) return FALLBACK_USER_NAME; + return `${user.sUserName}(${user.sUserType})`; +} diff --git a/frontend/tests/unit/AppLayout.topbar.test.tsx b/frontend/tests/unit/AppLayout.topbar.test.tsx new file mode 100644 index 0000000..d15713b --- /dev/null +++ b/frontend/tests/unit/AppLayout.topbar.test.tsx @@ -0,0 +1,133 @@ +// REQ-USR-004: 顶栏结构 + 当前用户 + 退出登录(BR3/BR9) +import { describe, it, expect, vi } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'; +import { App as AntdApp } from 'antd'; +import { renderShell } from './renderShell'; +import TopBar from '../../src/layouts/AppLayout/TopBar'; +import { useAppDispatch } from '../../src/store/hooks'; +import { clearCredentials } from '../../src/store/slices/authSlice'; +import { LOGOUT_SUCCESS_TEXT } from '../../src/layouts/AppLayout/shellMessages'; +import { HOME_TAB, BIZ_TABS, type TabItem } from '../../src/layouts/AppLayout/useTabStack'; +import type { AuthUser } from '../../src/api/types'; + +const ADMIN: AuthUser = { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }; + +// 顶栏测试宿主:以真实 onLogout(dispatch + message + navigate)驱动,模拟 AppLayout 提供的回调 +function TopBarHost({ + user, + tabs, + activeKey = 'home', + navOverlayOpen = false, + onToggleNav = vi.fn(), + onSelectTab = vi.fn(), + onCloseTab = vi.fn(), + onLogoHome = vi.fn(), +}: { + user: AuthUser | null; + tabs: TabItem[]; + activeKey?: string; + navOverlayOpen?: boolean; + onToggleNav?: () => void; + onSelectTab?: (k: string) => void; + onCloseTab?: (k: string) => void; + onLogoHome?: () => void; +}) { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { message } = AntdApp.useApp(); + const handleLogout = () => { + dispatch(clearCredentials()); + message.success(LOGOUT_SUCCESS_TEXT); + navigate('/login', { replace: true }); + }; + return ( + + ); +} + +function LoginProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +describe('TopBar', () => { + it('renders brand logo / 全部导航 button / 主页 tab', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + expect(screen.getByRole('button', { name: '全部导航' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '品牌Logo 回到主页' })).toBeInTheDocument(); + const homeTab = screen.getByTestId('tab-home'); + expect(homeTab).toHaveTextContent('主页'); + // 主页 tab 无关闭按钮 + expect(within(homeTab).queryByText('✕')).not.toBeInTheDocument(); + }); + + it('renders current user as sUserName(sUserType)', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + expect(screen.getByText('朱子纯(超级管理员)')).toBeInTheDocument(); + }); + + it('user fallback when user is null', () => { + renderShell(, { + preloadedAuth: { token: 't', user: null }, + }); + expect(screen.getByText('未登录用户')).toBeInTheDocument(); + }); + + it('logout menu dispatches clearCredentials, shows success, navigates /login', async () => { + localStorage.setItem('xly_erp_token', 't'); + const { getState } = renderShell( + + } /> + } /> + , + { initialEntries: ['/'], preloadedAuth: { token: 't', user: ADMIN } }, + ); + // 展开当前用户下拉 + await userEvent.click(screen.getByText('朱子纯(超级管理员)')); + const logout = await screen.findByText('退出登录'); + await userEvent.click(logout); + expect(getState().auth.token).toBeNull(); + expect(localStorage.getItem('xly_erp_token')).toBeNull(); + expect(await screen.findByText(LOGOUT_SUCCESS_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId('login-probe').textContent).toBe('/login'); + }); + + it('nav toggle button highlights when navOverlayOpen', () => { + renderShell(, { + preloadedAuth: { token: 't', user: ADMIN }, + }); + const navBtn = screen.getByRole('button', { name: '全部导航' }); + expect(navBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('clicking business tab close calls onCloseTab', async () => { + const onCloseTab = vi.fn(); + renderShell( + , + { preloadedAuth: { token: 't', user: ADMIN } }, + ); + await userEvent.click(screen.getByTestId('tab-close-userlist')); + expect(onCloseTab).toHaveBeenCalledWith('userlist'); + }); +});