feat: 项目结构重构优化

This commit is contained in:
Ray.Hao
2025-12-26 12:35:37 +08:00
parent 65ad4fe59f
commit aa374dd2ba
164 changed files with 11305 additions and 3103 deletions

View File

@@ -4,7 +4,7 @@
<router-link :key="+collapse" class="wh-full flex-center" to="/">
<img :src="logo" class="w20px h20px" />
<span v-if="!collapse" class="title">
{{ defaultSettings.title }}
{{ appConfig.title }}
</span>
</router-link>
</transition>
@@ -12,8 +12,8 @@
</template>
<script lang="ts" setup>
import { defaultSettings } from "@/settings";
import logo from "@/assets/logo.png";
import { appConfig } from "@/settings";
import logo from "@/assets/images/logo.png";
defineProps({
collapse: {
@@ -40,7 +40,7 @@ defineProps({
</style>
<style lang="scss">
//
// <EFBFBD>?
.layout-top,
.layout-mix {
.logo {

View File

@@ -3,12 +3,12 @@
<div class="flex-y-center">
<!-- 菜单折叠按钮 -->
<Hamburger :is-active="isSidebarOpened" @toggle-click="toggleSideBar" />
<!-- 面包屑导航 -->
<!-- é<EFBFBD>¢åŒå±å¯¼èˆ?-->
<Breadcrumb />
</div>
<!-- 导航栏操作区域 -->
<!-- 导航æ <EFBFBD>æ<EFBFBD>作区åŸ?-->
<div class="navbar__actions">
<NavbarActions />
<LayoutToolbar />
</div>
</div>
</template>
@@ -17,19 +17,13 @@
import { useAppStore } from "@/store";
import Hamburger from "@/components/Hamburger/index.vue";
import Breadcrumb from "@/components/Breadcrumb/index.vue";
import NavbarActions from "./components/NavbarActions.vue";
const appStore = useAppStore();
//
const isSidebarOpened = computed(() => appStore.sidebar.opened);
// /
function toggleSideBar() {
console.log("🔄 Hamburger clicked! Current state:", isSidebarOpened.value);
console.log("🔄 Device type:", appStore.device);
appStore.toggleSidebar();
console.log("🔄 New state:", appStore.sidebar.opened);
}
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<el-drawer
v-model="drawerVisible"
size="380"
@@ -48,6 +48,22 @@
<span class="text-xs">{{ t("settings.showWatermark") }}</span>
<el-switch v-model="settingsStore.showWatermark" />
</div>
<div class="config-item flex-x-between">
<span class="text-xs">灰色模式</span>
<el-switch v-model="settingsStore.grayMode" />
</div>
<div class="config-item flex-x-between">
<span class="text-xs">色弱模式</span>
<el-switch v-model="settingsStore.colorWeak" />
</div>
<div v-if="aiSystemEnabled" class="config-item flex-x-between">
<span class="text-xs">AI 助手</span>
<el-switch v-model="settingsStore.userEnableAi" />
</div>
<div v-if="!isDark" class="config-item flex-x-between">
<span class="text-xs">{{ t("settings.sidebarColorScheme") }}</span>
<el-radio-group v-model="sidebarColor" @change="changeSidebarColor">
@@ -65,7 +81,7 @@
<section class="config-section">
<el-divider>{{ t("settings.navigation") }}</el-divider>
<!-- 整合的布局选择 -->
<!-- 整合的布局选择 -->
<div class="layout-select">
<div class="layout-grid">
<el-tooltip
@@ -110,7 +126,7 @@
<template #footer>
<div class="action-buttons">
<el-tooltip
content="复制配置将生成当前设置的代码,覆盖 src/settings.ts 下的 defaultSettings 变量"
content="复制配置将生成当前设置的代码,覆盖到 `src/settings.ts` 下的 `defaultSettings` 变量"
placement="top"
>
<el-button
@@ -145,12 +161,15 @@ import { DocumentCopy, RefreshLeft, Check } from "@element-plus/icons-vue";
const { t } = useI18n();
import { LayoutMode, SidebarColor, ThemeMode } from "@/enums";
import { useSettingsStore } from "@/store";
import { themeColorPresets } from "@/settings";
import { themeColorPresets, appConfig } from "@/settings";
//
const copyIcon = markRaw(DocumentCopy);
const resetIcon = markRaw(RefreshLeft);
// AI
const aiSystemEnabled = appConfig.aiEnabled;
//
const copyLoading = ref(false);
const resetLoading = ref(false);
@@ -168,8 +187,8 @@ const layoutOptions: LayoutOption[] = [
{ value: LayoutMode.MIX, label: t("settings.mixLayout"), className: "mix" },
];
// 使
const colorPresets = themeColorPresets;
// 使 prop
const colorPresets = [...themeColorPresets];
const settingsStore = useSettingsStore();
@@ -178,7 +197,9 @@ const sidebarColor = ref(settingsStore.sidebarColorScheme);
const selectedThemeColor = computed({
get: () => settingsStore.themeColor,
set: (value) => settingsStore.updateThemeColor(value),
set: (value) => {
settingsStore.themeColor = value;
},
});
const drawerVisible = computed({
@@ -192,7 +213,7 @@ const drawerVisible = computed({
* @param isDark 是否启用暗黑模式
*/
const handleThemeChange = (isDark: string | number | boolean) => {
settingsStore.updateTheme(isDark ? ThemeMode.DARK : ThemeMode.LIGHT);
settingsStore.theme = isDark ? ThemeMode.DARK : ThemeMode.LIGHT;
};
/**
@@ -201,7 +222,7 @@ const handleThemeChange = (isDark: string | number | boolean) => {
* @param val 颜色方案名称
*/
const changeSidebarColor = (val: any) => {
settingsStore.updateSidebarColorScheme(val);
settingsStore.sidebarColorScheme = val;
};
/**
@@ -212,7 +233,7 @@ const changeSidebarColor = (val: any) => {
const handleLayoutChange = (layout: LayoutMode) => {
if (settingsStore.layout === layout) return;
settingsStore.updateLayout(layout);
settingsStore.layout = layout;
};
/**
@@ -249,7 +270,7 @@ const handleResetSettings = async () => {
try {
settingsStore.resetSettings();
//
// "
isDark.value = settingsStore.theme === ThemeMode.DARK;
sidebarColor.value = settingsStore.sidebarColorScheme;
@@ -319,7 +340,7 @@ const handleCloseDrawer = () => {
/* 设置内容区域 */
.settings-content {
height: calc(100vh - 120px); /* 减去头部和底部按钮的高度 */
max-height: calc(100vh - 120px);
padding: 20px;
overflow-y: auto;
}
@@ -340,7 +361,6 @@ const handleCloseDrawer = () => {
}
}
}
/* 主题切换器优化 */
.theme-switch {
transform: scale(1.2);
@@ -571,7 +591,6 @@ const handleCloseDrawer = () => {
}
}
/* 复制配置对话框样式 */
:deep(.copy-config-dialog) {
.el-message-box__content {
max-height: 400px;

View File

@@ -14,8 +14,8 @@
@open="onMenuOpen"
@close="onMenuClose"
>
<!-- 菜单项 -->
<MenuItem
<!-- è<EFBFBD>œå<EFBFBD>é¡?-->
<LayoutSidebarItem
v-for="route in data"
:key="route.path"
:item="route"
@@ -32,7 +32,7 @@ import type { RouteRecordRaw } from "vue-router";
import { SidebarColor } from "@/enums/settings";
import { useSettingsStore, useAppStore } from "@/store";
import { isExternal } from "@/utils/index";
import MenuItem from "./components/MenuItem.vue";
import LayoutSidebarItem from "./LayoutSidebarItem.vue";
import variables from "@/styles/variables.module.scss";
const props = defineProps({
@@ -63,10 +63,10 @@ const expandedMenuIndexes = ref<string[]>([]);
//
const theme = computed(() => settingsStore.theme);
//
// 获å<EFBFBD>æµè²ä¸»é¢˜ä¸çšä¾§è¾¹æ <EFBFBD>é<EFBFBD>è²æ¹æ¡?
const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
//
// è<EFBFBD>œå<EFBFBD>主题属æ?
const menuThemeProps = computed(() => {
const isDarkOrClassicBlue =
theme.value === "dark" || sidebarColorScheme.value === SidebarColor.CLASSIC_BLUE;
@@ -78,11 +78,11 @@ const menuThemeProps = computed(() => {
};
});
//
// 计ç®å½å<EFBFBD>æ¿æ´»çšè<EFBFBD>œå<EFBFBD>é¡?
const activeMenuPath = computed((): string => {
const { meta, path } = currentRoute;
// metaactiveMenu使
// 妿žœè·¯ç±meta中设置äºactiveMenu,åˆä½¿ç¨å®ƒï¼ˆç¨äºŽå¤ç<EFBFBD>ä¸äºç¹æ®Šæƒåµï¼Œå¦è¯¦æƒé¡µï¼?
if (meta?.activeMenu && typeof meta.activeMenu === "string") {
return meta.activeMenu;
}
@@ -94,8 +94,8 @@ const activeMenuPath = computed((): string => {
/**
* 获取完整路径
*
* @param routePath 当前路由的相对路径 /user
* @returns 完整的绝对路径 D://vue3-element-admin/system/user
* @param routePath å½å<EFBFBD>è·¯ç±çšç¸å¯¹è·¯å¾? /user
* @returns 完æ´çšç»<EFBFBD>对路å¾?D://vue3-element-admin/system/user
*/
function resolveFullPath(routePath: string) {
if (isExternal(routePath)) {
@@ -143,8 +143,8 @@ watch(
);
/**
* 监听菜单模式变化当菜单模式切换为水平模式时关闭所有展开的菜单项
* 避免在水平模式下菜单项显示错位
* çå<EFBFBD>¬è<EFBFBD>œå<EFBFBD>模å¼<EFBFBD>å<EFBFBD>˜åŒï¼šå½è<EFBFBD>œå<EFBFBD>模å¼<EFBFBD>åˆæ<EFBFBD>¢ä¸ºæ°´å¹³æ¨¡å¼<EFBFBD>æï¼Œå³é­ææœå±å¼çšè<EFBFBD>œå<EFBFBD>项ï¼?
* é<EFBFBD>¿å<EFBFBD>在水平模å¼<EFBFBD>ä¸è<EFBFBD>œå<EFBFBD>项显示éä½<EFBFBD>ã?
*/
watch(
() => props.menuMode,
@@ -156,7 +156,7 @@ watch(
);
/**
* 监听激活菜单变化为包含激活子菜单的父菜单添加样式类
* çå<EFBFBD>¬æ¿æ´»è<EFBFBD>œå<EFBFBD>å<EFBFBD>˜åŒï¼Œä¸ºåŒå<EFBFBD>«æ¿æ´»å­<EFBFBD>è<EFBFBD>œå<EFBFBD>çšçˆè<EFBFBD>œå<EFBFBD>添加样å¼<EFBFBD>ç±?
*/
watch(
() => activeMenuPath.value,
@@ -169,7 +169,7 @@ watch(
);
/**
* 监听路由变化确保菜单能随TagsView切换而正确激活
* çå<EFBFBD>¬è·¯ç±å<EFBFBD>˜åŒï¼Œç¡®ä¿<EFBFBD>è<EFBFBD>œå<EFBFBD>能éš<EFBFBD>TagsViewåˆæ<EFBFBD>¢èŒæ­£ç¡®æ¿æ´?
*/
watch(
() => currentRoute.path,
@@ -181,7 +181,7 @@ watch(
);
/**
* 更新父菜单样式 - 为包含激活子菜单的父菜单添加 has-active-child
* æ´æ°çˆè<EFBFBD>œå<EFBFBD>æ ·å¼?- 为åŒå<EFBFBD>«æ¿æ´»å­<EFBFBD>è<EFBFBD>œå<EFBFBD>çšçˆè<EFBFBD>œå<EFBFBD>添加 has-active-child ç±?
*/
function updateParentMenuStyles() {
if (!menuRef.value?.$el) return;
@@ -191,13 +191,13 @@ function updateParentMenuStyles() {
const menuEl = menuRef.value?.$el as HTMLElement;
if (!menuEl) return;
// has-active-child
// ç§»é¤ææœçްæœçš has-active-child ç±?
const allSubMenus = menuEl.querySelectorAll(".el-sub-menu");
allSubMenus.forEach((subMenu) => {
subMenu.classList.remove("has-active-child");
});
//
// 查æ¾å½å<EFBFBD>æ¿æ´»çšè<EFBFBD>œå<EFBFBD>é¡?
const activeMenuItem = menuEl.querySelector(".el-menu-item.is-active");
if (activeMenuItem) {
@@ -210,12 +210,12 @@ function updateParentMenuStyles() {
parent = parent.parentElement;
}
} else {
//
// 水平模å¼<EFBFBD>ä¸å<EFBFBD>¯èƒ½éœè¦<EFBFBD>ç¹æ®Šå¤ç<EFBFBD>?
if (props.menuMode === "horizontal") {
// 使
// 对于水平è<EFBFBD>œå<EFBFBD>,使ç¨è·¯å¾åŒ¹é<EFBFBD>æ<EFBFBD>¥æ¾åˆ°çˆè<EFBFBD>œå<EFBFBD>?
const currentPath = activeMenuPath.value;
//
// æŸ¥æ¾ææœçˆè<EFBFBD>œå<EFBFBD>é¡¹ï¼Œæ£æŸ¥åªä¸ªåŒå<EFBFBD>«å½å<EFBFBD>è·¯å¾?
allSubMenus.forEach((subMenu) => {
const subMenuEl = subMenu as HTMLElement;
const subMenuPath =
@@ -239,7 +239,7 @@ function updateParentMenuStyles() {
* 组件挂载后立即更新父菜单样式
*/
onMounted(() => {
//
// ç¡®ä¿<EFBFBD>在ç»ä»æŒè½½å<EFBFBD>Žæ´æ°æ ·å¼<EFBFBD>,ä¸<EFBFBD>ä¾<EFBFBD>èµäºŽå¼æ­¥æ<EFBFBD>ä½?
updateParentMenuStyles();
});
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div v-if="!item.meta || !item.meta.hidden">
<!--叶子节点显示叶子节点或唯一子节点且父节点未配置始终显示 -->
<template
@@ -22,11 +22,12 @@
:index="resolvePath(onlyOneChild.path)"
:class="{ 'submenu-title-noDropdown': !isNest }"
>
<MenuItemContent
v-if="onlyOneChild.meta"
:icon="onlyOneChild.meta.icon || item.meta?.icon"
:title="onlyOneChild.meta.title"
/>
<template v-if="onlyOneChild.meta">
<MenuIcon :icon="onlyOneChild.meta.icon || item.meta?.icon" />
<span v-if="onlyOneChild.meta.title" class="ml-1">
{{ translateRouteTitle(onlyOneChild.meta.title) }}
</span>
</template>
</el-menu-item>
</AppLink>
</template>
@@ -34,10 +35,15 @@
<!--非叶子节点显示含多个子节点的父菜单或始终显示的单子节点 -->
<el-sub-menu v-else :index="resolvePath(item.path)" :data-path="item.path" teleported>
<template #title>
<MenuItemContent v-if="item.meta" :icon="item.meta.icon" :title="item.meta.title" />
<template v-if="item.meta">
<MenuIcon :icon="item.meta.icon" />
<span v-if="item.meta.title" class="ml-1">
{{ translateRouteTitle(item.meta.title) }}
</span>
</template>
</template>
<MenuItem
<LayoutSidebarItem
v-for="child in item.children"
:key="child.path"
:is-nest="true"
@@ -49,17 +55,39 @@
</template>
<script setup lang="ts">
import MenuItemContent from "./MenuItemContent.vue";
import path from "path-browserify";
import { RouteRecordRaw } from "vue-router";
import { isExternal } from "@/utils";
import { translateRouteTitle } from "@/lang/utils";
import { ElIcon } from "element-plus";
defineOptions({
name: "MenuItem",
name: "LayoutSidebarItem",
inheritAttrs: false,
});
import path from "path-browserify";
import { RouteRecordRaw } from "vue-router";
//
const MenuIcon = defineComponent({
props: { icon: String },
setup(props) {
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
const iconName = computed(() => props.icon?.replace("el-icon-", ""));
import { isExternal } from "@/utils";
return () => {
if (!props.icon) {
return h("div", { class: "i-svg:menu" });
}
// Element Plus
if (isElIcon.value) {
return h(ElIcon, null, () => h(resolveComponent(iconName.value!)));
}
// SVG
return h("div", { class: `i-svg:${props.icon}` });
};
},
});
const props = defineProps({
/**
@@ -112,7 +140,7 @@ function hasOneShowingChild(children: RouteRecordRaw[] = [], parent: RouteRecord
return true;
}
//
//
if (showingChildren.length === 0) {
//
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
@@ -137,7 +165,57 @@ function resolvePath(routePath: string) {
</script>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
/* 菜单图标统一样式 */
.el-menu-item,
.el-sub-menu__title {
.el-icon {
width: 1em !important;
margin-right: 0 !important;
font-size: 18px;
color: currentcolor;
}
[class^="i-svg:"] {
width: 18px;
height: 18px;
font-size: 18px;
color: currentcolor !important;
}
}
/* 折叠状态下的图标样式 - 确保 SVG 图标不被压缩 */
.el-menu--collapse {
.el-menu-item,
.el-sub-menu > .el-sub-menu__title {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
}
/* tooltip 弹出层中的图标 */
.el-tooltip__trigger {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
}
}
/* hideSidebar 状态下的图标 */
.hideSidebar {
[class^="i-svg:"] {
width: 18px !important;
min-width: 18px !important;
height: 18px !important;
font-size: 18px !important;
}
.submenu-title-noDropdown {
position: relative;
@@ -203,7 +281,7 @@ html.sidebar-color-blue {
}
}
//
// "
html.dark & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
@@ -215,7 +293,7 @@ html.sidebar-color-blue {
}
}
//
// "
html.sidebar-color-blue & {
&.has-active-child > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
@@ -227,4 +305,5 @@ html.sidebar-color-blue {
}
}
}
/* stylelint-enable no-descending-specificity */
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="tags-container">
<!-- 水平滚动容器 -->
<el-scrollbar
@@ -70,7 +70,7 @@
<script setup lang="ts">
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
import { resolve } from "path-browserify";
import { translateRouteTitle } from "@/utils/i18n";
import { translateRouteTitle } from "@/lang/utils";
import { usePermissionStore, useTagsViewStore } from "@/store";
interface ContextMenu {
@@ -91,7 +91,7 @@ const { visitedViews } = storeToRefs(tagsViewStore);
//
const selectedTag = ref<TagView | null>(null);
//
// "
const contextMenu = reactive<ContextMenu>({
visible: false,
x: 0,

View File

@@ -4,7 +4,7 @@
<template v-if="isDesktop">
<!-- 搜索 -->
<div class="navbar-actions__item">
<MenuSearch />
<CommandPalette />
</div>
<!-- 全屏 -->
@@ -24,10 +24,10 @@
<!-- 通知 -->
<div class="navbar-actions__item">
<Notification />
<NoticeDropdown />
</div>
<!-- 租户选择如果启用多租户 -->
<!-- 租户选择如果启用多租户-->
<div v-if="showTenantSelect" class="navbar-actions__item">
<TenantSwitcher @change="handleTenantChange" />
</div>
@@ -60,11 +60,7 @@
</div>
<!-- 系统设置 -->
<div
v-if="defaultSettings.showSettings"
class="navbar-actions__item"
@click="handleSettingsClick"
>
<div v-if="defaults.showSettings" class="navbar-actions__item" @click="handleSettingsClick">
<div class="i-svg:setting" />
</div>
</div>
@@ -73,18 +69,18 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { defaultSettings } from "@/settings";
import { defaults } from "@/settings";
import { DeviceEnum, SidebarColor, ThemeMode, LayoutMode } from "@/enums/settings";
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
//
import MenuSearch from "@/components/MenuSearch/index.vue";
import CommandPalette from "@/components/CommandPalette/index.vue";
import Fullscreen from "@/components/Fullscreen/index.vue";
import SizeSelect from "@/components/SizeSelect/index.vue";
import LangSelect from "@/components/LangSelect/index.vue";
import Notification from "@/components/Notification/index.vue";
import NoticeDropdown from "@/components/NoticeDropdown/index.vue";
import TenantSwitcher from "@/components/TenantSwitcher/index.vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { useTenantStoreHook } from "@/store/modules/tenant";
const { t } = useI18n();
const appStore = useAppStore();
@@ -105,7 +101,7 @@ const showTenantSelect = computed(() => {
if (tenantStore.tenantList.length === 0) {
return false;
}
//
// <EFBFBD>?
if (tenantStore.tenantList.length === 1) {
return false;
}
@@ -132,20 +128,20 @@ function handleProfileClick() {
router.push({ name: "Profile" });
}
//
// <EFBFBD>?
const navbarActionsClass = computed(() => {
const { theme, sidebarColorScheme, layout } = settingStore;
// 使
// 使<EFBFBD>?
if (theme === ThemeMode.DARK) {
return "navbar-actions--white-text";
}
//
// <EFBFBD>?
if (theme === ThemeMode.LIGHT) {
//
// - 使
// - 使
// - 使<EFBFBD>?
// - 使<EFBFBD>?
if (layout === LayoutMode.TOP || layout === LayoutMode.MIX) {
if (sidebarColorScheme === SidebarColor.CLASSIC_BLUE) {
return "navbar-actions--white-text";
@@ -159,7 +155,7 @@ const navbarActionsClass = computed(() => {
});
/**
* 退出登
* 退出登<EFBFBD>?
*/
function logout() {
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
@@ -193,7 +189,7 @@ function handleSettingsClick() {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px; /* 增加最小点击区域到44px符合人机交互标*/
min-width: 44px; /* 增加最小点击区域到44px符合人机交互标<EFBFBD>?*/
height: 100%;
min-height: 44px;
padding: 0 8px;
@@ -201,7 +197,7 @@ function handleSettingsClick() {
cursor: pointer;
transition: all 0.3s;
//
// <EFBFBD>?
> * {
display: flex;
align-items: center;
@@ -258,7 +254,7 @@ function handleSettingsClick() {
}
}
//
// <EFBFBD>?
.navbar-actions--white-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {
@@ -294,7 +290,7 @@ function handleSettingsClick() {
}
}
//
// <EFBFBD>?
.navbar-actions--dark-text {
.navbar-actions__item {
:deep([class^="i-svg:"]) {

View File

@@ -1,187 +0,0 @@
<!-- 混合布局顶部菜单 -->
<template>
<el-menu
mode="horizontal"
:default-active="activeTopMenuPath"
:background-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-background']
: undefined
"
:text-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-text']
: undefined
"
:active-text-color="
theme === 'dark' || sidebarColorScheme === SidebarColor.CLASSIC_BLUE
? variables['menu-active-text']
: undefined
"
@select="handleMenuSelect"
>
<el-menu-item v-for="menuItem in processedTopMenus" :key="menuItem.path" :index="menuItem.path">
<MenuItemContent
v-if="menuItem.meta"
:icon="menuItem.meta.icon"
:title="menuItem.meta.title"
/>
</el-menu-item>
</el-menu>
</template>
<script lang="ts" setup>
import MenuItemContent from "./components/MenuItemContent.vue";
defineOptions({
name: "MixTopMenu",
});
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
import variables from "@/styles/variables.module.scss";
import { SidebarColor } from "@/enums/settings";
const router = useRouter();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const settingsStore = useSettingsStore();
// 获取主题
const theme = computed(() => settingsStore.theme);
// 获取浅色主题下的侧边栏配色方案
const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
// 顶部菜单列表
const topMenus = ref<RouteRecordRaw[]>([]);
// 处理后的顶部菜单列表 - 智能显示唯一子菜单的标题
const processedTopMenus = computed(() => {
return topMenus.value.map((route) => {
// 如果路由设置了 alwaysShow=true或者没有子菜单直接返回原路由
if (route.meta?.alwaysShow || !route.children || route.children.length === 0) {
return route;
}
// 过滤出非隐藏的子菜单
const visibleChildren = route.children.filter((child) => !child.meta?.hidden);
// 如果只有一个非隐藏的子菜单,显示子菜单的信息
if (visibleChildren.length === 1) {
const onlyChild = visibleChildren[0];
return {
...route,
meta: {
...route.meta,
title: onlyChild.meta?.title || route.meta?.title,
icon: onlyChild.meta?.icon || route.meta?.icon,
},
};
}
// 其他情况返回原路由
return route;
});
});
/**
* 处理菜单点击事件,切换顶部菜单并加载对应的左侧菜单
* @param routePath 点击的菜单路径
*/
const handleMenuSelect = (routePath: string) => {
updateMenuState(routePath);
};
/**
* 更新菜单状态 - 同时处理点击和路由变化情况
* @param topMenuPath 顶级菜单路径
* @param skipNavigation 是否跳过导航路由变化时为true点击菜单时为false
*/
const updateMenuState = (topMenuPath: string, skipNavigation = false) => {
// 不相同才更新,避免重复操作
if (topMenuPath !== appStore.activeTopMenuPath) {
appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单
permissionStore.setMixLayoutSideMenus(topMenuPath); // 设置混合布局左侧菜单
}
// 如果是点击菜单且状态已变更,才进行导航
if (!skipNavigation) {
navigateToFirstLeftMenu(permissionStore.mixLayoutSideMenus); // 跳转到左侧第一个菜单
}
};
/**
* 跳转到左侧第一个可访问的菜单
* @param menus 左侧菜单列表
*/
const navigateToFirstLeftMenu = (menus: RouteRecordRaw[]) => {
if (menus.length === 0) return;
const [firstMenu] = menus;
// 如果第一个菜单有子菜单,递归跳转到第一个子菜单
if (firstMenu.children && firstMenu.children.length > 0) {
navigateToFirstLeftMenu(firstMenu.children as RouteRecordRaw[]);
} else if (firstMenu.name) {
router.push({
name: firstMenu.name,
query:
typeof firstMenu.meta?.params === "object"
? (firstMenu.meta.params as LocationQueryRaw)
: undefined,
});
}
};
// 获取当前路由路径的顶部菜单路径
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
onMounted(() => {
topMenus.value = permissionStore.routes.filter((item) => !item.meta || !item.meta.hidden);
// 初始化顶部菜单
const currentTopMenuPath =
useRoute().path.split("/").filter(Boolean).length > 1
? useRoute().path.match(/^\/[^/]+/)?.[0] || "/"
: "/";
appStore.activeTopMenu(currentTopMenuPath); // 设置激活的顶部菜单
permissionStore.setMixLayoutSideMenus(currentTopMenuPath); // 设置混合布局左侧菜单
});
// 监听路由变化,同步更新顶部菜单和左侧菜单的激活状态
watch(
() => router.currentRoute.value.path,
(newPath) => {
if (newPath) {
// 提取顶级路径
const topMenuPath =
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
// 使用公共方法更新菜单状态,但跳过导航(因为路由已经变化)
updateMenuState(topMenuPath, true);
}
}
);
</script>
<style lang="scss" scoped>
.el-menu {
width: 100%;
height: 100%;
&--horizontal {
height: $navbar-height !important;
// 确保菜单项垂直居中
:deep(.el-menu-item) {
height: 100%;
line-height: $navbar-height;
}
// 移除默认的底部边框
&:after {
display: none;
}
}
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<!-- 菜单图标 -->
<template v-if="icon">
<el-icon v-if="isElIcon" class="menu-icon">
<component :is="iconComponent" />
</el-icon>
<div v-else :class="`i-svg:${icon}`" class="menu-icon" />
</template>
<template v-else>
<div class="i-svg:menu menu-icon" />
</template>
<!-- 菜单标题 -->
<span v-if="title" class="menu-title ml-1">{{ translateRouteTitle(title) }}</span>
</template>
<script setup lang="ts">
import { translateRouteTitle } from "@/utils/i18n";
const props = defineProps<{
icon?: string;
title?: string;
}>();
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
const iconComponent = computed(() => props.icon?.replace("el-icon-", ""));
</script>
<style lang="scss" scoped>
.menu-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-right: 5px;
font-size: 18px;
color: currentcolor;
}
</style>