diff --git a/src/composables/useStomp.ts b/src/composables/useStomp.ts index 2e507461..98f5b13c 100644 --- a/src/composables/useStomp.ts +++ b/src/composables/useStomp.ts @@ -1,7 +1,5 @@ import { Client, IMessage, StompSubscription } from "@stomp/stompjs"; -import { Storage } from "@/utils/storage"; -import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys"; -import { ref, watch } from "vue"; +import { Auth } from "@/utils/auth"; export interface UseStompOptions { /** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */ @@ -67,7 +65,7 @@ export function useStomp(options: UseStompOptions = {}) { } // 每次连接前重新获取最新令牌,不依赖之前的token值 - const currentToken = Storage.get(ACCESS_TOKEN_KEY, ""); + const currentToken = Auth.getAccessToken(); // 检查令牌是否为空,如果为空则不进行连接 if (!currentToken) { @@ -121,7 +119,7 @@ export function useStomp(options: UseStompOptions = {}) { client.value = null; // 检查当前是否有有效令牌 - const freshToken = Storage.get(ACCESS_TOKEN_KEY, ""); + const freshToken = Auth.getAccessToken(); if (freshToken) { initializeClient(); connect(); diff --git a/src/constants/cache-keys.ts b/src/constants/cache-keys.ts index aa44695f..e1355933 100644 --- a/src/constants/cache-keys.ts +++ b/src/constants/cache-keys.ts @@ -4,4 +4,5 @@ export const ACCESS_TOKEN_KEY = "access_token"; export const REFRESH_TOKEN_KEY = "refresh_token"; export const DICT_CACHE_KEY = "dict_cache"; +export const REMEMBER_ME_KEY = "remember_me"; // 可在此处添加其他缓存键 diff --git a/src/layout/components/NavBar/components/NavbarRight.vue b/src/layout/components/NavBar/components/NavbarRight.vue index ba00392f..596277c9 100644 --- a/src/layout/components/NavBar/components/NavbarRight.vue +++ b/src/layout/components/NavBar/components/NavbarRight.vue @@ -46,14 +46,13 @@ const { t } = useI18n(); import defaultSettings from "@/settings"; import { DeviceEnum } from "@/enums/settings/device.enum"; -import { useAppStore, useSettingsStore, useUserStore, useTagsViewStore } from "@/store"; +import { useAppStore, useSettingsStore, useUserStore } from "@/store"; import { SidebarColor, ThemeMode } from "@/enums/settings/theme.enum"; const appStore = useAppStore(); const settingStore = useSettingsStore(); const userStore = useUserStore(); -const tagsViewStore = useTagsViewStore(); const route = useRoute(); const router = useRouter(); @@ -89,14 +88,9 @@ function logout() { type: "warning", lockScroll: false, }).then(() => { - userStore - .logout() - .then(() => { - tagsViewStore.delAllViews(); - }) - .then(() => { - router.push(`/login?redirect=${route.fullPath}`); - }); + userStore.logout().then(() => { + router.push(`/login?redirect=${route.fullPath}`); + }); }); } @@ -145,7 +139,6 @@ function logout() { color: #fff; } -// 添加更强力的选择器,确保能影响到深层嵌套的图标 .layout-top .navbar__right--white :deep([class^="i-svg:"]), .layout-mix .navbar__right--white :deep([class^="i-svg:"]) { color: #fff !important; diff --git a/src/plugins/permission.ts b/src/plugins/permission.ts index 254bf993..dbce0a03 100644 --- a/src/plugins/permission.ts +++ b/src/plugins/permission.ts @@ -1,7 +1,6 @@ import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router"; import NProgress from "@/utils/nprogress"; -import { Storage } from "@/utils/storage"; -import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys"; +import { Auth } from "@/utils/auth"; import router from "@/router"; import { usePermissionStore, useUserStore } from "@/store"; import { ROLE_ROOT } from "@/constants"; @@ -12,15 +11,19 @@ export function setupPermission() { router.beforeEach(async (to, from, next) => { NProgress.start(); + console.log("to.path", to.path); - const isLogin = !!Storage.get(ACCESS_TOKEN_KEY, ""); // 判断是否登录 + const isLogin = Auth.isLoggedIn(); + console.log("isLogin", isLogin); if (isLogin) { + console.log("to.path", to.path); if (to.path === "/login") { - // 已登录,跳转到首页 + // 如果已登录,跳转到首页 next({ path: "/" }); } else { // 未登录 const permissionStore = usePermissionStore(); + console.log("permissionStore.routesLoaded", permissionStore.routesLoaded); // 判断路由是否加载完成 if (permissionStore.routesLoaded) { if (to.matched.length === 0) { @@ -43,7 +46,7 @@ export function setupPermission() { } catch (error) { console.error(error); // 路由加载失败,重置 token 并重定向到登录页 - await useUserStore().clearSessionAndCache(); + await useUserStore().resetAllState(); redirectToLogin(to, next); NProgress.done(); } diff --git a/src/plugins/websocket.ts b/src/plugins/websocket.ts index a2a6f78c..edbdb290 100644 --- a/src/plugins/websocket.ts +++ b/src/plugins/websocket.ts @@ -1,6 +1,5 @@ import { useDictSync } from "@/composables/useDictSync"; -import { Storage } from "@/utils/storage"; -import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys"; +import { Auth } from "@/utils/auth"; // 用于防止重复初始化的状态标记 let isInitialized = false; @@ -24,9 +23,8 @@ export function setupWebSocket() { return; } - // 检查token是否存在 - const token = Storage.get(ACCESS_TOKEN_KEY, ""); - if (!token) { + // 检查是否已登录 + if (!Auth.isLoggedIn()) { console.warn( "[WebSocketPlugin] 未找到访问令牌,WebSocket初始化已跳过。用户登录后将自动重新连接。" ); diff --git a/src/store/modules/permission.store.ts b/src/store/modules/permission.store.ts index d08fce91..968eb08a 100644 --- a/src/store/modules/permission.store.ts +++ b/src/store/modules/permission.store.ts @@ -51,15 +51,18 @@ export const usePermissionStore = defineStore("permission", () => { * 重置路由 */ const resetRouter = () => { - // 从 router 实例中移除动态路由 + // 创建常量路由名称集合,用于O(1)时间复杂度的查找 + const constantRouteNames = new Set(constantRoutes.map((route) => route.name).filter(Boolean)); + + // 从 router 实例中移除动态路由 routes.value.forEach((route) => { - if (route.name && !constantRoutes.find((r) => r.name === route.name)) { + if (route.name && !constantRouteNames.has(route.name)) { router.removeRoute(route.name); } }); - // 清空本地存储的路由和菜单数据 - routes.value = []; + // 重置为仅包含常量路由 + routes.value = [...constantRoutes]; sideMenuRoutes.value = []; routesLoaded.value = false; }; @@ -78,7 +81,7 @@ export const usePermissionStore = defineStore("permission", () => { * 解析后端返回的路由数据并转换为 Vue Router 兼容的路由配置 * * @param rawRoutes 后端返回的原始路由数据 - * @returns 解析后的路由配置数组 + * @returns 解析后的路由集合 */ const parseDynamicRoutes = (rawRoutes: RouteVO[]): RouteRecordRaw[] => { const parsedRoutes: RouteRecordRaw[] = []; diff --git a/src/store/modules/user.store.ts b/src/store/modules/user.store.ts index a8ec019e..4d96ae49 100644 --- a/src/store/modules/user.store.ts +++ b/src/store/modules/user.store.ts @@ -1,15 +1,17 @@ import { store } from "@/store"; -import { usePermissionStoreHook } from "@/store/modules/permission.store"; -import { useDictStoreHook } from "@/store/modules/dict.store"; import AuthAPI, { type LoginFormData } from "@/api/auth.api"; import UserAPI, { type UserInfo } from "@/api/system/user.api"; -import { Storage } from "@/utils/storage"; -import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/constants/cache-keys"; +import { Auth } from "@/utils/auth"; +import { usePermissionStoreHook } from "@/store/modules/permission.store"; +import { useDictStoreHook } from "@/store/modules/dict.store"; +import { useTagsViewStore } from "@/store"; export const useUserStore = defineStore("user", () => { const userInfo = useStorage("userInfo", {} as UserInfo); + // 记住我状态 + const rememberMe = ref(Auth.getRememberMe()); /** * 登录 @@ -22,8 +24,9 @@ export const useUserStore = defineStore("user", () => { AuthAPI.login(LoginFormData) .then((data) => { const { accessToken, refreshToken } = data; - Storage.set(ACCESS_TOKEN_KEY, accessToken); - Storage.set(REFRESH_TOKEN_KEY, refreshToken); + // 保存记住我状态和token + rememberMe.value = LoginFormData.rememberMe; + Auth.setTokens(accessToken, refreshToken, rememberMe.value); resolve(); }) .catch((error) => { @@ -61,7 +64,8 @@ export const useUserStore = defineStore("user", () => { return new Promise((resolve, reject) => { AuthAPI.logout() .then(() => { - clearSessionAndCache(); + // 重置所有系统状态 + resetAllState(); resolve(); }) .catch((error) => { @@ -70,17 +74,49 @@ export const useUserStore = defineStore("user", () => { }); } + /** + * 重置所有系统状态 + * 统一处理所有清理工作,包括用户凭证、路由、缓存等 + */ + function resetAllState() { + // 1. 重置用户状态 + resetUserState(); + + // 2. 重置其他模块状态 + // 重置路由 + usePermissionStoreHook().resetRouter(); + // 清除字典缓存 + useDictStoreHook().clearDictCache(); + // 清除标签视图 + useTagsViewStore().delAllViews(); + + return Promise.resolve(); + } + + /** + * 重置用户状态 + * 仅处理用户模块内的状态 + */ + function resetUserState() { + // 清除用户凭证 + Auth.clearAuth(); + // 重置用户信息 + userInfo.value = {} as UserInfo; + } + /** * 刷新 token */ function refreshToken() { - const refreshToken = Storage.get(REFRESH_TOKEN_KEY, ""); + // 获取刷新令牌 + const refreshToken = Auth.getRefreshToken(); + return new Promise((resolve, reject) => { AuthAPI.refreshToken(refreshToken) .then((data) => { - const { accessToken, refreshToken } = data; - Storage.set(ACCESS_TOKEN_KEY, accessToken); - Storage.set(REFRESH_TOKEN_KEY, refreshToken); + const { accessToken, refreshToken: newRefreshToken } = data; + // 更新令牌,保持当前记住我状态 + Auth.setTokens(accessToken, newRefreshToken, Auth.getRememberMe()); resolve(); }) .catch((error) => { @@ -90,34 +126,21 @@ export const useUserStore = defineStore("user", () => { }); } - /** - * 清除用户会话和缓存 - */ - function clearSessionAndCache() { - return new Promise((resolve) => { - useDictStoreHook().clearDictCache(); - usePermissionStoreHook().resetRouter(); - Storage.remove(ACCESS_TOKEN_KEY); - Storage.remove(REFRESH_TOKEN_KEY); - userInfo.value = {} as UserInfo; - resolve(); - }); - } - return { userInfo, + rememberMe, getUserInfo, login, logout, - clearSessionAndCache, + resetAllState, + resetUserState, refreshToken, }; }); /** - * 用于在组件外部(如在Pinia Store 中)使用 Pinia 提供的 store 实例。 - * 官方文档解释了如何在组件外部使用 Pinia Store: - * https://pinia.vuejs.org/core-concepts/outside-component-usage.html#using-a-store-outside-of-a-component + * 在组件外部使用UserStore的钩子函数 + * @see https://pinia.vuejs.org/core-concepts/outside-component-usage.html */ export function useUserStoreHook() { return useUserStore(store); diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 00000000..869ea836 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,86 @@ +import { Storage } from "./storage"; +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, REMEMBER_ME_KEY } from "@/constants/cache-keys"; + +/** + * 身份验证工具类 + * 集中管理所有与认证相关的功能,包括: + * - 登录状态判断 + * - Token 的存取 + * - 记住我功能的状态管理 + */ +export class Auth { + /** + * 判断用户是否已登录 + * @returns 是否已登录 + */ + static isLoggedIn(): boolean { + return !!Auth.getAccessToken(); + } + + /** + * 获取当前有效的访问令牌 + * 会根据"记住我"状态从适当的存储位置获取 + * @returns 当前有效的访问令牌 + */ + static getAccessToken(): string { + const isRememberMe = Storage.get(REMEMBER_ME_KEY, false); + // 根据"记住我"状态决定从哪个存储位置获取token + return isRememberMe + ? Storage.get(ACCESS_TOKEN_KEY, "") + : Storage.sessionGet(ACCESS_TOKEN_KEY, ""); + } + + /** + * 获取刷新令牌 + * @returns 当前有效的刷新令牌 + */ + static getRefreshToken(): string { + const isRememberMe = Storage.get(REMEMBER_ME_KEY, false); + return isRememberMe + ? Storage.get(REFRESH_TOKEN_KEY, "") + : Storage.sessionGet(REFRESH_TOKEN_KEY, ""); + } + + /** + * 设置访问令牌和刷新令牌 + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + * @param rememberMe 是否记住我 + */ + static setTokens(accessToken: string, refreshToken: string, rememberMe: boolean): void { + // 保存"记住我"状态 + Storage.set(REMEMBER_ME_KEY, rememberMe); + + if (rememberMe) { + // 使用localStorage长期保存 + Storage.set(ACCESS_TOKEN_KEY, accessToken); + Storage.set(REFRESH_TOKEN_KEY, refreshToken); + } else { + // 使用sessionStorage临时保存 + Storage.sessionSet(ACCESS_TOKEN_KEY, accessToken); + Storage.sessionSet(REFRESH_TOKEN_KEY, refreshToken); + // 清除localStorage中可能存在的token + Storage.remove(ACCESS_TOKEN_KEY); + Storage.remove(REFRESH_TOKEN_KEY); + } + } + + /** + * 清除所有身份验证相关的数据 + */ + static clearAuth(): void { + Storage.remove(ACCESS_TOKEN_KEY); + Storage.remove(REFRESH_TOKEN_KEY); + Storage.sessionRemove(ACCESS_TOKEN_KEY); + Storage.sessionRemove(REFRESH_TOKEN_KEY); + // 不清除记住我设置,保留用户偏好 + } + + /** + * 获取"记住我"状态 + * @returns 是否记住我 + */ + static getRememberMe(): boolean { + return Storage.get(REMEMBER_ME_KEY, false); + } +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 7a67789b..15bf8860 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -2,8 +2,7 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio import qs from "qs"; import { useUserStoreHook } from "@/store/modules/user.store"; import { ResultEnum } from "@/enums/api/result.enum"; -import { Storage } from "@/utils/storage"; -import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys"; +import { Auth } from "@/utils/auth"; import router from "@/router"; // 创建 axios 实例 @@ -13,10 +12,11 @@ const service = axios.create({ headers: { "Content-Type": "application/json;charset=utf-8" }, paramsSerializer: (params) => qs.stringify(params), }); + // 请求拦截器 service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const accessToken = Storage.get(ACCESS_TOKEN_KEY, ""); + const accessToken = Auth.getAccessToken(); // 如果 Authorization 设置为 no-auth,则不携带 Token if (config.headers.Authorization !== "no-auth" && accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; @@ -70,7 +70,7 @@ async function handleTokenRefresh(config: InternalAxiosRequestConfig) { return new Promise((resolve) => { // 封装需要重试的请求 const retryRequest = () => { - config.headers.Authorization = `Bearer ${Storage.get(ACCESS_TOKEN_KEY, "")}`; + config.headers.Authorization = `Bearer ${Auth.getAccessToken()}`; resolve(service(config)); }; waitingQueue.push(retryRequest); @@ -101,6 +101,6 @@ async function handleSessionExpired() { message: "您的会话已过期,请重新登录", type: "info", }); - await useUserStoreHook().clearSessionAndCache(); + await useUserStoreHook().resetAllState(); router.push("/login"); } diff --git a/src/views/login/components/Login.vue b/src/views/login/components/Login.vue index 4771f9c2..24283388 100644 --- a/src/views/login/components/Login.vue +++ b/src/views/login/components/Login.vue @@ -113,6 +113,7 @@ import AuthAPI, { type LoginFormData } from "@/api/auth.api"; import router from "@/router"; import { useUserStore } from "@/store"; import CommonWrapper from "@/components/CommonWrapper/index.vue"; +import { Auth } from "@/utils/auth"; const { t } = useI18n(); const userStore = useUserStore(); @@ -124,13 +125,14 @@ const loginFormRef = ref(); const loading = ref(false); // 按钮 loading 状态 const isCapsLock = ref(false); // 是否大写锁定 const captchaBase64 = ref(); // 验证码图片Base64字符串 +const rememberMe = Auth.getRememberMe(); const loginFormData = ref({ username: "admin", password: "123456", captchaKey: "", captchaCode: "", - rememberMe: false, + rememberMe: rememberMe, }); const loginRules = computed(() => { @@ -195,9 +197,11 @@ async function handleLoginSubmit() { const redirect = resolveRedirectTarget(route.query); await router.push(redirect); - // TODO 5. 判断用户是否点击了记住我?采用明文保存或使用jsencrypt库? + // 5. 记住我功能已实现,根据用户选择决定token的存储方式: + // - 选中"记住我": token存储在localStorage中,浏览器关闭后仍然有效 + // - 未选中"记住我": token存储在sessionStorage中,浏览器关闭后失效 } catch (error) { - // 5. 统一错误处理 + // 6. 统一错误处理 getCaptcha(); // 刷新验证码 console.error("登录失败:", error); } finally { diff --git a/src/views/login/index.vue b/src/views/login/index.vue index a9a1c17e..40f01c19 100644 --- a/src/views/login/index.vue +++ b/src/views/login/index.vue @@ -1,7 +1,7 @@