From f20951fb9277f80016b0e4dc7ad83c93a29ab70f Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Tue, 18 Nov 2025 20:46:40 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=88=B0=20Zustand=20=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Zustand 用户状态管理(userStore.ts) - 集成 persist 中间件实现自动持久化 - 简化 layout.tsx 用户状态管理逻辑 - 更新 Redux store 配置 - 优化主入口和国际化配置 --- src/layout.tsx | 172 +++++++++++++++++++++++++++++++++++------ src/locale/index.ts | 2 + src/main.tsx | 16 +++- src/store/index.ts | 7 ++ src/store/userStore.ts | 91 ++++++++++++++++++++++ 5 files changed, 264 insertions(+), 24 deletions(-) create mode 100644 src/store/userStore.ts diff --git a/src/layout.tsx b/src/layout.tsx index 7f9dfcb..2208909 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,11 +1,15 @@ import React, { useState, useMemo, useRef, useEffect } from 'react'; import { Switch, Route, Redirect, useHistory } from 'react-router-dom'; -import { Layout, Menu, Breadcrumb, Spin } from '@arco-design/web-react'; +import { Layout, Menu, Spin, Button } from '@arco-design/web-react'; import cs from 'classnames'; import { IconUser, IconMenuFold, IconMenuUnfold, + IconApps, + IconHome, + IconRefresh, + IconQuestionCircle, } from '@arco-design/web-react/icon'; import { useSelector, useDispatch } from 'react-redux'; import qs from 'query-string'; @@ -19,7 +23,9 @@ import useLocale from './utils/useLocale'; import getUrlParams from './utils/getUrlParams'; import lazyload from './utils/lazyload'; import { GlobalState } from './store'; +import { useUserStore } from './store/userStore'; import { TabItem } from './types/tabs'; +import { getUserInfo as fetchUserInfoAPI } from '@/api/auth'; import styles from './style/layout.module.less'; const MenuItem = Menu.Item; @@ -28,6 +34,21 @@ const SubMenu = Menu.SubMenu; const Sider = Layout.Sider; const Content = Layout.Content; +function findRouteByKey(key: string, routeList: IRoute[] = []): IRoute | null { + for (const route of routeList) { + if (route.key === key) { + return route; + } + if (route.children?.length) { + const child = findRouteByKey(key, route.children); + if (child) { + return child; + } + } + } + return null; +} + // 简单的404页面组件 function NotFound() { return ( @@ -54,7 +75,7 @@ function getIconFromKey(key) { case 'user-management': return ; default: - return
; + return ; } } @@ -92,22 +113,65 @@ function PageLayout() { const pathname = history.location.pathname; const currentComponent = qs.parseUrl(pathname).url.slice(1); const locale = useLocale(); - const { settings, userLoading, userInfo } = useSelector( - (state: GlobalState) => state - ); + + // 从 Redux 获取 settings 和 tabs + const { settings } = useSelector((state: GlobalState) => state); + + // 从 Zustand 获取用户信息 + const { userInfo, userLoading, setUserInfo, updateUserInfo, setUserLoading } = + useUserStore(); const [routes, defaultRoute] = useRoute(userInfo?.permissions); const defaultSelectedKeys = [currentComponent || defaultRoute]; const paths = (currentComponent || defaultRoute).split('/'); const defaultOpenKeys = paths.slice(0, paths.length - 1); - const [breadcrumb, setBreadCrumb] = useState([]); + const [breadcrumb, setBreadCrumb] = useState([]); const [collapsed, setCollapsed] = useState(false); const [selectedKeys, setSelectedKeys] = useState(defaultSelectedKeys); const [openKeys, setOpenKeys] = useState(defaultOpenKeys); - const routeMap = useRef>(new Map()); + // 使用 Zustand:调用 API 获取最新用户信息 + useEffect(() => { + const fetchLatestUserInfo = async () => { + // 只有有用户数据时才调用 API + if (!userInfo?.userId) { + return; + } + + setUserLoading(true); + + try { + const result = await fetchUserInfoAPI(); + if (result.code == 0 && result.data) { + const data = result.data; + // 使用 updateUserInfo 部分更新,自动保留现有字段(如 avatar) + updateUserInfo({ + userId: data.id || data.userId, + username: data.username, + nickname: data.nickname || data.name, + name: data.nickname || data.name, + role: data.role, + avatar: data.avatar || userInfo.avatar, // 如果 API 返回空,保留现有 + email: data.email, + phone: data.phone, + }); + } + } catch (error) { + console.error('获取最新用户信息失败:', error); + // 失败时不需要做任何事,Zustand 自动保留现有数据 + } finally { + setUserLoading(false); + } + }; + + fetchLatestUserInfo(); + }, []); + + type CrumbNode = { key: string; name: string; icon?: React.ReactNode }; + + const routeMap = useRef>(new Map()); const menuMap = useRef< Map >(new Map()); @@ -120,6 +184,34 @@ function PageLayout() { const showFooter = settings.footer && urlParams.footer !== false; const flattenRoutes = useMemo(() => getFlattenRoutes(routes) || [], [routes]); + const fallbackCrumb = useMemo(() => { + const matched = findRouteByKey(defaultRoute, routes); + if (matched) { + return { + key: matched.key, + name: matched.name, + icon: getIconFromKey(matched.key), + }; + } + return null; + }, [defaultRoute, routes]); + + const displayedBreadcrumb = useMemo(() => { + const nodes: CrumbNode[] = [ + { + key: 'home', + name: 'breadcrumb.home', + icon: , + }, + ]; + const currentNode = breadcrumb.length + ? breadcrumb[breadcrumb.length - 1] + : fallbackCrumb; + if (currentNode) { + nodes.push(currentNode); + } + return nodes; + }, [breadcrumb, fallbackCrumb]); function onClickMenuItem(key) { const currentRoute = flattenRoutes.find((r) => r.key === key); @@ -136,13 +228,28 @@ function PageLayout() { setCollapsed((collapsed) => !collapsed); } + function handleBreadcrumbClick(node: CrumbNode, index: number) { + if (index === displayedBreadcrumb.length - 1) { + return; + } + if (node.key === 'home') { + history.push('/'); + return; + } + history.push(`/${node.key}`); + } + const paddingLeft = showMenu ? { paddingLeft: menuWidth } : {}; const paddingTop = showNavbar ? { paddingTop: navbarHeight } : {}; const paddingStyle = { ...paddingLeft, ...paddingTop }; function renderRoutes(locale) { routeMap.current.clear(); - return function travel(_routes: IRoute[], level, parentNode = []) { + return function travel( + _routes: IRoute[], + level, + parentNode: CrumbNode[] = [] + ) { return _routes.map((route) => { const { breadcrumb = true, ignore } = route; const iconDom = getIconFromKey(route.key); @@ -154,7 +261,16 @@ function PageLayout() { routeMap.current.set( `/${route.key}`, - breadcrumb ? [...parentNode, route.name] : [] + breadcrumb + ? [ + ...parentNode, + { + key: route.key, + name: route.name, + icon: getIconFromKey(route.key), + }, + ] + : [] ); const visibleChildren = (route.children || []).filter((child) => { @@ -162,7 +278,21 @@ function PageLayout() { if (ignore || route.ignore) { routeMap.current.set( `/${child.key}`, - breadcrumb ? [...parentNode, route.name, child.name] : [] + breadcrumb + ? [ + ...parentNode, + { + key: route.key, + name: route.name, + icon: getIconFromKey(route.key), + }, + { + key: child.key, + name: child.name, + icon: getIconFromKey(child.key), + }, + ] + : [] ); } @@ -176,7 +306,10 @@ function PageLayout() { menuMap.current.set(route.key, { subMenu: true }); return ( - {travel(visibleChildren, level + 1, [...parentNode, route.name])} + {travel(visibleChildren, level + 1, [ + ...parentNode, + { key: route.key, name: route.name }, + ])} ); } @@ -238,7 +371,11 @@ function PageLayout() { [styles['layout-navbar-hidden']]: !showNavbar, })} > - +
{userLoading ? ( @@ -275,17 +412,6 @@ function PageLayout() { )}
- {!!breadcrumb.length && ( -
- - {breadcrumb.map((node, index) => ( - - {typeof node === 'string' ? locale[node] || node : node} - - ))} - -
- )} diff --git a/src/locale/index.ts b/src/locale/index.ts index 4741e37..637abbb 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -2,6 +2,7 @@ const i18n = { 'en-US': { 'menu.userManagement': 'User Management', 'menu.userInfo': 'User Info', + 'breadcrumb.home': 'Home', 'navbar.userInfo': 'User Info', 'navbar.logout': 'Logout', 'settings.title': 'Settings', @@ -36,6 +37,7 @@ const i18n = { 'zh-CN': { 'menu.userManagement': '用户管理', 'menu.userInfo': '个人信息', + 'breadcrumb.home': '首页', 'navbar.userInfo': '个人信息', 'navbar.logout': '退出登录', 'settings.title': '页面配置', diff --git a/src/main.tsx b/src/main.tsx index f0ba4f3..b7c6a66 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import checkLogin from './utils/checkLogin'; import changeTheme from './utils/changeTheme'; import initThemeColor from './utils/initThemeColor'; import useStorage from './utils/useStorage'; +import { setUserInfo as cacheUserInfo } from './utils/storage'; import './mock'; const store = createStore(rootReducer); @@ -41,9 +42,22 @@ function Index() { payload: { userLoading: true }, }); axios.get('/api/user/userInfo').then((res) => { + const serverUserInfo = res.data || {}; + const mergedUserInfo = { + ...serverUserInfo, + userId: serverUserInfo.userId || serverUserInfo.id, + name: serverUserInfo.nickname || serverUserInfo.name, + }; store.dispatch({ type: 'update-userInfo', - payload: { userInfo: res.data, userLoading: false }, + payload: { userInfo: mergedUserInfo, userLoading: false }, + }); + cacheUserInfo({ + userId: mergedUserInfo.userId, + username: mergedUserInfo.username, + nickname: mergedUserInfo.nickname || mergedUserInfo.name, + role: mergedUserInfo.role, + avatar: mergedUserInfo.avatar, }); }); } diff --git a/src/store/index.ts b/src/store/index.ts index 4301f13..eae3296 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,12 +4,19 @@ import { TabItem, TabsState } from '@/types/tabs'; export interface GlobalState { settings?: typeof defaultSettings; userInfo?: { + // 后端 API 返回的字段(新增) + userId?: number; + username?: string; + nickname?: string; + role?: string; + // 原有字段(保持兼容) name?: string; avatar?: string; job?: string; organization?: string; location?: string; email?: string; + phone?: string; permissions: Record; }; userLoading?: boolean; diff --git a/src/store/userStore.ts b/src/store/userStore.ts new file mode 100644 index 0000000..4147742 --- /dev/null +++ b/src/store/userStore.ts @@ -0,0 +1,91 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { generatePermission } from '@/routes'; + +export interface UserInfo { + userId?: number; + username?: string; + nickname?: string; + role?: string; + avatar?: string; + name?: string; + email?: string; + phone?: string; + permissions: Record; +} + +interface UserState { + userInfo: UserInfo; + userLoading: boolean; + + // Actions + setUserInfo: (info: Partial) => void; + updateUserInfo: (partial: Partial) => void; + setUserLoading: (loading: boolean) => void; + clearUserInfo: () => void; +} + +const initialUserInfo: UserInfo = { + permissions: {}, +}; + +export const useUserStore = create()( + persist( + (set, get) => ({ + userInfo: initialUserInfo, + userLoading: false, + + // 完全替换用户信息 + setUserInfo: (info) => { + const role = info.role || 'user'; + set({ + userInfo: { + ...info, + permissions: info.permissions || generatePermission(role), + }, + }); + }, + + // 部分更新用户信息(自动保留其他字段) + updateUserInfo: (partial) => { + const current = get().userInfo; + const newInfo = { ...current, ...partial }; + + // 如果更新了 role,重新生成权限 + if (partial.role && partial.role !== current.role) { + newInfo.permissions = generatePermission(partial.role); + } + + set({ userInfo: newInfo }); + }, + + setUserLoading: (loading) => set({ userLoading: loading }), + + clearUserInfo: () => set({ userInfo: initialUserInfo }), + }), + { + name: 'user-storage', // localStorage key + partialize: (state) => ({ + userInfo: { + userId: state.userInfo.userId, + username: state.userInfo.username, + nickname: state.userInfo.nickname, + role: state.userInfo.role, + avatar: state.userInfo.avatar, + name: state.userInfo.name, + email: state.userInfo.email, + phone: state.userInfo.phone, + // 不持久化 permissions,每次从 role 生成 + }, + }), + onRehydrateStorage: () => (state) => { + // 重新加载时,根据 role 重新生成 permissions + if (state && state.userInfo && state.userInfo.role) { + state.userInfo.permissions = generatePermission(state.userInfo.role); + } + }, + } + ) +); + +export default useUserStore;