refactor: 前端登录/权限/界面状态迁移至 zustand
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
96
apps/admin/src/components/RouteKeeper.integration.test.tsx
Normal file
96
apps/admin/src/components/RouteKeeper.integration.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
43
apps/admin/src/components/RouteKeeper.tsx
Normal file
43
apps/admin/src/components/RouteKeeper.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user