refactor: store 目录扁平化重构为 stores(Pinia 推荐结构)

This commit is contained in:
Ray.Hao
2026-04-20 13:53:10 +08:00
parent f669e6951b
commit 33bde0eaf8
46 changed files with 71 additions and 72 deletions

71
src/stores/app.ts Normal file
View File

@@ -0,0 +1,71 @@
import zhCn from "element-plus/es/locale/lang/zh-cn";
import en from "element-plus/es/locale/lang/en";
import { store } from "@/stores";
import { DeviceEnum, SidebarStatus } from "@/enums";
import { STORAGE_KEYS } from "@/constants";
import { defaults } from "@/settings";
export const useAppStore = defineStore("app", () => {
const device = useStorage(STORAGE_KEYS.DEVICE, DeviceEnum.DESKTOP);
const size = useStorage(STORAGE_KEYS.SIZE, defaults.size);
const language = useStorage(STORAGE_KEYS.LANGUAGE, defaults.language);
const sidebarStatus = useStorage(STORAGE_KEYS.SIDEBAR_STATUS, SidebarStatus.CLOSED);
const sidebar = reactive({
opened: sidebarStatus.value === SidebarStatus.OPENED,
withoutAnimation: false,
});
const activeTopMenuPath = useStorage(STORAGE_KEYS.ACTIVE_TOP_MENU_PATH, "");
const locale = computed(() => (language?.value === "en" ? en : zhCn));
function toggleSidebar() {
sidebar.opened = !sidebar.opened;
sidebarStatus.value = sidebar.opened ? SidebarStatus.OPENED : SidebarStatus.CLOSED;
}
function closeSideBar() {
sidebar.opened = false;
sidebarStatus.value = SidebarStatus.CLOSED;
}
function openSideBar() {
sidebar.opened = true;
sidebarStatus.value = SidebarStatus.OPENED;
}
function toggleDevice(val: string) {
device.value = val;
}
function changeSize(val: string) {
size.value = val;
}
function changeLanguage(val: string) {
language.value = val;
}
function activeTopMenu(val: string) {
activeTopMenuPath.value = val;
}
return {
device,
sidebar,
language,
locale,
size,
activeTopMenu,
toggleDevice,
changeSize,
changeLanguage,
toggleSidebar,
closeSideBar,
openSideBar,
activeTopMenuPath,
};
});
export function useAppStoreHook() {
return useAppStore(store);
}

80
src/stores/dict.ts Normal file
View File

@@ -0,0 +1,80 @@
import { store } from "@/stores";
import DictAPI from "@/api/system/dict";
import type { DictItemOption } from "@/api/system/dict";
import { STORAGE_KEYS } from "@/constants";
export const useDictStore = defineStore("dict", () => {
// 字典数据缓存
const dictCache = useStorage<Record<string, DictItemOption[]>>(STORAGE_KEYS.DICT_CACHE, {});
// 请求队列(防止重复请求)
const requestQueue: Record<string, Promise<void>> = {};
/**
* 缓存字典数据
* @param dictCode 字典编码
* @param data 字典项列表
*/
const cacheDictItems = (dictCode: string, data: DictItemOption[]) => {
dictCache.value[dictCode] = data;
};
/**
* 加载字典数据(如果缓存中没有则请求)
* @param dictCode 字典编码
*/
const loadDictItems = async (dictCode: string) => {
if (dictCache.value[dictCode]) return;
// 防止重复请求
if (!requestQueue[dictCode]) {
requestQueue[dictCode] = DictAPI.getDictItems(dictCode)
.then((data) => {
cacheDictItems(dictCode, data);
Reflect.deleteProperty(requestQueue, dictCode);
})
.catch((error) => {
// 请求失败,清理队列,允许重试
Reflect.deleteProperty(requestQueue, dictCode);
throw error;
});
}
await requestQueue[dictCode];
};
/**
* 获取字典项列表
* @param dictCode 字典编码
* @returns 字典项列表
*/
const getDictItems = (dictCode: string): DictItemOption[] => {
return dictCache.value[dictCode] || [];
};
/**
* 移除指定字典项
* @param dictCode 字典编码
*/
const removeDictItem = (dictCode: string) => {
if (dictCache.value[dictCode]) {
Reflect.deleteProperty(dictCache.value, dictCode);
}
};
/**
* 清空字典缓存
*/
const clearDictCache = () => {
dictCache.value = {};
};
return {
loadDictItems,
getDictItems,
removeDictItem,
clearDictCache,
};
});
export function useDictStoreHook() {
return useDictStore(store);
}

17
src/stores/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { App } from "vue";
import { createPinia } from "pinia";
const store = createPinia();
export function setupStore(app: App<Element>) {
app.use(store);
}
export * from "./app";
export * from "./dict";
export * from "./permission";
export * from "./settings";
export * from "./tags-view";
export * from "./tenant";
export * from "./user";
export { store };

171
src/stores/permission.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { RouteRecordRaw } from "vue-router";
import { constantRoutes } from "@/router";
import { store } from "@/stores";
import router from "@/router";
import { useUserStoreHook } from "@/stores/user";
import MenuAPI from "@/api/system/menu";
import type { RouteItem } from "@/api/system/menu";
const modules = import.meta.glob("../views/**/**.vue");
const Layout = () => import("../layouts/index.vue");
function resolveViewComponent(componentPath: string) {
const normalized = componentPath
.trim()
.replace(/^\/+/, "")
.replace(/\.vue$/i, "");
return (
modules[`../views/${normalized}.vue`] ||
modules[`../views/${normalized}/index.vue`] ||
modules[`../views/error/404.vue`]
);
}
export const usePermissionStore = defineStore("permission", () => {
// 所有路由(静态路由 + 动态路由)
const routes = ref<RouteRecordRaw[]>([]);
// 混合布局的左侧菜单路由
const mixLayoutSideMenus = ref<RouteRecordRaw[]>([]);
// 动态路由是否已生成
const isRouteGenerated = ref(false);
/** 生成动态路由 */
async function generateRoutes(): Promise<RouteRecordRaw[]> {
try {
const data = await MenuAPI.getRoutes(); // 获取当前登录人的菜单路由
const dynamicRoutes = transformRoutes(data);
routes.value = [...constantRoutes, ...dynamicRoutes];
isRouteGenerated.value = true;
return dynamicRoutes;
} catch (error) {
// 路由生成失败,重置状态
isRouteGenerated.value = false;
throw error;
}
}
/** 设置混合布局左侧菜单 */
const setMixLayoutSideMenus = (parentPath: string) => {
const parentMenu = routes.value.find((item: RouteRecordRaw) => item.path === parentPath);
mixLayoutSideMenus.value = parentMenu?.children || [];
};
/** 重置路由状态 */
const resetRouter = () => {
// 移除动态添加的路由
const constantRouteNames = new Set(constantRoutes.map((route) => route.name).filter(Boolean));
routes.value.forEach((route: RouteRecordRaw) => {
if (route.name && !constantRouteNames.has(route.name)) {
router.removeRoute(route.name);
}
});
// 重置所有状态
routes.value = [...constantRoutes];
mixLayoutSideMenus.value = [];
isRouteGenerated.value = false;
};
let reloadPromise: Promise<RouteRecordRaw[]> | null = null;
/**
* 重新加载动态路由(单飞)。
*
* 典型场景后端权限变更导致接口返回权限不足A0301前端需要刷新路由和菜单以同步最新权限。
*
* - 会先清理已注册的动态路由resetRouter
* - 重新从后端拉取路由generateRoutes
* - 将动态路由注册到 vue-routerrouter.addRoute
*/
async function reloadDynamicRoutesOnce(): Promise<RouteRecordRaw[]> {
if (reloadPromise) return reloadPromise;
reloadPromise = (async () => {
try {
resetRouter();
const dynamicRoutes = await generateRoutes();
dynamicRoutes.forEach((route: RouteRecordRaw) => {
router.addRoute(route);
});
return dynamicRoutes;
} finally {
reloadPromise = null;
}
})();
return reloadPromise;
}
let snapshotPromise: Promise<void> | null = null;
/**
* 刷新权限快照(单飞)。
*
* - 刷新用户信息(包含 perms/roles 等)
* - 重新加载动态路由
*/
async function reloadPermissionSnapshotOnce(): Promise<void> {
if (snapshotPromise) return snapshotPromise;
snapshotPromise = (async () => {
try {
const userStore = useUserStoreHook();
await userStore.getUserInfo();
await reloadDynamicRoutesOnce();
} finally {
snapshotPromise = null;
}
})();
return snapshotPromise;
}
return {
routes,
mixLayoutSideMenus,
isRouteGenerated,
generateRoutes,
setMixLayoutSideMenus,
resetRouter,
reloadDynamicRoutesOnce,
reloadPermissionSnapshotOnce,
};
});
/**
* 转换后端路由数据为Vue Router配置
* 处理组件路径映射和Layout层级嵌套
*/
const transformRoutes = (routes: RouteItem[], isTopLevel: boolean = true): RouteRecordRaw[] => {
return routes.map((route) => {
const { component, children, ...args } = route;
// 处理组件顶层或非Layout保留组件中间层Layout设为undefined
const processedComponent = isTopLevel || component !== "Layout" ? component : undefined;
const normalizedRoute = { ...args } as RouteRecordRaw;
if (!processedComponent) {
// 多级菜单的父级菜单,不需要组件
normalizedRoute.component = undefined;
} else {
// 动态导入组件Layout特殊处理找不到组件时返回404
normalizedRoute.component =
processedComponent === "Layout" ? Layout : resolveViewComponent(processedComponent);
}
// 递归处理子路由
if (children && children.length > 0) {
normalizedRoute.children = transformRoutes(children, false);
}
return normalizedRoute;
});
};
/** 非组件环境使用权限store */
export function usePermissionStoreHook() {
return usePermissionStore(store);
}

92
src/stores/settings.ts Normal file
View File

@@ -0,0 +1,92 @@
import { SidebarColor, ThemeMode } from "@/enums";
import type { LayoutMode } from "@/enums";
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
import { STORAGE_KEYS } from "@/constants";
import { defaults } from "@/settings";
export const useSettingsStore = defineStore("setting", () => {
// 界面显示
const settingsVisible = ref(false);
const showTagsView = useStorage(STORAGE_KEYS.SHOW_TAGS_VIEW, defaults.showTagsView);
const showAppLogo = useStorage(STORAGE_KEYS.SHOW_APP_LOGO, defaults.showAppLogo);
const showWatermark = useStorage(STORAGE_KEYS.SHOW_WATERMARK, defaults.showWatermark);
const pageSwitchingAnimation = useStorage(
STORAGE_KEYS.PAGE_SWITCHING_ANIMATION,
defaults.pageSwitchingAnimation
);
// 布局
const layout = useStorage<LayoutMode>(STORAGE_KEYS.LAYOUT, defaults.layout as LayoutMode);
const sidebarColorScheme = useStorage(
STORAGE_KEYS.SIDEBAR_COLOR_SCHEME,
defaults.sidebarColorScheme
);
// 主题
const theme = useStorage<ThemeMode>(STORAGE_KEYS.THEME, defaults.theme);
const themeColor = useStorage(STORAGE_KEYS.THEME_COLOR, defaults.themeColor);
// 特殊模式
const grayMode = useStorage(STORAGE_KEYS.GRAY_MODE, false);
const colorWeak = useStorage(STORAGE_KEYS.COLOR_WEAK, false);
// 主题变化监听
watch(
[theme, themeColor],
([t, c]: [ThemeMode, string]) => {
toggleDarkMode(t === ThemeMode.DARK);
applyTheme(generateThemeColors(c, t));
},
{ immediate: true }
);
watch(sidebarColorScheme, (v) => toggleSidebarColor(v === SidebarColor.CLASSIC_BLUE), {
immediate: true,
});
// 灰色模式监听
watch(
grayMode,
(v) => {
document.documentElement.style.filter = v ? "grayscale(100%)" : "";
},
{ immediate: true }
);
// 色弱模式监听
watch(
colorWeak,
(v) => {
document.documentElement.classList.toggle("color-weak", v);
},
{ immediate: true }
);
function resetSettings() {
showTagsView.value = defaults.showTagsView;
showAppLogo.value = defaults.showAppLogo;
showWatermark.value = defaults.showWatermark;
pageSwitchingAnimation.value = defaults.pageSwitchingAnimation;
grayMode.value = false;
colorWeak.value = false;
sidebarColorScheme.value = defaults.sidebarColorScheme;
layout.value = defaults.layout as LayoutMode;
themeColor.value = defaults.themeColor;
theme.value = defaults.theme;
}
return {
settingsVisible,
showTagsView,
showAppLogo,
showWatermark,
pageSwitchingAnimation,
grayMode,
colorWeak,
sidebarColorScheme,
layout,
themeColor,
theme,
resetSettings,
};
});

275
src/stores/tags-view.ts Normal file
View File

@@ -0,0 +1,275 @@
export const useTagsViewStore = defineStore("tagsView", () => {
const visitedViews = ref<TagView[]>([]);
const cachedViews = ref<string[]>([]);
const router = useRouter();
const route = useRoute();
/**
* 添加已访问视图到已访问视图列表中
*/
function addVisitedView(view: TagView) {
// 如果已经存在于已访问的视图列表中或者是重定向地址,则不再添加
if (view.path.startsWith("/redirect")) {
return;
}
if (visitedViews.value.some((v) => v.path === view.path)) {
return;
}
// 如果视图是固定的affix则在已访问的视图列表的开头添加
if (view.affix) {
visitedViews.value.unshift(view);
} else {
// 如果视图不是固定的,则在已访问的视图列表的末尾添加
visitedViews.value.push(view);
}
}
/**
* 添加缓存视图到缓存视图列表中
*/
function addCachedView({ fullPath, keepAlive }: TagView) {
// 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
if (cachedViews.value.includes(fullPath)) {
return;
}
// 如果视图需要缓存keepAlive则将其路由名称添加到缓存视图列表中
if (keepAlive) {
cachedViews.value.push(fullPath);
}
}
/**
* 从已访问视图列表中删除指定的视图
*/
function delVisitedView(view: TagView) {
return new Promise((resolve) => {
for (const [i, v] of visitedViews.value.entries()) {
// 找到与指定视图路径匹配的视图,在已访问视图列表中删除该视图
if (v.path === view.path) {
visitedViews.value.splice(i, 1);
break;
}
}
resolve([...visitedViews.value]);
});
}
function delCachedView(view: TagView) {
const { fullPath } = view;
return new Promise((resolve) => {
const index = cachedViews.value.indexOf(fullPath);
if (index > -1) {
cachedViews.value.splice(index, 1);
}
resolve([...cachedViews.value]);
});
}
function delOtherVisitedViews(view: TagView) {
return new Promise((resolve) => {
visitedViews.value = visitedViews.value.filter((v) => {
return v?.affix || v.path === view.path;
});
resolve([...visitedViews.value]);
});
}
function delOtherCachedViews(view: TagView) {
const { fullPath } = view;
return new Promise((resolve) => {
const index = cachedViews.value.indexOf(fullPath);
if (index > -1) {
cachedViews.value = cachedViews.value.slice(index, index + 1);
} else {
// if index = -1, there is no cached tags
cachedViews.value = [];
}
resolve([...cachedViews.value]);
});
}
function updateVisitedView(view: TagView) {
for (const v of visitedViews.value) {
if (v.path === view.path) {
Object.assign(v, view);
break;
}
}
}
/**
* 根据路径更新标签名称
* @param fullPath 路径
* @param title 标签名称
*/
function updateTagName(fullPath: string, title: string) {
const tag = visitedViews.value.find((tag: TagView) => tag.fullPath === fullPath);
if (tag) {
tag.title = title;
}
}
function addView(view: TagView) {
addVisitedView(view);
addCachedView(view);
}
function delView(view: TagView) {
return new Promise((resolve) => {
delVisitedView(view);
delCachedView(view);
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delOtherViews(view: TagView) {
return new Promise((resolve) => {
delOtherVisitedViews(view);
delOtherCachedViews(view);
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delLeftViews(view: TagView) {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
if (index >= currIndex || item?.affix) {
return true;
}
const cacheIndex = cachedViews.value.indexOf(item.fullPath);
if (cacheIndex > -1) {
cachedViews.value.splice(cacheIndex, 1);
}
return false;
});
resolve({
visitedViews: [...visitedViews.value],
});
});
}
function delRightViews(view: TagView) {
return new Promise((resolve) => {
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
if (currIndex === -1) {
return;
}
visitedViews.value = visitedViews.value.filter((item, index) => {
if (index <= currIndex || item?.affix) {
return true;
}
const cacheIndex = cachedViews.value.indexOf(item.fullPath);
if (cacheIndex > -1) {
cachedViews.value.splice(cacheIndex, 1);
}
return false;
});
resolve({
visitedViews: [...visitedViews.value],
});
});
}
function delAllViews() {
return new Promise((resolve) => {
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
visitedViews.value = affixTags;
cachedViews.value = [];
resolve({
visitedViews: [...visitedViews.value],
cachedViews: [...cachedViews.value],
});
});
}
function delAllVisitedViews() {
return new Promise((resolve) => {
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
visitedViews.value = affixTags;
resolve([...visitedViews.value]);
});
}
function delAllCachedViews() {
return new Promise((resolve) => {
cachedViews.value = [];
resolve([...cachedViews.value]);
});
}
/**
* 关闭当前tagView
*/
function closeCurrentView() {
const tags: TagView = {
name: route.name as string,
title: route.meta.title as string,
path: route.path,
fullPath: route.fullPath,
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
query: route.query,
};
delView(tags).then((res: any) => {
if (isActive(tags)) {
toLastView(res.visitedViews, tags);
}
});
}
function isActive(tag: TagView) {
return tag.path === route.path;
}
function toLastView(visitedViews: TagView[], view?: TagView) {
const latestView = visitedViews.slice(-1)[0];
if (latestView && latestView.fullPath) {
router.push(latestView.fullPath);
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view?.name === "Dashboard") {
// to reload home page
router.replace("/redirect" + view.fullPath);
} else {
router.push("/");
}
}
}
return {
visitedViews,
cachedViews,
addVisitedView,
addCachedView,
delVisitedView,
delCachedView,
delOtherVisitedViews,
delOtherCachedViews,
updateVisitedView,
addView,
delView,
delOtherViews,
delLeftViews,
delRightViews,
delAllViews,
delAllVisitedViews,
delAllCachedViews,
closeCurrentView,
isActive,
toLastView,
updateTagName,
};
});

211
src/stores/tenant.ts Normal file
View File

@@ -0,0 +1,211 @@
import { store } from "@/stores";
import TenantAPI from "@/api/system/tenant";
import type { TenantInfo } from "@/api/system/tenant";
import { STORAGE_KEYS } from "@/constants";
import AuthAPI from "@/api/auth";
import { AuthStorage } from "@/utils/auth";
/**
* 租户 Store
*/
export const useTenantStore = defineStore("tenant", () => {
// 当前租户ID
const currentTenantId = ref<number | null>(null);
// 当前租户信息
const currentTenant = ref<TenantInfo | null>(null);
// 用户可访问的租户列表
const tenantList = ref<TenantInfo[]>([]);
/**
* 恢复租户信息
* 从 localStorage 恢复上次使用的租户
*/
function restoreTenant() {
const savedTenantId = localStorage.getItem(STORAGE_KEYS.TENANT_ID);
const savedTenantInfo = localStorage.getItem(STORAGE_KEYS.TENANT_INFO);
if (savedTenantId) {
currentTenantId.value = Number(savedTenantId);
}
if (savedTenantInfo) {
try {
currentTenant.value = JSON.parse(savedTenantInfo);
} catch (e) {
console.error("解析租户信息失败", e);
}
}
}
/**
* 获取用户租户列表
*/
async function fetchTenantList(): Promise<TenantInfo[]> {
const data = await TenantAPI.getTenantList();
tenantList.value = data || [];
return data || [];
}
/**
* 加载租户
*
* 执行流程:
* 1. 获取用户可访问的租户列表
* 2. 尝试获取后端当前租户
* 3. 如果只有一个租户,自动选中
* 4. 否则等待用户手动选择
*
* @remarks
* 此方法由路由守卫调用,仅在启用多租户时执行
*/
async function loadTenant() {
restoreTenant();
// 1. 获取租户列表
await fetchTenantList();
// 2. 校验本地恢复的租户是否仍然可用(避免 tenantId 不在列表导致无默认选中)
if (
currentTenantId.value != null &&
tenantList.value.length > 0 &&
!tenantList.value.some((t) => t.id === currentTenantId.value)
) {
console.debug("[Tenant] 本地租户已不可用,清除并重新选择:", currentTenantId.value);
clearLocalTenant();
}
// 3. 如果已有租户列表,则保证一定有一个默认租户被选中
if (tenantList.value.length > 0) {
// 3.1 优先后端当前租户
if (currentTenantId.value == null) {
const currentTenantInfo = await safeGetCurrentTenant();
if (currentTenantInfo) {
setCurrentTenant(currentTenantInfo);
return;
}
}
// 3.2 本地已有 tenantId但 currentTenant 为空时,从列表补全 tenantInfo保持展示名称一致
if (currentTenantId.value != null && !currentTenant.value) {
const matched = tenantList.value.find((t) => t.id === currentTenantId.value);
if (matched) {
setCurrentTenant(matched);
return;
}
}
// 3.3 兜底:默认选中第一个(即使有多个租户,也保证 TenantSwitcher 有默认选中)
if (currentTenantId.value == null) {
setCurrentTenant(tenantList.value[0]);
console.debug("[Tenant] 默认选中第一个租户:", tenantList.value[0].name);
}
}
}
/**
* 设置当前租户
*
* @param tenant 租户信息
*/
function setCurrentTenant(tenant: TenantInfo) {
currentTenantId.value = tenant.id;
currentTenant.value = tenant;
// 保存到 localStorage
localStorage.setItem(STORAGE_KEYS.TENANT_ID, String(tenant.id));
localStorage.setItem(STORAGE_KEYS.TENANT_INFO, JSON.stringify(tenant));
}
/**
* 切换租户
*
* @param tenantId 目标租户ID
*/
async function switchTenant(tenantId: number): Promise<void> {
await refreshTokenIfSupported(tenantId);
const tenantInfo = await TenantAPI.switchTenant(tenantId);
if (tenantInfo) {
setCurrentTenant(tenantInfo);
return;
}
const matched = tenantList.value.find((t) => t.id === tenantId);
if (matched) {
setCurrentTenant(matched);
return;
}
const fallback = await safeGetCurrentTenant();
if (fallback) {
setCurrentTenant(fallback);
return;
}
throw new Error("切换租户后无法获取租户信息");
}
/**
* 清除租户信息
*/
function clearTenant() {
currentTenantId.value = null;
currentTenant.value = null;
tenantList.value = [];
clearLocalTenant();
}
function clearLocalTenant() {
localStorage.removeItem(STORAGE_KEYS.TENANT_ID);
localStorage.removeItem(STORAGE_KEYS.TENANT_INFO);
}
async function safeGetCurrentTenant(): Promise<TenantInfo | null> {
try {
return await TenantAPI.getCurrentTenant();
} catch (error) {
console.debug("[Tenant] 获取当前租户失败,尝试本地/默认选择:", error);
return null;
}
}
async function refreshTokenIfSupported(tenantId: number): Promise<void> {
try {
const token = await AuthAPI.switchTenant(tenantId);
if (token?.accessToken && token?.refreshToken) {
AuthStorage.setTokens(token.accessToken, token.refreshToken, AuthStorage.getRememberMe());
}
} catch {
// 忽略:非平台用户或后端未启用该接口时,回退到旧接口
}
}
/**
* 设置租户列表
*/
function setTenantList(list: TenantInfo[]) {
tenantList.value = list || [];
}
// 恢复本地租户信息
restoreTenant();
return {
currentTenantId,
currentTenant,
tenantList,
loadTenant,
fetchTenantList,
setTenantList,
setCurrentTenant,
switchTenant,
clearTenant,
};
});
/**
* 在组件外部使用 TenantStore 的钩子函数
*/
export function useTenantStoreHook() {
return useTenantStore(store);
}

130
src/stores/user.ts Normal file
View File

@@ -0,0 +1,130 @@
import { store } from "@/stores";
import AuthAPI from "@/api/auth";
import UserAPI from "@/api/system/user";
import type { LoginRequest } from "@/api/auth";
import type { UserInfo } from "@/api/system/user";
import { AuthStorage } from "@/utils/auth";
import { usePermissionStoreHook } from "@/stores/permission";
import { useDictStoreHook } from "@/stores/dict";
import { useTagsViewStore } from "@/stores";
import { cleanupSseServices } from "@/composables";
export const useUserStore = defineStore("user", () => {
// 用户信息
const userInfo = ref<UserInfo>({} as UserInfo);
// 记住我状态
const rememberMe = ref(AuthStorage.getRememberMe());
/**
* 登录
*/
async function login(loginRequest: LoginRequest): Promise<void> {
const { accessToken, refreshToken } = await AuthAPI.login(loginRequest);
rememberMe.value = loginRequest.rememberMe ?? false;
AuthStorage.setTokens(accessToken, refreshToken, rememberMe.value);
}
let refreshPromise: Promise<void> | null = null;
/**
* 刷新 token单飞模式
*
* 多个并发请求遇到 token 过期时,共享同一次 refresh 请求。
*/
function refreshTokenOnce(): Promise<void> {
if (refreshPromise) return refreshPromise;
refreshPromise = doRefreshToken().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
/**
* 获取用户信息
*/
async function getUserInfo(): Promise<UserInfo> {
const data = await UserAPI.getInfo();
if (!data) {
throw new Error("Verification failed, please Login again.");
}
Object.assign(userInfo.value, data);
return data;
}
/**
* 登出
*/
async function logout(): Promise<void> {
await AuthAPI.logout();
resetAllState();
}
/**
* 重置所有系统状态
*
* 统一处理所有清理工作,包括用户凭证、路由、缓存等
*/
function resetAllState(): void {
// 1. 重置用户状态
resetUserState();
// 2. 重置其他模块状态
usePermissionStoreHook().resetRouter();
useDictStoreHook().clearDictCache();
useTagsViewStore().delAllViews();
// 3. 清理 SSE 连接
cleanupSseServices();
}
/**
* 重置用户状态
*
* 仅处理用户模块内的状态
*/
function resetUserState(): void {
AuthStorage.clearAuth();
userInfo.value = {} as UserInfo;
}
/**
* 刷新 token
*/
async function doRefreshToken(): Promise<void> {
const currentRefreshToken = AuthStorage.getRefreshToken();
if (!currentRefreshToken) {
throw new Error("没有有效的刷新令牌");
}
const { accessToken, refreshToken: newRefreshToken } =
await AuthAPI.refreshToken(currentRefreshToken);
AuthStorage.setTokens(accessToken, newRefreshToken, AuthStorage.getRememberMe());
}
return {
userInfo,
rememberMe,
isLoggedIn: () => !!AuthStorage.getAccessToken(),
login,
logout,
getUserInfo,
resetAllState,
resetUserState,
refreshToken: doRefreshToken,
refreshTokenOnce,
};
});
/**
* 在组件外部使用 UserStore 的钩子函数
*
* @see https://pinia.vuejs.org/core-concepts/outside-component-usage.html
*/
export function useUserStoreHook() {
return useUserStore(store);
}