diff --git a/docs/Zustand-Migration.md b/docs/Zustand-Migration.md index f309eeed..ed12784c 100644 --- a/docs/Zustand-Migration.md +++ b/docs/Zustand-Migration.md @@ -1,11 +1,13 @@ # Zustand 状态迁移与示例集成 ## 概要 + 本文件记录将项目中 Redux 逻辑替换为 Zustand(模块化 slice + 组合式 API)的完整变更与使用说明,包含:代码位置、改动说明、使用示例、验证步骤和扩展指南,作为后续功能扩展的参考示例。 --- ## 本次变更一览(文件) + - 新增 / 修改: - `src/store/types.ts` —— 新增状态类型声明 - `src/store/modules/counterSlice.ts` —— 新增 Counter slice @@ -21,8 +23,8 @@ - 删除: - 项目中原始 Redux demo / action / reducer 文件(已删除冗余 `src/actions` / `src/reducers` 中的示例文件) - ## 主要改动说明 + - 状态组织 - 采用 Slice Pattern:每个功能模块(例如 counter、app)在 `src/store/modules` 下维护自己的 state + actions。所有 slice 在 `src/store/index.ts` 中组合为单个 `useStore` Hook。 - 持久化:通过 `zustand/middleware` 的 `persist` 对 `isSidebarOpen` 做了示例性持久化(可在 `partialize` 中调整持久字段)。 @@ -37,8 +39,8 @@ - 清理 Redux 代码 - 项目中原有用于示例的 Redux action/reducer 已删除,且已从依赖中卸载 `redux react-redux @reduxjs/toolkit redux-logger`。若项目还有其它 Redux 依赖点,会在构建/运行时暴露引用错误,请按需删除或替换。 - ## 如何在组件中使用(示例) + - 仅使用需要的字段以避免不必要渲染: ```tsx @@ -49,54 +51,63 @@ const increment = useStore((s) => s.increment) ``` - 示例:控制侧边栏(任意组件) + ```tsx const toggle = useStore((s) => s.toggleSidebar) ``` - ## 新增 Slice(扩展步骤) + 1. 在 `src/store/modules` 新建 `yourSlice.ts`:导出 `createYourSlice: StateCreator`。 2. 在 `src/store/types.ts` 中添加 `YourSlice` 的类型,并将 `StoreState` 组合上新的类型。 3. 在 `src/store/index.ts` 中引入并展开该 slice: + ```ts ...createCounterSlice(...a), ...createAppSlice(...a), ...createYourSlice(...a), ``` -4. 在页面中直接使用 `useStore((s) => s.yourField)`。 +4. 在页面中直接使用 `useStore((s) => s.yourField)`。 ## 验证与运行 + - 本地开发预览: + ```bash npm run dev # 或(项目有多个脚本) npm run dev:vite ``` + - 打包测试(库构建): + ```bash npm run build:lib ``` + - 推荐检查点: - 访问 `/zustand` 页面,验证计数器、异步动作、侧边栏切换是否生效。 - 在多个页面切换,确认 `keepAlive`(路由 meta)与组件行为一致。 - ## 回滚与注意事项 + - 如果需回滚到 Redux,保留已删除文件的历史(Git 提交),通过 `git checkout` 恢复。当前改动假设项目不再使用 Redux。 - 注意第三方依赖:某些库或页面可能仍旧通过 `react-redux` 提供的 context 注入数据,迁移需逐步替换对应调用点(`useSelector`、`useDispatch`)。 - ## 结语 + 本次迁移以最小侵入(模块化 slice)方式将示例功能从 Redux 替换为 Zustand,并演示了如何将全局 UI 状态(如侧边栏)统一管理。该结构便于横向扩展(继续加入更多 slice),且组件可以按需订阅状态以获得高性能渲染。 如需我: + - 把 README / contrib 文档中添加迁移说明; - 运行一次端到端检查(`npm run test:e2e`)并修复发现的问题; - 或把更多业务模块按同样模式迁移 —— 我可以继续逐步替换并提交 PR。 --- + 文档位置:`docs/Zustand-Migration.md`。 ## 权限、角色与 i18n 更新说明 @@ -104,12 +115,14 @@ npm run build:lib 为确保 `Zustand` 示例页面在真实项目中按照角色与本地化规则正确展示,以下是本次针对权限与国际化(i18n)的具体修改与操作说明: ### 代码层面已做的修改 + - 路由:`src/routers/modules/business.routes.jsx` 中新增路由条目 `/zustand`,并在 `meta` 中声明 `permission`(示例为 `['admin','manager','dev','user']`)与 `keepAlive: true`。 - 懒加载:在 `src/routers/config/lazyLoad.config.jsx` 注册 `ZustandDemo`(`ZustandDemo: lazyLoad(() => import('@pages/zustand'), { preload: true })`)。 - 菜单:在 `src/config/menu.config.jsx` 中新增菜单项并设置 `i18nKey: 'menu.zustand'`。 - 路由权限映射:`src/mock/permission.ts` 中已包含 `/zustand` 对应的 `routePermissionMap`(`'/zustand': 'zustand:read'`),并在 `mockRoles` 中为部分角色分配了 `zustand:read` 权限(例如 `admin`)。 ### 如何为账号分配角色(开发/测试) + 项目提供了三种常见的测试/开发方式来给账号分配角色: 1. 使用内置测试账号(推荐快速验证) @@ -129,12 +142,13 @@ localStorage.setItem('user_role', 'admin') // 然后刷新,permissionService 会优先使用该覆盖 ``` - - 这种方式仅用于开发或 demo,不会影响真实后端数据。 +- 这种方式仅用于开发或 demo,不会影响真实后端数据。 3. 真实后端授权(生产环境) - 若项目对接真实权限 API,请在后端给对应用户分配 `roles` / `permissions`,后端 `permission` 接口会返回完整的 `UserPermission`(包含 `roles`、`permissions`、`routes`),`permissionService` 会从该接口读取并缓存。 ### 修改 i18n key 的建议与示例 + - 菜单项已设置 `i18nKey: 'menu.zustand'`(`src/config/menu.config.jsx`)。请在项目的 i18n 字典中添加对应条目,例如: ```json @@ -148,13 +162,15 @@ localStorage.setItem('user_role', 'admin') - 若需要页面标题国际化,可在路由配置添加 `i18nKey: 'routes.zustand.title'`,并在 i18n 词典中添加 `routes.zustand.title`。 ### 路由 & 权限生效说明 + - 应用启动或登录时,`permissionService.getPermissions()` 会根据: 1. `localStorage.user_role`(手动覆盖) 2. GitHub / token 标识(若启用) 3. 后端权限接口 - 依次获取并构建 `UserPermission` 对象,包含 `roles`、`permissions` 与 `routes`。路由访问控制通过 `permissionService.canAccessRoute(path)` 实现。 + 依次获取并构建 `UserPermission` 对象,包含 `roles`、`permissions` 与 `routes`。路由访问控制通过 `permissionService.canAccessRoute(path)` 实现。 ### 在代码中判断权限的示例 + 在组件或路由守卫中检查角色/权限: ```ts @@ -168,16 +184,17 @@ const canAccess = await permissionService.canAccessRoute('/zustand') ``` ### 文档记录位置与变更说明 + - 本节记录了如何为 `Zustand` 页面配置权限(路由 `meta.permission`)、如何给账号分配角色(mock、localStorage 覆盖、后端)以及 i18n key 的使用建议。 - 已在代码中完成的改动请参考上方“本次变更一览(文件)”。 ## 操作小结(快速步骤) + 1. 添加页面并注册路由:`src/pages/zustand/index.tsx` → `lazyLoad.config.jsx` → `business.routes.jsx`(加 `meta.permission`) 2. 在 `src/config/menu.config.jsx` 中添加 `i18nKey` 并在本地化文件中添加对应翻译。 3. 本地测试权限:使用 `localStorage.setItem('user_role', '')` 或使用测试账号登录。 4. 验证:登录后访问 `/zustand`,或在控制台调用 `permissionService.canAccessRoute('/zustand')`。 - ## 已添加的翻译条目 为方便直接展示菜单项,已在项目的中英文 i18n 词典中添加或确认了 `menu.zustand` 条目: @@ -186,5 +203,3 @@ const canAccess = await permissionService.canAccessRoute('/zustand') - 英文(已添加):[src/locales/en/translation.js](src/locales/en/translation.js#L1) 中的 `menu.zustand: 'Zustand'`。 如果你希望使用不同的显示文案(例如中文显示为 “Zustand 示例”),请直接在上述文件中修改对应值并重启应用以加载新的语言资源。 - - diff --git a/src/config/menu.config.jsx b/src/config/menu.config.jsx index 3b34269f..8ab48555 100644 --- a/src/config/menu.config.jsx +++ b/src/config/menu.config.jsx @@ -32,7 +32,7 @@ import { ProjectOutlined, FileTextOutlined, ThunderboltOutlined, - ToolOutlined, + ToolOutlined } from '@ant-design/icons' // 静态菜单配置 @@ -44,127 +44,127 @@ const rawMainLayoutMenu = [ label: 'Zustand演示', i18nKey: 'menu.zustand', key: '/zustand', - icon: , + icon: }, { label: 'Motion', i18nKey: 'menu.motion', key: '/motion', - icon: , + icon: }, { label: 'Mermaid', i18nKey: 'menu.mermaid', key: '/mermaid', - icon: , + icon: }, { label: 'Topology', i18nKey: 'menu.topology', key: '/topology', - icon: , + icon: }, { label: '权限示例', i18nKey: 'menu.permissionExample', key: '/permission', - icon: , + icon: }, { label: 'PH Bar', i18nKey: 'menu.phBar', key: '/ph-bar', - icon: , + icon: }, { label: 'ChatGPT', i18nKey: 'menu.chatgpt', key: '/markmap', - icon: , + icon: }, { label: 'React Tilt', i18nKey: 'menu.reactTilt', key: '/tilt', - icon: , + icon: }, { label: 'Music', i18nKey: 'menu.music', key: '/music', - icon: , + icon: }, { label: 'Crypto', i18nKey: 'menu.crypto', key: '/crypto', - icon: , + icon: }, { label: 'Video', i18nKey: 'menu.video', key: '/video', - icon: , + icon: }, { label: 'Big Screen', i18nKey: 'menu.bigScreen', key: '/big-screen', - icon: , + icon: }, { label: 'Echarts', i18nKey: 'menu.echarts', key: '/echarts', - icon: , + icon: }, { label: 'Qr Generate', i18nKey: 'menu.qrGenerate', key: '/qrcode', - icon: , + icon: }, { label: 'Business', i18nKey: 'menu.business', key: '/business', - icon: , + icon: }, { label: 'Prism Render', i18nKey: 'menu.prismRender', key: '/prism', - icon: , + icon: }, { label: 'Post Message', i18nKey: 'menu.postMessage', key: '/postmessage', - icon: , + icon: }, { label: 'Geo Chart', i18nKey: 'menu.geoChart', key: '/geo', - icon: , + icon: }, { label: 'Print', i18nKey: 'menu.print', key: '/print', - icon: , + icon: }, { label: 'Profile', i18nKey: 'menu.profile', key: '/profile', - icon: , + icon: }, { label: 'Contact', i18nKey: 'menu.contact', key: '/contact', - icon: , + icon: }, { label: '前端技术栈', @@ -176,7 +176,7 @@ const rawMainLayoutMenu = [ label: 'React', i18nKey: 'menu.react', key: '/tech/frontend/react', - icon: , + icon: }, { label: 'Vue', @@ -194,31 +194,31 @@ const rawMainLayoutMenu = [ label: 'Vue3 API', i18nKey: 'menu.vue3Api', key: '/tech/frontend/plugins/vue3', - icon: , + icon: }, { label: '性能优化', i18nKey: 'menu.performanceOptimization', key: '/tech/frontend/plugins/perf', - icon: , - }, - ], - }, - ], + icon: + } + ] + } + ] }, { label: 'Angular', i18nKey: 'menu.angular', key: '/tech/frontend/angular', - icon: , + icon: }, { label: 'Node', i18nKey: 'menu.node', key: '/tech/frontend/node', - icon: , - }, - ], + icon: + } + ] }, { label: '后端技术栈', @@ -230,21 +230,21 @@ const rawMainLayoutMenu = [ label: 'Node', i18nKey: 'menu.node', key: '/tech/backend/node', - icon: , + icon: }, { label: 'Java', i18nKey: 'menu.java', key: '/tech/backend/java', - icon: , + icon: }, { label: 'Go', i18nKey: 'menu.go', key: '/tech/backend/go', - icon: , - }, - ], + icon: + } + ] }, { label: '构建工具', @@ -256,15 +256,15 @@ const rawMainLayoutMenu = [ label: 'Webpack', i18nKey: 'menu.webpack', key: '/build/webpack', - icon: , + icon: }, { label: 'Vite', i18nKey: 'menu.vite', key: '/build/vite', - icon: , - }, - ], + icon: + } + ] }, { @@ -272,12 +272,12 @@ const rawMainLayoutMenu = [ i18nKey: 'menu.error', key: '/sub-error', icon: , - children: [{ label: 'ErrorBoundary', i18nKey: 'menu.errorBoundary', key: '/error' }], - }, + children: [{ label: 'ErrorBoundary', i18nKey: 'menu.errorBoundary', key: '/error' }] + } ] // 规范化菜单:为每个项保证存在 `path` 字段(优先使用已有 path,否则复制 key)。 -function normalizeMenu(items) { +function normalizeMenu (items) { return items.map((it) => { const { children, ...rest } = it const normalized = { ...rest, path: (it && it.path) || it.key } diff --git a/src/locales/en/translation.js b/src/locales/en/translation.js index a239d3fa..507dd6a9 100644 --- a/src/locales/en/translation.js +++ b/src/locales/en/translation.js @@ -5,7 +5,7 @@ const en = { nav: { home: 'Home', dashboard: 'Dashboard', - portfolio: 'My Portfolio', + portfolio: 'My Portfolio' }, header: { search: 'Menu search', @@ -21,7 +21,7 @@ const en = { userCenter: 'Profile', userSettings: 'Settings', contactMe: 'Contact', - logout: 'Log out', + logout: 'Log out' }, settingDrawer: { title: 'Preferences', @@ -31,11 +31,11 @@ const en = { navigationMode: 'Navigation mode', contentWidth: 'Content width', other: 'Other settings', - visualEffects: 'Visual effects', + visualEffects: 'Visual effects' }, navTheme: { light: 'Light menu style', - dark: 'Dark menu style', + dark: 'Dark menu style' }, enableDarkMode: 'Enable dark mode', colors: { @@ -46,13 +46,13 @@ const en = { cyan: 'Cyan', auroraGreen: 'Aurora Green', geekBlue: 'Geek Blue', - purple: 'Purple', + purple: 'Purple' }, customColor: 'Custom color', layout: { side: 'Side menu layout', top: 'Top menu layout', - mix: 'Mixed menu layout', + mix: 'Mixed menu layout' }, contentWidth: 'Content width', contentWidthFixed: 'Fixed', @@ -62,12 +62,12 @@ const en = { grayMode: 'Grayscale mode', compactMode: 'Compact mode', fixedHeader: 'Fixed header', - fixedSider: 'Fixed sidebar', + fixedSider: 'Fixed sidebar' }, effects: { pointerFollow: 'Pointer follow', - pointerTrail: 'Pointer trail', - }, + pointerTrail: 'Pointer trail' + } }, menu: { motion: 'Motion', @@ -110,7 +110,7 @@ const en = { vite: 'Vite', error: 'Error', - errorBoundary: 'ErrorBoundary', + errorBoundary: 'ErrorBoundary' }, svp: { ariaPlaybackSettings: 'Playback settings', @@ -189,8 +189,8 @@ const en = { videoNetworkError: 'Network error: failed to load video', videoDecodeError: 'Decode error (unsupported format/codec)', videoSourceNotSupported: 'Video source unavailable (404/CORS/type unsupported)', - videoLoadFailed: 'Failed to load video', - }, + videoLoadFailed: 'Failed to load video' + } } export default en diff --git a/src/locales/zh/translation.js b/src/locales/zh/translation.js index e94519a1..a90b29fc 100644 --- a/src/locales/zh/translation.js +++ b/src/locales/zh/translation.js @@ -5,7 +5,7 @@ const zh = { nav: { home: '首页', dashboard: '多路由设置', - portfolio: '我的作品集', + portfolio: '我的作品集' }, header: { search: '菜单搜索', @@ -21,7 +21,7 @@ const zh = { userCenter: '个人中心', userSettings: '个人设置', contactMe: '联 系 我', - logout: '退出登录', + logout: '退出登录' }, settingDrawer: { title: '偏好设置', @@ -31,11 +31,11 @@ const zh = { navigationMode: '导航模式', contentWidth: '内容区域宽度', other: '其他设置', - visualEffects: '视觉特效', + visualEffects: '视觉特效' }, navTheme: { light: '亮色菜单风格', - dark: '暗色菜单风格', + dark: '暗色菜单风格' }, enableDarkMode: '开启暗黑模式', colors: { @@ -46,13 +46,13 @@ const zh = { cyan: '明青', auroraGreen: '极光绿', geekBlue: 'Geek Blue', - purple: '酱紫', + purple: '酱紫' }, customColor: '自定义颜色', layout: { side: '侧边菜单布局', top: '顶部菜单布局', - mix: '混合菜单布局', + mix: '混合菜单布局' }, contentWidth: '内容区域宽度', contentWidthFixed: '固定', @@ -62,12 +62,12 @@ const zh = { grayMode: '灰色模式', compactMode: '紧凑模式', fixedHeader: '固定 Header', - fixedSider: '固定侧边菜单', + fixedSider: '固定侧边菜单' }, effects: { pointerFollow: '指针跟随', - pointerTrail: '指针轨迹', - }, + pointerTrail: '指针轨迹' + } }, menu: { motion: '动效', @@ -110,7 +110,7 @@ const zh = { vite: 'Vite', error: '错误', - errorBoundary: '错误边界', + errorBoundary: '错误边界' }, svp: { ariaPlaybackSettings: '播放设置', @@ -189,8 +189,8 @@ const zh = { videoNetworkError: '网络错误导致视频加载失败', videoDecodeError: '视频解码失败(格式/编码不兼容)', videoSourceNotSupported: '视频源不可用(404/跨域/类型不支持)', - videoLoadFailed: '视频加载失败', - }, + videoLoadFailed: '视频加载失败' + } } export default zh diff --git a/src/pages/layout/index.jsx b/src/pages/layout/index.jsx index b8400a3b..aa0e26f4 100644 --- a/src/pages/layout/index.jsx +++ b/src/pages/layout/index.jsx @@ -30,16 +30,18 @@ const ProLayout = () => { return ( {themeSettings.pointerMove ? : null} - {themeSettings.magicTrail ? ( - - ) : null} + {themeSettings.magicTrail + ? ( + + ) + : null} { isMobile={isMobile} onMobileMenuClick={() => setMobileOpen(true)} > - {layout === 'top' && !isMobile ? : null} + {layout === 'top' && !isMobile ? : null} - {isMobile ? ( - setMobileOpen(false)} - open={mobileOpen} - size={208} - styles={{ body: { padding: 0 } }} - closable={false} - > - - setMobileOpen(false)} /> - - - ) : ( - layout !== 'top' && ( - - - - ) - )} + {isMobile + ? ( + setMobileOpen(false)} + open={mobileOpen} + size={208} + styles={{ body: { padding: 0 } }} + closable={false} + > + + setMobileOpen(false)} /> + + + ) + : ( + layout !== 'top' && ( + + + + ) + )} diff --git a/src/pages/layout/proSider/index.jsx b/src/pages/layout/proSider/index.jsx index 076bde3a..9bf2e9a0 100644 --- a/src/pages/layout/proSider/index.jsx +++ b/src/pages/layout/proSider/index.jsx @@ -24,16 +24,18 @@ const ProSider = ({ children, theme = 'light', isMobile }) => { {children} {!isMobile && ( )} @@ -43,7 +45,7 @@ const ProSider = ({ children, theme = 'light', isMobile }) => { ProSider.propTypes = { children: PropTypes.node, theme: PropTypes.string, - isMobile: PropTypes.bool, + isMobile: PropTypes.bool // 侧边栏折叠状态由全局 Zustand `isSidebarOpen` 管理 } diff --git a/src/routers/config/lazyLoad.config.jsx b/src/routers/config/lazyLoad.config.jsx index e5f8b974..0a13d45d 100644 --- a/src/routers/config/lazyLoad.config.jsx +++ b/src/routers/config/lazyLoad.config.jsx @@ -58,19 +58,19 @@ export const lazyComponents = { Mermaid: lazyLoad(() => import('@pages/mermaid'), { preload: true }), PostMessage: lazyLoad(() => import('@pages/postmessage'), { preload: true }), MyIframe: lazyLoad(() => import('@pages/postmessage/myIframe'), { - preload: true, + preload: true }), Print: lazyLoad(() => import('@pages/print'), { preload: true }), // 通知页面 Notifications: lazyLoad(() => import('@pages/notifications'), { - preload: true, + preload: true }), NotificationDetail: lazyLoad(() => import('@pages/notifications/NotificationDetail'), { preload: true }), PHBar: lazyLoad(() => import('@pages/phbar'), { preload: true }), // 权限示例页面 PermissionExample: lazyLoad(() => import('@pages/permission'), { - preload: true, + preload: true }), // 示例:如果新增一个按需加载的路由组件,请在此处注册(示例) @@ -85,45 +85,45 @@ export const lazyComponents = { // 嵌套路由 BackendStack: lazyLoad(() => import('@pages/tech/backend'), { - preload: true, + preload: true }), FrontendStack: lazyLoad(() => import('@pages/tech/frontend'), { - preload: true, + preload: true }), ReactDemo: lazyLoad(() => import('@pages/tech/demos/react'), { - preload: true, + preload: true }), VueDemo: lazyLoad(() => import('@pages/tech/demos/vue'), { preload: true }), AngularDemo: lazyLoad(() => import('@pages/tech/demos/angular'), { - preload: true, + preload: true }), NodeDemo: lazyLoad(() => import('@pages/tech/demos/node'), { preload: true }), VuePlugins: lazyLoad(() => import('@pages/tech/demos/vue/plugins'), { - preload: true, + preload: true }), Vue3Plugin: lazyLoad(() => import('@pages/tech/demos/vue/plugins/vue3'), { - preload: true, + preload: true }), VuePerfPlugin: lazyLoad(() => import('@pages/tech/demos/vue/plugins/perf'), { - preload: true, + preload: true }), WebpackList: lazyLoad(() => import('@pages/tech/demos/webpack'), { - preload: true, + preload: true }), ViteList: lazyLoad(() => import('@pages/order/list'), { preload: true }), // 异常页面 ErrorPage: lazyLoad(() => import('@pages/error'), { preload: true }), Exception403: lazyLoad(() => import('@stateless/Exception/exception403'), { - preload: true, + preload: true }), Exception404: lazyLoad(() => import('@stateless/Exception/exception404'), { - preload: true, + preload: true }), NoMatch: lazyLoad(() => import('@stateless/NoMatch'), { preload: true }), SectionNotFound: lazyLoad(() => import('@stateless/SectionNotFound'), { - preload: true, - }), + preload: true + }) } // 兼容性导出(保持原有导入方式) diff --git a/src/routers/modules/business.routes.jsx b/src/routers/modules/business.routes.jsx index 8b236a98..bf164bc3 100644 --- a/src/routers/modules/business.routes.jsx +++ b/src/routers/modules/business.routes.jsx @@ -11,37 +11,37 @@ export const businessRoutes = [ name: 'Demo', i18nKey: 'demo', key: '/demo', - element: , + element: }, { path: 'zustand', name: 'Zustand演示', i18nKey: 'zustand', key: '/zustand', - element: , + element: }, { path: 'motion', name: 'Motion', key: '/motion', - element: , + element: }, { path: 'business', name: 'Business', key: '/business', - element: , + element: }, { path: 'build/vite', name: 'Vite', key: '/build/vite', - element: , + element: }, { path: 'build/webpack', name: 'Webpack', key: '/build/webpack', - element: , - }, + element: + } ] diff --git a/src/routers/modules/zustand.routes.jsx b/src/routers/modules/zustand.routes.jsx index af821827..8feee44f 100644 --- a/src/routers/modules/zustand.routes.jsx +++ b/src/routers/modules/zustand.routes.jsx @@ -10,7 +10,7 @@ export const zustandRoutes = [ element: , // 下面会注册 meta: { permission: ['admin', 'dev', 'user'], // 示例权限 - keepAlive: true, - }, - }, + keepAlive: true + } + } ]