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

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