refactor: store 目录扁平化重构为 stores(Pinia 推荐结构)
This commit is contained in:
71
src/stores/app.ts
Normal file
71
src/stores/app.ts
Normal 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
80
src/stores/dict.ts
Normal 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
17
src/stores/index.ts
Normal 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
171
src/stores/permission.ts
Normal 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-router(router.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
92
src/stores/settings.ts
Normal 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
275
src/stores/tags-view.ts
Normal 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
211
src/stores/tenant.ts
Normal 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
130
src/stores/user.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user