feat: ✨ 实现记住我功能并重构认证逻辑为统一的Auth工具类
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
// 可在此处添加其他缓存键
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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初始化已跳过。用户登录后将自动重新连接。"
|
||||
);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
86
src/utils/auth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user