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

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