mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-09 02:57:35 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
Regular → Executable
+33
-48
@@ -1,60 +1,47 @@
|
||||
import type { App } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||
import { router } from "@/router";
|
||||
import { resetDynamicRoutesSync } from "@/router/beforeEach";
|
||||
import { useUserStore } from "./modules/user.store";
|
||||
import { useDictStore } from "./modules/dict.store";
|
||||
import { useNoticeStore } from "./modules/notice.store";
|
||||
import { useConfigStore } from "./modules/config.store";
|
||||
import { useTagsViewStore } from "./modules/tags-view.store";
|
||||
|
||||
const store = createPinia();
|
||||
|
||||
store.use(piniaPluginPersistedstate);
|
||||
|
||||
// 全局注册 store
|
||||
export function initStore(app: App<Element>) {
|
||||
app.use(store);
|
||||
}
|
||||
|
||||
export * from "./modules/app.store";
|
||||
export * from "./modules/settings.store";
|
||||
export * from "./modules/tags-view.store";
|
||||
export * from "./modules/lock.store";
|
||||
export * from "./modules/permission.store";
|
||||
export * from "./modules/user.store";
|
||||
export * from "./modules/dict.store";
|
||||
export * from "./modules/config.store";
|
||||
export * from "./modules/dict.store";
|
||||
export * from "./modules/lock.store";
|
||||
export * from "./modules/menu.store";
|
||||
export * from "./modules/notice.store";
|
||||
export { store };
|
||||
export * from "./modules/setting.store";
|
||||
export * from "./modules/table.store";
|
||||
export * from "./modules/tags-view.store";
|
||||
export * from "./modules/user.store";
|
||||
export * from "./modules/worktab.store";
|
||||
|
||||
// ------------------------------
|
||||
// 🔄 刷新缓存统一入口
|
||||
// ------------------------------
|
||||
import router from "@/router";
|
||||
import { useUserStoreHook } from "./modules/user.store";
|
||||
import { usePermissionStoreHook } from "./modules/permission.store";
|
||||
import { useDictStoreHook } from "./modules/dict.store";
|
||||
import { useConfigStoreHook } from "./modules/config.store";
|
||||
import { useNoticeStoreHook } from "./modules/notice.store";
|
||||
import { useTagsViewStore } from "./modules/tags-view.store";
|
||||
import { RouteRecordRaw } from "vue-router";
|
||||
export { store };
|
||||
export { useUserStore, useDictStore, useNoticeStore, useConfigStore, useTagsViewStore };
|
||||
|
||||
export interface RefreshCacheOptions {
|
||||
/** 需要刷新的字典类型列表,不传则不刷新字典 */
|
||||
dictTypes?: string[];
|
||||
/** 是否刷新用户信息(含角色与权限) */
|
||||
refreshUser?: boolean; // 默认 true
|
||||
/** 是否重置并重新生成动态路由 */
|
||||
refreshRoutes?: boolean; // 默认 true
|
||||
/** 是否刷新系统配置 */
|
||||
refreshConfig?: boolean; // 默认 true
|
||||
/** 是否刷新通知公告 */
|
||||
refreshNotice?: boolean; // 默认 true
|
||||
/** 是否清空标签视图(避免路由变化后出现不一致) */
|
||||
clearTags?: boolean; // 默认 false
|
||||
/** 刷新字典前是否先清空本地字典缓存 */
|
||||
clearDictBefore?: boolean; // 默认 false
|
||||
refreshUser?: boolean;
|
||||
refreshRoutes?: boolean;
|
||||
refreshConfig?: boolean;
|
||||
refreshNotice?: boolean;
|
||||
clearTags?: boolean;
|
||||
clearDictBefore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键刷新常用缓存项,可按需选择。
|
||||
* 建议:服务端数据(配置、字典、公告、用户/权限)适合刷新;UI 偏好(app/settings/lock)不刷新。
|
||||
*/
|
||||
export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
|
||||
const {
|
||||
dictTypes,
|
||||
@@ -66,11 +53,10 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
|
||||
clearDictBefore = false,
|
||||
} = opts;
|
||||
|
||||
const userStore = useUserStoreHook();
|
||||
const permStore = usePermissionStoreHook();
|
||||
const dictStore = useDictStoreHook();
|
||||
const noticeStore = useNoticeStoreHook();
|
||||
const configStore = useConfigStoreHook();
|
||||
const userStore = useUserStore(store);
|
||||
const dictStore = useDictStore(store);
|
||||
const noticeStore = useNoticeStore(store);
|
||||
const configStore = useConfigStore(store);
|
||||
const tagsViewStore = useTagsViewStore(store);
|
||||
|
||||
const tasks: Promise<any>[] = [];
|
||||
@@ -89,18 +75,17 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
|
||||
tasks.push(dictStore.getDict(dictTypes));
|
||||
}
|
||||
|
||||
// 并行刷新服务端数据
|
||||
await Promise.allSettled(tasks);
|
||||
|
||||
// 路由需要串行处理,先重置再生成
|
||||
if (refreshRoutes) {
|
||||
permStore.resetRouter();
|
||||
const dynamicRoutes = await permStore.generateRoutes();
|
||||
// 将新生成的动态路由注册到路由器,确保可用
|
||||
dynamicRoutes.forEach((route: RouteRecordRaw) => router.addRoute(route));
|
||||
resetDynamicRoutesSync();
|
||||
await router.replace({
|
||||
path: router.currentRoute.value.path,
|
||||
query: router.currentRoute.value.query,
|
||||
hash: router.currentRoute.value.hash,
|
||||
});
|
||||
}
|
||||
|
||||
// 可选:清空标签视图,避免路由变更后的不一致
|
||||
if (clearTags) {
|
||||
await tagsViewStore.delAllViews();
|
||||
}
|
||||
|
||||
@@ -1,129 +1,122 @@
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
// 导入 Element Plus 中英文语言包
|
||||
/**
|
||||
* 应用状态管理模块(兼容 web 项目)
|
||||
*
|
||||
* 提供应用相关的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 设备类型管理(桌面/平板/移动端)
|
||||
* - 布局大小管理(默认/紧凑/宽松)
|
||||
* - 语言管理(中文/英文)
|
||||
* - 侧边栏状态管理(展开/收起)
|
||||
* - 顶部菜单激活路径管理
|
||||
* - 引导功能可见性管理
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 响应式布局适配
|
||||
* - 多语言切换
|
||||
* - 侧边栏折叠展开
|
||||
* - 顶部菜单导航
|
||||
* - 新手引导展示
|
||||
*
|
||||
* @module store/modules/app.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
import en from "element-plus/es/locale/lang/en";
|
||||
import { store } from "@/store";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
import { SidebarStatus } from "@/enums/settings/layout.enum";
|
||||
import { defineStore } from "pinia";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, reactive } from "vue";
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
// 设备类型
|
||||
const device = useStorage("device", DeviceEnum.DESKTOP);
|
||||
// 布局大小
|
||||
const size = useStorage("size", defaultSettings.size);
|
||||
// 语言
|
||||
const language = useStorage("language", defaultSettings.language);
|
||||
// 侧边栏状态
|
||||
const sidebarStatus = useStorage("sidebarStatus", SidebarStatus.CLOSED);
|
||||
const sidebar = reactive({
|
||||
opened: sidebarStatus.value === SidebarStatus.OPENED,
|
||||
withoutAnimation: false,
|
||||
});
|
||||
export const useAppStore = defineStore(
|
||||
"appStore",
|
||||
() => {
|
||||
/** 设备类型 */
|
||||
const device = ref<string>(DeviceEnum.DESKTOP);
|
||||
|
||||
// 顶部菜单激活路径
|
||||
const activeTopMenuPath = useStorage("activeTopMenuPath", "");
|
||||
// 项目引导
|
||||
const guideVisible = useStorage("guideVisible", defaultSettings.guideVisible);
|
||||
/** 布局大小 */
|
||||
const size = ref<string>("default");
|
||||
|
||||
/**
|
||||
* 根据语言标识读取对应的语言包
|
||||
*/
|
||||
const locale = computed(() => {
|
||||
if (language?.value == "en") {
|
||||
return en;
|
||||
} else {
|
||||
return zhCn;
|
||||
/** 语言 */
|
||||
const language = ref<string>("zh");
|
||||
|
||||
/** 侧边栏状态 */
|
||||
const sidebar = ref({
|
||||
opened: true,
|
||||
withoutAnimation: false,
|
||||
});
|
||||
|
||||
/** 顶部菜单激活路径 */
|
||||
const activeTopMenuPath = ref<string>("");
|
||||
|
||||
/** 引导可见性 */
|
||||
const guideVisible = ref<boolean>(false);
|
||||
|
||||
/** 语言区域 */
|
||||
const locale = computed(() => {
|
||||
return language.value === "en" ? en : zhCn;
|
||||
});
|
||||
|
||||
/** 切换侧边栏 */
|
||||
function toggleSidebar() {
|
||||
sidebar.value.opened = !sidebar.value.opened;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换侧边栏
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
sidebar.opened = !sidebar.opened;
|
||||
sidebarStatus.value = sidebar.opened ? SidebarStatus.OPENED : SidebarStatus.CLOSED;
|
||||
}
|
||||
/** 关闭侧边栏 */
|
||||
function closeSideBar() {
|
||||
sidebar.value.opened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭侧边栏
|
||||
*/
|
||||
function closeSideBar() {
|
||||
sidebar.opened = false;
|
||||
sidebarStatus.value = SidebarStatus.CLOSED;
|
||||
}
|
||||
/** 打开侧边栏 */
|
||||
function openSideBar() {
|
||||
sidebar.value.opened = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开侧边栏
|
||||
*/
|
||||
function openSideBar() {
|
||||
sidebar.opened = true;
|
||||
sidebarStatus.value = SidebarStatus.OPENED;
|
||||
}
|
||||
/** 切换设备 */
|
||||
function toggleDevice(val: string) {
|
||||
device.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换设备
|
||||
* @param val 设备类型
|
||||
*/
|
||||
function toggleDevice(val: string) {
|
||||
device.value = val;
|
||||
}
|
||||
/** 改变布局大小 */
|
||||
function changeSize(val: string) {
|
||||
size.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 改变布局大小
|
||||
* @param val 布局大小 default | small | large
|
||||
*/
|
||||
function changeSize(val: string) {
|
||||
size.value = val;
|
||||
}
|
||||
/**
|
||||
* 切换语言
|
||||
* @param val
|
||||
*/
|
||||
function changeLanguage(val: string) {
|
||||
language.value = val;
|
||||
}
|
||||
/**
|
||||
* 混合模式顶部切换
|
||||
* @param val
|
||||
*/
|
||||
function activeTopMenu(val: string) {
|
||||
activeTopMenuPath.value = val;
|
||||
}
|
||||
/**
|
||||
* 显示或隐藏引导
|
||||
* @param val 是否显示引导
|
||||
*/
|
||||
function showGuide(val: boolean) {
|
||||
guideVisible.value = val;
|
||||
}
|
||||
return {
|
||||
device,
|
||||
sidebar,
|
||||
language,
|
||||
locale,
|
||||
size,
|
||||
activeTopMenu,
|
||||
toggleDevice,
|
||||
showGuide,
|
||||
changeSize,
|
||||
changeLanguage,
|
||||
toggleSidebar,
|
||||
closeSideBar,
|
||||
openSideBar,
|
||||
activeTopMenuPath,
|
||||
guideVisible,
|
||||
};
|
||||
});
|
||||
/** 改变语言 */
|
||||
function changeLanguage(val: string) {
|
||||
language.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于在组件外部(如在Pinia Store 中)使用 Pinia 提供的 store 实例。
|
||||
* 官方文档解释了如何在组件外部使用 Pinia Store:
|
||||
* https://pinia.vuejs.org/core-concepts/outside-component-usage.html#using-a-store-outside-of-a-component
|
||||
*/
|
||||
export function useAppStoreHook() {
|
||||
return useAppStore(store);
|
||||
}
|
||||
/** 顶部菜单切换 */
|
||||
function activeTopMenu(val: string) {
|
||||
activeTopMenuPath.value = val;
|
||||
}
|
||||
|
||||
/** 显示或隐藏引导 */
|
||||
function showGuide(val: boolean) {
|
||||
guideVisible.value = val;
|
||||
}
|
||||
|
||||
return {
|
||||
device,
|
||||
sidebar,
|
||||
language,
|
||||
locale,
|
||||
size,
|
||||
activeTopMenu,
|
||||
toggleDevice,
|
||||
showGuide,
|
||||
changeSize,
|
||||
changeLanguage,
|
||||
toggleSidebar,
|
||||
closeSideBar,
|
||||
openSideBar,
|
||||
activeTopMenuPath,
|
||||
guideVisible,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,59 +1,85 @@
|
||||
import { store } from "@/store";
|
||||
/**
|
||||
* 系统配置状态管理模块
|
||||
*
|
||||
* 提供系统配置参数的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 网站基础信息管理(标题、版本、描述)
|
||||
* - 网站图标配置(登录背景、favicon、Logo)
|
||||
* - 安全隐私配置(服务条款、版权、隐私政策)
|
||||
* - 接口安全配置(白名单、黑名单)
|
||||
* - 演示环境配置
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 系统初始化配置加载
|
||||
* - 登录页背景和Logo显示
|
||||
* - 底部版权信息展示
|
||||
* - 接口安全控制
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 自动缓存已加载的配置
|
||||
* - 支持强制刷新配置
|
||||
*
|
||||
* @module store/modules/config.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import ParamsAPI, { ConfigTable } from "@/api/module_system/params";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface ConfigState {
|
||||
// 网站信息
|
||||
sys_web_title: ConfigTable;
|
||||
sys_web_version: ConfigTable;
|
||||
sys_web_description: ConfigTable;
|
||||
// 网站图标
|
||||
sys_login_background: ConfigTable;
|
||||
sys_web_favicon: ConfigTable;
|
||||
sys_web_logo: ConfigTable;
|
||||
// 安全隐私配置
|
||||
sys_keep_record: ConfigTable;
|
||||
sys_web_clause: ConfigTable;
|
||||
sys_web_copyright: ConfigTable;
|
||||
sys_web_privacy: ConfigTable;
|
||||
sys_git_code: ConfigTable;
|
||||
sys_help_doc: ConfigTable;
|
||||
// 接口安全配置
|
||||
white_api_list_path: ConfigTable;
|
||||
ip_black_list: ConfigTable;
|
||||
// 演示环境配置
|
||||
demo_enable: ConfigTable;
|
||||
ip_white_list: ConfigTable;
|
||||
}
|
||||
export const useConfigStore = defineStore(
|
||||
"configStore",
|
||||
() => {
|
||||
// 配置数据
|
||||
const configData = ref<Record<string, ConfigTable>>({});
|
||||
// 是否已加载配置
|
||||
const isConfigLoaded = ref(false);
|
||||
// 是否正在加载配置
|
||||
const configLoading = ref(false);
|
||||
|
||||
export const useConfigStore = defineStore("config", {
|
||||
state: () => ({
|
||||
configData: {} as ConfigState,
|
||||
isConfigLoaded: false,
|
||||
configLoading: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async getConfig(force = false) {
|
||||
if ((this.isConfigLoaded && !force) || this.configLoading) {
|
||||
/**
|
||||
* 获取系统配置
|
||||
* @param force 是否强制刷新配置
|
||||
*/
|
||||
async function getConfig(force = false) {
|
||||
if ((isConfigLoaded.value && !force) || configLoading.value) {
|
||||
return;
|
||||
}
|
||||
this.configLoading = true;
|
||||
configLoading.value = true;
|
||||
try {
|
||||
const response = await ParamsAPI.getInitConfig();
|
||||
response.data.data.forEach((item: ConfigTable) => {
|
||||
if (item.config_value !== undefined) {
|
||||
this.configData[item.config_key as keyof ConfigState] = item;
|
||||
const list = response?.data?.data;
|
||||
if (!Array.isArray(list)) {
|
||||
console.warn("[configStore] getInitConfig: 响应 data 非数组", response?.data);
|
||||
return;
|
||||
}
|
||||
list.forEach((item: ConfigTable) => {
|
||||
if (item.config_value !== undefined && item.config_key) {
|
||||
configData.value[item.config_key] = item;
|
||||
}
|
||||
});
|
||||
this.isConfigLoaded = true;
|
||||
isConfigLoaded.value = true;
|
||||
} finally {
|
||||
this.configLoading = false;
|
||||
configLoading.value = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
configData,
|
||||
isConfigLoaded,
|
||||
configLoading,
|
||||
getConfig,
|
||||
};
|
||||
},
|
||||
persist: true,
|
||||
});
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export function useConfigStoreHook() {
|
||||
return useConfigStore(store);
|
||||
|
||||
@@ -1,47 +1,89 @@
|
||||
import { store } from "@/store";
|
||||
/**
|
||||
* 字典状态管理模块
|
||||
*
|
||||
* 提供数据字典的状态管理和缓存机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 字典数据缓存和管理
|
||||
* - 批量获取字典数据
|
||||
* - 字典标签查找
|
||||
* - 字典数据清空
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 表单下拉选择框数据填充
|
||||
* - 表格列数据格式化显示
|
||||
* - 状态字段标签转换
|
||||
* - 动态表单配置
|
||||
*
|
||||
* ## 核心特性
|
||||
*
|
||||
* - 自动去重缓存
|
||||
* - 按需加载字典
|
||||
* - 统一的数据格式
|
||||
* - 支持批量获取
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 缓存已加载的字典数据
|
||||
* - 减少重复请求
|
||||
*
|
||||
* @module store/modules/dict.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import DictAPI, { DictDataTable } from "@/api/module_system/dict";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
export const useDictStore = defineStore("dict", {
|
||||
state: () => ({
|
||||
dictData: {} as Record<string, DictDataTable[]>,
|
||||
isLoaded: false,
|
||||
}),
|
||||
getters: {
|
||||
getDictData(): Record<string, DictDataTable[]> {
|
||||
return this.dictData;
|
||||
},
|
||||
export const useDictStore = defineStore(
|
||||
"dictStore",
|
||||
() => {
|
||||
// 字典数据
|
||||
const dictData = ref<Record<string, DictDataTable[]>>({});
|
||||
// 是否已加载
|
||||
const isLoaded = ref(false);
|
||||
|
||||
// 获取指定类型的字典数据,确保返回数组
|
||||
getDictArray() {
|
||||
return (type: string): Array<{ dict_value: string; dict_label: string }> => {
|
||||
return (this.dictData[type] || [])
|
||||
.filter((item) => item.dict_value !== undefined && item.dict_label !== undefined)
|
||||
.map((item) => ({
|
||||
dict_value: item.dict_value!,
|
||||
dict_label: item.dict_label!,
|
||||
}));
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// 批量获取字典数据
|
||||
async getDict(types: string[]): Promise<Record<string, DictDataTable[]>> {
|
||||
/**
|
||||
* 获取所有字典数据
|
||||
*/
|
||||
const getDictData = computed(() => dictData.value);
|
||||
|
||||
/**
|
||||
* 获取指定类型的字典数据,确保返回数组
|
||||
*/
|
||||
const getDictArray = (type: string): Array<{ dict_value: string; dict_label: string }> => {
|
||||
return (dictData.value[type] || [])
|
||||
.filter((item) => item.dict_value !== undefined && item.dict_label !== undefined)
|
||||
.map((item) => ({
|
||||
dict_value: item.dict_value!,
|
||||
dict_label: item.dict_label!,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取字典数据
|
||||
* @param types 字典类型数组
|
||||
* @returns 指定类型的字典数据
|
||||
*/
|
||||
async function getDict(types: string[]): Promise<Record<string, DictDataTable[]>> {
|
||||
try {
|
||||
for (const type of types) {
|
||||
if (!this.dictData[type]) {
|
||||
if (!dictData.value[type]) {
|
||||
const response = await DictAPI.getInitDict(type);
|
||||
// 确保数据格式正确
|
||||
this.dictData[type] = ((response.data.data as DictDataTable[]) || []).filter(
|
||||
dictData.value[type] = ((response.data.data as DictDataTable[]) || []).filter(
|
||||
(item) => item.dict_value !== undefined && item.dict_label !== undefined
|
||||
);
|
||||
this.isLoaded = true;
|
||||
isLoaded.value = true;
|
||||
}
|
||||
}
|
||||
// 返回请求的字典数据
|
||||
return types.reduce(
|
||||
(result, type) => {
|
||||
result[type] = this.getDictArray(type);
|
||||
result[type] = getDictArray(type);
|
||||
return result;
|
||||
},
|
||||
{} as Record<string, DictDataTable[]>
|
||||
@@ -50,10 +92,16 @@ export const useDictStore = defineStore("dict", {
|
||||
console.error("获取字典数据失败", error);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
getDictLabel(type: string, value: string) {
|
||||
const result = this.dictData[type].find((item) => item.dict_value === value);
|
||||
/**
|
||||
* 根据类型和值获取字典标签
|
||||
* @param type 字典类型
|
||||
* @param value 字典值
|
||||
* @returns 字典标签对象或原值
|
||||
*/
|
||||
function getDictLabel(type: string, value: string) {
|
||||
const result = dictData.value[type]?.find((item) => item.dict_value === value);
|
||||
if (!result) {
|
||||
return value;
|
||||
}
|
||||
@@ -74,13 +122,29 @@ export const useDictStore = defineStore("dict", {
|
||||
updated_time: result.updated_time,
|
||||
};
|
||||
return dict_data;
|
||||
},
|
||||
clearDictData() {
|
||||
this.dictData = {};
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空字典数据
|
||||
*/
|
||||
function clearDictData() {
|
||||
dictData.value = {};
|
||||
}
|
||||
|
||||
return {
|
||||
dictData,
|
||||
isLoaded,
|
||||
getDictData,
|
||||
getDictArray,
|
||||
getDict,
|
||||
getDictLabel,
|
||||
clearDictData,
|
||||
};
|
||||
},
|
||||
persist: true,
|
||||
});
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export function useDictStoreHook() {
|
||||
return useDictStore(store);
|
||||
|
||||
@@ -1,48 +1,93 @@
|
||||
/**
|
||||
* 锁屏状态管理模块
|
||||
*
|
||||
* 提供锁屏功能的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 锁屏状态管理
|
||||
* - 锁屏密码验证
|
||||
* - 锁屏信息存储
|
||||
* - 解锁功能
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 用户离开时锁定屏幕
|
||||
* - 保护敏感操作
|
||||
* - 临时离开保护
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 锁屏状态跨页面保持
|
||||
*
|
||||
* @module store/modules/lock.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { store } from "../index";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface lockInfo {
|
||||
isLock?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
export const useLockStore = defineStore(
|
||||
"lockStore",
|
||||
() => {
|
||||
// 是否锁定屏幕
|
||||
const isLock = ref(false);
|
||||
// 锁屏密码
|
||||
const password = ref("");
|
||||
|
||||
interface LockState {
|
||||
lockInfo: lockInfo;
|
||||
}
|
||||
/**
|
||||
* 获取锁屏信息
|
||||
*/
|
||||
const getLockInfo = () => ({
|
||||
isLock: isLock.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
export const useLockStore = defineStore("lock", {
|
||||
state: (): LockState => {
|
||||
return {
|
||||
lockInfo: {
|
||||
// isLock: false, // 是否锁定屏幕
|
||||
// password: '' // 锁屏密码
|
||||
},
|
||||
/**
|
||||
* 设置锁屏信息
|
||||
* @param lockInfo 锁屏信息对象
|
||||
*/
|
||||
const setLockInfo = (lockInfo: { isLock?: boolean; password?: string }) => {
|
||||
if (lockInfo.isLock !== undefined) {
|
||||
isLock.value = lockInfo.isLock;
|
||||
}
|
||||
if (lockInfo.password !== undefined) {
|
||||
password.value = lockInfo.password;
|
||||
}
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
getLockInfo(): lockInfo {
|
||||
return this.lockInfo;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setLockInfo(lockInfo: lockInfo) {
|
||||
this.lockInfo = lockInfo;
|
||||
},
|
||||
resetLockInfo() {
|
||||
this.lockInfo = {};
|
||||
},
|
||||
unLock(password: string) {
|
||||
if (this.lockInfo?.password === password) {
|
||||
this.resetLockInfo();
|
||||
|
||||
/**
|
||||
* 重置锁屏信息
|
||||
*/
|
||||
const resetLockInfo = () => {
|
||||
isLock.value = false;
|
||||
password.value = "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 解锁屏幕
|
||||
* @param inputPassword 输入的解锁密码
|
||||
* @returns 是否解锁成功
|
||||
*/
|
||||
const unLock = (inputPassword: string) => {
|
||||
if (password.value === inputPassword) {
|
||||
resetLockInfo();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const useLockStoreWithOut = () => {
|
||||
return useLockStore(store);
|
||||
};
|
||||
return {
|
||||
isLock,
|
||||
password,
|
||||
getLockInfo,
|
||||
setLockInfo,
|
||||
resetLockInfo,
|
||||
unLock,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 菜单状态管理模块
|
||||
*
|
||||
* 提供菜单数据和动态路由的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 菜单列表存储和管理
|
||||
* - 首页路径配置
|
||||
* - 动态路由注册和移除
|
||||
* - 路由移除函数管理
|
||||
* - 菜单宽度配置
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 动态菜单加载和渲染
|
||||
* - 路由权限控制
|
||||
* - 首页路径动态设置
|
||||
* - 登出时清理动态路由
|
||||
*
|
||||
* ## 工作流程
|
||||
*
|
||||
* 1. 获取菜单数据(前端/后端模式)
|
||||
* 2. 设置菜单列表和首页路径
|
||||
* 3. 注册动态路由并保存移除函数
|
||||
* 4. 登出时调用移除函数清理路由
|
||||
*
|
||||
* @module store/modules/menu.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { AppRouteRecord } from "@/types/router";
|
||||
import { getFirstMenuPath } from "@utils";
|
||||
import { HOME_PAGE_PATH } from "@/router";
|
||||
import { mergeShellRoutesIntoMenu } from "@/router/staticRoutes";
|
||||
|
||||
/**
|
||||
* 菜单状态管理
|
||||
* 管理应用的菜单列表、首页路径、菜单宽度和动态路由移除函数
|
||||
*/
|
||||
export const useMenuStore = defineStore(
|
||||
"menuStore",
|
||||
() => {
|
||||
/** 首页路径 */
|
||||
const homePath = ref(HOME_PAGE_PATH);
|
||||
/** 菜单列表 */
|
||||
const menuList = ref<AppRouteRecord[]>([]);
|
||||
/** 菜单宽度 */
|
||||
const menuWidth = ref("");
|
||||
/** 存储路由移除函数的数组 */
|
||||
const removeRouteFns = ref<(() => void)[]>([]);
|
||||
|
||||
/**
|
||||
* 设置菜单列表
|
||||
* @param list 菜单路由记录数组
|
||||
*/
|
||||
const setMenuList = (list: AppRouteRecord[]) => {
|
||||
const merged = mergeShellRoutesIntoMenu(list);
|
||||
menuList.value = merged;
|
||||
setHomePath(HOME_PAGE_PATH || getFirstMenuPath(merged));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取首页路径
|
||||
* @returns 首页路径字符串
|
||||
*/
|
||||
const getHomePath = () => homePath.value;
|
||||
|
||||
/**
|
||||
* 设置主页路径
|
||||
* @param path 主页路径
|
||||
*/
|
||||
const setHomePath = (path: string) => {
|
||||
homePath.value = path;
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加路由移除函数
|
||||
* @param fns 要添加的路由移除函数数组
|
||||
*/
|
||||
const addRemoveRouteFns = (fns: (() => void)[]) => {
|
||||
removeRouteFns.value.push(...fns);
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除所有动态路由
|
||||
* 执行所有存储的路由移除函数并清空数组
|
||||
*/
|
||||
const removeAllDynamicRoutes = () => {
|
||||
removeRouteFns.value.forEach((fn) => fn());
|
||||
removeRouteFns.value = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空路由移除函数数组
|
||||
*/
|
||||
const clearRemoveRouteFns = () => {
|
||||
removeRouteFns.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
menuList,
|
||||
menuWidth,
|
||||
removeRouteFns,
|
||||
setMenuList,
|
||||
getHomePath,
|
||||
setHomePath,
|
||||
addRemoveRouteFns,
|
||||
removeAllDynamicRoutes,
|
||||
clearRemoveRouteFns,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
pick: ["homePath", "menuWidth"],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,62 +1,123 @@
|
||||
import { store } from "@/store";
|
||||
/**
|
||||
* 通知状态管理模块
|
||||
*
|
||||
* 提供系统通知的状态管理和持久化
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 通知列表获取和管理
|
||||
* - 已读通知标记
|
||||
* - 批量标记已读
|
||||
* - 通知数量统计
|
||||
* - 已读状态持久化
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 通知中心展示
|
||||
* - 未读通知提醒
|
||||
* - 通知阅读状态跟踪
|
||||
* - 登录后的通知加载
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 已读通知ID集合持久化
|
||||
* - 刷新页面后保留已读状态
|
||||
*
|
||||
* @module store/modules/notice.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { store } from "@stores";
|
||||
import NoticeAPI, { NoticeTable } from "@/api/module_system/notice";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useNoticeStore = defineStore("notice", {
|
||||
state: () => ({
|
||||
noticeList: [] as NoticeTable[],
|
||||
total: 0,
|
||||
isNoticeLoaded: false,
|
||||
export const useNoticeStore = defineStore(
|
||||
"noticeStore",
|
||||
() => {
|
||||
// 通知列表
|
||||
const noticeList = ref<NoticeTable[]>([]);
|
||||
// 通知总数
|
||||
const total = ref(0);
|
||||
// 是否已加载通知
|
||||
const isNoticeLoaded = ref(false);
|
||||
// 已读通知ID集合(前端持久化,避免刷新后重复展示)
|
||||
readIds: [] as number[],
|
||||
}),
|
||||
actions: {
|
||||
async getNotice() {
|
||||
const readIds = ref<number[]>([]);
|
||||
|
||||
/**
|
||||
* 获取通知列表
|
||||
*/
|
||||
async function getNotice() {
|
||||
const response = await NoticeAPI.listNoticeAvailable();
|
||||
const items = response.data.data.items || [];
|
||||
// 过滤掉已读的通知
|
||||
const readSet = new Set(this.readIds);
|
||||
const readSet = new Set(readIds.value);
|
||||
const filtered = items.filter(
|
||||
(item) => item.id !== undefined && !readSet.has(item.id as number)
|
||||
(item: NoticeTable) => item.id !== undefined && !readSet.has(item.id as number)
|
||||
);
|
||||
this.noticeList = filtered;
|
||||
this.total = filtered.length;
|
||||
this.isNoticeLoaded = true;
|
||||
},
|
||||
// 标记单条通知为已读
|
||||
markAsRead(id?: number) {
|
||||
noticeList.value = filtered;
|
||||
total.value = filtered.length;
|
||||
isNoticeLoaded.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条通知为已读
|
||||
* @param id 通知ID
|
||||
*/
|
||||
const markAsRead = (id?: number) => {
|
||||
if (id === undefined) return;
|
||||
if (!this.readIds.includes(id)) {
|
||||
this.readIds.push(id);
|
||||
if (!readIds.value.includes(id)) {
|
||||
readIds.value.push(id);
|
||||
}
|
||||
// 同步过滤当前列表
|
||||
this.noticeList = this.noticeList.filter((item) => item.id !== id);
|
||||
this.total = this.noticeList.length;
|
||||
},
|
||||
// 标记当前列表全部为已读
|
||||
markAllAsRead(ids: number[] = []) {
|
||||
noticeList.value = noticeList.value.filter((item) => item.id !== id);
|
||||
total.value = noticeList.value.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 标记当前列表全部为已读
|
||||
* @param ids 指定的通知ID数组,不传则标记所有
|
||||
*/
|
||||
const markAllAsRead = (ids: number[] = []) => {
|
||||
const targets = ids.length
|
||||
? ids
|
||||
: this.noticeList.map((item) => item.id!).filter((id): id is number => id !== undefined);
|
||||
const readSet = new Set(this.readIds);
|
||||
: noticeList.value.map((item) => item.id!).filter((id): id is number => id !== undefined);
|
||||
const readSet = new Set(readIds.value);
|
||||
targets.forEach((id) => {
|
||||
if (!readSet.has(id)) this.readIds.push(id);
|
||||
if (!readSet.has(id)) readIds.value.push(id);
|
||||
});
|
||||
// 同步过滤当前列表
|
||||
this.noticeList = this.noticeList.filter(
|
||||
(item) => item.id !== undefined && !this.readIds.includes(item.id as number)
|
||||
noticeList.value = noticeList.value.filter(
|
||||
(item) => item.id !== undefined && !readIds.value.includes(item.id as number)
|
||||
);
|
||||
this.total = this.noticeList.length;
|
||||
},
|
||||
clearUserInfo() {
|
||||
this.noticeList = [];
|
||||
this.total = 0;
|
||||
this.isNoticeLoaded = false;
|
||||
this.readIds = [];
|
||||
},
|
||||
total.value = noticeList.value.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空用户通知信息(用于登出)
|
||||
*/
|
||||
const clearUserInfo = () => {
|
||||
noticeList.value = [];
|
||||
total.value = 0;
|
||||
isNoticeLoaded.value = false;
|
||||
readIds.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
noticeList,
|
||||
total,
|
||||
isNoticeLoaded,
|
||||
readIds,
|
||||
getNotice,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
clearUserInfo,
|
||||
};
|
||||
},
|
||||
persist: true,
|
||||
});
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export function useNoticeStoreHook() {
|
||||
return useNoticeStore(store);
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import router, { constantRoutes } from "@/router";
|
||||
import { store, useUserStore } from "@/store";
|
||||
import { MenuTable } from "@/api/module_system/menu";
|
||||
import { defineStore } from "pinia";
|
||||
import { Component, ref } from "vue";
|
||||
|
||||
const modules = import.meta.glob("../../views/**/**.vue");
|
||||
const Layout = () => import("@/layouts/index.vue");
|
||||
|
||||
export interface Meta {
|
||||
/** 【目录】只有一个子路由是否始终显示 */
|
||||
alwaysShow?: boolean;
|
||||
/** 是否隐藏(true-是 false-否) */
|
||||
hidden?: boolean;
|
||||
/** 菜单终端(后端 sys_menu.client,PC 动态路由仅下发 pc) */
|
||||
client?: string;
|
||||
/** ICON */
|
||||
icon?: string;
|
||||
/** 【菜单】是否开启页面缓存 */
|
||||
keepAlive?: boolean;
|
||||
/** 路由title */
|
||||
title?: string;
|
||||
/** 排序 */
|
||||
order?: number;
|
||||
/** 参数 */
|
||||
params?: { key: string; value: string }[];
|
||||
/** 是否固定路由 */
|
||||
affix?: boolean;
|
||||
}
|
||||
|
||||
// 修改 component 类型,使其能接受动态导入函数
|
||||
export interface RouteVO {
|
||||
/** 子路由列表 */
|
||||
children: RouteVO[];
|
||||
/** 组件路径或组件导入函数 */
|
||||
component?: string | (() => Promise<Component>);
|
||||
/** 路由属性 */
|
||||
meta?: Meta;
|
||||
/** 路由名称 */
|
||||
name?: string;
|
||||
/** 路由路径 */
|
||||
path?: string;
|
||||
/** 跳转链接 */
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端常把「模块目录」与「其下功能菜单」写成同一绝对 route_path(如均为 /module_xxx/demo02)。
|
||||
* Vue Router 嵌套子路由应使用空 path 挂到父 path 下,否则重复绝对路径会导致匹配异常。
|
||||
*/
|
||||
function normalizeMenuNestedPaths(items: MenuTable[], parentAbsolutePath?: string): MenuTable[] {
|
||||
return items.map((node) => {
|
||||
const raw = (node.route_path ?? "").trim();
|
||||
let routePath: string | null | undefined = node.route_path;
|
||||
if (parentAbsolutePath && raw && raw === parentAbsolutePath) {
|
||||
routePath = "";
|
||||
}
|
||||
const canonical = raw.startsWith("/") ? raw : parentAbsolutePath;
|
||||
const children = node.children?.length
|
||||
? normalizeMenuNestedPaths(node.children, canonical)
|
||||
: undefined;
|
||||
return { ...node, route_path: routePath, children };
|
||||
});
|
||||
}
|
||||
|
||||
export const generator = (routers: MenuTable[]): RouteVO[] => {
|
||||
return routers.map((item) => {
|
||||
const currentRouter: RouteVO = {
|
||||
children: [],
|
||||
path: item.route_path,
|
||||
name: item.route_name,
|
||||
component: item.component_path,
|
||||
redirect: item.redirect,
|
||||
meta: {
|
||||
title: item.title,
|
||||
icon: item.icon || undefined,
|
||||
client: item.client,
|
||||
keepAlive: item.keep_alive,
|
||||
hidden: item.hidden,
|
||||
order: item.order,
|
||||
alwaysShow: item.always_show,
|
||||
params: item.params,
|
||||
affix: item.affix,
|
||||
},
|
||||
};
|
||||
if (item.children && item.children.length > 0) {
|
||||
currentRouter.children = item.children ? generator(item.children) : [];
|
||||
}
|
||||
return currentRouter;
|
||||
});
|
||||
};
|
||||
|
||||
export const usePermissionStore = defineStore("permission", () => {
|
||||
// 存储所有路由,包括静态路由和动态路由
|
||||
const routes = ref<RouteRecordRaw[]>([]);
|
||||
// 混合模式左侧菜单路由
|
||||
const mixLayoutSideMenus = ref<RouteRecordRaw[]>([]);
|
||||
// 路由是否加载完成
|
||||
const isRouteGenerated = ref(false);
|
||||
|
||||
/**
|
||||
* 获取后台动态路由数据,解析并注册到全局路由
|
||||
*
|
||||
* @returns Promise<RouteRecordRaw[]> 解析后的动态路由列表
|
||||
*/
|
||||
async function generateRoutes(): Promise<RouteRecordRaw[]> {
|
||||
try {
|
||||
const userStore = useUserStore();
|
||||
// 确保获取用户信息和路由列表
|
||||
if (!userStore.hasGetRoute) {
|
||||
await userStore.getUserInfo();
|
||||
}
|
||||
|
||||
const data = generator(normalizeMenuNestedPaths(userStore.routeList));
|
||||
|
||||
// 解析动态路由
|
||||
const dynamicRoutes = transformRoutes(data);
|
||||
|
||||
routes.value = [...constantRoutes, ...dynamicRoutes];
|
||||
|
||||
isRouteGenerated.value = true;
|
||||
|
||||
return dynamicRoutes;
|
||||
} catch (error: any) {
|
||||
// 即使失败也要设置状态,避免无限重试
|
||||
isRouteGenerated.value = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据父菜单路径设置侧边菜单
|
||||
*
|
||||
* @param parentPath 父菜单的路径,用于查找对应的菜单项
|
||||
*/
|
||||
const setMixLayoutSideMenus = (parentPath: string) => {
|
||||
const parentMenu = routes.value.find((item) => item.path === parentPath);
|
||||
mixLayoutSideMenus.value = parentMenu?.children || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置路由
|
||||
*/
|
||||
const resetRouter = () => {
|
||||
// 创建常量路由名称集合,用于O(1)时间复杂度的查找
|
||||
const constantRouteNames = new Set(constantRoutes.map((route) => route.name).filter(Boolean));
|
||||
|
||||
// 从 router 实例中移除动态路由
|
||||
routes.value.forEach((route) => {
|
||||
if (route.name && !constantRouteNames.has(route.name)) {
|
||||
router.removeRoute(route.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 重置所有状态
|
||||
routes.value = [...constantRoutes];
|
||||
mixLayoutSideMenus.value = [];
|
||||
isRouteGenerated.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
routes,
|
||||
mixLayoutSideMenus,
|
||||
isRouteGenerated,
|
||||
generateRoutes,
|
||||
setMixLayoutSideMenus,
|
||||
resetRouter,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 转换后端路由数据为Vue Router配置
|
||||
* 处理组件路径映射和Layout层级嵌套
|
||||
*/
|
||||
const transformRoutes = (routes: RouteVO[], isTopLevel: boolean = true): RouteRecordRaw[] => {
|
||||
return routes.map((route) => {
|
||||
// 创建路由对象,保留所有路由属性
|
||||
const normalizedRoute = { ...route } as RouteRecordRaw;
|
||||
|
||||
// 处理组件路径映射
|
||||
// normalizedRoute.component = !normalizedRoute.component
|
||||
// ? Layout
|
||||
// : modules[`../../views/${normalizedRoute.component}.vue`] ||
|
||||
// modules["../../views/error/404.vue"];
|
||||
|
||||
// 关键优化:
|
||||
// 1. 顶级路由(一级目录)使用Layout组件,确保菜单和navbar能正常显示
|
||||
// 2. 二级及以上的父路由不使用Layout组件,只作为路由容器,避免Layout嵌套
|
||||
// 3. 叶子路由使用实际组件
|
||||
// 4. 递归处理子路由,实现无限层级菜单
|
||||
if (normalizedRoute.children && normalizedRoute.children.length > 0) {
|
||||
// normalizedRoute.children = transformRoutes(route.children);
|
||||
|
||||
// 非叶子路由
|
||||
if (isTopLevel) {
|
||||
// 顶级路由(一级目录),使用Layout组件
|
||||
normalizedRoute.component = Layout;
|
||||
} else {
|
||||
// 二级及以上的父路由,不使用Layout组件,只作为路由容器
|
||||
normalizedRoute.component = undefined;
|
||||
}
|
||||
// 递归处理子路由,标记为非顶级路由
|
||||
normalizedRoute.children = transformRoutes(route.children, false);
|
||||
} else {
|
||||
// 叶子路由,使用实际组件
|
||||
normalizedRoute.component = normalizedRoute.component
|
||||
? modules[`../../views/${normalizedRoute.component}.vue`] ||
|
||||
modules["../../views/error/404.vue"]
|
||||
: modules["../../views/error/404.vue"];
|
||||
}
|
||||
|
||||
return normalizedRoute;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出此hook函数用于在非组件环境(如其他store、工具函数等)中获取权限store实例
|
||||
*
|
||||
* 在组件中可直接使用usePermissionStore(),但在组件外部需要传入store实例
|
||||
* 此函数简化了这个过程,避免每次都手动传入store参数
|
||||
*/
|
||||
export function usePermissionStoreHook() {
|
||||
return usePermissionStore(store);
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/** Settings store. */
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { MenuThemeType } from "@/types/store";
|
||||
import AppConfig from "@/config";
|
||||
import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from "@/enums/appEnum";
|
||||
import { setElementThemeColor } from "@utils/ui";
|
||||
import { formatToDate } from "@utils/common";
|
||||
import { StorageConfig } from "@utils/storage";
|
||||
import { SETTING_DEFAULT_CONFIG } from "@/config/setting";
|
||||
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@utils/ui";
|
||||
import { SETTINGS_KEYS } from "@/constants";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { defaultSettings } from "@/config/setting";
|
||||
import { SidebarColor, ThemeMode } from "@/enums/settings/theme.enum";
|
||||
import type { LayoutMode } from "@/enums/settings/layout.enum";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
export const useSettingsStore = defineStore(
|
||||
"settingStore",
|
||||
() => {
|
||||
// 菜单
|
||||
const menuType = ref(SETTING_DEFAULT_CONFIG.menuType);
|
||||
const menuOpenWidth = ref(SETTING_DEFAULT_CONFIG.menuOpenWidth);
|
||||
const menuOpen = ref(SETTING_DEFAULT_CONFIG.menuOpen);
|
||||
const dualMenuShowText = ref(SETTING_DEFAULT_CONFIG.dualMenuShowText);
|
||||
|
||||
// 主题
|
||||
const systemThemeType = ref(SETTING_DEFAULT_CONFIG.systemThemeType);
|
||||
const systemThemeMode = ref(SETTING_DEFAULT_CONFIG.systemThemeMode);
|
||||
const menuThemeType = ref(SETTING_DEFAULT_CONFIG.menuThemeType);
|
||||
const systemThemeColor = ref(SETTING_DEFAULT_CONFIG.systemThemeColor);
|
||||
|
||||
// 显示
|
||||
const showMenuButton = ref(SETTING_DEFAULT_CONFIG.showMenuButton);
|
||||
const showFastEnter = ref(SETTING_DEFAULT_CONFIG.showFastEnter);
|
||||
const showRefreshButton = ref(SETTING_DEFAULT_CONFIG.showRefreshButton);
|
||||
const showCrumbs = ref(SETTING_DEFAULT_CONFIG.showCrumbs);
|
||||
const showWorkTab = ref(SETTING_DEFAULT_CONFIG.showWorkTab);
|
||||
const showLanguage = ref(SETTING_DEFAULT_CONFIG.showLanguage);
|
||||
const showNprogress = ref(SETTING_DEFAULT_CONFIG.showNprogress);
|
||||
const showSettingGuide = ref(SETTING_DEFAULT_CONFIG.showSettingGuide);
|
||||
const showFestivalText = ref(SETTING_DEFAULT_CONFIG.showFestivalText);
|
||||
const watermarkVisible = ref(SETTING_DEFAULT_CONFIG.watermarkVisible);
|
||||
|
||||
// 功能
|
||||
const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose);
|
||||
const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened);
|
||||
const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak);
|
||||
const refresh = ref(SETTING_DEFAULT_CONFIG.refresh);
|
||||
const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded);
|
||||
|
||||
// 样式
|
||||
const boxBorderMode = ref(SETTING_DEFAULT_CONFIG.boxBorderMode);
|
||||
const pageTransition = ref(SETTING_DEFAULT_CONFIG.pageTransition);
|
||||
const tabStyle = ref(SETTING_DEFAULT_CONFIG.tabStyle);
|
||||
const customRadius = ref(SETTING_DEFAULT_CONFIG.customRadius);
|
||||
const containerWidth = ref(SETTING_DEFAULT_CONFIG.containerWidth);
|
||||
|
||||
// 节日
|
||||
const festivalDate = ref("");
|
||||
|
||||
// 面板开关(非持久化)
|
||||
const settingsVisible = ref<boolean>(false);
|
||||
|
||||
// 持久化(web-style:useStorage)
|
||||
const showTagsView = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_TAGS_VIEW,
|
||||
defaultSettings.showTagsView
|
||||
);
|
||||
const showAppLogo = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_APP_LOGO,
|
||||
defaultSettings.showAppLogo
|
||||
);
|
||||
const showWatermark = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_WATERMARK,
|
||||
defaultSettings.showWatermark
|
||||
);
|
||||
const showSettings = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_SETTINGS,
|
||||
defaultSettings.showSettings
|
||||
);
|
||||
const showGuide = useStorage<boolean>(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide);
|
||||
|
||||
// 桌面端工具设置 - 持久化
|
||||
const showMenuSearch = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_MENU_SEARCH,
|
||||
defaultSettings.showMenuSearch
|
||||
);
|
||||
const showFullscreen = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_FULLSCREEN,
|
||||
defaultSettings.showFullscreen
|
||||
);
|
||||
const showSizeSelect = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_SIZE_SELECT,
|
||||
defaultSettings.showSizeSelect
|
||||
);
|
||||
const showLangSelect = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_LANG_SELECT,
|
||||
defaultSettings.showLangSelect
|
||||
);
|
||||
const showNotification = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_NOTIFICATION,
|
||||
defaultSettings.showNotification
|
||||
);
|
||||
|
||||
// 布局和主题设置 - 持久化
|
||||
const sidebarColorScheme = useStorage<string>(
|
||||
SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME,
|
||||
defaultSettings.sidebarColorScheme
|
||||
);
|
||||
const layout = useStorage<LayoutMode>(
|
||||
SETTINGS_KEYS.LAYOUT,
|
||||
defaultSettings.layout as LayoutMode
|
||||
);
|
||||
const themeColor = useStorage<string>(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor);
|
||||
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
|
||||
|
||||
// 系统设置 - 持久化
|
||||
const grayMode = useStorage<boolean>(SETTINGS_KEYS.GRAY_MODE, defaultSettings.grayMode);
|
||||
const userEnableAi = useStorage<boolean>(SETTINGS_KEYS.AI_ENABLED, defaultSettings.aiEnabled);
|
||||
const pageSwitchingAnimation = useStorage<string>(
|
||||
SETTINGS_KEYS.PAGE_SWITCHING_ANIMATION,
|
||||
defaultSettings.pageSwitchingAnimation
|
||||
);
|
||||
|
||||
const getMenuTheme = computed((): MenuThemeType => {
|
||||
const list = AppConfig.themeList.filter((item) => item.theme === menuThemeType.value);
|
||||
if (isDark.value) {
|
||||
return AppConfig.darkMenuStyles[0];
|
||||
} else {
|
||||
return list[0];
|
||||
}
|
||||
});
|
||||
|
||||
const isDark = computed((): boolean => {
|
||||
return systemThemeType.value === SystemThemeEnum.DARK;
|
||||
});
|
||||
|
||||
const getMenuOpenWidth = computed((): string => {
|
||||
return menuOpenWidth.value + "px" || SETTING_DEFAULT_CONFIG.menuOpenWidth + "px";
|
||||
});
|
||||
|
||||
const getCustomRadius = computed((): string => {
|
||||
return customRadius.value + "rem" || SETTING_DEFAULT_CONFIG.customRadius + "rem";
|
||||
});
|
||||
|
||||
/** festivalDate 存「上次完成烟花播放」的自然日 YYYY-MM-DD,与今天相同则当天不再播 */
|
||||
const isShowFireworks = computed((): boolean => {
|
||||
const today = formatToDate(new Date());
|
||||
return festivalDate.value !== today;
|
||||
});
|
||||
|
||||
const settingsMap = {
|
||||
showTagsView,
|
||||
showAppLogo,
|
||||
showWatermark,
|
||||
showSettings,
|
||||
showGuide,
|
||||
showMenuSearch,
|
||||
showFullscreen,
|
||||
showSizeSelect,
|
||||
showLangSelect,
|
||||
showNotification,
|
||||
sidebarColorScheme,
|
||||
layout,
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
} as const;
|
||||
|
||||
watch(
|
||||
[theme, themeColor],
|
||||
([newTheme, newThemeColor]) => {
|
||||
toggleDarkMode(newTheme === ThemeMode.DARK);
|
||||
const colors = generateThemeColors(newThemeColor, newTheme);
|
||||
applyTheme(colors);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
[sidebarColorScheme],
|
||||
([newSidebarColorScheme]) => {
|
||||
toggleSidebarColor(newSidebarColorScheme === SidebarColor.CLASSIC_BLUE);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
grayMode,
|
||||
(v) => {
|
||||
document.documentElement.style.filter = v ? "grayscale(100%)" : "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
||||
menuType.value = type;
|
||||
};
|
||||
|
||||
const setMenuOpenWidth = (width: number) => {
|
||||
menuOpenWidth.value = width;
|
||||
};
|
||||
|
||||
const setGlopTheme = (theme: SystemThemeEnum, themeMode: SystemThemeEnum) => {
|
||||
systemThemeType.value = theme;
|
||||
systemThemeMode.value = themeMode;
|
||||
localStorage.setItem(StorageConfig.THEME_KEY, theme);
|
||||
};
|
||||
|
||||
const switchMenuStyles = (theme: MenuThemeEnum) => {
|
||||
menuThemeType.value = theme;
|
||||
};
|
||||
|
||||
const setElementTheme = (theme: string) => {
|
||||
systemThemeColor.value = theme;
|
||||
setElementThemeColor(theme);
|
||||
};
|
||||
|
||||
const setBorderMode = () => {
|
||||
boxBorderMode.value = !boxBorderMode.value;
|
||||
};
|
||||
|
||||
const setContainerWidth = (width: ContainerWidthEnum) => {
|
||||
containerWidth.value = width;
|
||||
};
|
||||
|
||||
const setUniqueOpened = () => {
|
||||
uniqueOpened.value = !uniqueOpened.value;
|
||||
};
|
||||
|
||||
const setButton = () => {
|
||||
showMenuButton.value = !showMenuButton.value;
|
||||
};
|
||||
|
||||
const setFastEnter = () => {
|
||||
showFastEnter.value = !showFastEnter.value;
|
||||
};
|
||||
|
||||
const setAutoClose = () => {
|
||||
autoClose.value = !autoClose.value;
|
||||
};
|
||||
|
||||
const setShowRefreshButton = () => {
|
||||
showRefreshButton.value = !showRefreshButton.value;
|
||||
};
|
||||
|
||||
const setCrumbs = () => {
|
||||
showCrumbs.value = !showCrumbs.value;
|
||||
};
|
||||
|
||||
const setWorkTab = (show: boolean) => {
|
||||
showWorkTab.value = show;
|
||||
};
|
||||
|
||||
const setLanguage = () => {
|
||||
showLanguage.value = !showLanguage.value;
|
||||
};
|
||||
|
||||
const setNprogress = () => {
|
||||
showNprogress.value = !showNprogress.value;
|
||||
};
|
||||
|
||||
const setColorWeak = () => {
|
||||
colorWeak.value = !colorWeak.value;
|
||||
};
|
||||
|
||||
const hideSettingGuide = () => {
|
||||
showSettingGuide.value = false;
|
||||
};
|
||||
|
||||
const openSettingGuide = () => {
|
||||
showSettingGuide.value = true;
|
||||
};
|
||||
|
||||
const setPageTransition = (transition: string) => {
|
||||
pageTransition.value = transition;
|
||||
};
|
||||
|
||||
const setTabStyle = (style: string) => {
|
||||
tabStyle.value = style;
|
||||
};
|
||||
|
||||
const setMenuOpen = (open: boolean) => {
|
||||
menuOpen.value = open;
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
refresh.value = !refresh.value;
|
||||
};
|
||||
|
||||
const setWatermarkVisible = (visible: boolean) => {
|
||||
watermarkVisible.value = visible;
|
||||
};
|
||||
|
||||
const setCustomRadius = (radius: string) => {
|
||||
customRadius.value = radius;
|
||||
document.documentElement.style.setProperty("--custom-radius", `${radius}rem`);
|
||||
};
|
||||
|
||||
const setholidayFireworksLoaded = (isLoad: boolean) => {
|
||||
holidayFireworksLoaded.value = isLoad;
|
||||
};
|
||||
|
||||
const setShowFestivalText = (show: boolean) => {
|
||||
showFestivalText.value = show;
|
||||
};
|
||||
|
||||
const setFestivalDate = (date: string) => {
|
||||
festivalDate.value = date;
|
||||
};
|
||||
|
||||
const setDualMenuShowText = (show: boolean) => {
|
||||
dualMenuShowText.value = show;
|
||||
};
|
||||
|
||||
function updateSetting<K extends keyof typeof settingsMap>(
|
||||
key: K,
|
||||
value: boolean | string
|
||||
): void {
|
||||
const setting = settingsMap[key];
|
||||
if (setting) {
|
||||
(setting as Ref<any>).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTheme(newTheme: ThemeMode): void {
|
||||
theme.value = newTheme;
|
||||
}
|
||||
|
||||
function updateThemeColor(newColor: string): void {
|
||||
themeColor.value = newColor;
|
||||
}
|
||||
|
||||
function updateSidebarColorScheme(newScheme: string): void {
|
||||
sidebarColorScheme.value = newScheme;
|
||||
}
|
||||
|
||||
function updateLayout(newLayout: LayoutMode): void {
|
||||
layout.value = newLayout;
|
||||
}
|
||||
|
||||
function toggleSettingsPanel(): void {
|
||||
settingsVisible.value = !settingsVisible.value;
|
||||
}
|
||||
|
||||
function showSettingsPanel(): void {
|
||||
settingsVisible.value = true;
|
||||
}
|
||||
|
||||
function hideSettingsPanel(): void {
|
||||
settingsVisible.value = false;
|
||||
}
|
||||
|
||||
function updateUserEnableAi(newValue: boolean): void {
|
||||
userEnableAi.value = newValue;
|
||||
}
|
||||
|
||||
function updateGrayMode(newValue: boolean): void {
|
||||
grayMode.value = newValue;
|
||||
}
|
||||
|
||||
function updatePageSwitchingAnimation(newValue: string): void {
|
||||
pageSwitchingAnimation.value = newValue;
|
||||
}
|
||||
|
||||
function resetSettings(): void {
|
||||
// 界面显示设置
|
||||
showTagsView.value = defaultSettings.showTagsView;
|
||||
showAppLogo.value = defaultSettings.showAppLogo;
|
||||
showWatermark.value = defaultSettings.showWatermark;
|
||||
showSettings.value = defaultSettings.showSettings;
|
||||
showGuide.value = defaultSettings.showGuide;
|
||||
|
||||
// 桌面端工具设置
|
||||
showMenuSearch.value = defaultSettings.showMenuSearch;
|
||||
showFullscreen.value = defaultSettings.showFullscreen;
|
||||
showSizeSelect.value = defaultSettings.showSizeSelect;
|
||||
showLangSelect.value = defaultSettings.showLangSelect;
|
||||
showNotification.value = defaultSettings.showNotification;
|
||||
|
||||
// 布局和主题设置
|
||||
sidebarColorScheme.value = defaultSettings.sidebarColorScheme;
|
||||
layout.value = defaultSettings.layout as LayoutMode;
|
||||
themeColor.value = defaultSettings.themeColor;
|
||||
theme.value = defaultSettings.theme;
|
||||
|
||||
// 系统设置
|
||||
grayMode.value = defaultSettings.grayMode;
|
||||
userEnableAi.value = defaultSettings.aiEnabled;
|
||||
pageSwitchingAnimation.value = defaultSettings.pageSwitchingAnimation;
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// 返回所有状态和方法
|
||||
// ==============================================
|
||||
|
||||
return {
|
||||
// 从 setting.ts 来的状态
|
||||
menuType,
|
||||
menuOpenWidth,
|
||||
menuOpen,
|
||||
dualMenuShowText,
|
||||
systemThemeType,
|
||||
systemThemeMode,
|
||||
menuThemeType,
|
||||
systemThemeColor,
|
||||
showMenuButton,
|
||||
showFastEnter,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
showSettingGuide,
|
||||
showFestivalText,
|
||||
watermarkVisible,
|
||||
autoClose,
|
||||
uniqueOpened,
|
||||
colorWeak,
|
||||
refresh,
|
||||
holidayFireworksLoaded,
|
||||
boxBorderMode,
|
||||
pageTransition,
|
||||
tabStyle,
|
||||
customRadius,
|
||||
containerWidth,
|
||||
festivalDate,
|
||||
|
||||
// 从 settings.store.ts 来的状态
|
||||
settingsVisible,
|
||||
showTagsView,
|
||||
showAppLogo,
|
||||
showWatermark,
|
||||
showSettings,
|
||||
showGuide,
|
||||
showMenuSearch,
|
||||
showFullscreen,
|
||||
showSizeSelect,
|
||||
showLangSelect,
|
||||
showNotification,
|
||||
sidebarColorScheme,
|
||||
layout,
|
||||
themeColor,
|
||||
theme,
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
pageSwitchingAnimation,
|
||||
|
||||
// 计算属性
|
||||
getMenuTheme,
|
||||
isDark,
|
||||
getMenuOpenWidth,
|
||||
getCustomRadius,
|
||||
isShowFireworks,
|
||||
|
||||
// 从 setting.ts 来的方法
|
||||
switchMenuLayouts,
|
||||
setMenuOpenWidth,
|
||||
setGlopTheme,
|
||||
switchMenuStyles,
|
||||
setElementTheme,
|
||||
setBorderMode,
|
||||
setContainerWidth,
|
||||
setUniqueOpened,
|
||||
setButton,
|
||||
setFastEnter,
|
||||
setAutoClose,
|
||||
setShowRefreshButton,
|
||||
setCrumbs,
|
||||
setWorkTab,
|
||||
setLanguage,
|
||||
setNprogress,
|
||||
setColorWeak,
|
||||
hideSettingGuide,
|
||||
openSettingGuide,
|
||||
setPageTransition,
|
||||
setTabStyle,
|
||||
setMenuOpen,
|
||||
reload,
|
||||
setWatermarkVisible,
|
||||
setCustomRadius,
|
||||
setholidayFireworksLoaded,
|
||||
setShowFestivalText,
|
||||
setFestivalDate,
|
||||
setDualMenuShowText,
|
||||
|
||||
// 从 settings.store.ts 来的方法
|
||||
updateSetting,
|
||||
updateTheme,
|
||||
updateThemeColor,
|
||||
updateSidebarColorScheme,
|
||||
updateLayout,
|
||||
toggleSettingsPanel,
|
||||
showSettingsPanel,
|
||||
hideSettingsPanel,
|
||||
updateUserEnableAi,
|
||||
updateGrayMode,
|
||||
updatePageSwitchingAnimation,
|
||||
resetSettings,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "setting",
|
||||
storage: localStorage,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,303 +0,0 @@
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { SidebarColor, ThemeMode } from "@/enums/settings/theme.enum";
|
||||
import type { LayoutMode } from "@/enums/settings/layout.enum";
|
||||
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
|
||||
import { SETTINGS_KEYS } from "@/constants";
|
||||
import { defineStore } from "pinia";
|
||||
import { Ref, ref, watch } from "vue";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
// 🎯 设置项类型定义
|
||||
interface SettingsState {
|
||||
// 界面显示设置
|
||||
settingsVisible: boolean;
|
||||
showTagsView: boolean;
|
||||
showAppLogo: boolean;
|
||||
showWatermark: boolean;
|
||||
showSettings: boolean;
|
||||
showGuide: boolean;
|
||||
|
||||
// 桌面端工具显示设置
|
||||
showMenuSearch: boolean;
|
||||
showFullscreen: boolean;
|
||||
showSizeSelect: boolean;
|
||||
showLangSelect: boolean;
|
||||
showNotification: boolean;
|
||||
|
||||
// 布局设置
|
||||
layout: LayoutMode;
|
||||
sidebarColorScheme: string;
|
||||
grayMode: boolean;
|
||||
userEnableAi: boolean;
|
||||
pageSwitchingAnimation: string;
|
||||
|
||||
// 主题设置
|
||||
theme: ThemeMode;
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
// 🎯 可变更的设置项类型
|
||||
type MutableSetting = Exclude<keyof SettingsState, "settingsVisible">;
|
||||
type SettingValue<K extends MutableSetting> = SettingsState[K];
|
||||
|
||||
export const useSettingsStore = defineStore("setting", () => {
|
||||
// 🎯 基础设置 - 非持久化
|
||||
const settingsVisible = ref<boolean>(false);
|
||||
|
||||
// 🎯 界面显示设置 - 持久化
|
||||
const showTagsView = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_TAGS_VIEW,
|
||||
defaultSettings.showTagsView
|
||||
);
|
||||
const showAppLogo = useStorage<boolean>(SETTINGS_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo);
|
||||
// 是否显示水印
|
||||
const showWatermark = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_WATERMARK,
|
||||
defaultSettings.showWatermark
|
||||
);
|
||||
|
||||
// 是否显示系统设置
|
||||
const showSettings = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_SETTINGS,
|
||||
defaultSettings.showSettings
|
||||
);
|
||||
|
||||
// 是否显示引导功能
|
||||
const showGuide = useStorage<boolean>(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide);
|
||||
|
||||
// 🎯 桌面端工具设置 - 持久化
|
||||
const showMenuSearch = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_MENU_SEARCH,
|
||||
defaultSettings.showMenuSearch
|
||||
);
|
||||
|
||||
// 是否显示全屏切换
|
||||
const showFullscreen = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_FULLSCREEN,
|
||||
defaultSettings.showFullscreen
|
||||
);
|
||||
|
||||
// 是否显示布局大小选择
|
||||
const showSizeSelect = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_SIZE_SELECT,
|
||||
defaultSettings.showSizeSelect
|
||||
);
|
||||
|
||||
// 是否显示语言选择
|
||||
const showLangSelect = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_LANG_SELECT,
|
||||
defaultSettings.showLangSelect
|
||||
);
|
||||
|
||||
// 是否显示通知
|
||||
const showNotification = useStorage<boolean>(
|
||||
SETTINGS_KEYS.SHOW_NOTIFICATION,
|
||||
defaultSettings.showNotification
|
||||
);
|
||||
|
||||
// 🎯 布局和主题设置 - 持久化
|
||||
const sidebarColorScheme = useStorage<string>(
|
||||
SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME,
|
||||
defaultSettings.sidebarColorScheme
|
||||
);
|
||||
|
||||
// 布局设置
|
||||
const layout = useStorage<LayoutMode>(SETTINGS_KEYS.LAYOUT, defaultSettings.layout as LayoutMode);
|
||||
// 主题颜色
|
||||
const themeColor = useStorage<string>(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor);
|
||||
// 主题模式
|
||||
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
|
||||
|
||||
// 是否开启灰色模式
|
||||
const grayMode = useStorage<boolean>(SETTINGS_KEYS.GRAY_MODE, defaultSettings.grayMode);
|
||||
// 是否开启AI助手
|
||||
const userEnableAi = useStorage<boolean>(SETTINGS_KEYS.AI_ENABLED, defaultSettings.aiEnabled);
|
||||
// 页面切换动画
|
||||
const pageSwitchingAnimation = useStorage<string>(
|
||||
SETTINGS_KEYS.PAGE_SWITCHING_ANIMATION,
|
||||
defaultSettings.pageSwitchingAnimation
|
||||
);
|
||||
|
||||
// 🎯 设置项映射
|
||||
const settingsMap = {
|
||||
showTagsView,
|
||||
showAppLogo,
|
||||
showWatermark,
|
||||
showSettings,
|
||||
showGuide,
|
||||
showMenuSearch,
|
||||
showFullscreen,
|
||||
showSizeSelect,
|
||||
showLangSelect,
|
||||
showNotification,
|
||||
sidebarColorScheme,
|
||||
layout,
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
} as const;
|
||||
|
||||
// 🎯 监听器 - 主题变化
|
||||
watch(
|
||||
[theme, themeColor],
|
||||
([newTheme, newThemeColor]) => {
|
||||
toggleDarkMode(newTheme === ThemeMode.DARK);
|
||||
const colors = generateThemeColors(newThemeColor, newTheme);
|
||||
applyTheme(colors);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 🎯 监听器 - 侧边栏配色方案变化
|
||||
watch(
|
||||
[sidebarColorScheme],
|
||||
([newSidebarColorScheme]) => {
|
||||
toggleSidebarColor(newSidebarColorScheme === SidebarColor.CLASSIC_BLUE);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 灰色模式监听
|
||||
watch(
|
||||
grayMode,
|
||||
(v) => {
|
||||
document.documentElement.style.filter = v ? "grayscale(100%)" : "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 🎯 统一的设置更新方法 - 类型安全
|
||||
function updateSetting<K extends keyof typeof settingsMap>(key: K, value: SettingValue<K>): void {
|
||||
const setting = settingsMap[key];
|
||||
if (setting) {
|
||||
(setting as Ref<any>).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 主题相关的专用更新方法
|
||||
function updateTheme(newTheme: ThemeMode): void {
|
||||
theme.value = newTheme;
|
||||
}
|
||||
|
||||
// 更新主题颜色
|
||||
function updateThemeColor(newColor: string): void {
|
||||
themeColor.value = newColor;
|
||||
}
|
||||
|
||||
// 更新侧边栏配色方案
|
||||
function updateSidebarColorScheme(newScheme: string): void {
|
||||
sidebarColorScheme.value = newScheme;
|
||||
}
|
||||
|
||||
// 更新布局
|
||||
function updateLayout(newLayout: LayoutMode): void {
|
||||
layout.value = newLayout;
|
||||
}
|
||||
|
||||
// 🎯 设置面板显示控制
|
||||
function toggleSettingsPanel(): void {
|
||||
settingsVisible.value = !settingsVisible.value;
|
||||
}
|
||||
|
||||
// 显示设置面板
|
||||
function showSettingsPanel(): void {
|
||||
settingsVisible.value = true;
|
||||
}
|
||||
|
||||
// 隐藏设置面板
|
||||
function hideSettingsPanel(): void {
|
||||
settingsVisible.value = false;
|
||||
}
|
||||
|
||||
// 更新AI助手状态
|
||||
function updateUserEnableAi(newValue: boolean): void {
|
||||
userEnableAi.value = newValue;
|
||||
}
|
||||
|
||||
// 更新灰色模式状态
|
||||
function updateGrayMode(newValue: boolean): void {
|
||||
grayMode.value = newValue;
|
||||
}
|
||||
|
||||
// 更新页面切换动画
|
||||
function updatePageSwitchingAnimation(newValue: string): void {
|
||||
pageSwitchingAnimation.value = newValue;
|
||||
}
|
||||
|
||||
// 🎯 批量重置设置
|
||||
function resetSettings(): void {
|
||||
// 界面显示设置
|
||||
showTagsView.value = defaultSettings.showTagsView;
|
||||
showAppLogo.value = defaultSettings.showAppLogo;
|
||||
showWatermark.value = defaultSettings.showWatermark;
|
||||
showSettings.value = defaultSettings.showSettings;
|
||||
showGuide.value = defaultSettings.showGuide;
|
||||
|
||||
// 桌面端工具设置
|
||||
showMenuSearch.value = defaultSettings.showMenuSearch;
|
||||
showFullscreen.value = defaultSettings.showFullscreen;
|
||||
showSizeSelect.value = defaultSettings.showSizeSelect;
|
||||
showLangSelect.value = defaultSettings.showLangSelect;
|
||||
showNotification.value = defaultSettings.showNotification;
|
||||
|
||||
// 布局和主题设置
|
||||
sidebarColorScheme.value = defaultSettings.sidebarColorScheme;
|
||||
layout.value = defaultSettings.layout as LayoutMode;
|
||||
themeColor.value = defaultSettings.themeColor;
|
||||
theme.value = defaultSettings.theme;
|
||||
|
||||
// 系统设置
|
||||
grayMode.value = defaultSettings.grayMode;
|
||||
userEnableAi.value = defaultSettings.aiEnabled;
|
||||
pageSwitchingAnimation.value = defaultSettings.pageSwitchingAnimation;
|
||||
}
|
||||
|
||||
return {
|
||||
// 🎯 基础状态
|
||||
settingsVisible,
|
||||
|
||||
// 🎯 界面显示状态
|
||||
showTagsView,
|
||||
showAppLogo,
|
||||
showWatermark,
|
||||
showSettings,
|
||||
showGuide,
|
||||
|
||||
// 🎯 桌面端工具状态
|
||||
showMenuSearch,
|
||||
showFullscreen,
|
||||
showSizeSelect,
|
||||
showLangSelect,
|
||||
showNotification,
|
||||
|
||||
// 🎯 布局和主题状态
|
||||
sidebarColorScheme,
|
||||
layout,
|
||||
themeColor,
|
||||
theme,
|
||||
|
||||
// 🎯 更新方法
|
||||
updateSetting,
|
||||
updateTheme,
|
||||
updateThemeColor,
|
||||
updateSidebarColorScheme,
|
||||
updateLayout,
|
||||
|
||||
// 🎯 面板控制
|
||||
toggleSettingsPanel,
|
||||
showSettingsPanel,
|
||||
hideSettingsPanel,
|
||||
|
||||
// 🎯 系统设置状态
|
||||
grayMode,
|
||||
userEnableAi,
|
||||
pageSwitchingAnimation,
|
||||
updatePageSwitchingAnimation,
|
||||
|
||||
// 🎯 更新系统设置方法
|
||||
updateUserEnableAi,
|
||||
updateGrayMode,
|
||||
|
||||
// 🎯 重置功能
|
||||
resetSettings,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 表格状态管理模块
|
||||
*
|
||||
* 提供表格显示配置的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 表格尺寸配置(紧凑、默认、宽松)
|
||||
* - 斑马纹显示开关
|
||||
* - 边框显示开关
|
||||
* - 表头背景显示开关
|
||||
* - 全屏模式开关
|
||||
* - 表格行拖拽排序(仅当前页数据,需数据为可变数组)
|
||||
*
|
||||
* ## 使用场景
|
||||
* - 表格组件样式配置
|
||||
* - 用户表格偏好设置
|
||||
* - 表格工具栏功能控制
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 存储键:sys-v{version}-table
|
||||
* - 用户配置跨页面保持
|
||||
*
|
||||
* @module store/modules/table.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { TableSizeEnum } from "@/enums/formEnum";
|
||||
|
||||
// 表格
|
||||
export const useTableStore = defineStore(
|
||||
"tableStore",
|
||||
() => {
|
||||
// 表格大小
|
||||
const tableSize = ref(TableSizeEnum.DEFAULT);
|
||||
// 斑马纹
|
||||
const isZebra = ref(false);
|
||||
// 边框
|
||||
const isBorder = ref(false);
|
||||
// 表头背景
|
||||
const isHeaderBackground = ref(false);
|
||||
|
||||
// 是否全屏
|
||||
const isFullScreen = ref(false);
|
||||
// 是否开启表格行拖拽排序(工具栏开关,与 ArtTable + VueDraggable 配合)
|
||||
const isRowDrag = ref(false);
|
||||
|
||||
/**
|
||||
* 设置表格大小
|
||||
* @param size 表格大小枚举值
|
||||
*/
|
||||
const setTableSize = (size: TableSizeEnum) => (tableSize.value = size);
|
||||
|
||||
/**
|
||||
* 设置斑马纹显示状态
|
||||
* @param value 是否显示斑马纹
|
||||
*/
|
||||
const setIsZebra = (value: boolean) => (isZebra.value = value);
|
||||
|
||||
/**
|
||||
* 设置表格边框显示状态
|
||||
* @param value 是否显示边框
|
||||
*/
|
||||
const setIsBorder = (value: boolean) => (isBorder.value = value);
|
||||
|
||||
/**
|
||||
* 设置表头背景显示状态
|
||||
* @param value 是否显示表头背景
|
||||
*/
|
||||
const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value);
|
||||
|
||||
/**
|
||||
* 设置是否全屏
|
||||
* @param value 是否全屏
|
||||
*/
|
||||
const setIsFullScreen = (value: boolean) => (isFullScreen.value = value);
|
||||
|
||||
const setIsRowDrag = (value: boolean) => (isRowDrag.value = value);
|
||||
|
||||
return {
|
||||
tableSize,
|
||||
isZebra,
|
||||
isBorder,
|
||||
isHeaderBackground,
|
||||
setTableSize,
|
||||
setIsZebra,
|
||||
setIsBorder,
|
||||
setIsHeaderBackground,
|
||||
isFullScreen,
|
||||
setIsFullScreen,
|
||||
isRowDrag,
|
||||
setIsRowDrag,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "table",
|
||||
storage: localStorage,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,262 +1,313 @@
|
||||
/**
|
||||
* 标签页状态管理模块
|
||||
*
|
||||
* 提供标签页的状态管理和缓存机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 已访问视图列表管理
|
||||
* - 缓存视图列表管理
|
||||
* - 添加、删除、更新标签页
|
||||
* - 关闭当前标签页并跳转
|
||||
* - 关闭其他标签页/关闭左侧/关闭右侧
|
||||
* - 固定标签页支持
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 多标签页导航
|
||||
* - 页面缓存管理
|
||||
* - 标签页右键菜单操作
|
||||
* - 路由切换时的标签状态同步
|
||||
*
|
||||
* ## 核心特性
|
||||
*
|
||||
* - 支持固定标签(affix)
|
||||
* - 支持keepAlive缓存
|
||||
* - 关闭标签时自动跳转上一个标签
|
||||
* - 区分已访问视图和缓存视图
|
||||
*
|
||||
* @module store/modules/tags-view.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { store } from "@stores";
|
||||
|
||||
// tags缓存
|
||||
export const useTagsViewStore = defineStore("tagsView", () => {
|
||||
const visitedViews = ref<TagView[]>([]);
|
||||
const cachedViews = ref<string[]>([]);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
export const useTagsViewStore = defineStore(
|
||||
"tagsViewStore",
|
||||
() => {
|
||||
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.name === view.name)) {
|
||||
return;
|
||||
}
|
||||
// 如果视图是固定的(affix),则在已访问的视图列表的开头添加
|
||||
if (view.affix) {
|
||||
visitedViews.value.unshift(view);
|
||||
} else {
|
||||
// 如果视图不是固定的,则在已访问的视图列表的末尾添加
|
||||
visitedViews.value.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缓存视图到缓存视图列表中
|
||||
*/
|
||||
function addCachedView(view: TagView) {
|
||||
const viewName = view.name;
|
||||
// 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
|
||||
if (cachedViews.value.includes(viewName)) {
|
||||
return;
|
||||
/**
|
||||
* 添加已访问视图到已访问视图列表中
|
||||
*/
|
||||
function addVisitedView(view: TagView) {
|
||||
// 如果已经存在于已访问的视图列表中或者是重定向地址,则不再添加
|
||||
if (view.path.startsWith("/redirect")) {
|
||||
return;
|
||||
}
|
||||
if (visitedViews.value.some((v) => v.name === view.name)) {
|
||||
return;
|
||||
}
|
||||
// 如果视图是固定的(affix),则在已访问的视图列表的开头添加
|
||||
if (view.affix) {
|
||||
visitedViews.value.unshift(view);
|
||||
} else {
|
||||
// 如果视图不是固定的,则在已访问的视图列表的末尾添加
|
||||
visitedViews.value.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果视图需要缓存(keepAlive),则将其路由名称添加到缓存视图列表中
|
||||
if (view.keepAlive) {
|
||||
cachedViews.value.push(viewName);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 添加缓存视图到缓存视图列表中
|
||||
*/
|
||||
function addCachedView(view: TagView) {
|
||||
const viewName = view.name;
|
||||
// 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
|
||||
if (cachedViews.value.includes(viewName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已访问视图列表中删除指定的视图
|
||||
*/
|
||||
function delVisitedView(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
for (const [i, v] of visitedViews.value.entries()) {
|
||||
// 找到与指定视图路径匹配的视图,在已访问视图列表中删除该视图
|
||||
// 如果视图需要缓存(keepAlive),则将其路由名称添加到缓存视图列表中
|
||||
if (view.keepAlive) {
|
||||
cachedViews.value.push(viewName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已访问视图列表中删除指定的视图
|
||||
*/
|
||||
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 viewName = view.name;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
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 viewName = view.name as string;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
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 (let v of visitedViews.value) {
|
||||
if (v.path === view.path) {
|
||||
visitedViews.value.splice(i, 1);
|
||||
v = Object.assign(v, view);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function delCachedView(view: TagView) {
|
||||
const viewName = view.name;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
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;
|
||||
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],
|
||||
});
|
||||
});
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function delOtherCachedViews(view: TagView) {
|
||||
const viewName = view.name as string;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
if (index > -1) {
|
||||
cachedViews.value = cachedViews.value.slice(index, index + 1);
|
||||
} else {
|
||||
// if index = -1, there is no cached tags
|
||||
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.name);
|
||||
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;
|
||||
}
|
||||
});
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delAllViews() {
|
||||
return new Promise((resolve) => {
|
||||
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
|
||||
visitedViews.value = affixTags;
|
||||
cachedViews.value = [];
|
||||
}
|
||||
resolve([...cachedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
function updateVisitedView(view: TagView) {
|
||||
for (let v of visitedViews.value) {
|
||||
if (v.path === view.path) {
|
||||
v = Object.assign(v, view);
|
||||
break;
|
||||
}
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 delAllVisitedViews() {
|
||||
return new Promise((resolve) => {
|
||||
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
|
||||
visitedViews.value = affixTags;
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function delOtherViews(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
delOtherVisitedViews(view);
|
||||
delOtherCachedViews(view);
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
cachedViews: [...cachedViews.value],
|
||||
function delAllCachedViews() {
|
||||
return new Promise((resolve) => {
|
||||
cachedViews.value = [];
|
||||
resolve([...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.name);
|
||||
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;
|
||||
/**
|
||||
* 关闭当前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);
|
||||
}
|
||||
});
|
||||
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 isActive(tag: TagView) {
|
||||
return tag.path === route.path;
|
||||
}
|
||||
|
||||
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);
|
||||
function toLastView(visitedViews: TagView[], view?: TagView) {
|
||||
const latestView = visitedViews.slice(-1)[0];
|
||||
if (latestView && latestView.fullPath) {
|
||||
router.push(latestView.fullPath);
|
||||
} else {
|
||||
router.push("/");
|
||||
// now the default is to redirect to the home page if there is no tags-view,
|
||||
// you can adjust it according to your needs.
|
||||
const vn = view?.name != null ? String(view.name) : "";
|
||||
if (
|
||||
vn === "Home" ||
|
||||
vn === "Workplace" ||
|
||||
vn === "Dashboard" ||
|
||||
vn.startsWith("Dashboard")
|
||||
) {
|
||||
// to reload home page
|
||||
if (view?.fullPath) {
|
||||
router.replace("/redirect" + view.fullPath);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addVisitedView,
|
||||
addCachedView,
|
||||
delVisitedView,
|
||||
delCachedView,
|
||||
delOtherVisitedViews,
|
||||
delOtherCachedViews,
|
||||
updateVisitedView,
|
||||
addView,
|
||||
delView,
|
||||
delOtherViews,
|
||||
delLeftViews,
|
||||
delRightViews,
|
||||
delAllViews,
|
||||
delAllVisitedViews,
|
||||
delAllCachedViews,
|
||||
closeCurrentView,
|
||||
isActive,
|
||||
toLastView,
|
||||
};
|
||||
});
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addVisitedView,
|
||||
addCachedView,
|
||||
delVisitedView,
|
||||
delCachedView,
|
||||
delOtherVisitedViews,
|
||||
delOtherCachedViews,
|
||||
updateVisitedView,
|
||||
addView,
|
||||
delView,
|
||||
delOtherViews,
|
||||
delLeftViews,
|
||||
delRightViews,
|
||||
delAllViews,
|
||||
delAllVisitedViews,
|
||||
delAllCachedViews,
|
||||
closeCurrentView,
|
||||
isActive,
|
||||
toLastView,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export function useTagsViewStoreHook() {
|
||||
return useTagsViewStore(store);
|
||||
}
|
||||
|
||||
Regular → Executable
+352
-120
@@ -1,106 +1,239 @@
|
||||
import { store, useTagsViewStore, usePermissionStoreHook, useDictStoreHook } from "@/store";
|
||||
|
||||
import AuthAPI, { type LoginFormData } from "@/api/module_system/auth";
|
||||
import UserAPI, { type UserInfo } from "@/api/module_system/user";
|
||||
import type { MenuTable } from "@/api/module_system/menu";
|
||||
import { Auth } from "@/utils/auth";
|
||||
import { ResultEnum } from "@/enums";
|
||||
/** User store. */
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { LanguageEnum } from "@/enums/appEnum";
|
||||
import { router } from "@/router";
|
||||
import { useSettingsStore } from "./setting.store";
|
||||
import { useWorktabStore } from "./worktab.store";
|
||||
import { useMenuStore } from "./menu.store";
|
||||
import { AppRouteRecord } from "@/types/router";
|
||||
import { setPageTitle } from "@utils/navigation";
|
||||
import { resetRouterState, resetRouteInitState } from "@/router/beforeEach";
|
||||
import { StorageConfig } from "@utils/storage";
|
||||
import AuthAPI from "@/api/module_system/auth";
|
||||
import UserAPI from "@/api/module_system/user";
|
||||
import type { MenuTable } from "@/api/module_system/menu";
|
||||
import { Auth } from "@utils/auth";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { ElNotification } from "element-plus";
|
||||
import { store, useTagsViewStore, useDictStore } from "@stores";
|
||||
import type { UserInfo } from "@/api/module_system/user";
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state: () => ({
|
||||
basicInfo: {} as UserInfo,
|
||||
routeList: [] as MenuTable[],
|
||||
prems: [] as string[],
|
||||
hasGetRoute: false,
|
||||
/** {@link useUserStore} 的 `logout` 可选参数 */
|
||||
export interface LogoutOptions {
|
||||
/**
|
||||
* 为 false 时仅清理状态,不跳转登录(由调用方自行 next/router,如锁屏页 replace)
|
||||
* @default true
|
||||
*/
|
||||
navigate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态管理
|
||||
* 管理用户登录状态、个人信息、语言设置、搜索历史、锁屏状态等
|
||||
*/
|
||||
export const useUserStore = defineStore(
|
||||
"userStore",
|
||||
() => {
|
||||
// 语言设置
|
||||
const language = ref(LanguageEnum.ZH);
|
||||
// 登录状态
|
||||
const isLogin = ref(false);
|
||||
// 锁屏状态
|
||||
const isLock = ref(false);
|
||||
// 锁屏密码
|
||||
const lockPassword = ref("");
|
||||
// 用户信息
|
||||
const info = ref<Partial<UserInfo>>({});
|
||||
// 搜索历史记录
|
||||
const searchHistory = ref<AppRouteRecord[]>([]);
|
||||
// 访问令牌
|
||||
const accessToken = ref("");
|
||||
// 刷新令牌
|
||||
const refreshToken = ref("");
|
||||
// 路由列表
|
||||
const routeList = ref<MenuTable[]>([]);
|
||||
// 权限列表
|
||||
const prems = ref<string[]>([]);
|
||||
// 是否已获取路由
|
||||
const hasGetRoute = ref(false);
|
||||
// 记住我状态
|
||||
rememberMe: ref(Auth.getRememberMe()),
|
||||
}),
|
||||
const rememberMe = ref(Auth.getRememberMe());
|
||||
// 计算属性:基础用户信息(兼容 web 原有结构)
|
||||
const basicInfo = computed(() => info.value as any);
|
||||
// 计算属性:获取设置状态
|
||||
const getSettingState = computed(() => useSettingsStore().$state);
|
||||
// 计算属性:获取工作台状态
|
||||
const getWorktabState = computed(() => useWorktabStore().$state);
|
||||
// 计算属性:获取基础信息(兼容 web 项目)
|
||||
const getBasicInfo = computed(() => info.value as any);
|
||||
// 计算属性:获取路由列表
|
||||
const getRouteList = computed(() => routeList.value);
|
||||
// 计算属性:获取权限列表
|
||||
const getPerms = computed(() => prems.value);
|
||||
// 计算属性:是否已获取路由
|
||||
const getHasGetRoute = computed(() => hasGetRoute.value);
|
||||
|
||||
getters: {
|
||||
getBasicInfo: (state) => state.basicInfo,
|
||||
getRouteList: (state) => state.routeList,
|
||||
getPerms: (state) => state.prems,
|
||||
getHasGetRoute: (state) => state.hasGetRoute,
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 获取用户信息
|
||||
async getUserInfo() {
|
||||
const response = await UserAPI.getCurrentUserInfo();
|
||||
const routers: MenuTable[] = response.data.data.menus || [];
|
||||
delete response.data.data.menus;
|
||||
this.basicInfo = { ...this.basicInfo, ...response.data.data };
|
||||
// 然后设置路由信息
|
||||
this.setRoute(routers);
|
||||
},
|
||||
|
||||
// 设置用户信息
|
||||
setUserInfo(info: UserInfo) {
|
||||
this.basicInfo = info;
|
||||
/**
|
||||
* 设置用户信息
|
||||
* @param newInfo 新的用户信息
|
||||
*/
|
||||
const setUserInfo = (newInfo: UserInfo | UserInfo) => {
|
||||
info.value = newInfo;
|
||||
// 设置用户信息后自动更新权限
|
||||
this.setPermissions([]);
|
||||
},
|
||||
setPermissions([]);
|
||||
};
|
||||
|
||||
// 设置路由
|
||||
setRoute(routers: MenuTable[]) {
|
||||
this.routeList = routers;
|
||||
this.hasGetRoute = true;
|
||||
// 设置路由后自动更新权限
|
||||
this.setPermissions(routers);
|
||||
},
|
||||
/**
|
||||
* 设置登录状态
|
||||
* @param status 登录状态
|
||||
*/
|
||||
const setLoginStatus = (status: boolean) => {
|
||||
isLogin.value = status;
|
||||
};
|
||||
|
||||
setPermissions(menus: MenuTable[]) {
|
||||
this.prems = []; // 先清空现有权限
|
||||
/**
|
||||
* 设置语言
|
||||
* @param lang 语言枚举值
|
||||
*/
|
||||
const setLanguage = (lang: LanguageEnum) => {
|
||||
setPageTitle(router.currentRoute.value);
|
||||
language.value = lang;
|
||||
};
|
||||
|
||||
// 确保 basicInfo.roles 存在,避免空值错误
|
||||
if (!this.basicInfo.roles) {
|
||||
/**
|
||||
* 设置搜索历史
|
||||
* @param list 搜索历史列表
|
||||
*/
|
||||
const setSearchHistory = (list: AppRouteRecord[]) => {
|
||||
searchHistory.value = list;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置锁屏状态
|
||||
* @param status 锁屏状态
|
||||
*/
|
||||
const setLockStatus = (status: boolean) => {
|
||||
isLock.value = status;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置锁屏密码
|
||||
* @param password 锁屏密码
|
||||
*/
|
||||
const setLockPassword = (password: string) => {
|
||||
lockPassword.value = password;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置令牌
|
||||
* @param newAccessToken 访问令牌
|
||||
* @param newRefreshToken 刷新令牌(可选)
|
||||
*/
|
||||
const setToken = (newAccessToken: string, newRefreshToken?: string) => {
|
||||
accessToken.value = newAccessToken;
|
||||
if (newRefreshToken) {
|
||||
refreshToken.value = newRefreshToken;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并清理工作台标签页
|
||||
* 如果不是同一用户登录,清空工作台标签页
|
||||
* 应在登录成功后调用
|
||||
*/
|
||||
const checkAndClearWorktabs = () => {
|
||||
const lastUserId = localStorage.getItem(StorageConfig.LAST_USER_ID_KEY);
|
||||
const currentUserId = (info.value as any).id || (info.value as any).user_id;
|
||||
|
||||
// 无法获取当前用户 ID,跳过检查
|
||||
if (!currentUserId) return;
|
||||
|
||||
// 首次登录或缓存已清除,保留现有标签页
|
||||
if (!lastUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并所有角色的菜单列表,使用数组扁平化方法简化代码并过滤undefined
|
||||
const roleMenus = this.basicInfo.roles
|
||||
// 不同用户登录,清空工作台标签页
|
||||
if (String(currentUserId) !== lastUserId) {
|
||||
const worktabStore = useWorktabStore();
|
||||
worktabStore.opened = [];
|
||||
worktabStore.keepAliveExclude = [];
|
||||
}
|
||||
|
||||
// 清除临时存储
|
||||
localStorage.removeItem(StorageConfig.LAST_USER_ID_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async function getUserInfo() {
|
||||
try {
|
||||
const response = await UserAPI.getCurrentUserInfo();
|
||||
const data = response.data.data;
|
||||
const menus: MenuTable[] = data?.menus || [];
|
||||
delete data?.menus;
|
||||
info.value = { ...info.value, ...data } as Partial<UserInfo>;
|
||||
setRoute(menus);
|
||||
} catch (error) {
|
||||
console.error("获取用户信息失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置头像
|
||||
*/
|
||||
function setAvatar(avatar: string) {
|
||||
info.value = { ...info.value, avatar };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置路由
|
||||
*/
|
||||
function setRoute(routers: MenuTable[]) {
|
||||
routeList.value = routers;
|
||||
hasGetRoute.value = true;
|
||||
setPermissions(routers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置权限
|
||||
*/
|
||||
function setPermissions(menus: MenuTable[]) {
|
||||
prems.value = [];
|
||||
if (!info.value.roles) return;
|
||||
|
||||
const roleMenus = info.value.roles
|
||||
.filter((role) => role.menus && role.menus.length > 0)
|
||||
.flatMap((role) => role.menus)
|
||||
.filter((menu): menu is MenuTable => menu !== undefined);
|
||||
|
||||
// 合并传入的菜单和角色菜单
|
||||
const allMenus = [...menus, ...roleMenus];
|
||||
|
||||
const permissionSet = new Set<string>();
|
||||
const collect = (items: MenuTable[]) => {
|
||||
items.forEach((item) => {
|
||||
// 收集当前菜单的权限
|
||||
if (item.permission) {
|
||||
permissionSet.add(item.permission);
|
||||
}
|
||||
|
||||
// 递归收集子菜单的权限,确保子菜单不包含undefined
|
||||
if (item.children && item.children.length > 0) {
|
||||
collect(item.children.filter((child): child is MenuTable => child !== undefined));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 收集所有权限
|
||||
collect(allMenus);
|
||||
this.prems = Array.from(permissionSet);
|
||||
},
|
||||
setAvatar(avatar: string) {
|
||||
this.basicInfo = { ...this.basicInfo, avatar };
|
||||
},
|
||||
clearUserInfo() {
|
||||
this.basicInfo = {};
|
||||
this.routeList = [];
|
||||
this.hasGetRoute = false;
|
||||
},
|
||||
prems.value = Array.from(permissionSet);
|
||||
}
|
||||
|
||||
// 登录
|
||||
async login(LoginFormData: LoginFormData) {
|
||||
const response = await AuthAPI.login(LoginFormData);
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
async function login(loginForm: any) {
|
||||
const response = await AuthAPI.login(loginForm);
|
||||
const data = response.data.data;
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElNotification({
|
||||
title: "通知",
|
||||
@@ -108,76 +241,175 @@ export const useUserStore = defineStore("user", {
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
this.rememberMe = LoginFormData.remember;
|
||||
Auth.setTokens(
|
||||
response.data.data.access_token,
|
||||
response.data.data.refresh_token,
|
||||
this.rememberMe
|
||||
);
|
||||
},
|
||||
rememberMe.value = loginForm.remember;
|
||||
// 清除上次会话里「动态路由初始化失败」标记,避免重新登录后侧栏/菜单不注册
|
||||
resetRouteInitState();
|
||||
Auth.setTokens(data.access_token, data.refresh_token, rememberMe.value);
|
||||
setToken(data.access_token, data.refresh_token);
|
||||
setLoginStatus(true);
|
||||
await getUserInfo();
|
||||
}
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
const response = await AuthAPI.logout({ token: Auth.getAccessToken() });
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElNotification({
|
||||
title: "通知",
|
||||
message: response.data.msg,
|
||||
type: "success",
|
||||
/**
|
||||
* 登出:有 token 时请求后端;统一清理状态;默认跳转登录页并带 redirect
|
||||
*/
|
||||
async function logout(options?: LogoutOptions) {
|
||||
const shouldNavigate = options?.navigate !== false;
|
||||
|
||||
const currentUserId = (info.value as any).id || (info.value as any).user_id;
|
||||
if (currentUserId) {
|
||||
localStorage.setItem(StorageConfig.LAST_USER_ID_KEY, String(currentUserId));
|
||||
}
|
||||
|
||||
let apiError: unknown;
|
||||
const token = Auth.getAccessToken();
|
||||
if (token) {
|
||||
try {
|
||||
const response = await AuthAPI.logout({ token });
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElNotification({
|
||||
title: "通知",
|
||||
message: response.data.msg,
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
apiError = e;
|
||||
}
|
||||
}
|
||||
|
||||
resetAllState();
|
||||
sessionStorage.removeItem("iframeRoutes");
|
||||
useMenuStore().setHomePath("");
|
||||
resetRouterState(500);
|
||||
|
||||
if (shouldNavigate) {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const redirect = currentRoute.path !== "/login" ? currentRoute.fullPath : undefined;
|
||||
await router.push({
|
||||
name: "Login",
|
||||
query: redirect ? { redirect } : undefined,
|
||||
});
|
||||
}
|
||||
this.resetAllState();
|
||||
},
|
||||
|
||||
// 重置所有状态
|
||||
resetAllState() {
|
||||
// 重置用户状态
|
||||
if (apiError) {
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有状态
|
||||
*/
|
||||
function resetAllState() {
|
||||
Auth.clearAuth();
|
||||
// 重置用户信息
|
||||
this.clearUserInfo();
|
||||
// 重置路由
|
||||
usePermissionStoreHook().resetRouter();
|
||||
// 清除标签视图
|
||||
useTagsViewStore().delAllViews();
|
||||
// 重置字典
|
||||
useDictStoreHook().clearDictData();
|
||||
info.value = {};
|
||||
routeList.value = [];
|
||||
hasGetRoute.value = false;
|
||||
isLogin.value = false;
|
||||
isLock.value = false;
|
||||
lockPassword.value = "";
|
||||
accessToken.value = "";
|
||||
refreshToken.value = "";
|
||||
prems.value = [];
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* 清空用户信息
|
||||
*/
|
||||
function clearUserInfo() {
|
||||
info.value = {};
|
||||
routeList.value = [];
|
||||
hasGetRoute.value = false;
|
||||
}
|
||||
|
||||
// 刷新token
|
||||
refreshToken() {
|
||||
const refreshToken = Auth.getRefreshToken();
|
||||
/**
|
||||
* 刷新token
|
||||
*/
|
||||
function refreshTokenFn() {
|
||||
const currentRefreshToken = Auth.getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
if (!currentRefreshToken) {
|
||||
return Promise.reject(new Error("没有有效的刷新令牌"));
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||
.then((response) => {
|
||||
AuthAPI.refreshToken({ refresh_token: currentRefreshToken })
|
||||
.then((response: any) => {
|
||||
const data = response.data.data;
|
||||
// 更新令牌,保持当前记住我状态
|
||||
Auth.setTokens(
|
||||
response.data.data.access_token,
|
||||
response.data.data.refresh_token,
|
||||
Auth.getRememberMe()
|
||||
);
|
||||
Auth.setTokens(data.access_token, data.refresh_token, Auth.getRememberMe());
|
||||
setToken(data.access_token, data.refresh_token);
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: any) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全重置所有状态(包括路由、标签页、字典等)
|
||||
*/
|
||||
function fullResetAllState() {
|
||||
// 重置用户状态
|
||||
Auth.clearAuth();
|
||||
// 重置用户信息
|
||||
clearUserInfo();
|
||||
// 清除标签视图
|
||||
useTagsViewStore().delAllViews();
|
||||
// 重置字典
|
||||
useDictStore(store).clearDictData();
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
isLogin,
|
||||
isLock,
|
||||
lockPassword,
|
||||
info,
|
||||
searchHistory,
|
||||
accessToken,
|
||||
routeList,
|
||||
prems,
|
||||
hasGetRoute,
|
||||
rememberMe,
|
||||
getUserInfo,
|
||||
getSettingState,
|
||||
getWorktabState,
|
||||
basicInfo,
|
||||
getBasicInfo,
|
||||
getRouteList,
|
||||
getPerms,
|
||||
getHasGetRoute,
|
||||
setUserInfo,
|
||||
setLoginStatus,
|
||||
setLanguage,
|
||||
setSearchHistory,
|
||||
setLockStatus,
|
||||
setLockPassword,
|
||||
setToken,
|
||||
setAvatar,
|
||||
setRoute,
|
||||
setPermissions,
|
||||
login,
|
||||
logout,
|
||||
checkAndClearWorktabs,
|
||||
clearUserInfo,
|
||||
refreshTokenFn,
|
||||
resetAllState,
|
||||
fullResetAllState,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "user",
|
||||
storage: localStorage,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
persist: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 在组件外部使用UserStore的钩子函数
|
||||
* @see https://pinia.vuejs.org/core-concepts/outside-component-usage.html
|
||||
*/
|
||||
export function useUserStoreHook() {
|
||||
return useUserStore(store);
|
||||
}
|
||||
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* 工作标签页状态管理模块
|
||||
*
|
||||
* 提供多标签页功能的完整状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 标签页打开和关闭
|
||||
* - 标签页固定和取消固定
|
||||
* - 批量关闭(左侧、右侧、其他、全部)
|
||||
* - 标签页缓存管理(KeepAlive)
|
||||
* - 标签页标题自定义
|
||||
* - 标签页路由验证
|
||||
* - 动态路由参数处理
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 多标签页导航
|
||||
* - 页面缓存控制
|
||||
* - 标签页右键菜单
|
||||
* - 固定常用页面
|
||||
* - 批量关闭标签
|
||||
*
|
||||
* ## 核心特性
|
||||
*
|
||||
* - 智能标签页复用(同路由名称复用)
|
||||
* - 固定标签页保护(不可关闭)
|
||||
* - KeepAlive 缓存排除管理
|
||||
* - 路由有效性验证
|
||||
* - 首页自动保留
|
||||
*
|
||||
* ## 持久化
|
||||
* - 使用 localStorage 存储
|
||||
* - 存储键:sys-v{version}-worktab
|
||||
* - 刷新页面保持标签状态
|
||||
*
|
||||
* @module store/modules/worktab.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { router } from "@/router";
|
||||
import { LocationQueryRaw, Router } from "vue-router";
|
||||
import { WorkTab } from "@/types";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
|
||||
interface WorktabState {
|
||||
current: Partial<WorkTab>;
|
||||
opened: WorkTab[];
|
||||
keepAliveExclude: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作台标签页管理 Store
|
||||
*/
|
||||
export const useWorktabStore = defineStore(
|
||||
"worktabStore",
|
||||
() => {
|
||||
// 状态定义
|
||||
const current = ref<Partial<WorkTab>>({});
|
||||
const opened = ref<WorkTab[]>([]);
|
||||
const keepAliveExclude = ref<string[]>([]);
|
||||
|
||||
// 计算属性
|
||||
const hasOpenedTabs = computed(() => opened.value.length > 0);
|
||||
const hasMultipleTabs = computed(() => opened.value.length > 1);
|
||||
const currentTabIndex = computed(() =>
|
||||
current.value.path ? opened.value.findIndex((tab) => tab.path === current.value.path) : -1
|
||||
);
|
||||
|
||||
/**
|
||||
* 查找标签页索引
|
||||
*/
|
||||
const findTabIndex = (path: string): number => {
|
||||
return opened.value.findIndex((tab) => tab.path === path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取标签页
|
||||
*/
|
||||
const getTab = (path: string): WorkTab | undefined => {
|
||||
return opened.value.find((tab) => tab.path === path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查标签页是否可关闭
|
||||
*/
|
||||
const isTabClosable = (tab: WorkTab): boolean => {
|
||||
return !tab.fixedTab;
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全的路由跳转
|
||||
*/
|
||||
const safeRouterPush = (tab: Partial<WorkTab>): void => {
|
||||
if (!tab.path) {
|
||||
console.warn("尝试跳转到无效路径的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
router.push({
|
||||
path: tab.path,
|
||||
query: tab.query as LocationQueryRaw,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("路由跳转失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开或激活一个选项卡
|
||||
*/
|
||||
const openTab = (tab: WorkTab): void => {
|
||||
if (!tab.path) {
|
||||
console.warn("尝试打开无效的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 keepAlive 排除列表中移除
|
||||
if (tab.name) {
|
||||
removeKeepAliveExclude(tab.name);
|
||||
}
|
||||
|
||||
// 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找
|
||||
let existingIndex = -1;
|
||||
if (tab.name) {
|
||||
existingIndex = opened.value.findIndex((t) => t.name === tab.name);
|
||||
}
|
||||
if (existingIndex === -1) {
|
||||
existingIndex = findTabIndex(tab.path);
|
||||
}
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// 新增标签页
|
||||
const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length;
|
||||
const newTab = { ...tab };
|
||||
|
||||
if (tab.fixedTab) {
|
||||
opened.value.splice(insertIndex, 0, newTab);
|
||||
} else {
|
||||
opened.value.push(newTab);
|
||||
}
|
||||
|
||||
current.value = newTab;
|
||||
} else {
|
||||
// 更新现有标签页(当动态路由参数或查询变更时,复用同一标签)
|
||||
const existingTab = opened.value[existingIndex];
|
||||
|
||||
opened.value[existingIndex] = {
|
||||
...existingTab,
|
||||
path: tab.path,
|
||||
params: tab.params,
|
||||
query: tab.query,
|
||||
title: tab.title || existingTab.title,
|
||||
fixedTab: tab.fixedTab ?? existingTab.fixedTab,
|
||||
keepAlive: tab.keepAlive ?? existingTab.keepAlive,
|
||||
name: tab.name || existingTab.name,
|
||||
icon: tab.icon || existingTab.icon,
|
||||
};
|
||||
|
||||
current.value = opened.value[existingIndex];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找固定标签页的插入位置
|
||||
*/
|
||||
const findFixedTabInsertIndex = (): number => {
|
||||
let insertIndex = 0;
|
||||
for (let i = 0; i < opened.value.length; i++) {
|
||||
if (opened.value[i].fixedTab) {
|
||||
insertIndex = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return insertIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭指定的选项卡
|
||||
*/
|
||||
const removeTab = (path: string): void => {
|
||||
const targetTab = getTab(path);
|
||||
const targetIndex = findTabIndex(path);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
console.warn(`尝试关闭不存在的标签页: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetTab && !isTabClosable(targetTab)) {
|
||||
console.warn(`尝试关闭固定标签页: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从标签页列表中移除
|
||||
opened.value.splice(targetIndex, 1);
|
||||
|
||||
// 处理缓存排除
|
||||
if (targetTab?.name) {
|
||||
addKeepAliveExclude(targetTab);
|
||||
}
|
||||
|
||||
const { homePath } = useCommon();
|
||||
|
||||
// 如果关闭后无标签页,跳转首页
|
||||
if (!hasOpenedTabs.value) {
|
||||
if (path !== homePath.value) {
|
||||
current.value = {};
|
||||
safeRouterPush({ path: homePath.value });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果关闭的是当前激活标签,需要激活其他标签
|
||||
if (current.value.path === path) {
|
||||
const newIndex = targetIndex >= opened.value.length ? opened.value.length - 1 : targetIndex;
|
||||
current.value = opened.value[newIndex];
|
||||
safeRouterPush(current.value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭左侧选项卡
|
||||
*/
|
||||
const removeLeft = (path: string): void => {
|
||||
const targetIndex = findTabIndex(path);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
console.warn(`尝试关闭左侧标签页,但目标标签页不存在: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取左侧可关闭的标签页
|
||||
const leftTabs = opened.value.slice(0, targetIndex);
|
||||
const closableLeftTabs = leftTabs.filter(isTabClosable);
|
||||
|
||||
if (closableLeftTabs.length === 0) {
|
||||
console.warn("左侧没有可关闭的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为缓存排除
|
||||
markTabsToRemove(closableLeftTabs);
|
||||
|
||||
// 移除左侧可关闭的标签页
|
||||
opened.value = opened.value.filter(
|
||||
(tab, index) => index >= targetIndex || !isTabClosable(tab)
|
||||
);
|
||||
|
||||
// 确保当前标签是激活状态
|
||||
const targetTab = getTab(path);
|
||||
if (targetTab) {
|
||||
current.value = targetTab;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭右侧选项卡
|
||||
*/
|
||||
const removeRight = (path: string): void => {
|
||||
const targetIndex = findTabIndex(path);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
console.warn(`尝试关闭右侧标签页,但目标标签页不存在: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取右侧可关闭的标签页
|
||||
const rightTabs = opened.value.slice(targetIndex + 1);
|
||||
const closableRightTabs = rightTabs.filter(isTabClosable);
|
||||
|
||||
if (closableRightTabs.length === 0) {
|
||||
console.warn("右侧没有可关闭的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为缓存排除
|
||||
markTabsToRemove(closableRightTabs);
|
||||
|
||||
// 移除右侧可关闭的标签页
|
||||
opened.value = opened.value.filter(
|
||||
(tab, index) => index <= targetIndex || !isTabClosable(tab)
|
||||
);
|
||||
|
||||
// 确保当前标签是激活状态
|
||||
const targetTab = getTab(path);
|
||||
if (targetTab) {
|
||||
current.value = targetTab;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭其他选项卡
|
||||
*/
|
||||
const removeOthers = (path: string): void => {
|
||||
const targetTab = getTab(path);
|
||||
|
||||
if (!targetTab) {
|
||||
console.warn(`尝试关闭其他标签页,但目标标签页不存在: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取其他可关闭的标签页
|
||||
const otherTabs = opened.value.filter((tab) => tab.path !== path);
|
||||
const closableTabs = otherTabs.filter(isTabClosable);
|
||||
|
||||
if (closableTabs.length === 0) {
|
||||
console.warn("没有其他可关闭的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为缓存排除
|
||||
markTabsToRemove(closableTabs);
|
||||
|
||||
// 只保留当前标签和固定标签
|
||||
opened.value = opened.value.filter((tab) => tab.path === path || !isTabClosable(tab));
|
||||
|
||||
// 确保当前标签是激活状态
|
||||
current.value = targetTab;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭所有可关闭的标签页
|
||||
*/
|
||||
const removeAll = (): void => {
|
||||
const { homePath } = useCommon();
|
||||
const hasFixedTabs = opened.value.some((tab) => tab.fixedTab);
|
||||
|
||||
// 获取可关闭的标签页
|
||||
const closableTabs = opened.value.filter((tab) => {
|
||||
if (!isTabClosable(tab)) return false;
|
||||
// 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页
|
||||
return hasFixedTabs || tab.path !== homePath.value;
|
||||
});
|
||||
|
||||
if (closableTabs.length === 0) {
|
||||
console.warn("没有可关闭的标签页");
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为缓存排除
|
||||
markTabsToRemove(closableTabs);
|
||||
|
||||
// 保留不可关闭的标签页和首页(当没有固定标签时)
|
||||
opened.value = opened.value.filter((tab) => {
|
||||
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === homePath.value);
|
||||
});
|
||||
|
||||
// 处理激活状态
|
||||
if (!hasOpenedTabs.value) {
|
||||
current.value = {};
|
||||
safeRouterPush({ path: homePath.value });
|
||||
return;
|
||||
}
|
||||
|
||||
// 选择激活的标签页:优先首页,其次第一个可用标签
|
||||
const homeTab = opened.value.find((tab) => tab.path === homePath.value);
|
||||
const targetTab = homeTab || opened.value[0];
|
||||
|
||||
current.value = targetTab;
|
||||
safeRouterPush(targetTab);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将指定选项卡添加到 keepAlive 排除列表中
|
||||
*/
|
||||
const addKeepAliveExclude = (tab: WorkTab): void => {
|
||||
if (!tab.keepAlive || !tab.name) return;
|
||||
|
||||
if (!keepAliveExclude.value.includes(tab.name)) {
|
||||
keepAliveExclude.value.push(tab.name);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 keepAlive 排除列表中移除指定组件名称
|
||||
*/
|
||||
const removeKeepAliveExclude = (name: string): void => {
|
||||
if (!name) return;
|
||||
|
||||
keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将传入的一组选项卡的组件名称标记为排除缓存
|
||||
*/
|
||||
const markTabsToRemove = (tabs: WorkTab[]): void => {
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.name) {
|
||||
addKeepAliveExclude(tab);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换指定标签页的固定状态
|
||||
*/
|
||||
const toggleFixedTab = (path: string): void => {
|
||||
const targetIndex = findTabIndex(path);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
console.warn(`尝试切换不存在标签页的固定状态: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = { ...opened.value[targetIndex] };
|
||||
tab.fixedTab = !tab.fixedTab;
|
||||
|
||||
// 移除原位置
|
||||
opened.value.splice(targetIndex, 1);
|
||||
|
||||
if (tab.fixedTab) {
|
||||
// 固定标签插入到所有固定标签的末尾
|
||||
const firstNonFixedIndex = opened.value.findIndex((t) => !t.fixedTab);
|
||||
const insertIndex = firstNonFixedIndex === -1 ? opened.value.length : firstNonFixedIndex;
|
||||
opened.value.splice(insertIndex, 0, tab);
|
||||
} else {
|
||||
// 非固定标签插入到所有固定标签后
|
||||
const fixedCount = opened.value.filter((t) => t.fixedTab).length;
|
||||
opened.value.splice(fixedCount, 0, tab);
|
||||
}
|
||||
|
||||
// 更新当前标签引用
|
||||
if (current.value.path === path) {
|
||||
current.value = tab;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证工作台标签页的路由有效性
|
||||
*/
|
||||
const validateWorktabs = (routerInstance: Router): void => {
|
||||
try {
|
||||
// 动态路由校验:优先使用路由 name 判断有效性;否则用 resolve 匹配参数化路径
|
||||
const isTabRouteValid = (tab: Partial<WorkTab>): boolean => {
|
||||
try {
|
||||
if (tab.name) {
|
||||
const routes = routerInstance.getRoutes();
|
||||
if (routes.some((r) => r.name === tab.name)) return true;
|
||||
}
|
||||
if (tab.path) {
|
||||
const resolved = routerInstance.resolve({
|
||||
path: tab.path,
|
||||
query: (tab.query as LocationQueryRaw) || undefined,
|
||||
});
|
||||
return resolved.matched.length > 0;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤出有效的标签页
|
||||
const validTabs = opened.value.filter((tab) => isTabRouteValid(tab));
|
||||
|
||||
if (validTabs.length !== opened.value.length) {
|
||||
console.warn("发现无效的标签页路由,已自动清理");
|
||||
opened.value = validTabs;
|
||||
}
|
||||
|
||||
// 验证当前激活标签的有效性
|
||||
const isCurrentValid = current.value && isTabRouteValid(current.value);
|
||||
|
||||
if (!isCurrentValid && validTabs.length > 0) {
|
||||
console.warn("当前激活标签无效,已自动切换");
|
||||
current.value = validTabs[0];
|
||||
} else if (!isCurrentValid) {
|
||||
current.value = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("验证工作台标签页失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有状态(用于登出等场景)
|
||||
*/
|
||||
const clearAll = (): void => {
|
||||
current.value = {};
|
||||
opened.value = [];
|
||||
keepAliveExclude.value = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态快照(用于持久化存储)
|
||||
*/
|
||||
const getStateSnapshot = (): WorktabState => {
|
||||
return {
|
||||
current: { ...current.value },
|
||||
opened: [...opened.value],
|
||||
keepAliveExclude: [...keepAliveExclude.value],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取标签页标题
|
||||
*/
|
||||
const getTabTitle = (path: string): WorkTab | undefined => {
|
||||
const tab = getTab(path);
|
||||
return tab;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新标签页标题
|
||||
*/
|
||||
const updateTabTitle = (path: string, title: string): void => {
|
||||
const tab = getTab(path);
|
||||
if (tab) {
|
||||
tab.customTitle = title;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置标签页标题
|
||||
*/
|
||||
const resetTabTitle = (path: string): void => {
|
||||
const tab = getTab(path);
|
||||
if (tab) {
|
||||
tab.customTitle = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
current,
|
||||
opened,
|
||||
keepAliveExclude,
|
||||
|
||||
// 计算属性
|
||||
hasOpenedTabs,
|
||||
hasMultipleTabs,
|
||||
currentTabIndex,
|
||||
|
||||
// 方法
|
||||
openTab,
|
||||
removeTab,
|
||||
removeLeft,
|
||||
removeRight,
|
||||
removeOthers,
|
||||
removeAll,
|
||||
toggleFixedTab,
|
||||
validateWorktabs,
|
||||
clearAll,
|
||||
getStateSnapshot,
|
||||
|
||||
// 工具方法
|
||||
findTabIndex,
|
||||
getTab,
|
||||
isTabClosable,
|
||||
addKeepAliveExclude,
|
||||
removeKeepAliveExclude,
|
||||
markTabsToRemove,
|
||||
getTabTitle,
|
||||
updateTabTitle,
|
||||
resetTabTitle,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "worktab",
|
||||
storage: localStorage,
|
||||
},
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user