feat: 项目结构重构优化
This commit is contained in:
@@ -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>
|
||||
@@ -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
360
src/layouts/MixLayout.vue
Normal 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>
|
||||
@@ -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%;
|
||||
|
||||
// Logo样式由AppLogo组件的全局样式控制
|
||||
: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;
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
// 如果路由meta中设置了activeMenu,则使用它(用于处理一些特殊情况,如详情页)
|
||||
// 如果路由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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
@@ -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:"]) {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
108
src/layouts/useLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user