refactor(状态管理): 迁移到 Zustand 并优化布局
- 新增 Zustand 用户状态管理(userStore.ts) - 集成 persist 中间件实现自动持久化 - 简化 layout.tsx 用户状态管理逻辑 - 更新 Redux store 配置 - 优化主入口和国际化配置
This commit is contained in:
parent
b3c0a05a3e
commit
f20951fb92
172
src/layout.tsx
172
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 <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>
|
||||
|
||||
@ -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': '页面配置',
|
||||
|
||||
16
src/main.tsx
16
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
91
src/store/userStore.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user