refactor: 前端登录/权限/界面状态迁移至 zustand

This commit is contained in:
2026-08-04 14:41:27 +08:00
parent ce1dcc07ea
commit f07ffdc64c
39 changed files with 970 additions and 211 deletions

View File

@@ -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}</> : <Navigate to="/login" />;
};

View File

@@ -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) {

View File

@@ -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<typeof createRoot> | 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('编辑学生');
});
});

View File

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

View File

@@ -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 <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
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 <Navigate to={firstPath} replace />;
return (

View File

@@ -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) => {

View File

@@ -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<PermissionRouteProps> = ({ permission, children }) => {
const { permissions, permissionsReady, hasPermission } = usePermission();
const roles = useUserStore((state) => state.user?.roles ?? []);
const navigate = useNavigate();
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
if (!hasPermission(permission)) {
let roles: string[] = [];
try {
roles = JSON.parse(localStorage.getItem('user') || '{}').roles || [];
} catch {
roles = [];
}
const firstPath = findRoleAwareLandingPath(roles, permissions);
return (
<Result

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo } from 'react';
import type { DragEndEvent } from '@dnd-kit/core';
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import {
@@ -12,13 +12,7 @@ import { Tabs } from 'antd';
import type { TabsProps } from 'antd';
import type { Location } from 'react-router-dom';
import type { AppMenuItem } from '../../auth/menu-policy';
const STORAGE_KEY = 'gongxue-route-dock';
interface DockTab {
key: string;
label: string;
}
import { useAppStore } from '../../store';
interface RouteDockProps {
location: Location;
@@ -50,19 +44,6 @@ function getRouteLabel(items: readonly AppMenuItem[], pathname: string): string
return pathname === '/' ? '首页' : '页面';
}
function readStoredTabs(): DockTab[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(tab): tab is DockTab =>
typeof tab?.key === 'string' && tab.key.startsWith('/') && typeof tab?.label === 'string',
);
} catch {
return [];
}
}
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props['data-node-key'],
@@ -87,28 +68,20 @@ const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
const activeKey = `${location.pathname}${location.search}`;
const [tabs, setTabs] = useState<DockTab[]>(() => {
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<NonNullable<TabsProps['items']>>(
() =>
@@ -124,7 +97,7 @@ const RouteDock: React.FC<RouteDockProps> = ({ 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<RouteDockProps> = ({ 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

View File

@@ -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<typeof createRoot> | null = null;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
});
function PageA() {
const navigate = useNavigate();
return (
<div>
<input data-testid="input-a" aria-label="A 输入" />
<button data-testid="go-b" onClick={() => navigate('/b')}>
B
</button>
</div>
);
}
function PageB() {
const navigate = useNavigate();
return (
<div>
<input data-testid="input-b" aria-label="B 输入" />
<button data-testid="go-a" onClick={() => navigate('/')}>
A
</button>
</div>
);
}
function Harness() {
return (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<RouteKeeper />}>
<Route index element={<PageA />} />
<Route path="b" element={<PageB />} />
</Route>
</Routes>
</MemoryRouter>
);
}
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(<Harness />));
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');
});
});

View File

@@ -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<Map<string, React.ReactNode>>(new Map());
const orderRef = useRef<string[]>([]);
// 仅以 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]) => (
<div
key={key}
className="route-keeper-page"
style={{ display: key === pageKey ? undefined : 'none' }}
>
{node}
</div>
))}
</>
);
};
export default RouteKeeper;

View File

@@ -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,

View File

@@ -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<string, React.ReactNode> = {
};
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [aiChatOpen, setAiChatOpen] = useState(false);
const [openKeys, setOpenKeys] = useState<string[]>([]);
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 = () => {
<MenuFoldOutlined />
)
}
onClick={() => (usesDrawer ? setDrawerOpen(true) : setCollapsed(!collapsed))}
onClick={() => (usesDrawer ? setDrawerOpen(true) : toggleSidebar())}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{hasPermission('ai:chat:use') && (
<Tooltip title="AI 助理">
<Button
type="text"
aria-label="打开 AI 助理"
icon={<RobotOutlined style={{ fontSize: 17 }} />}
onClick={() => setAiChatOpen(true)}
/>
<Tooltip title={aiWorking ? 'AI 处理中' : 'AI 助理'}>
<Badge dot={aiWorking} color="#007AFF" offset={[-3, 5]}>
<Button
type="text"
aria-label={aiWorking ? 'AI 助理处理中' : '打开 AI 助理'}
icon={
aiWorking ? (
<LoadingOutlined style={{ fontSize: 17, color: '#007AFF' }} spin />
) : (
<RobotOutlined style={{ fontSize: 17 }} />
)
}
onClick={() => setAiChatOpen(true)}
/>
</Badge>
</Tooltip>
)}
{hasPermission('notification:view') && <NotificationBell />}
@@ -336,7 +346,7 @@ const MainLayout: React.FC = () => {
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}
>
<Avatar icon={<UserOutlined />} />
{isDesktop && <span>{user.name || user.username || '用户'}</span>}
{isDesktop && <span>{user?.name || user?.username || '用户'}</span>}
</div>
</Dropdown>
</div>
@@ -356,12 +366,16 @@ const MainLayout: React.FC = () => {
borderRadius: 12,
}}
>
<Outlet />
<RouteKeeper />
</Content>
</Layout>
{hasPermission('ai:chat:use') && aiChatOpen && (
{hasPermission('ai:chat:use') && (
<React.Suspense fallback={null}>
<AiChatDrawer open onClose={() => setAiChatOpen(false)} />
<AiChatDrawer
open={aiChatOpen}
onClose={() => setAiChatOpen(false)}
onRequestingChange={setAiWorking}
/>
</React.Suspense>
)}
</Layout>

View File

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

View File

@@ -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={<DownloadOutlined />}
onClick={() => {
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
fetch(`/api/classes/${id}/roster/export`, {
headers: { Authorization: `Bearer ${token}` },
})

View File

@@ -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(' · ');

View File

@@ -368,12 +368,12 @@ const ClassroomSchedulePage: React.FC = () => {
{detailModal.startDate} ~ {detailModal.endDate}
{dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}
</div>
{detailModal.dailyRate && (
{detailModal.dailyRate != null && (
<div>
<strong></strong>¥{detailModal.dailyRate}
</div>
)}
{detailModal.totalAmount && (
{detailModal.totalAmount != null && (
<div>
<strong></strong>¥{detailModal.totalAmount}
</div>

View File

@@ -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<string, { text: string; color: string }> = {
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={<DownloadOutlined />}
onClick={() => {
const baseURL = '/api';
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
fetch(`${baseURL}/classrooms/export`, {
headers: { Authorization: `Bearer ${token}` },
})

View File

@@ -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<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
@@ -220,14 +220,14 @@ const DashboardPage: React.FC = () => {
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
setStats(s);
setRoomRanking(rr);
setClassRanking(cr);
setGanttData(g);
const co = await api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy');
setClassroomOccupancy(co);
const cu = await api.get<ClassroomUtilStats>('/dashboard/classroom-utilization');
setClassroomUtil(cu);
loadedRef.current = true;
} catch (e) {

View File

@@ -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 (

View File

@@ -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: <BellOutlined />, label: '全部' },
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
{ key: 'announcement', icon: <SettingOutlined />, 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: <BellOutlined />, label: '全部' },
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
];
return (
<Layout className="notifications-layout" style={{ minHeight: '100%', background: '#fff' }}>
{!isMobile && (
@@ -117,7 +117,7 @@ const NotificationsPage: React.FC = () => {
mode="inline"
selectedKeys={[filter]}
onClick={({ key }) => setFilter(key)}
items={filterItems}
items={FILTER_ITEMS}
/>
</Sider>
)}
@@ -132,7 +132,7 @@ const NotificationsPage: React.FC = () => {
<Select
value={filter}
onChange={setFilter}
options={filterItems.map((item) => ({ value: item.key, label: item.label }))}
options={FILTER_ITEMS.map((item) => ({ value: item.key, label: item.label }))}
className="notifications-filter"
/>
)}

View File

@@ -132,8 +132,9 @@ const OccupanciesPage: React.FC = () => {
selectedBatchRecords
.map((item) => item.checkInDate)
.filter(Boolean)
.sort()
.at(-1),
.reduce((latest: string | undefined, date) =>
!latest || date > latest ? date : latest,
undefined),
[selectedBatchRecords],
);
const latestSelectedBillingStartDate = useMemo(
@@ -141,8 +142,9 @@ const OccupanciesPage: React.FC = () => {
selectedBatchRecords
.map((item) => item.billingStartDate || item.checkInDate)
.filter(Boolean)
.sort()
.at(-1),
.reduce((latest: string | undefined, date) =>
!latest || date > latest ? date : latest,
undefined),
[selectedBatchRecords],
);

View File

@@ -181,7 +181,7 @@ const RoomsPage: React.FC = () => {
// 获取楼栋列表用于筛选
const buildings = useMemo(() => {
const set = new Set(data.map((r: any) => r.building).filter(Boolean));
const set = new Set(data.flatMap((r: any) => (r.building ? [r.building] : [])));
return [...set].sort();
}, [data]);

View File

@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { appUiPersistStorage, APP_UI_STORAGE_NAME } from '../middleware/persist';
import type { AppPersistedState, AppStore } from './appTypes';
/**
* 应用级 UI Store布局、抽屉、路由页签等跨组件共享状态。
* 侧边栏折叠与路由页签持久化;其余为会话内状态。
*/
export const useAppStore = create<AppStore>()(
devtools(
persist(
(set) => ({
sidebarCollapsed: false,
mobileDrawerOpen: false,
menuOpenKeys: [],
aiChatOpen: false,
aiWorking: false,
routeDockTabs: [],
toggleSidebar: () => {
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), false, 'app/toggleSidebar');
},
setSidebarCollapsed: (collapsed) => {
set({ sidebarCollapsed: collapsed }, false, 'app/setSidebarCollapsed');
},
setMobileDrawerOpen: (open) => {
set({ mobileDrawerOpen: open }, false, 'app/setMobileDrawerOpen');
},
setMenuOpenKeys: (keys) => {
set(
(state) => ({
menuOpenKeys: typeof keys === 'function' ? keys(state.menuOpenKeys) : keys,
}),
false,
'app/setMenuOpenKeys',
);
},
setAiChatOpen: (open) => {
set({ aiChatOpen: open }, false, 'app/setAiChatOpen');
},
setAiWorking: (working) => {
set({ aiWorking: working }, false, 'app/setAiWorking');
},
setRouteDockTabs: (updater) => {
set(
(state) => ({
routeDockTabs:
typeof updater === 'function' ? updater(state.routeDockTabs) : updater,
}),
false,
'app/setRouteDockTabs',
);
},
}),
{
name: APP_UI_STORAGE_NAME,
storage: appUiPersistStorage,
partialize: (state): AppPersistedState => ({
sidebarCollapsed: state.sidebarCollapsed,
routeDockTabs: state.routeDockTabs,
}),
version: 1,
},
),
{ name: 'app-store', enabled: import.meta.env.DEV },
),
);

View File

@@ -0,0 +1,39 @@
export interface DockTab {
key: string;
label: string;
}
export interface AppState {
/** 桌面端侧边栏折叠 */
sidebarCollapsed: boolean;
/** 移动端导航抽屉 */
mobileDrawerOpen: boolean;
/** 菜单展开的分组 key */
menuOpenKeys: string[];
/** AI 助理抽屉 */
aiChatOpen: boolean;
/** AI 请求处理中 */
aiWorking: boolean;
/** 路由页签RouteDock */
routeDockTabs: DockTab[];
}
export interface AppActions {
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
setMobileDrawerOpen: (open: boolean) => void;
/** 函数式更新,与 setState 语义一致 */
setMenuOpenKeys: (keys: string[] | ((current: string[]) => string[])) => void;
setAiChatOpen: (open: boolean) => void;
setAiWorking: (working: boolean) => void;
/** 函数式更新,与 setState 语义一致 */
setRouteDockTabs: (updater: DockTab[] | ((current: DockTab[]) => DockTab[])) => void;
}
export type AppStore = AppState & AppActions;
/** 持久化子集 */
export interface AppPersistedState {
sidebarCollapsed: boolean;
routeDockTabs: DockTab[];
}

View File

@@ -0,0 +1,26 @@
/**
* 全局状态统一出口。
* 业务代码从这里或对应 Store 文件导入,避免散落的局部状态管理。
*/
export { useAppStore } from './app/appStore';
export { usePermissionStore } from './permission/permissionStore';
export { useSettingsStore } from './settings/settingsStore';
export { useUserStore } from './user/userStore';
export type { AppActions, AppPersistedState, AppState, AppStore, DockTab } from './app/appTypes';
export type {
PermissionActions,
PermissionPersistedState,
PermissionState,
PermissionStatus,
PermissionStore,
} from './permission/permissionTypes';
export type { AiChatSettings, SettingsActions, SettingsState, SettingsStore } from './settings/settingsTypes';
export type { StoreStatus } from './types';
export type {
UserActions,
UserInfo,
UserPersistedState,
UserState,
UserStore,
} from './user/userTypes';

View File

@@ -0,0 +1,175 @@
/**
* 持久化中间件基础设施。
*
* 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key
* token / user / permissions / gongxue-route-dock包装成 zustand
* persist 的 StateStorage保证迁移前后数据格式兼容。
*/
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
import type { AppPersistedState, DockTab } from '../app/appTypes';
import type { PermissionPersistedState } from '../permission/permissionTypes';
import type { UserInfo, UserPersistedState } from '../user/userTypes';
export const AUTH_STORAGE_NAME = 'gongxue-auth';
export const LEGACY_TOKEN_KEY = 'token';
export const LEGACY_USER_KEY = 'user';
export const PERMISSION_STORAGE_NAME = 'permissions';
export const APP_UI_STORAGE_NAME = 'gongxue-app-ui';
export const LEGACY_DOCK_STORAGE_KEY = 'gongxue-route-dock';
export const SETTINGS_STORAGE_NAME = 'gongxue-settings';
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isDockTab(value: unknown): value is DockTab {
return (
isRecord(value) &&
typeof value.key === 'string' &&
value.key.startsWith('/') &&
typeof value.label === 'string'
);
}
/**
* 用户会话持久化:继续使用旧的 `token` / `user` 两个 key
* 保持与后端、既有代码及浏览器缓存格式一致。
*/
const legacyAuthStorage: StateStorage = {
getItem: () => {
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
if (token === null && rawUser === null) return null;
let user: UserInfo | null = null;
if (rawUser !== null) {
try {
const parsed: unknown = JSON.parse(rawUser);
user = isRecord(parsed) ? (parsed as UserInfo) : null;
} catch {
user = null;
}
}
return JSON.stringify({ state: { token, user }, version: 1 });
},
setItem: (_name, value) => {
try {
const persisted = JSON.parse(value) as { state?: UserPersistedState };
const { token, user } = persisted.state ?? {};
if (token) {
localStorage.setItem(LEGACY_TOKEN_KEY, token);
} else {
localStorage.removeItem(LEGACY_TOKEN_KEY);
}
if (user) {
localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(LEGACY_USER_KEY);
}
} catch {
// 持久化写入失败不应影响应用运行
}
},
removeItem: () => {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
},
};
/**
* 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。
* 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed
* 直到 `/auth/profile` 校验成功。
*/
const legacyPermissionStorage: StateStorage = {
getItem: () => {
const raw = localStorage.getItem(PERMISSION_STORAGE_NAME);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return JSON.stringify({
state: { permissions: parsed.filter(isString), status: 'unknown' },
version: 1,
});
}
if (isRecord(parsed) && isRecord(parsed.state)) {
const permissions = Array.isArray(parsed.state.permissions)
? parsed.state.permissions.filter(isString)
: [];
return JSON.stringify({
state: { permissions, status: 'unknown' },
version: 1,
});
}
} catch {
// 损坏的缓存按无权限处理
}
return null;
},
setItem: (_name, value) => {
try {
const persisted = JSON.parse(value) as { state?: PermissionPersistedState };
const permissions = Array.isArray(persisted.state?.permissions)
? persisted.state.permissions.filter(isString)
: [];
localStorage.setItem(PERMISSION_STORAGE_NAME, JSON.stringify(permissions));
} catch {
// 忽略损坏数据
}
},
removeItem: () => {
localStorage.removeItem(PERMISSION_STORAGE_NAME);
},
};
/**
* 应用 UI 状态持久化:新 key `gongxue-app-ui`
* 首次读取时自动迁移旧 key `gongxue-route-dock` 中已打开的页签。
*/
const appUiStorage: StateStorage = {
getItem: (name) => {
const current = localStorage.getItem(name);
if (current) return current;
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
if (!legacy) return null;
try {
const parsed: unknown = JSON.parse(legacy);
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
} catch {
return null;
}
},
setItem: (name, value) => {
try {
localStorage.setItem(name, value);
} catch {
// 持久化失败不应影响应用运行
}
},
removeItem: (name) => {
try {
localStorage.removeItem(name);
} catch {
// 持久化失败不应影响应用运行
}
},
};
/** 会话 Store 使用的 persist storage兼容旧 token/user key */
export const authPersistStorage = createJSONStorage(() => legacyAuthStorage);
/** 权限 Store 使用的 persist storage兼容旧 permissions key */
export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage);
/** 应用 UI Store 使用的 persist storage含旧 RouteDock key 迁移) */
export const appUiPersistStorage = createJSONStorage(() => appUiStorage);
export type { AppPersistedState, PermissionPersistedState, UserPersistedState };

View File

@@ -0,0 +1,48 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import {
permissionPersistStorage,
PERMISSION_STORAGE_NAME,
} from '../middleware/persist';
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
/**
* 权限 Store。
*
* 迁移自旧的模块单例 + window 事件方案:
* - 组件通过 usePermissionStore 订阅,不再依赖自定义事件;
* - 持久化的权限只作为缓存rehydrate 后 status 仍为 `unknown`
* 必须等待 /auth/profile 校验成功后才视为可用(保持 fail-closed
*/
export const usePermissionStore = create<PermissionStore>()(
devtools(
persist(
(set, get) => ({
permissions: [],
status: 'unknown',
beginPermissionVerification: () => {
// 后台刷新时保留已就绪的权限,避免界面闪加载
if (get().status === 'ready') return;
set({ permissions: [], status: 'loading' }, false, 'permission/beginVerification');
},
writePermissions: (permissions) => {
set(
{ permissions: [...new Set(permissions)], status: 'ready' },
false,
'permission/write',
);
},
clearPermissions: (status = 'unknown') => {
set({ permissions: [], status }, false, 'permission/clear');
},
}),
{
name: PERMISSION_STORAGE_NAME,
storage: permissionPersistStorage,
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
version: 1,
},
),
{ name: 'permission-store', enabled: import.meta.env.DEV },
),
);

View File

@@ -0,0 +1,25 @@
import type { StoreStatus } from '../types';
export type PermissionStatus = StoreStatus;
export interface PermissionState {
/** 已校验通过的权限码 */
permissions: string[];
status: PermissionStatus;
}
export interface PermissionActions {
/** 开始后台校验:非 ready 状态时清空权限并进入 loadingfail-closed */
beginPermissionVerification: () => void;
/** 写入已校验的权限 */
writePermissions: (permissions: string[]) => void;
/** 清空权限(退出登录 / 401 */
clearPermissions: (status?: PermissionStatus) => void;
}
export type PermissionStore = PermissionState & PermissionActions;
/** 持久化子集:只保存权限码,状态恢复后一律为 unknown */
export interface PermissionPersistedState {
permissions: string[];
}

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createJSONStorage } from 'zustand/middleware';
import { SETTINGS_STORAGE_NAME } from '../middleware/persist';
import type { SettingsState, SettingsStore } from './settingsTypes';
const defaultSettings: SettingsState = {
aiChat: {
deepThinking: false,
},
};
/**
* 用户偏好 StoreAI 聊天等非业务、可持久化的设置。
*/
export const useSettingsStore = create<SettingsStore>()(
devtools(
persist(
(set) => ({
...defaultSettings,
setAiChatDeepThinking: (enabled) => {
set(
(state) => ({ aiChat: { ...state.aiChat, deepThinking: enabled } }),
false,
'settings/setAiChatDeepThinking',
);
},
toggleAiChatDeepThinking: () => {
set(
(state) => ({ aiChat: { ...state.aiChat, deepThinking: !state.aiChat.deepThinking } }),
false,
'settings/toggleAiChatDeepThinking',
);
},
}),
{
name: SETTINGS_STORAGE_NAME,
storage: createJSONStorage(() => localStorage),
partialize: (state): SettingsState => ({ aiChat: state.aiChat }),
version: 1,
},
),
{ name: 'settings-store', enabled: import.meta.env.DEV },
),
);

View File

@@ -0,0 +1,15 @@
export interface AiChatSettings {
/** 深度思考reasoningEffort: high偏好 */
deepThinking: boolean;
}
export interface SettingsState {
aiChat: AiChatSettings;
}
export interface SettingsActions {
setAiChatDeepThinking: (enabled: boolean) => void;
toggleAiChatDeepThinking: () => void;
}
export type SettingsStore = SettingsState & SettingsActions;

View File

@@ -0,0 +1,14 @@
/**
* 全局状态管理通用类型。
*
* 各领域 Store 采用“模块化切片”设计:
* - 一个领域一个目录state/actions 分别定义;
* - 所有 Store 由 zustand `create` + `devtools` + `persist` 组合创建;
* - 组件只通过 `src/store/index.ts` 或具体 Store 文件访问,保持单向数据流。
*/
/** 权限校验状态未知fail-closed→ 校验中 → 已就绪 */
export type StoreStatus = 'unknown' | 'loading' | 'ready';
/** 持久化时从 Store 中挑选出的字段 */
export type Partialize<T> = (state: T) => Partial<T>;

View File

@@ -0,0 +1,33 @@
import type { StateCreator } from 'zustand';
import type { UserActions, UserStore } from './userTypes';
/** user Store 应用到的中间件,用于让 createUserActions 获得完整 set 类型 */
export type UserStoreMutators = [
['zustand/devtools', never],
['zustand/persist', unknown],
];
/**
* 用户会话 actions与 state 分离,避免 Store 无限膨胀。
* 由 userStore.ts 组合进 create()。
*/
export const createUserActions: StateCreator<UserStore, UserStoreMutators, [], UserActions> = (
set,
) => ({
setSession: (token, user) => {
set({ token, user }, false, 'user/setSession');
},
setUser: (user) => {
set({ user }, false, 'user/setUser');
},
updateUser: (patch) => {
set(
(state) => (state.user ? { user: { ...state.user, ...patch } } : {}),
false,
'user/updateUser',
);
},
logout: () => {
set({ token: null, user: null }, false, 'user/logout');
},
});

View File

@@ -0,0 +1,29 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { authPersistStorage, AUTH_STORAGE_NAME } from '../middleware/persist';
import { createUserActions } from './userActions';
import type { UserPersistedState, UserStore } from './userTypes';
/**
* 用户会话 Storetoken + 用户资料)。
* 使用 zustand 官方推荐写法create()(devtools(persist(...)))。
* 持久化沿用旧 `token` / `user` localStorage key。
*/
export const useUserStore = create<UserStore>()(
devtools(
persist(
(set, get, api) => ({
token: null,
user: null,
...createUserActions(set, get, api),
}),
{
name: AUTH_STORAGE_NAME,
storage: authPersistStorage,
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
version: 1,
},
),
{ name: 'user-store', enabled: import.meta.env.DEV },
),
);

View File

@@ -0,0 +1,38 @@
import type { StoreStatus } from '../types';
/** 登录用户信息(与后端 /auth/login、/auth/profile 返回结构对齐) */
export interface UserInfo {
id: number;
username: string;
name?: string;
roles?: string[];
permissions?: string[];
[key: string]: unknown;
}
export interface UserState {
token: string | null;
user: UserInfo | null;
}
export interface UserActions {
/** 登录成功后写入完整会话 */
setSession: (token: string, user: UserInfo) => void;
/** 整体替换用户信息 */
setUser: (user: UserInfo | null) => void;
/** 局部更新用户信息(如 profile 校验返回的最新数据) */
updateUser: (patch: Partial<UserInfo>) => void;
/** 退出登录:清空会话 */
logout: () => void;
}
export type UserStore = UserState & UserActions;
/** 持久化子集 */
export interface UserPersistedState {
token: string | null;
user: UserInfo | null;
}
/** 兼容:用户状态中的权限由 permission Store 统一管理 */
export type { StoreStatus };

View File

@@ -8,6 +8,9 @@
import { expect } from 'vitest';
import { CREDENTIALS } from './fixtures';
import { BASE } from './setup';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore';
import type { UserInfo } from '../store/user/userTypes';
// ── Types ───────────────────────────────────────────────────────────
@@ -37,8 +40,10 @@ export async function loginAs(
expect(res.status).toBe(201);
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
expect(json.code).toBe(0);
localStorage.setItem('token', json.data.token);
localStorage.setItem('user', JSON.stringify(json.data.user));
useUserStore.getState().setSession(json.data.token, json.data.user as unknown as UserInfo);
usePermissionStore
.getState()
.writePermissions((json.data.user.permissions ?? []) as string[]);
return json.data;
}
@@ -46,15 +51,14 @@ export async function loginAs(
* Logout: clear localStorage.
*/
export function logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
useUserStore.getState().logout();
usePermissionStore.getState().clearPermissions();
}
// ── API helpers (authenticated) ─────────────────────────────────────
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),

View File

@@ -3,6 +3,10 @@
* Runs once before all tests.
*/
import { beforeAll, afterEach } from 'vitest';
import { useAppStore } from '../store/app/appStore';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useSettingsStore } from '../store/settings/settingsStore';
import { useUserStore } from '../store/user/userStore';
// Base URL: the Vite dev server proxies /api → localhost:3003
const BASE = 'http://localhost:3002';
@@ -18,6 +22,17 @@ afterEach(() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
useUserStore.getState().logout();
usePermissionStore.getState().clearPermissions();
useAppStore.setState({
sidebarCollapsed: false,
mobileDrawerOpen: false,
menuOpenKeys: [],
aiChatOpen: false,
aiWorking: false,
routeDockTabs: [],
});
useSettingsStore.setState({ aiChat: { deepThinking: false } });
});
export { BASE };

View File

@@ -1,3 +1,5 @@
import { useUserStore } from '../store/user/userStore';
/**
* Download a file from the API as a blob and trigger a browser download.
*
@@ -9,7 +11,7 @@ export async function downloadBlob(endpoint: string, filename: string): Promise<
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
const res = await fetch(`${baseURL}${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
});

View File

@@ -5,6 +5,9 @@ import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),

98
docs/zustand-migration.md Normal file
View File

@@ -0,0 +1,98 @@
# Zustand 全局状态迁移方案
> 目标:将 `apps/admin` 中分散的全局状态管理统一迁移到 Zustand建立清晰、可维护、单向数据流的状态架构。
## 一、现状分析(迁移前)
迁移前,全局状态散落在多个位置:
| 状态 | 原实现 | 问题 |
| --- | --- | --- |
| token / user | 各组件直接读写 `localStorage`Login、MainLayout、api 拦截器、下载、SSE 等) | 无类型约束、无订阅、重复解析 |
| permissions | `auth/permission-store.ts` 模块单例 + `window` 自定义事件 + `usePermission` 手动订阅 | 事件驱动易遗漏、无法细粒度订阅 |
| 布局 UI侧边栏/抽屉/AI 抽屉/菜单展开) | `MainLayout` 内多个 `useState` | 局部状态无法跨组件共享、不持久化 |
| 路由页签RouteDock | 组件内 `useState` + 直接写 `localStorage('gongxue-route-dock')` | 持久化逻辑与 UI 耦合 |
| AI 聊天偏好(深度思考) | `AiChatDrawer``useState(false)` | 每次打开重置 |
## 二、目标架构
```text
apps/admin/src/store/
├── index.ts # 统一出口
├── types.ts # 通用类型StoreStatus 等)
├── middleware/
│ └── persist.ts # 持久化适配器(兼容旧 localStorage key
├── user/
│ ├── userTypes.ts # UserInfo / UserState / UserActions
│ ├── userActions.ts # 会话 actions与 state 分离)
│ └── userStore.ts # create()(devtools(persist(...)))
├── app/
│ ├── appTypes.ts # 布局/抽屉/路由页签状态
│ └── appStore.ts
├── permission/
│ ├── permissionTypes.ts # permissions + statusfail-closed
│ └── permissionStore.ts
└── settings/
├── settingsTypes.ts # 用户偏好AI 深度思考等)
└── settingsStore.ts
```
统一写法Zustand 官方推荐):
```ts
export const useUserStore = create<UserStore>()(
devtools(
persist(
(set) => ({ token: null, user: null, ...createUserActions(set) }),
{ name: 'gongxue-auth', storage: authPersistStorage, version: 1 },
),
{ name: 'user-store', enabled: import.meta.env.DEV },
),
);
```
设计要点:
- **state 与 actions 分离**`userActions.ts` 独立成文件,其余 Store 的 actions 量小,随 Store 内联,避免无限膨胀。
- **单向数据流**:组件通过 selector 订阅;仅通过 action 修改状态;持久化由 middleware 统一处理。
- **devtools 仅在开发环境启用**,生产不产生额外开销。
- **持久化版本化**:所有 Store `version: 1`,后续 schema 变更通过 `migrate` 平滑升级。
## 三、持久化兼容策略
为了平滑迁移且不破坏已有浏览器缓存:
| Store | persist name | 兼容的旧 key | 说明 |
| --- | --- | --- | --- |
| user | `gongxue-auth` | `token``user` | 适配器继续读写旧 key格式不变 |
| permission | `permissions` | `permissions`(原始 JSON 数组) | 同时兼容旧数组与 zustand 信封rehydrate 后强制 `status: 'unknown'`,保持 fail-closed |
| app | `gongxue-app-ui` | `gongxue-route-dock` | 首次读取自动迁移旧页签数据 |
| settings | `gongxue-settings` | 无 | 新 key |
权限状态特别说明:旧实现“缓存权限但未校验前不使用”。迁移后依然如此——持久化只保存权限码,`status` 恢复后一律为 `unknown`,必须等待 `/auth/profile` 校验成功后(`writePermissions`)才变为 `ready`
## 四、已迁移的消费方
- `pages/Login`:登录后写入 user Store权限写入 permission Store。
- `App.tsx``PrivateRoute` 从 user Store 读取 token响应式
- `api/index.ts`:请求拦截器与 401 处理改读 Store / 调用 `logout()`
- `layouts/MainLayout`:用户信息、权限校验、布局 UI、AI 抽屉开关全部迁移到 Store。
- `hooks/usePermission`:改为 Zustand selector 订阅,删除自定义事件。
- `components/RouteDock`:页签状态迁移到 app Store持久化仍生效
- `components/AiChat/AiChatDrawer`:深度思考偏好迁移到 settings Store持久化
- 下载、SSE、AiChat provider 等所有 token 读取统一走 `useUserStore.getState().token`
- `auth/permission-store.ts` 已删除;测试 helper/setup 同步迁移。
## 五、验证
- `npx tsc -b apps/admin/tsconfig.app.json --noEmit`
- `npx vitest run --root apps/admin`142 个浏览器集成测试)✅
- `npm run build --workspace @gongxue/admin`
- `npm run lint --workspace @gongxue/admin` ✅(无新增告警)
## 六、后续可选优化(本次未纳入)
- **页面级缓存**`integration-config-cache.ts``schedule-visibility.ts``unavailable-dates-cache.ts``inspection-state.ts` 等仍是模块级单例,属于页面内缓存,可按需迁移为 Zustand或保持现状配合 React Query/SWR
- **请求去重**:若多个页面出现同一资源重复请求,可引入 SWR 统一缓存(当前各页数据获取均为单次请求,收益有限)。
- **包体积**`@ant-design/icons` 为 barrel 导出但 `sideEffects: false`Vite 构建可正确 tree-shake如需进一步压缩 dev 冷启动,可改为深路径导入。`lucide-react` 已安装但未被引用,可择机移除。
- **大列表渲染**:消息列表/大表格可补充 `content-visibility` 或虚拟滚动;搜索大列表时可用 `useDeferredValue`