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

@@ -1,21 +1,17 @@
<template>
<div class="layout" :class="layoutClass">
<!-- 移动端遮罩层 - 当侧边栏打开时显示 -->
<!-- 移动端遮罩层 -->
<div v-if="isMobile && isSidebarOpen" class="layout__overlay" @click="closeSidebar" />
<!-- 布局内容插槽 - 各种布局模式的具体内容 -->
<slot></slot>
<!-- 布局内容插槽 -->
<slot />
</div>
</template>
<script setup lang="ts">
import { useLayout, useDeviceDetection } from "@/composables";
import { useLayout } from "./useLayout";
/// Layout-related functionality and state management
const { layoutClass, isSidebarOpen, closeSidebar } = useLayout();
/// Device detection for responsive layout
const { isMobile } = useDeviceDetection();
const { layoutClass, isSidebarOpen, isMobile, closeSidebar } = useLayout();
</script>
<style lang="scss" scoped>

View File

@@ -1,47 +1,40 @@
<template>
<BaseLayout>
<!-- 左侧菜单栏 -->
<!-- 左侧è<EFBFBD>œå<EFBFBD>æ ?-->
<div class="layout__sidebar" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<div :class="{ 'has-logo': isShowLogo }" class="layout-sidebar">
<!-- Logo -->
<AppLogo v-if="isShowLogo" :collapse="!isSidebarOpen" />
<!-- 主菜单内容 -->
<div :class="{ 'has-logo': showLogo }" class="layout-sidebar">
<LayoutLogo v-if="showLogo" :collapse="!isSidebarOpen" />
<el-scrollbar>
<BasicMenu :data="routes" base-path="" />
<LayoutSidebar :data="routes" base-path="" />
</el-scrollbar>
</div>
</div>
<!-- 主内容区 -->
<div
class="layout__main"
:class="{
hasTagsView: isShowTagsView,
hasTagsView: showTagsView,
'layout__main--collapsed': !isSidebarOpen,
}"
class="layout__main"
>
<NavBar />
<TagsView v-if="isShowTagsView" />
<AppMain />
<LayoutNavbar />
<LayoutTagsView v-if="showTagsView" />
<LayoutMain />
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { useLayout } from "@/composables/layout/useLayout";
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
import BaseLayout from "../base/index.vue";
import AppLogo from "../../components/AppLogo/index.vue";
import NavBar from "../../components/NavBar/index.vue";
import TagsView from "../../components/TagsView/index.vue";
import AppMain from "../../components/AppMain/index.vue";
import BasicMenu from "../../components/Menu/BasicMenu.vue";
import { useLayout } from "./useLayout";
import BaseLayout from "./BaseLayout.vue";
import LayoutLogo from "./components/LayoutLogo.vue";
import LayoutNavbar from "./components/LayoutNavbar.vue";
import LayoutTagsView from "./components/LayoutTagsView.vue";
import LayoutMain from "./components/LayoutMain.vue";
import LayoutSidebar from "./components/LayoutSidebar.vue";
//
const { isShowTagsView, isShowLogo, isSidebarOpen } = useLayout();
//
const { routes } = useLayoutMenu();
const { showTagsView, showLogo, isSidebarOpen, routes } = useLayout();
</script>
<style lang="scss" scoped>
@@ -98,7 +91,7 @@ const { routes } = useLayoutMenu();
}
}
/* 移动端样式 */
/* 移动端样�*/
.mobile {
.layout__sidebar {
width: $sidebar-width !important;

360
src/layouts/MixLayout.vue Normal file
View File

@@ -0,0 +1,360 @@
<template>
<BaseLayout>
<!-- 顶部菜单栏 -->
<div class="layout__header">
<div class="layout__header-content">
<div v-if="showLogo" class="layout__header-logo">
<LayoutLogo :collapse="isLogoCollapsed" />
</div>
<!-- 顶部菜单 -->
<div class="layout__header-menu">
<el-menu
mode="horizontal"
:default-active="activeTopMenuPath"
:background-color="useMenuColors ? variables['menu-background'] : undefined"
:text-color="useMenuColors ? variables['menu-text'] : undefined"
:active-text-color="useMenuColors ? variables['menu-active-text'] : undefined"
@select="handleTopMenuSelect"
>
<el-menu-item v-for="item in topMenuItems" :key="item.path" :index="item.path">
<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>
</el-menu-item>
</el-menu>
</div>
<div class="layout__header-actions">
<LayoutToolbar />
</div>
</div>
</div>
<!-- 主内容区容器 -->
<div class="layout__container">
<!-- 左侧菜单栏 -->
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<el-scrollbar>
<el-menu
:default-active="activeSideMenuPath"
:collapse="!isSidebarOpen"
:collapse-transition="false"
:unique-opened="false"
:background-color="variables['menu-background']"
:text-color="variables['menu-text']"
:active-text-color="variables['menu-active-text']"
>
<LayoutSidebarItem
v-for="item in sideMenuRoutes"
:key="item.path"
:item="item"
:base-path="resolvePath(item.path)"
/>
</el-menu>
</el-scrollbar>
<div class="layout__sidebar-toggle">
<Hamburger :is-active="isSidebarOpen" @toggle-click="toggleSidebar" />
</div>
</div>
<!-- 主内容区 -->
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
<LayoutTagsView v-if="showTagsView" />
<LayoutMain />
</div>
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import type { LocationQueryRaw, RouteRecordRaw } from "vue-router";
import { useWindowSize } from "@vueuse/core";
import { useLayout } from "./useLayout";
import { useAppStore, usePermissionStore, useSettingsStore } from "@/store";
import { isExternal } from "@/utils/index";
import { translateRouteTitle } from "@/lang/utils";
import { SidebarColor } from "@/enums/settings";
import { ElIcon } from "element-plus";
import BaseLayout from "./BaseLayout.vue";
import LayoutLogo from "./components/LayoutLogo.vue";
import LayoutToolbar from "./components/LayoutToolbar.vue";
import LayoutTagsView from "./components/LayoutTagsView.vue";
import LayoutMain from "./components/LayoutMain.vue";
import LayoutSidebarItem from "./components/LayoutSidebarItem.vue";
import Hamburger from "@/components/Hamburger/index.vue";
import variables from "@/styles/variables.module.scss";
// 菜单图标渲染组件
const MenuIcon = defineComponent({
props: { icon: String },
setup(props) {
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
const iconName = computed(() => props.icon?.replace("el-icon-", ""));
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 route = useRoute();
const router = useRouter();
const { width } = useWindowSize();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const settingsStore = useSettingsStore();
const { showTagsView, showLogo, isSidebarOpen, toggleSidebar, sideMenuRoutes, activeTopMenuPath } =
useLayout();
const isLogoCollapsed = computed(() => width.value < 768);
// 是否使用深色菜单配色(暗色主题或经典蓝侧边栏)
const useMenuColors = computed(
() =>
settingsStore.theme === "dark" || settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
);
// 顶部菜单项(处理单子菜单显示优化)
const topMenuItems = computed(() => {
const routes = permissionStore.routes.filter((item) => !item.meta?.hidden);
return routes.map((route) => {
// alwaysShow 或无子菜单,直接返回
if (route.meta?.alwaysShow || !route.children?.length) return route;
// 过滤可见子菜单
const visibleChildren = route.children.filter((child) => !child.meta?.hidden);
// 仅一个可见子菜单时,显示子菜单信息
if (visibleChildren.length === 1) {
const child = visibleChildren[0];
return {
...route,
meta: {
...route.meta,
title: child.meta?.title || route.meta?.title,
icon: child.meta?.icon || route.meta?.icon,
},
};
}
return route;
});
});
// 左侧菜单激活路径
const activeSideMenuPath = computed(() => {
const { meta, path } = route;
return typeof meta?.activeMenu === "string" ? meta.activeMenu : path;
});
// 解析左侧菜单路径
function resolvePath(routePath: string) {
if (isExternal(routePath)) return routePath;
if (routePath.startsWith("/")) return activeTopMenuPath.value + routePath;
return `${activeTopMenuPath.value}/${routePath}`;
}
// 从路径提取顶级菜单路径
function extractTopMenuPath(path: string): string {
return path.split("/").filter(Boolean).length > 1 ? path.match(/^\/[^/]+/)?.[0] || "/" : "/";
}
// 顶部菜单点击
function handleTopMenuSelect(menuPath: string) {
if (menuPath === activeTopMenuPath.value) return;
appStore.activeTopMenu(menuPath);
permissionStore.setMixLayoutSideMenus(menuPath);
navigateToFirstMenu(permissionStore.mixLayoutSideMenus);
}
// 导航到第一个可访问菜单
function navigateToFirstMenu(menus: RouteRecordRaw[]) {
if (!menus.length) return;
const [first] = menus;
if (first.children?.length) {
navigateToFirstMenu(first.children as RouteRecordRaw[]);
} else if (first.name) {
router.push({
name: first.name,
query:
typeof first.meta?.params === "object"
? (first.meta.params as LocationQueryRaw)
: undefined,
});
}
}
// 监听路由变化,同步顶部菜单状态
watch(
() => route.path,
(newPath) => {
const topMenuPath = extractTopMenuPath(newPath);
if (topMenuPath !== activeTopMenuPath.value) {
appStore.activeTopMenu(topMenuPath);
permissionStore.setMixLayoutSideMenus(topMenuPath);
}
},
{ immediate: true }
);
</script>
<style lang="scss" scoped>
.layout {
&__header {
position: sticky;
top: 0;
z-index: 999;
width: 100%;
height: $navbar-height;
background-color: var(--menu-background);
border-bottom: 1px solid var(--el-border-color-lighter);
&-content {
display: flex;
align-items: center;
height: 100%;
padding: 0;
}
&-logo {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 100%;
}
&-menu {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
overflow: hidden;
:deep(.el-menu) {
height: 100%;
background-color: transparent;
border: none;
}
:deep(.el-menu--horizontal) {
display: flex;
align-items: center;
height: 100%;
.el-menu-item {
height: 100%;
line-height: $navbar-height;
border-bottom: none;
&.is-active {
background-color: rgba(255, 255, 255, 0.12);
border-bottom: 2px solid var(--el-color-primary);
}
}
}
}
&-actions {
display: flex;
flex-shrink: 0;
align-items: center;
height: 100%;
padding: 0 16px;
}
}
&__container {
display: flex;
height: calc(100vh - $navbar-height);
padding-top: 0;
.layout__sidebar--left {
position: relative;
width: $sidebar-width;
height: 100%;
background-color: var(--menu-background);
transition: width 0.28s;
&.layout__sidebar--collapsed {
width: $sidebar-width-collapsed !important;
}
:deep(.el-scrollbar) {
height: calc(100vh - $navbar-height - 50px);
}
:deep(.el-menu) {
height: 100%;
border: none;
}
.layout__sidebar-toggle {
position: absolute;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
line-height: 50px;
background-color: var(--menu-background);
box-shadow: 0 0 6px -2px var(--el-color-primary);
}
}
.layout__main {
flex: 1;
min-width: 0;
height: 100%;
margin-left: 0;
overflow-y: auto;
}
}
}
:deep(.mobile) {
.layout__container {
.layout__sidebar--left {
position: fixed;
top: $navbar-height;
bottom: 0;
left: 0;
z-index: 1000;
transition: transform 0.28s;
}
}
&.hideSidebar {
.layout__sidebar--left {
width: $sidebar-width !important;
transform: translateX(-$sidebar-width);
}
}
}
:deep(.hasTagsView) {
.app-main {
height: calc(100vh - $navbar-height - $tags-view-height) !important;
}
}
</style>

View File

@@ -1,47 +1,37 @@
<template>
<BaseLayout>
<!-- 顶部菜单栏 -->
<!-- é¡éƒ¨è<EFBFBD>œå<EFBFBD>æ ?-->
<div class="layout__header">
<div class="layout__header-left">
<!-- Logo -->
<AppLogo v-if="isShowLogo" :collapse="isLogoCollapsed" />
<!-- 菜单 -->
<BasicMenu :data="routes" menu-mode="horizontal" base-path="" />
<LayoutLogo v-if="showLogo" :collapse="isLogoCollapsed" />
<LayoutSidebar :data="routes" menu-mode="horizontal" base-path="" />
</div>
<!-- 操作按钮 -->
<div class="layout__header-right">
<NavbarActions />
<LayoutToolbar />
</div>
</div>
<!-- 主内容区 -->
<div :class="{ hasTagsView: isShowTagsView }" class="layout__main">
<TagsView v-if="isShowTagsView" />
<AppMain />
<div :class="{ hasTagsView: showTagsView }" class="layout__main">
<LayoutTagsView v-if="showTagsView" />
<LayoutMain />
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { useLayout } from "@/composables/layout/useLayout";
import { useLayoutMenu } from "@/composables/layout/useLayoutMenu";
import BaseLayout from "../base/index.vue";
import AppLogo from "../../components/AppLogo/index.vue";
import BasicMenu from "../../components/Menu/BasicMenu.vue";
import NavbarActions from "../../components/NavBar/components/NavbarActions.vue";
import TagsView from "../../components/TagsView/index.vue";
import AppMain from "../../components/AppMain/index.vue";
import { useWindowSize } from "@vueuse/core";
import { useLayout } from "./useLayout";
import BaseLayout from "./BaseLayout.vue";
import LayoutLogo from "./components/LayoutLogo.vue";
import LayoutSidebar from "./components/LayoutSidebar.vue";
import LayoutToolbar from "./components/LayoutToolbar.vue";
import LayoutTagsView from "./components/LayoutTagsView.vue";
import LayoutMain from "./components/LayoutMain.vue";
//
const { isShowTagsView, isShowLogo } = useLayout();
//
const { routes } = useLayoutMenu();
//
const { showTagsView, showLogo, routes } = useLayout();
const { width } = useWindowSize();
// Logo
const isLogoCollapsed = computed(() => width.value < 768);
</script>
@@ -62,30 +52,28 @@ const isLogoCollapsed = computed(() => width.value < 768);
display: flex;
flex: 1;
align-items: center;
min-width: 0; // flex
min-width: 0;
height: 100%;
// LogoAppLogo
:deep(.logo) {
flex-shrink: 0; // Logo
flex-shrink: 0;
height: $navbar-height;
}
}
&-right {
display: flex;
flex-shrink: 0; //
flex-shrink: 0;
align-items: center;
height: 100%;
padding-left: 12px;
}
//
:deep(.el-menu--horizontal) {
flex: 1;
min-width: 0; //
min-width: 0;
height: $navbar-height;
overflow: hidden; //
overflow: hidden;
line-height: $navbar-height;
background-color: transparent;
border: none;
@@ -101,7 +89,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
line-height: $navbar-height;
}
// -
&.has-active-child {
.el-sub-menu__title {
color: var(--el-color-primary) !important;
@@ -114,7 +101,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
}
}
//
.el-menu--popup {
min-width: 160px;
}
@@ -127,7 +113,6 @@ const isLogoCollapsed = computed(() => width.value < 768);
}
}
// TagsView
.hasTagsView {
:deep(.app-main) {
height: calc(100vh - $navbar-height - $tags-view-height) !important;

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>

View File

@@ -1,42 +1,35 @@
<template>
<div class="layout-wrapper">
<component :is="currentLayoutComponent" />
<!-- 设置面板 - 独立于布局组件 -->
<Settings v-if="isShowSettings" />
<Settings v-if="showSettings" />
</div>
</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
import { useLayout } from "@/composables/layout/useLayout";
import LeftLayout from "@/layouts/modes/left/index.vue";
import TopLayout from "@/layouts/modes/top/index.vue";
import MixLayout from "@/layouts/modes/mix/index.vue";
import Settings from "./components/Settings/index.vue";
import { useLayout } from "./useLayout";
import { LayoutMode } from "@/enums/settings";
import { defaultSettings } from "@/settings";
import LeftLayout from "./LeftLayout.vue";
import TopLayout from "./TopLayout.vue";
import MixLayout from "./MixLayout.vue";
import Settings from "./components/LayoutSettings.vue";
const { currentLayout } = useLayout();
const route = useRoute();
const { currentLayout, showSettings } = useLayout();
/// Select the corresponding component based on the current layout mode
const currentLayoutComponent = computed(() => {
const override = route.meta?.layout as LayoutMode | undefined;
const layoutToUse = override ?? currentLayout.value;
switch (layoutToUse) {
const layout = override ?? currentLayout.value;
switch (layout) {
case LayoutMode.TOP:
return TopLayout;
case LayoutMode.MIX:
return MixLayout;
case LayoutMode.LEFT:
default:
return LeftLayout;
}
});
/// Whether to show the settings panel
const isShowSettings = computed(() => defaultSettings.showSettings);
</script>
<style lang="scss" scoped>

View File

@@ -1,281 +0,0 @@
<template>
<BaseLayout>
<!-- 顶部菜单栏 -->
<div class="layout__header">
<div class="layout__header-content">
<!-- Logo区域 -->
<div v-if="isShowLogo" class="layout__header-logo">
<AppLogo :collapse="isLogoCollapsed" />
</div>
<!-- 顶部菜单区域 -->
<div class="layout__header-menu">
<MixTopMenu />
</div>
<!-- 右侧操作区域 -->
<div class="layout__header-actions">
<NavbarActions />
</div>
</div>
</div>
<!-- 主内容区容器 -->
<div class="layout__container">
<!-- 左侧菜单栏 -->
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<el-scrollbar>
<el-menu
:default-active="activeLeftMenuPath"
:collapse="!isSidebarOpen"
:collapse-transition="false"
:unique-opened="false"
:background-color="variables['menu-background']"
:text-color="variables['menu-text']"
:active-text-color="variables['menu-active-text']"
>
<MenuItem
v-for="item in sideMenuRoutes"
:key="item.path"
:item="item"
:base-path="resolvePath(item.path)"
/>
</el-menu>
</el-scrollbar>
<!-- 侧边栏切换按钮 -->
<div class="layout__sidebar-toggle">
<Hamburger :is-active="isSidebarOpen" @toggle-click="toggleSidebar" />
</div>
</div>
<!-- 主内容区 -->
<div :class="{ hasTagsView: isShowTagsView }" class="layout__main">
<TagsView v-if="isShowTagsView" />
<AppMain />
</div>
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
import { useWindowSize } from "@vueuse/core";
import { useLayout, useLayoutMenu } from "@/composables";
import BaseLayout from "../base/index.vue";
import AppLogo from "../../components/AppLogo/index.vue";
import MixTopMenu from "../../components/Menu/MixTopMenu.vue";
import NavbarActions from "../../components/NavBar/components/NavbarActions.vue";
import TagsView from "../../components/TagsView/index.vue";
import AppMain from "../../components/AppMain/index.vue";
import MenuItem from "../../components/Menu/components/MenuItem.vue";
import Hamburger from "@/components/Hamburger/index.vue";
import variables from "@/styles/variables.module.scss";
import { isExternal } from "@/utils/index";
import { useAppStore, usePermissionStore } from "@/store";
const route = useRoute();
// 布局相关参数
const { isShowTagsView, isShowLogo, isSidebarOpen, toggleSidebar } = useLayout();
// 菜单相关
const { sideMenuRoutes, activeTopMenuPath } = useLayoutMenu();
// 响应式窗口尺寸
const { width } = useWindowSize();
// 只有在小屏设备移动设备时才折叠Logo只显示图标隐藏文字
const isLogoCollapsed = computed(() => width.value < 768);
// 当前激活的菜单
const activeLeftMenuPath = computed(() => {
const { meta, path } = route;
// 如果设置了activeMenu则使用
if ((meta?.activeMenu as unknown as string) && typeof meta.activeMenu === "string") {
return meta.activeMenu as unknown as string;
}
return path;
});
/**
* 解析路径 - 混合模式下,左侧菜单是从顶级菜单下的子菜单开始的
* 所以需要拼接顶级菜单路径
*/
function resolvePath(routePath: string) {
if (isExternal(routePath)) {
return routePath;
}
if (routePath.startsWith("/")) {
return activeTopMenuPath.value + routePath;
}
return `${activeTopMenuPath.value}/${routePath}`;
}
// 监听路由变化确保左侧菜单能随TagsView切换而正确激活
watch(
() => route.path,
(newPath: string) => {
// 获取顶级路径
const topMenuPath =
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
// 如果当前路径属于当前激活的顶部菜单
if (newPath.startsWith(activeTopMenuPath.value)) {
// no-op
}
// 如果路径改变了顶级菜单,确保顶部菜单和左侧菜单都更新
else if (topMenuPath !== activeTopMenuPath.value) {
const appStore = useAppStore();
const permissionStore = usePermissionStore();
appStore.activeTopMenu(topMenuPath);
permissionStore.setMixLayoutSideMenus(topMenuPath);
}
},
{ immediate: true }
);
</script>
<style lang="scss" scoped>
.layout {
&__header {
position: sticky;
top: 0;
z-index: 999;
width: 100%;
height: $navbar-height;
background-color: var(--menu-background);
border-bottom: 1px solid var(--el-border-color-lighter);
&-content {
display: flex;
align-items: center;
height: 100%;
padding: 0;
}
&-logo {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 100%;
}
&-menu {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
overflow: hidden;
:deep(.el-menu) {
height: 100%;
background-color: transparent;
border: none;
}
:deep(.el-menu--horizontal) {
display: flex;
align-items: center;
height: 100%;
.el-menu-item {
height: 100%;
line-height: $navbar-height;
border-bottom: none;
&.is-active {
background-color: rgba(255, 255, 255, 0.12);
border-bottom: 2px solid var(--el-color-primary);
}
}
}
}
&-actions {
display: flex;
flex-shrink: 0;
align-items: center;
height: 100%;
padding: 0 16px;
}
}
&__container {
display: flex;
height: calc(100vh - $navbar-height);
padding-top: 0;
.layout__sidebar--left {
position: relative;
width: $sidebar-width;
height: 100%;
background-color: var(--menu-background);
transition: width 0.28s;
&.layout__sidebar--collapsed {
width: $sidebar-width-collapsed !important;
}
:deep(.el-scrollbar) {
height: calc(100vh - $navbar-height - 50px);
}
:deep(.el-menu) {
height: 100%;
border: none;
}
.layout__sidebar-toggle {
position: absolute;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
line-height: 50px;
background-color: var(--menu-background);
box-shadow: 0 0 6px -2px var(--el-color-primary);
}
}
.layout__main {
flex: 1;
min-width: 0;
height: 100%;
margin-left: 0;
overflow-y: auto;
}
}
}
/* 移动端样式 */
:deep(.mobile) {
.layout__container {
.layout__sidebar--left {
position: fixed;
top: $navbar-height;
bottom: 0;
left: 0;
z-index: 1000;
transition: transform 0.28s;
}
}
&.hideSidebar {
.layout__sidebar--left {
width: $sidebar-width !important;
transform: translateX(-$sidebar-width);
}
}
}
:deep(.hasTagsView) {
.app-main {
height: calc(100vh - $navbar-height - $tags-view-height) !important;
}
}
</style>

108
src/layouts/useLayout.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* 布局 Composable
*
* 整合布局状态、设备检测、菜单数据
*/
import { useRoute } from "vue-router";
import { useWindowSize } from "@vueuse/core";
import { useAppStore, usePermissionStore, useSettingsStore } from "@/store";
import { DeviceEnum } from "@/enums/settings";
import { defaults } from "@/settings";
const DESKTOP_BREAKPOINT = 992;
export function useLayout() {
const route = useRoute();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const { width } = useWindowSize();
// ============================================
// 设备检测
// ============================================
const isDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT);
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
// 监听窗口变化,自动调整设备类型和侧边栏
watchEffect(() => {
const device = isDesktop.value ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE;
appStore.toggleDevice(device);
if (isDesktop.value) {
appStore.openSideBar();
} else {
appStore.closeSideBar();
}
});
// ============================================
// 布局状态
// ============================================
const currentLayout = computed(() => settingsStore.layout);
const isSidebarOpen = computed(() => appStore.sidebar.opened);
const showTagsView = computed(() => settingsStore.showTagsView);
const showSettings = computed(() => defaults.showSettings);
const showLogo = computed(() => settingsStore.showAppLogo);
const layoutClass = computed(() => ({
hideSidebar: !appStore.sidebar.opened,
openSidebar: appStore.sidebar.opened,
mobile: appStore.device === DeviceEnum.MOBILE,
[`layout-${settingsStore.layout}`]: true,
}));
// ============================================
// 菜单数据
// ============================================
/** 路由列表(左侧/顶部菜单) */
const routes = computed(() => permissionStore.routes);
/** 混合布局侧边菜单 */
const sideMenuRoutes = computed(() => permissionStore.mixLayoutSideMenus);
/** 顶部菜单激活路径 */
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
/** 当前激活菜单 */
const activeMenu = computed(() => {
const { meta, path } = route;
return meta?.activeMenu || path;
});
// ============================================
// 操作方法
// ============================================
function toggleSidebar() {
appStore.toggleSidebar();
}
function closeSidebar() {
appStore.closeSideBar();
}
return {
// 设备
isDesktop,
isMobile,
// 布局
currentLayout,
layoutClass,
isSidebarOpen,
showTagsView,
showSettings,
showLogo,
// 菜单
routes,
sideMenuRoutes,
activeMenu,
activeTopMenuPath,
// 方法
toggleSidebar,
closeSidebar,
};
}