feat: 实现记住我功能并重构认证逻辑为统一的Auth工具类

This commit is contained in:
Ray.Hao
2025-05-21 17:40:14 +08:00
parent aee9443fe0
commit 521ba98d6a
11 changed files with 194 additions and 73 deletions

View File

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

View File

@@ -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";
// 可在此处添加其他缓存键

View File

@@ -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}`);
});
});
}
</script>
@@ -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;

View File

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

View File

@@ -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初始化已跳过。用户登录后将自动重新连接。"
);

View File

@@ -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[] = [];

View File

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

86
src/utils/auth.ts Normal file
View File

@@ -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<boolean>(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<boolean>(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<boolean>(REMEMBER_ME_KEY, false);
}
}

View File

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

View File

@@ -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<FormInstance>();
const loading = ref(false); // 按钮 loading 状态
const isCapsLock = ref(false); // 是否大写锁定
const captchaBase64 = ref(); // 验证码图片Base64字符串
const rememberMe = Auth.getRememberMe();
const loginFormData = ref<LoginFormData>({
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 {

View File

@@ -1,7 +1,7 @@
<template>
<div class="wh-full flex-center flex-col login">
<div class="login-container">
<!-- 右侧切换主题语言按钮 -->
<div class="fixed flex-center gap-8px text-lg responsive-toggles">
<div class="action-bar">
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
<CommonWrapper>
<DarkModeSwitch />
@@ -63,13 +63,19 @@ const formComponents = {
</script>
<style lang="scss" scoped>
.login {
.login-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
// 添加伪元素作为背景层
.login::before {
.login-container::before {
position: fixed;
top: 0;
left: 0;
@@ -82,10 +88,16 @@ const formComponents = {
background-size: cover;
}
.responsive-toggles {
.action-bar {
position: fixed;
top: 10px;
right: 10px;
z-index: 10;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
font-size: 1.125rem;
@media (max-width: 480px) {
top: 10px;