refactor(状态管理): 迁移到 Zustand 并优化布局

- 新增 Zustand 用户状态管理(userStore.ts)
- 集成 persist 中间件实现自动持久化
- 简化 layout.tsx 用户状态管理逻辑
- 更新 Redux store 配置
- 优化主入口和国际化配置
This commit is contained in:
gaoziman 2025-11-18 20:46:40 +08:00
parent b3c0a05a3e
commit f20951fb92
5 changed files with 264 additions and 24 deletions

View File

@ -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 <IconUser className={styles.icon} />;
default:
return <div className={styles['icon-empty']} />;
return <IconApps className={styles.icon} />;
}
}
@ -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<CrumbNode[]>([]);
const [collapsed, setCollapsed] = useState<boolean>(false);
const [selectedKeys, setSelectedKeys] =
useState<string[]>(defaultSelectedKeys);
const [openKeys, setOpenKeys] = useState<string[]>(defaultOpenKeys);
const routeMap = useRef<Map<string, React.ReactNode[]>>(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<Map<string, CrumbNode[]>>(new Map());
const menuMap = useRef<
Map<string, { menuItem?: boolean; subMenu?: boolean }>
>(new Map());
@ -120,6 +184,34 @@ function PageLayout() {
const showFooter = settings.footer && urlParams.footer !== false;
const flattenRoutes = useMemo(() => getFlattenRoutes(routes) || [], [routes]);
const fallbackCrumb = useMemo<CrumbNode | null>(() => {
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<CrumbNode[]>(() => {
const nodes: CrumbNode[] = [
{
key: 'home',
name: 'breadcrumb.home',
icon: <IconHome />,
},
];
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 (
<SubMenu key={route.key} title={titleDom}>
{travel(visibleChildren, level + 1, [...parentNode, route.name])}
{travel(visibleChildren, level + 1, [
...parentNode,
{ key: route.key, name: route.name },
])}
</SubMenu>
);
}
@ -238,7 +371,11 @@ function PageLayout() {
[styles['layout-navbar-hidden']]: !showNavbar,
})}
>
<Navbar show={showNavbar} />
<Navbar
show={showNavbar}
breadcrumb={displayedBreadcrumb}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</div>
{userLoading ? (
<Spin className={styles['spin']} />
@ -275,17 +412,6 @@ function PageLayout() {
)}
<Layout className={styles['layout-content']} style={paddingStyle}>
<div className={styles['layout-content-wrapper']}>
{!!breadcrumb.length && (
<div className={styles['layout-breadcrumb']}>
<Breadcrumb>
{breadcrumb.map((node, index) => (
<Breadcrumb.Item key={index}>
{typeof node === 'string' ? locale[node] || node : node}
</Breadcrumb.Item>
))}
</Breadcrumb>
</div>
)}
<TabBar />
<Content>
<Switch>

View File

@ -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': '页面配置',

View File

@ -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,
});
});
}

View File

@ -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<string, string[]>;
};
userLoading?: boolean;

91
src/store/userStore.ts Normal file
View File

@ -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<string, string[]>;
}
interface UserState {
userInfo: UserInfo;
userLoading: boolean;
// Actions
setUserInfo: (info: Partial<UserInfo>) => void;
updateUserInfo: (partial: Partial<UserInfo>) => void;
setUserLoading: (loading: boolean) => void;
clearUserInfo: () => void;
}
const initialUserInfo: UserInfo = {
permissions: {},
};
export const useUserStore = create<UserState>()(
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;