From f07ffdc64c8b86050bea41bafcb8363b4ac03cc8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 4 Aug 2026 14:41:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E6=9D=83=E9=99=90/=E7=95=8C=E9=9D=A2=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BF=81=E7=A7=BB=E8=87=B3=20zustand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/App.tsx | 3 +- apps/admin/src/api/index.ts | 10 +- .../permission-state.integration.test.tsx | 32 ++-- apps/admin/src/auth/permission-store.ts | 40 ---- apps/admin/src/components/DefaultRoute.tsx | 9 +- .../admin/src/components/NotificationBell.tsx | 3 +- apps/admin/src/components/PermissionRoute.tsx | 8 +- apps/admin/src/components/RouteDock/index.tsx | 43 +---- .../RouteKeeper.integration.test.tsx | 96 ++++++++++ apps/admin/src/components/RouteKeeper.tsx | 43 +++++ apps/admin/src/hooks/usePermission.ts | 21 +-- apps/admin/src/layouts/MainLayout.tsx | 100 +++++----- apps/admin/src/pages/Attendance/index.tsx | 11 +- apps/admin/src/pages/Classes/detail.tsx | 3 +- .../src/pages/Classes/teacher-candidate.ts | 11 +- .../src/pages/ClassroomSchedule/index.tsx | 4 +- apps/admin/src/pages/Classrooms/index.tsx | 5 +- apps/admin/src/pages/Dashboard/index.tsx | 6 +- apps/admin/src/pages/Login/index.tsx | 11 +- apps/admin/src/pages/Notifications/index.tsx | 20 +- apps/admin/src/pages/Occupancies/index.tsx | 10 +- apps/admin/src/pages/Rooms/index.tsx | 2 +- apps/admin/src/store/app/appStore.ts | 67 +++++++ apps/admin/src/store/app/appTypes.ts | 39 ++++ apps/admin/src/store/index.ts | 26 +++ apps/admin/src/store/middleware/persist.ts | 175 ++++++++++++++++++ .../src/store/permission/permissionStore.ts | 48 +++++ .../src/store/permission/permissionTypes.ts | 25 +++ .../admin/src/store/settings/settingsStore.ts | 45 +++++ .../admin/src/store/settings/settingsTypes.ts | 15 ++ apps/admin/src/store/types.ts | 14 ++ apps/admin/src/store/user/userActions.ts | 33 ++++ apps/admin/src/store/user/userStore.ts | 29 +++ apps/admin/src/store/user/userTypes.ts | 38 ++++ apps/admin/src/test/helpers.ts | 16 +- apps/admin/src/test/setup.ts | 15 ++ apps/admin/src/utils/download.ts | 4 +- apps/admin/vitest.config.ts | 3 + docs/zustand-migration.md | 98 ++++++++++ 39 files changed, 970 insertions(+), 211 deletions(-) delete mode 100644 apps/admin/src/auth/permission-store.ts create mode 100644 apps/admin/src/components/RouteKeeper.integration.test.tsx create mode 100644 apps/admin/src/components/RouteKeeper.tsx create mode 100644 apps/admin/src/store/app/appStore.ts create mode 100644 apps/admin/src/store/app/appTypes.ts create mode 100644 apps/admin/src/store/index.ts create mode 100644 apps/admin/src/store/middleware/persist.ts create mode 100644 apps/admin/src/store/permission/permissionStore.ts create mode 100644 apps/admin/src/store/permission/permissionTypes.ts create mode 100644 apps/admin/src/store/settings/settingsStore.ts create mode 100644 apps/admin/src/store/settings/settingsTypes.ts create mode 100644 apps/admin/src/store/types.ts create mode 100644 apps/admin/src/store/user/userActions.ts create mode 100644 apps/admin/src/store/user/userStore.ts create mode 100644 apps/admin/src/store/user/userTypes.ts create mode 100644 docs/zustand-migration.md diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 77ee72f..2176015 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -8,6 +8,7 @@ import MainLayout from './layouts/MainLayout'; import PermissionRoute from './components/PermissionRoute'; import DefaultRoute from './components/DefaultRoute'; import AppMessageBridge from './ui/AppMessageBridge'; +import { useUserStore } from './store/user/userStore'; const LoginPage = lazy(() => import('./pages/Login')); const DashboardPage = lazy(() => import('./pages/Dashboard')); @@ -42,7 +43,7 @@ const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig')); const AiConfigPage = lazy(() => import('./pages/AiConfig')); const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const token = localStorage.getItem('token'); + const token = useUserStore((state) => state.token); return token ? <>{children} : ; }; diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index cc2e4c1..76b6f5b 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -1,5 +1,6 @@ import axios, { type AxiosRequestConfig } from 'axios'; -import { clearPermissions } from '../auth/permission-store'; +import { usePermissionStore } from '../store/permission/permissionStore'; +import { useUserStore } from '../store/user/userStore'; const instance = axios.create({ baseURL: '/api', @@ -7,7 +8,7 @@ const instance = axios.create({ }); instance.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -20,9 +21,8 @@ instance.interceptors.response.use( const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; if (err.response?.status === 401 && !isLoginRequest) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - clearPermissions(); + useUserStore.getState().logout(); + usePermissionStore.getState().clearPermissions(); window.location.href = '/login'; } if (err.response?.status === 403) { diff --git a/apps/admin/src/auth/permission-state.integration.test.tsx b/apps/admin/src/auth/permission-state.integration.test.tsx index 04d7198..6355d1b 100644 --- a/apps/admin/src/auth/permission-state.integration.test.tsx +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -2,12 +2,7 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import PermissionButton from '../components/PermissionButton'; -import { - beginPermissionVerification, - clearPermissions, - readPermissionState, - writePermissions, -} from './permission-store'; +import { usePermissionStore } from '../store/permission/permissionStore'; let container: HTMLDivElement | null = null; let root: ReturnType | null = null; @@ -27,18 +22,25 @@ async function renderPermissionButton() { }); } +function readPermissionState() { + return { + permissions: usePermissionStore.getState().permissions, + status: usePermissionStore.getState().status, + }; +} + afterEach(async () => { if (root) await act(async () => root?.unmount()); container?.remove(); root = null; container = null; - clearPermissions(); + usePermissionStore.getState().clearPermissions(); }); describe('permission state', () => { it('ignores cached localStorage permissions until profile verification succeeds', async () => { localStorage.setItem('permissions', JSON.stringify(['student:edit'])); - beginPermissionVerification(); + usePermissionStore.getState().beginPermissionVerification(); expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); await renderPermissionButton(); @@ -46,20 +48,20 @@ describe('permission state', () => { }); it('renders permission actions only after verified permissions are written', async () => { - beginPermissionVerification(); + usePermissionStore.getState().beginPermissionVerification(); await renderPermissionButton(); expect(container?.textContent).not.toContain('编辑学生'); - await act(async () => writePermissions(['student:edit'])); + await act(async () => usePermissionStore.getState().writePermissions(['student:edit'])); expect(container?.textContent).toContain('编辑学生'); }); - it('stays fail-closed while profile verification is retried after a failure', async () => { - writePermissions(['student:edit']); - beginPermissionVerification(); + it('keeps verified permissions while profile verification refreshes in the background', async () => { + usePermissionStore.getState().writePermissions(['student:edit']); + usePermissionStore.getState().beginPermissionVerification(); - expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); + expect(readPermissionState()).toEqual({ permissions: ['student:edit'], status: 'ready' }); await renderPermissionButton(); - expect(container?.textContent).not.toContain('编辑学生'); + expect(container?.textContent).toContain('编辑学生'); }); }); diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts deleted file mode 100644 index e20e888..0000000 --- a/apps/admin/src/auth/permission-store.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated'; - -export type PermissionStatus = 'unknown' | 'loading' | 'ready'; - -export interface PermissionState { - permissions: string[]; - status: PermissionStatus; -} - -let permissionState: PermissionState = { permissions: [], status: 'unknown' }; - -function notifyPermissionStateChanged(): void { - window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT)); -} - -export function readPermissionState(): PermissionState { - return permissionState; -} - -export function readPermissions(): string[] { - return permissionState.status === 'ready' ? permissionState.permissions : []; -} - -export function beginPermissionVerification(): void { - permissionState = { permissions: [], status: 'loading' }; - notifyPermissionStateChanged(); -} - -export function writePermissions(permissions: string[]): void { - const uniquePermissions = [...new Set(permissions)]; - localStorage.setItem('permissions', JSON.stringify(uniquePermissions)); - permissionState = { permissions: uniquePermissions, status: 'ready' }; - notifyPermissionStateChanged(); -} - -export function clearPermissions(status: PermissionStatus = 'unknown'): void { - localStorage.removeItem('permissions'); - permissionState = { permissions: [], status }; - notifyPermissionStateChanged(); -} diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index 3c8da54..c9566b5 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -3,19 +3,14 @@ import { Navigate } from 'react-router-dom'; import { Result, Spin } from 'antd'; import { usePermission } from '../hooks/usePermission'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; +import { useUserStore } from '../store/user/userStore'; const DefaultRoute: React.FC = () => { const { permissions, permissionsReady } = usePermission(); if (!permissionsReady) { return ; } - const roles = (() => { - try { - return JSON.parse(localStorage.getItem('user') || '{}').roles || []; - } catch { - return []; - } - })(); + const roles = useUserStore((state) => state.user?.roles ?? []); const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; return ( diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index bc69f82..e8fe643 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -4,6 +4,7 @@ import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../api'; import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; +import { useUserStore } from '../store/user/userStore'; interface NotificationItem { id: number; @@ -56,7 +57,7 @@ const NotificationBell: React.FC = () => { // SSE connection — decoupled from popover open state useEffect(() => { fetchUnread(); - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; if (!token) return; const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); es.onmessage = (event) => { diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index a5dfe86..ff3f805 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -3,6 +3,7 @@ import { Result, Button, Spin } from 'antd'; import { useNavigate } from 'react-router-dom'; import { findRoleAwareLandingPath } from '../auth/menu-policy'; import { usePermission } from '../hooks/usePermission'; +import { useUserStore } from '../store/user/userStore'; interface PermissionRouteProps { permission: string; @@ -11,17 +12,12 @@ interface PermissionRouteProps { const PermissionRoute: React.FC = ({ permission, children }) => { const { permissions, permissionsReady, hasPermission } = usePermission(); + const roles = useUserStore((state) => state.user?.roles ?? []); const navigate = useNavigate(); if (!permissionsReady) { return ; } if (!hasPermission(permission)) { - let roles: string[] = []; - try { - roles = JSON.parse(localStorage.getItem('user') || '{}').roles || []; - } catch { - roles = []; - } const firstPath = findRoleAwareLandingPath(roles, permissions); return ( - typeof tab?.key === 'string' && tab.key.startsWith('/') && typeof tab?.label === 'string', - ); - } catch { - return []; - } -} - const DraggableTabNode: React.FC> = ({ ...props }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props['data-node-key'], @@ -87,28 +68,20 @@ const DraggableTabNode: React.FC> = ({ ...props const RouteDock: React.FC = ({ location, menuItems, onNavigate, draggable }) => { const activeKey = `${location.pathname}${location.search}`; - const [tabs, setTabs] = useState(() => { - const storedTabs = readStoredTabs(); - if (location.pathname === '/') return storedTabs; - if (storedTabs.some((tab) => tab.key === activeKey)) return storedTabs; - return [...storedTabs, { key: activeKey, label: getRouteLabel(menuItems, location.pathname) }]; - }); + const tabs = useAppStore((state) => state.routeDockTabs); + const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); useEffect(() => { if (location.pathname === '/') return; - setTabs((currentTabs) => { + setRouteDockTabs((currentTabs) => { const label = getRouteLabel(menuItems, location.pathname); const existing = currentTabs.find((tab) => tab.key === activeKey); if (!existing) return [...currentTabs, { key: activeKey, label }]; if (existing.label === label) return currentTabs; return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab)); }); - }, [activeKey, location.pathname, menuItems]); - - useEffect(() => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs)); - }, [tabs]); + }, [activeKey, location.pathname, menuItems, setRouteDockTabs]); const tabItems = useMemo>( () => @@ -124,7 +97,7 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, const targetIndex = tabs.findIndex((tab) => tab.key === targetKey); if (targetIndex < 0 || tabs.length === 1) return; const nextTabs = tabs.filter((tab) => tab.key !== targetKey); - setTabs(nextTabs); + setRouteDockTabs(nextTabs); if (targetKey === activeKey) { const nextActiveTab = nextTabs[Math.min(targetIndex, nextTabs.length - 1)]; if (nextActiveTab) onNavigate(nextActiveTab.key); @@ -133,7 +106,7 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; - setTabs((currentTabs) => { + setRouteDockTabs((currentTabs) => { const activeIndex = currentTabs.findIndex((tab) => tab.key === active.id); const overIndex = currentTabs.findIndex((tab) => tab.key === over.id); return activeIndex < 0 || overIndex < 0 diff --git a/apps/admin/src/components/RouteKeeper.integration.test.tsx b/apps/admin/src/components/RouteKeeper.integration.test.tsx new file mode 100644 index 0000000..8c23e72 --- /dev/null +++ b/apps/admin/src/components/RouteKeeper.integration.test.tsx @@ -0,0 +1,96 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RouteKeeper } from './RouteKeeper'; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function PageA() { + const navigate = useNavigate(); + return ( +
+ + +
+ ); +} + +function PageB() { + const navigate = useNavigate(); + return ( +
+ + +
+ ); +} + +function Harness() { + return ( + + + }> + } /> + } /> + + + + ); +} + +function type(target: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(target, value); + target.dispatchEvent(new Event('input', { bubbles: true })); +} + +describe('RouteKeeper', () => { + it('keeps page instances and input values alive across navigation', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root?.render()); + + const inputA = document.querySelector('[data-testid="input-a"]') as HTMLInputElement; + expect(inputA).not.toBeNull(); + await act(async () => type(inputA, '待保存的学生姓名')); + + await act(async () => { + (document.querySelector('[data-testid="go-b"]') as HTMLButtonElement).click(); + }); + const inputB = document.querySelector('[data-testid="input-b"]') as HTMLInputElement; + expect(inputB).not.toBeNull(); + await act(async () => type(inputB, '待保存的宿舍号')); + + await act(async () => { + (document.querySelector('[data-testid="go-a"]') as HTMLButtonElement).click(); + }); + + const keptA = document.querySelector('[data-testid="input-a"]') as HTMLInputElement; + expect(keptA).not.toBeNull(); + expect(keptA.value).toBe('待保存的学生姓名'); + const keptB = document.querySelector('[data-testid="input-b"]') as HTMLInputElement; + expect(keptB.value).toBe('待保存的宿舍号'); + + const pages = document.querySelectorAll('.route-keeper-page'); + expect(pages.length).toBe(2); + const hidden = pages[1] as HTMLElement; + expect(hidden.style.display).toBe('none'); + }); +}); diff --git a/apps/admin/src/components/RouteKeeper.tsx b/apps/admin/src/components/RouteKeeper.tsx new file mode 100644 index 0000000..4ae4111 --- /dev/null +++ b/apps/admin/src/components/RouteKeeper.tsx @@ -0,0 +1,43 @@ +import React, { useRef } from 'react'; +import { useLocation, useOutlet } from 'react-router-dom'; + +const MAX_CACHED_PAGES = 30; + +/** + * 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。 + * 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。 + */ +export const RouteKeeper: React.FC = () => { + const location = useLocation(); + const outlet = useOutlet(); + const cacheRef = useRef>(new Map()); + const orderRef = useRef([]); + // 仅以 pathname 作为缓存键:页面内部通过 URL 参数同步状态时不会 + // 产生第二个实例,切回时也不会因此重挂载。 + const pageKey = location.pathname; + + if (outlet && !cacheRef.current.has(pageKey)) { + cacheRef.current.set(pageKey, outlet); + orderRef.current.push(pageKey); + if (orderRef.current.length > MAX_CACHED_PAGES) { + const oldest = orderRef.current.shift(); + if (oldest && oldest !== pageKey) cacheRef.current.delete(oldest); + } + } + + return ( + <> + {Array.from(cacheRef.current.entries()).map(([key, node]) => ( +
+ {node} +
+ ))} + + ); +}; + +export default RouteKeeper; diff --git a/apps/admin/src/hooks/usePermission.ts b/apps/admin/src/hooks/usePermission.ts index 924c26c..9d51ece 100644 --- a/apps/admin/src/hooks/usePermission.ts +++ b/apps/admin/src/hooks/usePermission.ts @@ -1,19 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; -import { PERMISSIONS_UPDATED_EVENT, readPermissionState } from '../auth/permission-store'; +import { useCallback } from 'react'; +import { usePermissionStore } from '../store/permission/permissionStore'; export function usePermission() { - const [state, setState] = useState(readPermissionState); - - useEffect(() => { - const refresh = () => setState(readPermissionState()); - window.addEventListener(PERMISSIONS_UPDATED_EVENT, refresh); - return () => { - window.removeEventListener(PERMISSIONS_UPDATED_EVENT, refresh); - }; - }, []); - - const permissions = state.permissions; - const permissionsReady = state.status === 'ready'; + const permissions = usePermissionStore((state) => state.permissions); + const permissionStatus = usePermissionStore((state) => state.status); + const permissionsReady = permissionStatus === 'ready'; const hasPermission = useCallback( (code: string): boolean => permissionsReady && permissions.includes(code), [permissions, permissionsReady], @@ -31,7 +22,7 @@ export function usePermission() { return { permissions, - permissionStatus: state.status, + permissionStatus, permissionsReady, hasPermission, hasAnyPermission, diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 779f41e..64ffeed 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid, Tooltip } from 'antd'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd'; import { DashboardOutlined, TeamOutlined, @@ -28,16 +28,16 @@ import { TrophyOutlined, ApiOutlined, RobotOutlined, + LoadingOutlined, } from '@ant-design/icons'; import { usePermission } from '../hooks/usePermission'; import api from '../api'; -import { - beginPermissionVerification, - clearPermissions, - writePermissions, -} from '../auth/permission-store'; +import { useAppStore } from '../store/app/appStore'; +import { usePermissionStore } from '../store/permission/permissionStore'; +import { useUserStore } from '../store/user/userStore'; import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; +import RouteKeeper from '../components/RouteKeeper'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); @@ -75,16 +75,22 @@ const iconMap: Record = { }; const MainLayout: React.FC = () => { - const [collapsed, setCollapsed] = useState(false); - const [drawerOpen, setDrawerOpen] = useState(false); - const [aiChatOpen, setAiChatOpen] = useState(false); - const [openKeys, setOpenKeys] = useState([]); const prevPathname = useRef(''); const navigate = useNavigate(); const location = useLocation(); - const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() => - JSON.parse(localStorage.getItem('user') || '{}'), - ); + const user = useUserStore((state) => state.user); + const updateUser = useUserStore((state) => state.updateUser); + const logoutUser = useUserStore((state) => state.logout); + const collapsed = useAppStore((state) => state.sidebarCollapsed); + const drawerOpen = useAppStore((state) => state.mobileDrawerOpen); + const aiChatOpen = useAppStore((state) => state.aiChatOpen); + const aiWorking = useAppStore((state) => state.aiWorking); + const openKeys = useAppStore((state) => state.menuOpenKeys); + const toggleSidebar = useAppStore((state) => state.toggleSidebar); + const setDrawerOpen = useAppStore((state) => state.setMobileDrawerOpen); + const setAiChatOpen = useAppStore((state) => state.setAiChatOpen); + const setAiWorking = useAppStore((state) => state.setAiWorking); + const setOpenKeys = useAppStore((state) => state.setMenuOpenKeys); const { permissions, hasPermission } = usePermission(); useEffect(() => { @@ -93,13 +99,13 @@ const MainLayout: React.FC = () => { let verificationInFlight = false; const verifyPermissions = () => { - if (cancelled || verificationInFlight || !localStorage.getItem('token')) return; + if (cancelled || verificationInFlight || !useUserStore.getState().token) return; if (retryTimer !== undefined) { window.clearTimeout(retryTimer); retryTimer = undefined; } verificationInFlight = true; - beginPermissionVerification(); + usePermissionStore.getState().beginPermissionVerification(); api .get<{ id: number; username: string; permissions: string[]; roles?: string[] }>( '/auth/profile', @@ -107,22 +113,19 @@ const MainLayout: React.FC = () => { .then((profile) => { if (cancelled) return; verificationInFlight = false; - writePermissions(profile.permissions || []); - const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); - const nextUser = { ...cachedUser, ...profile }; - localStorage.setItem('user', JSON.stringify(nextUser)); - setUser(nextUser); + usePermissionStore.getState().writePermissions(profile.permissions || []); + updateUser(profile); }) .catch(() => { verificationInFlight = false; - if (cancelled || !localStorage.getItem('token')) return; + if (cancelled || !useUserStore.getState().token) return; retryTimer = window.setTimeout(verifyPermissions, 5_000); }); }; const handleStorage = (event: StorageEvent) => { if (event.key !== 'token' && event.key !== 'permissions') return; - beginPermissionVerification(); + usePermissionStore.getState().beginPermissionVerification(); window.location.reload(); }; const handleOnline = () => verifyPermissions(); @@ -141,7 +144,7 @@ const MainLayout: React.FC = () => { window.removeEventListener('online', handleOnline); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, []); + }, [updateUser]); const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; // < 576px (仅 xs) @@ -150,16 +153,15 @@ const MainLayout: React.FC = () => { const usesDrawer = !isDesktop; const menuItems = useMemo( - () => buildMenu(user.roles ?? [], permissions), - [user.roles, permissions], + () => buildMenu(user?.roles ?? [], permissions), + [user, permissions], ); const handleLogout = useCallback(() => { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - clearPermissions(); + logoutUser(); + usePermissionStore.getState().clearPermissions(); navigate('/login'); - }, [navigate]); + }, [logoutUser, navigate]); const handleMenuClick = useCallback( (key: string) => { @@ -303,17 +305,25 @@ const MainLayout: React.FC = () => { ) } - onClick={() => (usesDrawer ? setDrawerOpen(true) : setCollapsed(!collapsed))} + onClick={() => (usesDrawer ? setDrawerOpen(true) : toggleSidebar())} />
{hasPermission('ai:chat:use') && ( - -
@@ -356,12 +366,16 @@ const MainLayout: React.FC = () => { borderRadius: 12, }} > - + - {hasPermission('ai:chat:use') && aiChatOpen && ( + {hasPermission('ai:chat:use') && ( - setAiChatOpen(false)} /> + setAiChatOpen(false)} + onRequestingChange={setAiWorking} + /> )} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 98b4c83..379c7bd 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -38,6 +38,7 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; +import { useUserStore } from '../../store/user/userStore'; import { canPullAttendance, getAttendanceExperience, @@ -184,12 +185,8 @@ const EMPTY_SUMMARY: AttendanceSummary = { }; function readCurrentRoles(): string[] { - try { - const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] }; - return Array.isArray(user.roles) ? user.roles : []; - } catch { - return []; - } + const roles = useUserStore.getState().user?.roles; + return Array.isArray(roles) ? roles : []; } function displayAttendanceStatus(status?: string | null): string { @@ -732,7 +729,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const handleExport = useCallback(() => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value)); - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; fetch(`/api/attendance-records/export?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` }, }) diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 07247a2..19e0fe6 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import { useUserStore } from '../../store/user/userStore'; import { Card, Tabs, @@ -572,7 +573,7 @@ const ClassDetailPage: React.FC = () => { permission="class:view" icon={} onClick={() => { - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; fetch(`/api/classes/${id}/roster/export`, { headers: { Authorization: `Bearer ${token}` }, }) diff --git a/apps/admin/src/pages/Classes/teacher-candidate.ts b/apps/admin/src/pages/Classes/teacher-candidate.ts index cae2d8c..451732a 100644 --- a/apps/admin/src/pages/Classes/teacher-candidate.ts +++ b/apps/admin/src/pages/Classes/teacher-candidate.ts @@ -28,9 +28,16 @@ export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => { displayName && displayName !== user.username ? `${displayName}(${user.username})` : user.username; - const roleNames = [...new Set((user.roles || []).map((role) => role.name).filter(Boolean))]; + const roleNames = [ + ...new Set((user.roles || []).flatMap((role) => (role.name ? [role.name] : []))), + ]; const subjects = [ - ...new Set((user.profile?.subjects || []).map((subject) => subject.trim()).filter(Boolean)), + ...new Set( + (user.profile?.subjects || []).flatMap((subject) => { + const trimmed = subject.trim(); + return trimmed ? [trimmed] : []; + }), + ), ]; return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · '); diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index a095863..5677ef5 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -368,12 +368,12 @@ const ClassroomSchedulePage: React.FC = () => { {detailModal.startDate} ~ {detailModal.endDate}( {dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}天) - {detailModal.dailyRate && ( + {detailModal.dailyRate != null && (
日租金:¥{detailModal.dailyRate}
)} - {detailModal.totalAmount && ( + {detailModal.totalAmount != null && (
合同总额:¥{detailModal.totalAmount}
diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index c9b894b..dfe611a 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -26,6 +26,7 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useUserStore } from '../../store/user/userStore'; const statusMap: Record = { available: { text: '可用', color: 'green' }, @@ -143,7 +144,7 @@ const ClassroomsPage: React.FC = () => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { @@ -389,7 +390,7 @@ const ClassroomsPage: React.FC = () => { icon={} onClick={() => { const baseURL = '/api'; - const token = localStorage.getItem('token'); + const token = useUserStore.getState().token; fetch(`${baseURL}/classrooms/export`, { headers: { Authorization: `Bearer ${token}` }, }) diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 778656e..94f8a10 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -209,7 +209,7 @@ const DashboardPage: React.FC = () => { setLoading(true); } try { - const [s, rr, cr, g] = await Promise.all([ + const [s, rr, cr, g, co, cu] = await Promise.all([ api.get('/dashboard/stats'), api.get>('/dashboard/room-ranking', { params: { periodStart: period[0], periodEnd: period[1] }, @@ -220,14 +220,14 @@ const DashboardPage: React.FC = () => { api.get('/dashboard/gantt', { params: { periodStart: period[0], periodEnd: period[1] }, }), + api.get('/dashboard/classroom-occupancy'), + api.get('/dashboard/classroom-utilization'), ]); setStats(s); setRoomRanking(rr); setClassRanking(cr); setGanttData(g); - const co = await api.get('/dashboard/classroom-occupancy'); setClassroomOccupancy(co); - const cu = await api.get('/dashboard/classroom-utilization'); setClassroomUtil(cu); loadedRef.current = true; } catch (e) { diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index 9abb8a5..cd9f195 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -4,14 +4,18 @@ import { Form, Input, Button, Card, Typography } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import api from '../../api'; import { message } from '../../ui/app-message'; -import { clearPermissions, writePermissions } from '../../auth/permission-store'; import { findRoleAwareLandingPath } from '../../auth/menu-policy'; +import { usePermissionStore } from '../../store/permission/permissionStore'; +import { useUserStore } from '../../store/user/userStore'; const { Title } = Typography; const LoginPage: React.FC = () => { const [loading, setLoading] = useState(false); const navigate = useNavigate(); + const setSession = useUserStore((state) => state.setSession); + const clearPermissions = usePermissionStore((state) => state.clearPermissions); + const writePermissions = usePermissionStore((state) => state.writePermissions); const onFinish = useCallback( async (values: any) => { @@ -19,8 +23,7 @@ const LoginPage: React.FC = () => { setLoading(true); try { const res: any = await api.post('/auth/login', values); - localStorage.setItem('token', res.access_token); - localStorage.setItem('user', JSON.stringify(res.user)); + setSession(res.access_token, res.user); const permissions = res.user.permissions || []; writePermissions(permissions); message.success('登录成功'); @@ -33,7 +36,7 @@ const LoginPage: React.FC = () => { setLoading(false); } }, - [navigate], + [clearPermissions, navigate, setSession, writePermissions], ); return ( diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index 9dc152a..cd677d7 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -49,6 +49,14 @@ function timeAgo(dateStr: string): string { return new Date(dateStr).toLocaleDateString('zh-CN'); } +const FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }> = [ + { key: 'all', icon: , label: '全部' }, + { key: 'bill_generated', icon: , label: '账单' }, + { key: 'check_in', icon: , label: '入住' }, + { key: 'class_change', icon: , label: '班级' }, + { key: 'announcement', icon: , label: '公告' }, +]; + const NotificationsPage: React.FC = () => { const screens = useBreakpoint(); const isMobile = !screens.sm; @@ -101,14 +109,6 @@ const NotificationsPage: React.FC = () => { const filtered = filter === 'all' ? notifications : notifications.filter((n) => n.type === filter); - const filterItems = [ - { key: 'all', icon: , label: '全部' }, - { key: 'bill_generated', icon: , label: '账单' }, - { key: 'check_in', icon: , label: '入住' }, - { key: 'class_change', icon: , label: '班级' }, - { key: 'announcement', icon: , label: '公告' }, - ]; - return ( {!isMobile && ( @@ -117,7 +117,7 @@ const NotificationsPage: React.FC = () => { mode="inline" selectedKeys={[filter]} onClick={({ key }) => setFilter(key)} - items={filterItems} + items={FILTER_ITEMS} /> )} @@ -132,7 +132,7 @@ const NotificationsPage: React.FC = () => {