mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-02 08:29:21 +00:00
发布v2.0.0分支
This commit is contained in:
@@ -1,500 +0,0 @@
|
||||
<template>
|
||||
<a-layout class="basic-layout" >
|
||||
|
||||
<!-- 左侧导航 -->
|
||||
<a-layout-sider class="layout-sider" :collapsed="menuState.collapsed" :trigger="null" collapsible
|
||||
:width="240" :collapsedWidth="80">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="logo-container">
|
||||
<a-image :src="initConfigState.web_favicon" :preview="false" :width="30" :height="30" />
|
||||
<h1 class="logo-title" v-show="!menuState.collapsed">{{ initConfigState.web_title }}</h1>
|
||||
</div>
|
||||
|
||||
<a-menu class="side-menu" mode="inline" v-model:openKeys="menuState.openKeys"
|
||||
v-model:selectedKeys="menuState.selectedKeys" :items="menuState.menus" @click="handleMenuClick" />
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 右侧布局 -->
|
||||
<a-layout class="layout-main" :class="{ collapsed: menuState.collapsed }">
|
||||
<!-- 顶部导航栏 -->
|
||||
<a-layout-header class="layout-header">
|
||||
|
||||
<div class="header-left">
|
||||
<!-- 左侧折叠按钮 -->
|
||||
<a-button type="link" class="collapse-btn" @click="toggleCollapsed">
|
||||
<MenuFoldOutlined v-if="menuState.collapsed" />
|
||||
<MenuUnfoldOutlined v-else />
|
||||
</a-button>
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="header-right">
|
||||
<!-- 主题切换按钮 -->
|
||||
<a-tooltip :title="isDarkMode ? '切换亮色主题' : '切换暗色主题'" placement="bottom">
|
||||
<a-button type="link" @click="handleThemeChange" class="tool-btn">
|
||||
<component :is="isDarkMode ? BulbFilled : BulbOutlined" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 文档按钮 -->
|
||||
<a-tooltip title="说明文档" placement="bottom">
|
||||
<a-button type="link" @click="openDocumentation" class="tool-btn">
|
||||
<QuestionCircleOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 通知按钮 -->
|
||||
<a-popover placement="bottom" trigger="hover" :autoAdjustOverflow="true">
|
||||
<template #content>
|
||||
<div class="notice-popover">
|
||||
<a-list :data-source="noticeState.notices" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<span>
|
||||
<a-tag :color="item.notice_type === 1 ? 'blue' : 'orange'">
|
||||
{{ item.notice_type === 1 ? '通知' : '公告' }}
|
||||
</a-tag>
|
||||
{{ item.notice_title }}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="notice-content">{{ item.notice_content }}</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="notice-header">
|
||||
<WarningOutlined style="color: #faad14" /> 公告通知
|
||||
</div>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</template>
|
||||
<a-badge :count="noticeState.total" :dot="noticeState.total > 99">
|
||||
<a-button type="link" class="tool-btn">
|
||||
<BellOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-popover>
|
||||
|
||||
<!-- 用户信息下拉菜单 -->
|
||||
<a-dropdown>
|
||||
<div class="user-dropdown">
|
||||
<a-avatar v-if="userInfo.avatar" :src="userInfo.avatar" :size="32" />
|
||||
<a-avatar v-else :size="32">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="username">{{ userInfo.username }}</span>
|
||||
<DownOutlined />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleUserMenuClick">
|
||||
<a-menu-item key="profile">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="configinfo">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
配置中心
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout">
|
||||
<template #icon>
|
||||
<LogoutOutlined />
|
||||
</template>
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
</a-layout-header>
|
||||
<div class="header-tag">
|
||||
<topTags></topTags>
|
||||
</div>
|
||||
<!-- 主内容区 -->
|
||||
<a-layout-content class="layout-content">
|
||||
<div class="header-centent">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<!-- <div :key="route.path"> -->
|
||||
<keep-alive v-if="route.meta.keepAlive">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-else />
|
||||
<!-- </div> -->
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, computed, watch, onMounted, inject, type Ref } from "vue";
|
||||
import type { MenuProps, ItemType } from "ant-design-vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import storage from 'store';
|
||||
import * as icons from '@ant-design/icons-vue';
|
||||
import { h } from 'vue';
|
||||
import { listToTree } from '@/utils/util';
|
||||
|
||||
import TopTags from '@/components/TopTags/index.vue';
|
||||
import Breadcrumb from '@/components/Breadcrumb/index.vue';
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
BellOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
SettingOutlined,
|
||||
WarningOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useUserStore, useNoticeStore, useConfigStore } from "@/store/index";
|
||||
import { logout } from '@/api/system/auth';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 路由相关
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 计算属性
|
||||
const userInfo = computed(() => userStore.basicInfo);
|
||||
|
||||
// 菜单状态
|
||||
const menuState = reactive({
|
||||
collapsed: false,
|
||||
rootSubmenuKeys: [],
|
||||
openKeys: [],
|
||||
selectedKeys: [route.path],
|
||||
menus: []
|
||||
});
|
||||
|
||||
// 切换菜单折叠状态
|
||||
const toggleCollapsed = () => {
|
||||
menuState.collapsed = !menuState.collapsed;
|
||||
};
|
||||
|
||||
// 处理用户菜单点击
|
||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
router.push('/profile');
|
||||
break;
|
||||
case 'configinfo':
|
||||
router.push('/configinfo');
|
||||
break;
|
||||
case 'logout':
|
||||
handleLogout();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 清空 Pinia 状态
|
||||
|
||||
await logout({ token: storage.get('Access-Token') });
|
||||
|
||||
// 重置 store 状态
|
||||
userStore.$reset();
|
||||
|
||||
storage.remove('Access-Token');
|
||||
storage.remove('Refresh-Token');
|
||||
await userStore.clearUserInfo;
|
||||
// 强制刷新页面
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick: MenuProps['onClick'] = (menuInfo) => {
|
||||
menuState.openKeys = menuInfo.keyPath.slice(0, -1) as string[];
|
||||
router.push(menuInfo.key.toString());
|
||||
};
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, (newPath) => {
|
||||
const routePath = userStore.routeList.map(item => item.route_path);
|
||||
if (!routePath.includes(newPath)) {
|
||||
menuState.openKeys = [];
|
||||
menuState.selectedKeys = [];
|
||||
} else {
|
||||
menuState.selectedKeys = [newPath];
|
||||
}
|
||||
});
|
||||
|
||||
// 生成菜单项
|
||||
const generateMenuItem = (item: any): ItemType => {
|
||||
const icon = item.icon ? () => h(icons[item.icon]) : null;
|
||||
const children = item.children?.length
|
||||
? item.children
|
||||
.filter((child: any) => !child.hidden)
|
||||
.map(generateMenuItem)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
key: item.route_path,
|
||||
title: item.name,
|
||||
label: item.name,
|
||||
icon: icon,
|
||||
children: children?.length ? children : undefined
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化菜单
|
||||
const initMenu = () => {
|
||||
// 确保路由列表存在
|
||||
if (!userStore.routeList?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuTree = listToTree(userStore.routeList);
|
||||
menuState.menus = menuTree
|
||||
.filter(item => !item.hidden)
|
||||
.map(generateMenuItem)
|
||||
.filter(item => item !== null);
|
||||
|
||||
// 设置当前选中的菜单
|
||||
const currentPath = route.path;
|
||||
menuState.selectedKeys = [currentPath];
|
||||
|
||||
// 设置展开的菜单
|
||||
const findParentPath = (menus: any[], targetPath: string, parentPath: string[] = []): string[] => {
|
||||
for (const menu of menus) {
|
||||
if (menu.key === targetPath) {
|
||||
return parentPath;
|
||||
}
|
||||
if (menu.children?.length) {
|
||||
const result = findParentPath(menu.children, targetPath, [...parentPath, menu.key]);
|
||||
if (result.length) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
menuState.openKeys = findParentPath(menuState.menus, currentPath);
|
||||
};
|
||||
|
||||
// 打开文档
|
||||
const openDocumentation = () => {
|
||||
window.open('/site/index.html', '_blank');
|
||||
};
|
||||
|
||||
// 主题相关
|
||||
const isDarkMode = inject('isDarkMode') as Ref<boolean>
|
||||
const toggleTheme = inject('toggleTheme') as (darkMode: boolean) => void
|
||||
|
||||
// 切换主题
|
||||
const handleThemeChange = () => {
|
||||
toggleTheme(!isDarkMode.value)
|
||||
localStorage.setItem('theme', isDarkMode.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
const noticeStore = useNoticeStore();
|
||||
// 通知状态管理
|
||||
const noticeState = reactive({
|
||||
notices: computed(() => noticeStore.noticeList),
|
||||
total: computed(() => noticeStore.total),
|
||||
loading: false
|
||||
});
|
||||
|
||||
const configStore = useConfigStore();
|
||||
|
||||
const initConfigState = reactive({
|
||||
web_title: computed(() => configStore.getConfigValue("sys_web_title")),
|
||||
web_favicon: computed(() => configStore.getConfigValue("sys_web_logo")),
|
||||
});
|
||||
|
||||
// 在页面加载时获取通知列表
|
||||
onMounted(() => {
|
||||
initMenu();
|
||||
noticeStore.getNotice();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-layout {
|
||||
.layout-sider {
|
||||
background-color: var(--background-color);
|
||||
height: var(--basic-layout);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
|
||||
.logo-container {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.logo-title {
|
||||
margin: 0 0 0 12px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
background-color: var(--background-color);
|
||||
height: calc(100vh - 64px);
|
||||
border-right: 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
margin-left: 240px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.collapsed {
|
||||
margin-left: 80px;
|
||||
width: calc(100% - 80px);
|
||||
|
||||
.layout-header {
|
||||
transition: left 0.2s;
|
||||
left: 80px; // 菜单折叠时调整头部左边距
|
||||
}
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
background-color: var(--background-color);
|
||||
// 新增样式,让头部固定在顶部
|
||||
top: 0;
|
||||
left: 240px;
|
||||
right: 0;
|
||||
z-index: 98;
|
||||
transition: left 0.3s;
|
||||
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.collapse-btn {
|
||||
padding: 0 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
.tool-btn {
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.username {
|
||||
margin: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调整内容区域的上边距
|
||||
.layout-content {
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 64px - 40px);
|
||||
}
|
||||
|
||||
.header-tag {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// 添加通知图标的hover效果
|
||||
.tool-btn {
|
||||
&:hover {
|
||||
.anticon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加通知弹出框样式
|
||||
.notice-popover {
|
||||
width: 240px;
|
||||
max-height: 400px;
|
||||
|
||||
.notice-header {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.ant-list-item) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-list-item-meta-title) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="logo">
|
||||
<transition enter-active-class="animate__animated animate__fadeInLeft">
|
||||
<router-link :key="+collapse" class="wh-full flex-center" to="/">
|
||||
<img :src="configStore.configData.sys_web_logo.config_value" class="w20px h20px" />
|
||||
<span v-if="!collapse" class="title">
|
||||
<!-- {{ defaultSettings.title }} -->
|
||||
{{ configStore.configData.sys_web_title.config_value }}
|
||||
</span>
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { useConfigStore } from "@/store";
|
||||
// import logo from "@/assets/logo.png";
|
||||
const configStore = useConfigStore();
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
width: 100%;
|
||||
height: $navbar-height;
|
||||
background-color: $sidebar-logo-background;
|
||||
|
||||
.title {
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: $sidebar-logo-text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// 顶部布局和混合布局的特殊处理
|
||||
.layout-top,
|
||||
.layout-mix {
|
||||
.logo {
|
||||
background-color: transparent !important;
|
||||
|
||||
.title {
|
||||
color: var(--menu-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 宽屏时:openSidebar 状态下显示完整Logo+文字
|
||||
.openSidebar {
|
||||
&.layout-top .layout__header-left .logo,
|
||||
&.layout-mix .layout__header-logo .logo {
|
||||
width: $sidebar-width; // 210px,显示logo+文字
|
||||
}
|
||||
}
|
||||
|
||||
// 窄屏时:hideSidebar 状态下只显示Logo图标
|
||||
.hideSidebar {
|
||||
&.layout-top .layout__header-left .logo,
|
||||
&.layout-mix .layout__header-logo .logo {
|
||||
width: $sidebar-width-collapsed; // 54px,只显示logo
|
||||
}
|
||||
|
||||
// 隐藏文字,只显示图标
|
||||
.logo .title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<section class="app-main" :style="{ height: appMainHeight }">
|
||||
<router-view>
|
||||
<template #default="{ Component, route }">
|
||||
<transition enter-active-class="animate__animated animate__fadeIn" mode="out-in">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</template>
|
||||
</router-view>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore, useTagsViewStore } from "@/store";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
|
||||
// 缓存页面集合
|
||||
const cachedViews = computed(() => useTagsViewStore().cachedViews);
|
||||
const appMainHeight = computed(() => {
|
||||
if (useSettingsStore().showTagsView) {
|
||||
return `calc(100vh - ${variables["navbar-height"]} - ${variables["tags-view-height"]})`;
|
||||
} else {
|
||||
return `calc(100vh - ${variables["navbar-height"]})`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-main {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
background-color: var(--el-bg-color-page);
|
||||
|
||||
/* 布局切换动画优化 */
|
||||
&.animate__animated {
|
||||
animation-duration: 0.4s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
&.animate__fadeOut {
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
|
||||
&.animate__fadeIn {
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<!-- 菜单组件 -->
|
||||
<template>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
:default-active="activeMenuPath"
|
||||
:collapse="!appStore.sidebar.opened"
|
||||
:background-color="menuThemeProps.backgroundColor"
|
||||
:text-color="menuThemeProps.textColor"
|
||||
:active-text-color="menuThemeProps.activeTextColor"
|
||||
:popper-effect="theme"
|
||||
:unique-opened="false"
|
||||
:collapse-transition="false"
|
||||
:mode="menuMode"
|
||||
@open="onMenuOpen"
|
||||
@close="onMenuClose"
|
||||
>
|
||||
<!-- 菜单项 -->
|
||||
<MenuItem
|
||||
v-for="route in data"
|
||||
:key="route.path"
|
||||
:item="route"
|
||||
:base-path="resolveFullPath(route.path)"
|
||||
/>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import path from "path-browserify";
|
||||
import type { MenuInstance } from "element-plus";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { SidebarColor } from "@/enums/settings/theme.enum";
|
||||
import { useSettingsStore, useAppStore } from "@/store";
|
||||
import { isExternal } from "@/utils/index";
|
||||
import MenuItem from "./components/MenuItem.vue";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array as PropType<RouteRecordRaw[]>,
|
||||
default: () => [],
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
example: "/system",
|
||||
},
|
||||
menuMode: {
|
||||
type: String as PropType<"vertical" | "horizontal">,
|
||||
default: "vertical",
|
||||
validator: (value: string) => ["vertical", "horizontal"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const menuRef = ref<MenuInstance>();
|
||||
const settingsStore = useSettingsStore();
|
||||
const appStore = useAppStore();
|
||||
const currentRoute = useRoute();
|
||||
|
||||
// 存储已展开的菜单项索引
|
||||
const expandedMenuIndexes = ref<string[]>([]);
|
||||
|
||||
// 获取主题
|
||||
const theme = computed(() => settingsStore.theme);
|
||||
|
||||
// 获取浅色主题下的侧边栏配色方案
|
||||
const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
|
||||
|
||||
// 菜单主题属性
|
||||
const menuThemeProps = computed(() => {
|
||||
const isDarkOrClassicBlue =
|
||||
theme.value === "dark" || sidebarColorScheme.value === SidebarColor.CLASSIC_BLUE;
|
||||
|
||||
return {
|
||||
backgroundColor: isDarkOrClassicBlue ? variables["menu-background"] : undefined,
|
||||
textColor: isDarkOrClassicBlue ? variables["menu-text"] : undefined,
|
||||
activeTextColor: isDarkOrClassicBlue ? variables["menu-active-text"] : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// 计算当前激活的菜单项
|
||||
const activeMenuPath = computed((): string => {
|
||||
const { meta, path } = currentRoute;
|
||||
|
||||
// 如果路由meta中设置了activeMenu,则使用它(用于处理一些特殊情况,如详情页)
|
||||
if (meta?.activeMenu && typeof meta.activeMenu === "string") {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
|
||||
// 否则使用当前路由路径
|
||||
return path;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取完整路径
|
||||
*
|
||||
* @param routePath 当前路由的相对路径 /user
|
||||
* @returns 完整的绝对路径 D://vue3-element-admin/system/user
|
||||
*/
|
||||
function resolveFullPath(routePath: string) {
|
||||
if (isExternal(routePath)) {
|
||||
return routePath;
|
||||
}
|
||||
if (isExternal(props.basePath)) {
|
||||
return props.basePath;
|
||||
}
|
||||
|
||||
// 如果 basePath 为空(顶部布局),直接返回 routePath
|
||||
if (!props.basePath || props.basePath === "") {
|
||||
return routePath;
|
||||
}
|
||||
|
||||
// 解析路径,生成完整的绝对路径
|
||||
return path.resolve(props.basePath, routePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开菜单
|
||||
*
|
||||
* @param index 当前展开的菜单项索引
|
||||
*/
|
||||
const onMenuOpen = (index: string) => {
|
||||
expandedMenuIndexes.value.push(index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭菜单
|
||||
*
|
||||
* @param index 当前收起的菜单项索引
|
||||
*/
|
||||
const onMenuClose = (index: string) => {
|
||||
expandedMenuIndexes.value = expandedMenuIndexes.value.filter((item) => item !== index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听菜单模式变化:当菜单模式切换为水平模式时,关闭所有展开的菜单项,
|
||||
* 避免在水平模式下菜单项显示错位。
|
||||
*/
|
||||
watch(
|
||||
() => props.menuMode,
|
||||
(newMode) => {
|
||||
if (newMode === "horizontal" && menuRef.value) {
|
||||
expandedMenuIndexes.value.forEach((item) => menuRef.value!.close(item));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听激活菜单变化,为包含激活子菜单的父菜单添加样式类
|
||||
*/
|
||||
watch(
|
||||
() => activeMenuPath.value,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
updateParentMenuStyles();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听路由变化,确保菜单能随TagsView切换而正确激活
|
||||
*/
|
||||
watch(
|
||||
() => currentRoute.path,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
updateParentMenuStyles();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新父菜单样式 - 为包含激活子菜单的父菜单添加 has-active-child 类
|
||||
*/
|
||||
function updateParentMenuStyles() {
|
||||
if (!menuRef.value?.$el) return;
|
||||
|
||||
nextTick(() => {
|
||||
try {
|
||||
const menuEl = menuRef.value?.$el as HTMLElement;
|
||||
if (!menuEl) return;
|
||||
|
||||
// 移除所有现有的 has-active-child 类
|
||||
const allSubMenus = menuEl.querySelectorAll(".el-sub-menu");
|
||||
allSubMenus.forEach((subMenu) => {
|
||||
subMenu.classList.remove("has-active-child");
|
||||
});
|
||||
|
||||
// 查找当前激活的菜单项
|
||||
const activeMenuItem = menuEl.querySelector(".el-menu-item.is-active");
|
||||
|
||||
if (activeMenuItem) {
|
||||
// 向上查找父级 el-sub-menu 元素
|
||||
let parent = activeMenuItem.parentElement;
|
||||
while (parent && parent !== menuEl) {
|
||||
if (parent.classList.contains("el-sub-menu")) {
|
||||
parent.classList.add("has-active-child");
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} else {
|
||||
// 水平模式下可能需要特殊处理
|
||||
if (props.menuMode === "horizontal") {
|
||||
// 对于水平菜单,使用路径匹配来找到父菜单
|
||||
const currentPath = activeMenuPath.value;
|
||||
|
||||
// 查找所有父菜单项,检查哪个包含当前路径
|
||||
allSubMenus.forEach((subMenu) => {
|
||||
const subMenuEl = subMenu as HTMLElement;
|
||||
const subMenuPath =
|
||||
subMenuEl.getAttribute("data-path") ||
|
||||
subMenuEl.querySelector(".el-sub-menu__title")?.getAttribute("data-path");
|
||||
|
||||
// 如果找到包含当前路径的父菜单,则添加激活类
|
||||
if (subMenuPath && currentPath.startsWith(subMenuPath)) {
|
||||
subMenuEl.classList.add("has-active-child");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating parent menu styles:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件挂载后立即更新父菜单样式
|
||||
*/
|
||||
onMounted(() => {
|
||||
// 确保在组件挂载后更新样式,不依赖于异步操作
|
||||
updateParentMenuStyles();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
<!-- 混合布局顶部菜单 -->
|
||||
<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/theme.enum";
|
||||
|
||||
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.updateSideMenu(topMenuPath); // 更新左侧菜单
|
||||
}
|
||||
|
||||
// 如果是点击菜单且状态已变更,才进行导航
|
||||
if (!skipNavigation) {
|
||||
navigateToFirstLeftMenu(permissionStore.sideMenuRoutes); // 跳转到左侧第一个菜单
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转到左侧第一个可访问的菜单
|
||||
* @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.updateSideMenu(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>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div v-if="!item.meta || !item.meta.hidden">
|
||||
<!--【叶子节点】显示叶子节点或唯一子节点且父节点未配置始终显示 -->
|
||||
<template
|
||||
v-if="
|
||||
// 未配置始终显示,使用唯一子节点替换父节点显示为叶子节点
|
||||
(hasOneShowingChild(item.children, item) &&
|
||||
!item.meta?.alwaysShow &&
|
||||
(!onlyOneChild.children || onlyOneChild.noShowingChildren)) ||
|
||||
// 即使配置了始终显示,但无子节点,也显示为叶子节点
|
||||
(item.meta?.alwaysShow && !item.children)
|
||||
"
|
||||
>
|
||||
<AppLink
|
||||
v-if="onlyOneChild.meta"
|
||||
:to="{
|
||||
path: resolvePath(onlyOneChild.path),
|
||||
query: onlyOneChild.meta.params,
|
||||
}"
|
||||
>
|
||||
<el-menu-item
|
||||
: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"
|
||||
/>
|
||||
</el-menu-item>
|
||||
</AppLink>
|
||||
</template>
|
||||
|
||||
<!--【非叶子节点】显示含多个子节点的父菜单,或始终显示的单子节点 -->
|
||||
<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>
|
||||
|
||||
<MenuItem
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:is-nest="true"
|
||||
:item="child"
|
||||
:base-path="resolvePath(child.path)"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MenuItemContent from "./MenuItemContent.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "MenuItem",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import path from "path-browserify";
|
||||
import { RouteRecordRaw } from "vue-router";
|
||||
|
||||
import { isExternal } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 当前路由对象
|
||||
*/
|
||||
item: {
|
||||
type: Object as PropType<RouteRecordRaw>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* 父级完整路径
|
||||
*/
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否为嵌套路由
|
||||
*/
|
||||
isNest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 可见的唯一子节点
|
||||
const onlyOneChild = ref();
|
||||
|
||||
/**
|
||||
* 检查是否仅有一个可见子节点
|
||||
*
|
||||
* @param children 子路由数组
|
||||
* @param parent 父级路由
|
||||
* @returns 是否仅有一个可见子节点
|
||||
*/
|
||||
function hasOneShowingChild(children: RouteRecordRaw[] = [], parent: RouteRecordRaw) {
|
||||
// 过滤出可见子节点
|
||||
const showingChildren = children.filter((route: RouteRecordRaw) => {
|
||||
if (!route.meta?.hidden) {
|
||||
onlyOneChild.value = route;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 仅有一个节点
|
||||
if (showingChildren.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 无子节点时
|
||||
if (showingChildren.length === 0) {
|
||||
// 父节点设置为唯一显示节点,并标记为无子节点
|
||||
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整路径,适配外部链接
|
||||
*
|
||||
* @param routePath 路由路径
|
||||
* @returns 绝对路径
|
||||
*/
|
||||
function resolvePath(routePath: string) {
|
||||
if (isExternal(routePath)) return routePath;
|
||||
if (isExternal(props.basePath)) return props.basePath;
|
||||
|
||||
// 拼接父路径和当前路径
|
||||
return path.resolve(props.basePath, routePath);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hideSidebar {
|
||||
.submenu-title-noDropdown {
|
||||
position: relative;
|
||||
|
||||
& > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
overflow: hidden;
|
||||
|
||||
& > .el-sub-menu__title {
|
||||
.sub-el-icon {
|
||||
margin-left: 19px;
|
||||
}
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
width: $sidebar-width-collapsed;
|
||||
|
||||
.el-sub-menu {
|
||||
& > .el-sub-menu__title > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.el-menu-item:hover {
|
||||
background-color: $menu-hover;
|
||||
}
|
||||
}
|
||||
|
||||
html.sidebar-color-blue {
|
||||
.el-menu-item:hover {
|
||||
background-color: $menu-hover;
|
||||
}
|
||||
}
|
||||
|
||||
// 父菜单激活状态样式 - 当子菜单激活时,父菜单显示激活状态
|
||||
.el-sub-menu {
|
||||
// 当父菜单包含激活子菜单时的样式
|
||||
&.has-active-child .el-sub-menu__title {
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题下的父菜单激活状态
|
||||
html.dark & {
|
||||
&.has-active-child .el-sub-menu__title {
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
background-color: rgba(64, 128, 255, 0.15) !important;
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深蓝色侧边栏配色下的父菜单激活状态
|
||||
html.sidebar-color-blue & {
|
||||
&.has-active-child .el-sub-menu__title {
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
background-color: rgba(64, 128, 255, 0.2) !important;
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
width="500px"
|
||||
max-height="170px"
|
||||
class="v-lock-dialog"
|
||||
:title="dialogTitle"
|
||||
>
|
||||
<div class="lock-dialog-content">
|
||||
<!-- 头像 -->
|
||||
<!-- <img src="@/assets/logo.png" alt="" class="lock-dialog-avatar" /> -->
|
||||
<img :src="userStore.basicInfo.avatar" alt="" class="lock-dialog-avatar" />
|
||||
<!-- 用户名 -->
|
||||
<!-- <span class="lock-dialog-name">{{ t('navbar.lock') }}</span> -->
|
||||
<span class="lock-dialog-name">{{ userStore.basicInfo.name }}</span>
|
||||
</div>
|
||||
<el-form ref="lockFormRef" :model="form" :rules="rules" >
|
||||
<el-form-item :label="t('lock.lockPassword')" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
@keydown.enter="handleLock"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleLock">{{ t('navbar.lock') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useLockStore } from '@/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
import { useUserStore } from "@/store/modules/user.store";
|
||||
const userStore = useUserStore();
|
||||
|
||||
const { t } = useI18n()
|
||||
const lockStore = useLockStore()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const dialogTitle = ref(t('lock.lockScreen'))
|
||||
const lockFormRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
password: [
|
||||
{ required: true, message: t('lock.required'), trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 优化后的锁定逻辑
|
||||
const handleLock = async () => {
|
||||
try {
|
||||
await lockFormRef.value?.validate()
|
||||
dialogVisible.value = false
|
||||
lockStore.setLockInfo({
|
||||
isLock: true,
|
||||
password: form.password
|
||||
})
|
||||
} catch {
|
||||
// 验证失败时不执行任何操作
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-lock-dialog {
|
||||
@media (width <=767px) {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.lock-dialog {
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
color: var(--top-header-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="lockpage">
|
||||
<div
|
||||
v-show="showDate"
|
||||
class="unlock-container"
|
||||
@click="handleShowForm(false)"
|
||||
>
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>{{ t('lock.unlock') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="time-container w-screen h-screen">
|
||||
<div class="hour-container mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5">
|
||||
<span>{{ hour }}</span>
|
||||
<span v-show="showDate" class="meridiem absolute left-5 top-5 text-md xl:text-xl">{{ meridiem }}</span>
|
||||
</div>
|
||||
<div class="minute-container w-2/5 h-2/5 md:h-4/5">
|
||||
<span>{{ minute }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div v-show="!showDate" class="entry-wrapper">
|
||||
<div class="entry-content">
|
||||
<div class="avatar-container">
|
||||
<!-- <img src="@/assets/logo.png" alt="" class="avatar" /> -->
|
||||
<img :src="userStore.basicInfo.avatar" alt="" class="avatar" />
|
||||
<span class="username">{{ userStore.basicInfo.name }}</span>
|
||||
</div>
|
||||
<ElInput
|
||||
ref="passwordInputRef"
|
||||
v-model="password"
|
||||
:placeholder="t('lock.placeholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
clearable
|
||||
@keydown.enter="unLock"
|
||||
/>
|
||||
<span v-if="errMsg" class="error-message">
|
||||
{{ t('lock.message') }}
|
||||
</span>
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="back-button"
|
||||
link
|
||||
:disabled="loading"
|
||||
@click="handleShowForm(true)"
|
||||
>
|
||||
{{ t('common.back') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="login-button"
|
||||
link
|
||||
:disabled="loading"
|
||||
@click="goLogin"
|
||||
>
|
||||
{{ t('lock.backToLogin') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="entry-button"
|
||||
size="small"
|
||||
link
|
||||
:disabled="loading"
|
||||
@click="unLock()"
|
||||
>
|
||||
{{ t('lock.entrySystem') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="date-container">
|
||||
<div v-show="!showDate" class="time-display">
|
||||
{{ hour }}:{{ minute }} <span class="meridiem-display">{{ meridiem }}</span>
|
||||
</div>
|
||||
<div class="full-date">{{ year }}/{{ month }}/{{ day }} {{ week }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore, useLockStore } from "@/store";
|
||||
import { ElInput } from 'element-plus';
|
||||
import { useNow } from '@/utils/dateUtil'
|
||||
import { useTagsViewStore } from '@/store'
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
|
||||
const { replace } = useRouter()
|
||||
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errMsg = ref(false)
|
||||
const showDate = ref(true)
|
||||
|
||||
|
||||
const lockStore = useLockStore()
|
||||
|
||||
const { hour, month, minute, meridiem, year, day, week } = useNow(true)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 解锁
|
||||
async function unLock() {
|
||||
if (!password.value) {
|
||||
return
|
||||
}
|
||||
const pwd = password.value
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await lockStore.unLock(pwd)
|
||||
errMsg.value = !res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回登录
|
||||
async function goLogin() {
|
||||
await userStore.logout().catch(() => {})
|
||||
userStore.resetAllState()
|
||||
tagsViewStore.delAllViews()
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
lockStore.resetLockInfo()
|
||||
replace('/login')
|
||||
}
|
||||
|
||||
|
||||
|
||||
const passwordInputRef = ref<InstanceType<typeof ElInput>>()
|
||||
|
||||
function handleShowForm(show = false) {
|
||||
showDate.value = show
|
||||
if (!show) {
|
||||
requestAnimationFrame(() => {
|
||||
passwordInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lockpage {
|
||||
z-index: 3000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
width: 100%;;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
backdrop-filter: blur(8px);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.unlock-container {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
padding-top: 1.25rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.time-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.hour-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
font-weight: 700;
|
||||
color: #bababa;
|
||||
background-color: #141313;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 220px;
|
||||
}
|
||||
|
||||
.minute-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
color: #bababa;
|
||||
background-color: #141313;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.meridiem {
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
top: 1.25rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
.entry-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.entry-content {
|
||||
width: 260px;
|
||||
text-align: center;
|
||||
color: #bababa;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.625rem 0;
|
||||
color: var(--logo-title-text-color);
|
||||
}
|
||||
|
||||
.password-input {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.625rem;
|
||||
color: #ed6f6f;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: space-between; // 左右对齐
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.back-button,
|
||||
.login-button,
|
||||
.entry-button {
|
||||
padding: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.date-container {
|
||||
position: absolute;
|
||||
bottom: 1.25rem;
|
||||
width: 100%;
|
||||
color: #d1d5db;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.meridiem-display {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.full-date {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div ref="navbar-actions" :class="['navbar-actions', navbarActionsClass]">
|
||||
<!-- 桌面端工具项 -->
|
||||
<template v-if="isDesktop">
|
||||
<!-- 搜索 -->
|
||||
<div class="navbar-actions__item">
|
||||
<MenuSearch />
|
||||
</div>
|
||||
|
||||
<!-- 全屏 -->
|
||||
<div class="navbar-actions__item">
|
||||
<Fullscreen />
|
||||
</div>
|
||||
|
||||
<!-- 布局大小 -->
|
||||
<div class="navbar-actions__item">
|
||||
<SizeSelect />
|
||||
</div>
|
||||
|
||||
<!-- 语言选择 -->
|
||||
<div class="navbar-actions__item">
|
||||
<LangSelect />
|
||||
</div>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="navbar-actions__item">
|
||||
<Notification />
|
||||
</div>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<div class="navbar-actions__item">
|
||||
<el-dropdown trigger="click">
|
||||
<div class="user-profile">
|
||||
<img class="user-profile__avatar" :src="userStore.basicInfo.avatar" />
|
||||
<span class="user-profile__name">{{ userStore.basicInfo.username }}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleProfileClick">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ t("navbar.profile") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleConfigClick">
|
||||
<el-icon><Setting /></el-icon>
|
||||
{{ t("navbar.config") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleDocumentClick">
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ t("navbar.document") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleGiteeClick">
|
||||
<el-icon><Reading /></el-icon>
|
||||
{{ t("navbar.gitee") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleTourClick">
|
||||
<el-icon><Position /></el-icon>
|
||||
{{ t("navbar.tour") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handlelockScreen">
|
||||
<el-icon><Lock /></el-icon>
|
||||
{{ t("navbar.lock") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
{{ t("navbar.logout") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 引导 -->
|
||||
<Guide v-if="guideVisible" v-model="guideVisible" />
|
||||
|
||||
<!-- 锁屏弹窗 -->
|
||||
<LockDialog v-if="dialogVisible" v-model="dialogVisible" />
|
||||
<teleport to="body">
|
||||
<transition name="fade-bottom" mode="out-in">
|
||||
<LockPage v-if="getIsLock" />
|
||||
</transition>
|
||||
</teleport>
|
||||
|
||||
<!-- 配置中心抽屉 -->
|
||||
<ConfigInfoDrawer v-if="drawerVisible" v-model="drawerVisible"/>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
import { useAppStore, useSettingsStore, useUserStore, useLockStore } from "@/store";
|
||||
import { SidebarColor, ThemeMode } from "@/enums/settings/theme.enum";
|
||||
import { LayoutMode } from "@/enums";
|
||||
|
||||
// 导入子组件
|
||||
import MenuSearch from "@/components/MenuSearch/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 LockDialog from './LockDialog.vue'
|
||||
import LockPage from './LockPage.vue'
|
||||
import Guide from '@/components/Guide/index.vue'
|
||||
import ConfigInfoDrawer from "@/views/system/config/components/ConfigInfoDrawer.vue"
|
||||
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const settingStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 是否为桌面设备
|
||||
const isDesktop = computed(() => appStore.device === DeviceEnum.DESKTOP);
|
||||
|
||||
/**
|
||||
* 打开个人中心页面
|
||||
*/
|
||||
function handleProfileClick() {
|
||||
router.push({ name: "Profile" });
|
||||
}
|
||||
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
/**
|
||||
* 打开配置中心页面
|
||||
*/
|
||||
function handleConfigClick() {
|
||||
// router.push({ name: "Configinfo" });
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 项目文档
|
||||
*/
|
||||
function handleDocumentClick() {
|
||||
window.open('/site/index.html', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitee 项目地址
|
||||
*/
|
||||
function handleGiteeClick() {
|
||||
window.open('https://element-plus-admin-doc.cn/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目引导
|
||||
*/
|
||||
const guideVisible = ref<boolean>(false)
|
||||
function handleTourClick() {
|
||||
// 如果是移动端,直接跳转到引导页面
|
||||
if (appStore.device === DeviceEnum.MOBILE) {
|
||||
router.push({ name: "Guide" });
|
||||
} else {
|
||||
guideVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁屏
|
||||
*/
|
||||
const lockStore = useLockStore()
|
||||
const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false)
|
||||
const dialogVisible = ref<boolean>(false)
|
||||
// 锁定屏幕
|
||||
const handlelockScreen = () => {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
// 根据主题和侧边栏配色方案选择样式类
|
||||
const navbarActionsClass = computed(() => {
|
||||
const { theme, sidebarColorScheme, layout } = settingStore;
|
||||
|
||||
// 暗黑主题下,所有布局都使用白色文字
|
||||
if (theme === ThemeMode.DARK) {
|
||||
return "navbar-actions--white-text";
|
||||
}
|
||||
|
||||
// 明亮主题下
|
||||
if (theme === ThemeMode.LIGHT) {
|
||||
// 顶部布局和混合布局的顶部区域:
|
||||
// - 如果侧边栏是经典蓝色,使用白色文字
|
||||
// - 如果侧边栏是极简白色,使用深色文字
|
||||
if (layout === LayoutMode.TOP || layout === LayoutMode.MIX) {
|
||||
if (sidebarColorScheme === SidebarColor.CLASSIC_BLUE) {
|
||||
return "navbar-actions--white-text";
|
||||
} else {
|
||||
return "navbar-actions--dark-text";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "navbar-actions--dark-text";
|
||||
});
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
function logout() {
|
||||
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
lockScroll: false,
|
||||
}).then(() => {
|
||||
userStore.logout().then(() => {
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px; /* 增加最小点击区域到44px,符合人机交互标准 */
|
||||
height: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
// 确保子元素居中
|
||||
> * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 确保 Element Plus 组件可以正常工作
|
||||
:deep(.el-dropdown),
|
||||
:deep(.el-tooltip) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 图标样式
|
||||
:deep([class^="i-svg:"]) {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
:deep([class^="i-svg:"]) {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
|
||||
&__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-left: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 白色文字样式(用于深色背景:暗黑主题、顶部布局、混合布局)
|
||||
.navbar-actions--white-text {
|
||||
.navbar-actions__item {
|
||||
:deep([class^="i-svg:"]) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
:deep([class^="i-svg:"]) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-profile__name {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
// 深色文字样式(用于浅色背景:明亮主题下的左侧布局)
|
||||
.navbar-actions--dark-text {
|
||||
.navbar-actions__item {
|
||||
:deep([class^="i-svg:"]) {
|
||||
color: var(--el-text-color-regular) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
:deep([class^="i-svg:"]) {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-profile__name {
|
||||
color: var(--el-text-color-regular) !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div ref="navbar" class="navbar">
|
||||
<div class="flex-y-center">
|
||||
<!-- 菜单折叠按钮 -->
|
||||
<Hamburger :is-active="isSidebarOpened" @toggle-click="toggleSideBar" />
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<!-- 导航栏操作区域 -->
|
||||
<div class="navbar__actions">
|
||||
<NavbarActions />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: $navbar-height;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
size="380"
|
||||
:title="t('settings.project')"
|
||||
:before-close="handleCloseDrawer"
|
||||
class="settings-drawer"
|
||||
>
|
||||
<div class="settings-content">
|
||||
<!-- 主题设置 -->
|
||||
<section class="config-section">
|
||||
<el-divider>{{ t("settings.theme") }}</el-divider>
|
||||
|
||||
<div class="flex-center">
|
||||
<el-switch
|
||||
v-model="isDark"
|
||||
active-action-icon="Moon"
|
||||
inactive-action-icon="Sunny"
|
||||
@change="handleThemeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局设置 -->
|
||||
<section class="config-section">
|
||||
<el-divider>{{ t("settings.layoutSetting") }}</el-divider>
|
||||
|
||||
<!-- 整合的布局选择器 -->
|
||||
<div class="layout-select">
|
||||
<div class="layout-grid">
|
||||
<el-tooltip
|
||||
v-for="item in layoutOptions"
|
||||
:key="item.value"
|
||||
:content="item.label"
|
||||
placement="bottom"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
'layout-item',
|
||||
item.className,
|
||||
{
|
||||
'is-active': settingsStore.layout === item.value,
|
||||
},
|
||||
]"
|
||||
@click="handleLayoutChange(item.value)"
|
||||
@keydown.enter.space="handleLayoutChange(item.value)"
|
||||
>
|
||||
<!-- 布局预览图标 -->
|
||||
<div class="layout-preview">
|
||||
<div v-if="item.value !== LayoutMode.LEFT" class="layout-header"></div>
|
||||
<div v-if="item.value !== LayoutMode.TOP" class="layout-sidebar"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
<!-- 布局名称 -->
|
||||
<div class="layout-name">{{ item.label }}</div>
|
||||
<!-- 选中状态指示器 -->
|
||||
<div v-if="settingsStore.layout === item.value" class="layout-check">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 系统主题 -->
|
||||
<section class="config-section">
|
||||
<el-divider>{{ t("settings.systemTheme") }}</el-divider>
|
||||
<div class="config-item flex-x-between">
|
||||
<span class="text-xs">{{ t("settings.themeColor") }}</span>
|
||||
<el-color-picker
|
||||
v-model="selectedThemeColor"
|
||||
:predefine="colorPresets"
|
||||
popper-class="theme-picker-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 导航主题 -->
|
||||
<section v-if="!isDark" class="config-section ">
|
||||
<el-divider>{{ t("settings.navigation") }}</el-divider>
|
||||
<div class="config-item flex-x-between">
|
||||
<span class="text-xs">{{ t("settings.sidebarColorScheme") }}</span>
|
||||
<el-radio-group v-model="sidebarColor" @change="changeSidebarColor">
|
||||
<el-radio :value="SidebarColor.CLASSIC_BLUE">
|
||||
{{ t("settings.classicBlue") }}
|
||||
</el-radio>
|
||||
<el-radio :value="SidebarColor.MINIMAL_WHITE">
|
||||
{{ t("settings.minimalWhite") }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 界面设置 -->
|
||||
<section class="config-section">
|
||||
<el-divider>{{ t("settings.interface") }}</el-divider>
|
||||
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs">{{ t("settings.showTagsView") }}</span>
|
||||
<el-switch v-model="settingsStore.showTagsView" />
|
||||
</div>
|
||||
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs">{{ t("settings.showAppLogo") }}</span>
|
||||
<el-switch v-model="settingsStore.showAppLogo" />
|
||||
</div>
|
||||
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs">{{ t("settings.showWatermark") }}</span>
|
||||
<el-switch v-model="settingsStore.showWatermark" />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 - 固定到底部 -->
|
||||
<div class="action-footer">
|
||||
<div class="action-divider"></div>
|
||||
<div class="action-card">
|
||||
<div class="action-buttons">
|
||||
<el-tooltip
|
||||
content="复制配置将生成当前设置的代码,覆盖 src/settings.ts 下的 defaultSettings 变量"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:icon="copyIcon"
|
||||
:loading="copyLoading"
|
||||
class="action-btn"
|
||||
@click="handleCopySettings"
|
||||
>
|
||||
{{ copyLoading ? "复制中..." : t("settings.copyConfig") }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="重置将恢复所有设置为默认值" placement="top">
|
||||
<el-button
|
||||
type="warning"
|
||||
size="default"
|
||||
:icon="resetIcon"
|
||||
:loading="resetLoading"
|
||||
class="action-btn"
|
||||
@click="handleResetSettings"
|
||||
>
|
||||
{{ resetLoading ? "重置中..." : t("settings.resetConfig") }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
// 按钮图标
|
||||
const copyIcon = markRaw(DocumentCopy);
|
||||
const resetIcon = markRaw(RefreshLeft);
|
||||
|
||||
// 加载状态
|
||||
const copyLoading = ref(false);
|
||||
const resetLoading = ref(false);
|
||||
|
||||
// 布局选项配置
|
||||
interface LayoutOption {
|
||||
value: LayoutMode;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const layoutOptions: LayoutOption[] = [
|
||||
{ value: LayoutMode.LEFT, label: t("settings.leftLayout"), className: "left" },
|
||||
{ value: LayoutMode.TOP, label: t("settings.topLayout"), className: "top" },
|
||||
{ value: LayoutMode.MIX, label: t("settings.mixLayout"), className: "mix" },
|
||||
];
|
||||
|
||||
// 使用统一的颜色预设配置
|
||||
const colorPresets = themeColorPresets;
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const isDark = ref<boolean>(settingsStore.theme === ThemeMode.DARK);
|
||||
const sidebarColor = ref(settingsStore.sidebarColorScheme);
|
||||
|
||||
const selectedThemeColor = computed({
|
||||
get: () => settingsStore.themeColor,
|
||||
set: (value) => settingsStore.updateThemeColor(value),
|
||||
});
|
||||
|
||||
const drawerVisible = computed({
|
||||
get: () => settingsStore.settingsVisible,
|
||||
set: (value) => (settingsStore.settingsVisible = value),
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理主题切换
|
||||
*
|
||||
* @param isDark 是否启用暗黑模式
|
||||
*/
|
||||
const handleThemeChange = (isDark: string | number | boolean) => {
|
||||
settingsStore.updateTheme(isDark ? ThemeMode.DARK : ThemeMode.LIGHT);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更改侧边栏颜色
|
||||
*
|
||||
* @param val 颜色方案名称
|
||||
*/
|
||||
const changeSidebarColor = (val: any) => {
|
||||
settingsStore.updateSidebarColorScheme(val);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换布局
|
||||
*
|
||||
* @param layout - 布局模式
|
||||
*/
|
||||
const handleLayoutChange = (layout: LayoutMode) => {
|
||||
if (settingsStore.layout === layout) return;
|
||||
|
||||
settingsStore.updateLayout(layout);
|
||||
};
|
||||
|
||||
/**
|
||||
* 复制当前配置
|
||||
*/
|
||||
const handleCopySettings = async () => {
|
||||
try {
|
||||
copyLoading.value = true;
|
||||
|
||||
// 生成配置代码
|
||||
const configCode = generateSettingsCode();
|
||||
|
||||
// 复制到剪贴板
|
||||
await navigator.clipboard.writeText(configCode);
|
||||
|
||||
// 显示成功消息
|
||||
ElMessage.success({
|
||||
message: t("settings.copySuccess"),
|
||||
duration: 3000,
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error("复制配置失败");
|
||||
} finally {
|
||||
copyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置为默认配置
|
||||
*/
|
||||
const handleResetSettings = async () => {
|
||||
resetLoading.value = true;
|
||||
|
||||
try {
|
||||
settingsStore.resetSettings();
|
||||
|
||||
// 同步更新本地状态
|
||||
isDark.value = settingsStore.theme === ThemeMode.DARK;
|
||||
sidebarColor.value = settingsStore.sidebarColorScheme;
|
||||
|
||||
ElMessage.success(t("settings.resetSuccess"));
|
||||
} catch {
|
||||
ElMessage.error("重置配置失败");
|
||||
} finally {
|
||||
resetLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成配置代码字符串
|
||||
*/
|
||||
const generateSettingsCode = (): string => {
|
||||
const settings = {
|
||||
title: "pkg.name",
|
||||
version: "pkg.version",
|
||||
showSettings: true,
|
||||
showTagsView: settingsStore.showTagsView,
|
||||
showAppLogo: settingsStore.showAppLogo,
|
||||
layout: `LayoutMode.${settingsStore.layout.toUpperCase()}`,
|
||||
theme: `ThemeMode.${settingsStore.theme.toUpperCase()}`,
|
||||
size: "ComponentSize.DEFAULT",
|
||||
language: "LanguageEnum.ZH_CN",
|
||||
themeColor: `"${settingsStore.themeColor}"`,
|
||||
showWatermark: settingsStore.showWatermark,
|
||||
watermarkContent: "pkg.name",
|
||||
sidebarColorScheme: `SidebarColor.${settingsStore.sidebarColorScheme.toUpperCase().replace("-", "_")}`,
|
||||
};
|
||||
|
||||
return `const defaultSettings: AppSettings = {
|
||||
title: ${settings.title},
|
||||
version: ${settings.version},
|
||||
showSettings: ${settings.showSettings},
|
||||
showTagsView: ${settings.showTagsView},
|
||||
showAppLogo: ${settings.showAppLogo},
|
||||
layout: ${settings.layout},
|
||||
theme: ${settings.theme},
|
||||
size: ${settings.size},
|
||||
language: ${settings.language},
|
||||
themeColor: ${settings.themeColor},
|
||||
showWatermark: ${settings.showWatermark},
|
||||
watermarkContent: ${settings.watermarkContent},
|
||||
sidebarColorScheme: ${settings.sidebarColorScheme},
|
||||
};`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭抽屉前的回调
|
||||
*/
|
||||
const handleCloseDrawer = () => {
|
||||
settingsStore.settingsVisible = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 设置抽屉样式 */
|
||||
.settings-drawer {
|
||||
:deep(.el-drawer__body) {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 设置内容区域 */
|
||||
.settings-content {
|
||||
height: calc(100vh - 120px); /* 减去头部和底部按钮的高度 */
|
||||
padding: 20px;
|
||||
padding-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 底部操作区域样式 */
|
||||
.action-footer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
background: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
|
||||
.action-divider {
|
||||
display: none; /* 移除重复的分割线 */
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题切换器优化 */
|
||||
.theme-switch {
|
||||
transform: scale(1.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.config-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
margin: 0 -8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 布局选择器样式优化 */
|
||||
.layout-select {
|
||||
padding: 16px 8px;
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-item {
|
||||
position: relative;
|
||||
width: 70px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 2px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(145deg, #ffffff 0%, var(--el-color-primary-light-9) 100%);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
.layout-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
margin: 8px 0 4px 0;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 4px;
|
||||
left: 4px;
|
||||
height: 8px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--el-color-primary) 0%,
|
||||
var(--el-color-primary-light-3) 100%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
width: 12px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--el-color-primary-dark-2) 0%,
|
||||
var(--el-color-primary) 100%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.layout-name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 6px;
|
||||
left: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular);
|
||||
text-align: center;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.layout-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
background: var(--el-color-success);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// 左侧布局
|
||||
&.left {
|
||||
.layout-sidebar {
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
}
|
||||
.layout-main {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部布局
|
||||
&.top {
|
||||
.layout-header {
|
||||
height: 12px;
|
||||
}
|
||||
.layout-main {
|
||||
top: 16px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 混合布局
|
||||
&.mix {
|
||||
.layout-header {
|
||||
height: 10px;
|
||||
}
|
||||
.layout-sidebar {
|
||||
top: 14px;
|
||||
bottom: 4px;
|
||||
}
|
||||
.layout-main {
|
||||
top: 14px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-color-primary-light-9) 0%,
|
||||
var(--el-color-primary-light-8) 100%
|
||||
);
|
||||
border-color: var(--el-color-primary);
|
||||
transform: translateY(-2px) scale(1.08);
|
||||
|
||||
.layout-name {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式适配 */
|
||||
.dark {
|
||||
.action-footer {
|
||||
background: var(--el-bg-color);
|
||||
border-top-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.layout-item {
|
||||
background: linear-gradient(145deg, var(--el-bg-color) 0%, var(--el-bg-color-page) 100%);
|
||||
border-color: var(--el-border-color);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-bg-color-page) 0%,
|
||||
var(--el-color-primary-light-9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-color-primary-light-9) 0%,
|
||||
var(--el-color-primary-light-8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: linear-gradient(135deg, var(--el-fill-color) 0%, var(--el-fill-color-light) 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 复制配置对话框样式 */
|
||||
:deep(.copy-config-dialog) {
|
||||
.el-message-box__content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<div ref="tags-container" class="tags-container">
|
||||
<!-- 向左移动按钮 -->
|
||||
<el-icon class="btn" @click="scrollLeft">
|
||||
<DArrowLeft />
|
||||
</el-icon>
|
||||
|
||||
<!-- 标签导航容器 -->
|
||||
<nav role="navigation" class="scroll-wrapper">
|
||||
|
||||
<el-scrollbar ref="scrollbarRef" class="scroll-container" @wheel="handleScroll">
|
||||
<VueDraggable v-model="visitedViews" :animation="150">
|
||||
<router-link
|
||||
v-for="tag in visitedViews" :key="tag.fullPath"
|
||||
:class="['tags-item', { active: tagsViewStore.isActive(tag) }]" :to="{ path: tag.path, query: tag.query }"
|
||||
@click.middle="handleMiddleClick(tag)">
|
||||
<el-dropdown
|
||||
v-if="tagsViewStore.isActive(tag)" trigger="contextmenu"
|
||||
@visible-change="onContextMenuVisibleChange" @click.stop>
|
||||
<span class="tag-text">{{ translateRouteTitle(tag.title) }}</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="refreshSelectedTag(tag)">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
{{ t("navbar.refresh") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :disabled="tag.affix || visitedViews.length <= 1" @click="closeSelectedTag(tag)">
|
||||
<el-icon>
|
||||
<Close />
|
||||
</el-icon>
|
||||
{{ t("navbar.close") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :disabled="isFirstView || !tagsViewStore.isActive(tag)" @click="closeLeftTags">
|
||||
<el-icon>
|
||||
<Back />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeLeft") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :disabled="isLastView || !tagsViewStore.isActive(tag)" @click="closeRightTags">
|
||||
<el-icon>
|
||||
<Right />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeRight") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:disabled="visitedViews.length <= 1 || !tagsViewStore.isActive(tag)"
|
||||
@click="closeOtherTags">
|
||||
<el-icon>
|
||||
<Remove />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeOther") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :disabled="visitedViews.length <= 1" @click="closeAllTags(tag)">
|
||||
<el-icon>
|
||||
<Minus />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeAll") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span v-else class="tag-text">{{ translateRouteTitle(tag.title) }}</span>
|
||||
<span v-if="!tag.affix" class="tag-close-btn" @click.prevent.stop="closeSelectedTag(tag)">
|
||||
<el-icon>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</span>
|
||||
</router-link>
|
||||
</VueDraggable>
|
||||
|
||||
</el-scrollbar>
|
||||
</nav>
|
||||
|
||||
<!-- 向右移动按钮 -->
|
||||
<el-icon class="btn" @click="scrollRight">
|
||||
<DArrowRight />
|
||||
</el-icon>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<el-icon class="btn" @click="refreshSelectedTag(selectedTag)">
|
||||
<RefreshRight />
|
||||
</el-icon>
|
||||
|
||||
<!-- 设置按钮 -->
|
||||
<el-dropdown class="btn" trigger="click">
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleAction('refresh')">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
{{ t("navbar.refresh") }}
|
||||
</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item v-if="!selectedTag?.affix" @click="handleAction('close')">
|
||||
<el-icon>
|
||||
<Close />
|
||||
</el-icon>
|
||||
{{ t("navbar.close") }}
|
||||
</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :disabled="isFirstView" @click="handleAction('closeLeft')">
|
||||
<el-icon>
|
||||
<Back />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeLeft") }}
|
||||
</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :disabled="isLastView" @click="handleAction('closeRight')">
|
||||
<el-icon>
|
||||
<Right />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeRight") }}
|
||||
</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item :disabled="!selectedTag" @click="handleAction('closeOther')">
|
||||
<el-icon>
|
||||
<Remove />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeOther") }}
|
||||
</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item @click="handleAction('closeAll')">
|
||||
<el-icon>
|
||||
<Minus />
|
||||
</el-icon>
|
||||
{{ t("navbar.closeAll") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
|
||||
import { resolve } from "path-browserify";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
import { usePermissionStore, useTagsViewStore } from "@/store";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 状态管理
|
||||
const permissionStore = usePermissionStore();
|
||||
const tagsViewStore = useTagsViewStore();
|
||||
|
||||
|
||||
const { visitedViews } = storeToRefs(tagsViewStore);
|
||||
|
||||
|
||||
// 当前选中的标签
|
||||
const selectedTag = ref<TagView | null>(null);
|
||||
|
||||
// 滚动条引用
|
||||
const scrollbarRef = ref();
|
||||
|
||||
// 路由映射缓存,提升查找性能
|
||||
const routePathMap = computed(() => {
|
||||
const map = new Map<string, TagView>();
|
||||
visitedViews.value.forEach((tag) => {
|
||||
map.set(tag.path, tag);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// 判断是否为第一个标签
|
||||
const isFirstView = computed(() => {
|
||||
if (!selectedTag.value) return false;
|
||||
return (
|
||||
selectedTag.value.path === "/dashboard" ||
|
||||
selectedTag.value.fullPath === visitedViews.value[1]?.fullPath
|
||||
);
|
||||
});
|
||||
|
||||
// 判断是否为最后一个标签
|
||||
const isLastView = computed(() => {
|
||||
if (!selectedTag.value) return false;
|
||||
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1]?.fullPath;
|
||||
});
|
||||
|
||||
/**
|
||||
* 递归提取固定标签
|
||||
*/
|
||||
const extractAffixTags = (routes: RouteRecordRaw[], basePath = "/"): TagView[] => {
|
||||
const affixTags: TagView[] = [];
|
||||
|
||||
const traverse = (routeList: RouteRecordRaw[], currentBasePath: string) => {
|
||||
routeList.forEach((route) => {
|
||||
const fullPath = resolve(currentBasePath, route.path);
|
||||
|
||||
// 如果是固定标签,添加到列表
|
||||
if (route.meta?.affix) {
|
||||
affixTags.push({
|
||||
path: fullPath,
|
||||
fullPath,
|
||||
name: String(route.name || ""),
|
||||
title: route.meta.title || "no-name",
|
||||
affix: true,
|
||||
keepAlive: route.meta.keepAlive || false,
|
||||
});
|
||||
}
|
||||
|
||||
// 递归处理子路由
|
||||
if (route.children?.length) {
|
||||
traverse(route.children, fullPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(routes, basePath);
|
||||
return affixTags;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化固定标签
|
||||
*/
|
||||
const initAffixTags = () => {
|
||||
const affixTags = extractAffixTags(permissionStore.routes);
|
||||
|
||||
affixTags.forEach((tag) => {
|
||||
if (tag.name) {
|
||||
tagsViewStore.addVisitedView(tag);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加当前路由标签
|
||||
*/
|
||||
const addCurrentTag = () => {
|
||||
if (!route.meta?.title) return;
|
||||
|
||||
tagsViewStore.addView({
|
||||
name: route.name as string,
|
||||
title: route.meta.title,
|
||||
path: route.path,
|
||||
fullPath: route.fullPath,
|
||||
affix: route.meta.affix || false,
|
||||
keepAlive: route.meta.keepAlive || false,
|
||||
query: route.query,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新当前标签
|
||||
*/
|
||||
const updateCurrentTag = () => {
|
||||
nextTick(() => {
|
||||
const currentTag = routePathMap.value.get(route.path);
|
||||
|
||||
if (currentTag && currentTag.fullPath !== route.fullPath) {
|
||||
tagsViewStore.updateVisitedView({
|
||||
name: route.name as string,
|
||||
title: route.meta?.title || "",
|
||||
path: route.path,
|
||||
fullPath: route.fullPath,
|
||||
affix: route.meta?.affix || false,
|
||||
keepAlive: route.meta?.keepAlive || false,
|
||||
query: route.query,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理中键点击
|
||||
*/
|
||||
const handleMiddleClick = (tag: TagView) => {
|
||||
if (!tag.affix) {
|
||||
closeSelectedTag(tag);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理滚轮事件(优化后)
|
||||
*/
|
||||
const handleScroll = (event: WheelEvent) => {
|
||||
|
||||
const scrollWrapper = scrollbarRef.value?.wrapRef;
|
||||
if (!scrollWrapper) return;
|
||||
|
||||
// 检查是否有水平或垂直滚动
|
||||
const hasHorizontalScroll = scrollWrapper.scrollWidth > scrollWrapper.clientWidth;
|
||||
const hasVerticalScroll = scrollWrapper.scrollHeight > scrollWrapper.clientHeight;
|
||||
|
||||
if (!hasHorizontalScroll && !hasVerticalScroll) return;
|
||||
|
||||
const deltaY = event.deltaY || -(event as any).wheelDelta || 0;
|
||||
const deltaX = event.deltaX || 0;
|
||||
|
||||
// 计算新的滚动位置
|
||||
const newScrollLeft = Math.max(0, Math.min(scrollWrapper.scrollWidth - scrollWrapper.clientWidth, scrollWrapper.scrollLeft + deltaX));
|
||||
const newScrollTop = Math.max(0, Math.min(scrollWrapper.scrollHeight - scrollWrapper.clientHeight, scrollWrapper.scrollTop + deltaY));
|
||||
|
||||
scrollbarRef.value.setScrollLeft(newScrollLeft);
|
||||
scrollbarRef.value.setScrollTop(newScrollTop); // 新增垂直滚动支持
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新标签
|
||||
*/
|
||||
const refreshSelectedTag = (tag: TagView | null) => {
|
||||
// 总是使用当前路由对应的标签
|
||||
const currentTag = routePathMap.value.get(route.path);
|
||||
if (!currentTag) return;
|
||||
|
||||
tagsViewStore.delCachedView(currentTag);
|
||||
nextTick(() => {
|
||||
router.replace("/redirect" + currentTag.fullPath);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭标签
|
||||
*/
|
||||
const closeSelectedTag = (tag: TagView | null) => {
|
||||
// 总是使用当前路由对应的标签
|
||||
const currentTag = routePathMap.value.get(route.path);
|
||||
if (!currentTag) return;
|
||||
|
||||
tagsViewStore.delView(currentTag).then((result: any) => {
|
||||
if (tagsViewStore.isActive(currentTag)) {
|
||||
tagsViewStore.toLastView(result.visitedViews, currentTag);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭左侧标签
|
||||
*/
|
||||
const closeLeftTags = () => {
|
||||
if (!selectedTag.value) return;
|
||||
|
||||
tagsViewStore.delLeftViews(selectedTag.value).then((result: any) => {
|
||||
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
|
||||
|
||||
if (!hasCurrentRoute) {
|
||||
tagsViewStore.toLastView(result.visitedViews);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭右侧标签
|
||||
*/
|
||||
const closeRightTags = () => {
|
||||
if (!selectedTag.value) return;
|
||||
|
||||
tagsViewStore.delRightViews(selectedTag.value).then((result: any) => {
|
||||
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
|
||||
|
||||
if (!hasCurrentRoute) {
|
||||
tagsViewStore.toLastView(result.visitedViews);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭其他标签
|
||||
*/
|
||||
const closeOtherTags = () => {
|
||||
if (!selectedTag.value) return;
|
||||
|
||||
router.push(selectedTag.value);
|
||||
tagsViewStore.delOtherViews(selectedTag.value).then(() => {
|
||||
updateCurrentTag();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭所有标签
|
||||
*/
|
||||
const closeAllTags = (tag: TagView | null) => {
|
||||
tagsViewStore.delAllViews().then((result: any) => {
|
||||
tagsViewStore.toLastView(result.visitedViews, tag || undefined);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一处理标签操作
|
||||
*/
|
||||
const handleAction = (action: string) => {
|
||||
// 总是使用当前路由对应的标签
|
||||
const currentTag = routePathMap.value.get(route.path);
|
||||
if (!currentTag) return;
|
||||
|
||||
switch (action) {
|
||||
case 'refresh':
|
||||
refreshSelectedTag(currentTag);
|
||||
break;
|
||||
case 'close':
|
||||
closeSelectedTag(currentTag);
|
||||
break;
|
||||
case 'closeRight':
|
||||
closeRightTags();
|
||||
break;
|
||||
case 'closeLeft':
|
||||
closeLeftTags();
|
||||
break;
|
||||
case 'closeOther':
|
||||
closeOtherTags();
|
||||
break;
|
||||
case 'closeAll':
|
||||
closeAllTags(currentTag);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown action: ${action}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 右键菜单显示状态变化处理函数
|
||||
*/
|
||||
const onContextMenuVisibleChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
// 总是获取最新的路由对应标签
|
||||
selectedTag.value = routePathMap.value.get(route.path) || null;
|
||||
} else {
|
||||
// 关闭菜单时清空选择
|
||||
selectedTag.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 向左滚动标签页
|
||||
*/
|
||||
const scrollLeft = () => {
|
||||
const scrollWrapper = scrollbarRef.value?.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const newScrollLeft = Math.max(0, scrollWrapper.scrollLeft - 200)
|
||||
scrollbarRef.value.setScrollLeft(newScrollLeft)
|
||||
}
|
||||
|
||||
/**
|
||||
* 向右滚动标签页
|
||||
*/
|
||||
const scrollRight = () => {
|
||||
const scrollWrapper = scrollbarRef.value?.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const maxScrollLeft = scrollWrapper.scrollWidth - scrollWrapper.clientWidth
|
||||
const newScrollLeft = Math.min(maxScrollLeft, scrollWrapper.scrollLeft + 200)
|
||||
scrollbarRef.value.setScrollLeft(newScrollLeft)
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
addCurrentTag();
|
||||
updateCurrentTag();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
initAffixTags();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: $tags-view-height;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
box-shadow: 0 1px 1px var(--el-box-shadow-light);
|
||||
|
||||
.btn {
|
||||
top: 1px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $tags-view-height;
|
||||
height: $tags-view-height;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
|
||||
.el-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 5px;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tag-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-white);
|
||||
background-color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--el-color-white);
|
||||
background-color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
|
||||
&::before {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 2px;
|
||||
content: "";
|
||||
background: var(--el-color-white);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
color: var(--el-color-white);
|
||||
}
|
||||
|
||||
.tag-close-btn {
|
||||
color: var(--el-color-white);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-white);
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useAppStore, useSettingsStore } from "@/store";
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
/**
|
||||
* 布局相关的通用逻辑
|
||||
*/
|
||||
export function useLayout() {
|
||||
const appStore = useAppStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 计算当前布局模式
|
||||
const currentLayout = computed(() => settingsStore.layout);
|
||||
|
||||
// 侧边栏展开状态
|
||||
const isSidebarOpen = computed(() => appStore.sidebar.opened);
|
||||
|
||||
// 是否显示标签视图
|
||||
const isShowTagsView = computed(() => settingsStore.showTagsView);
|
||||
|
||||
// 是否显示设置面板
|
||||
const isShowSettings = computed(() => defaultSettings.showSettings);
|
||||
|
||||
// 是否显示Logo
|
||||
const isShowLogo = computed(() => settingsStore.showAppLogo);
|
||||
|
||||
// 是否移动设备
|
||||
const isMobile = computed(() => appStore.device === "mobile");
|
||||
|
||||
// 布局CSS类
|
||||
const layoutClass = computed(() => ({
|
||||
hideSidebar: !appStore.sidebar.opened,
|
||||
openSidebar: appStore.sidebar.opened,
|
||||
mobile: appStore.device === "mobile",
|
||||
[`layout-${settingsStore.layout}`]: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 处理切换侧边栏的展开/收起状态
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭侧边栏(移动端)
|
||||
*/
|
||||
function closeSidebar() {
|
||||
appStore.closeSideBar();
|
||||
}
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isSidebarOpen,
|
||||
isShowTagsView,
|
||||
isShowSettings,
|
||||
isShowLogo,
|
||||
isMobile,
|
||||
layoutClass,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useAppStore, usePermissionStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 布局菜单处理逻辑
|
||||
*/
|
||||
export function useLayoutMenu() {
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 顶部菜单激活路径
|
||||
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath);
|
||||
|
||||
// 常规路由(左侧菜单或顶部菜单)
|
||||
const routes = computed(() => permissionStore.routes);
|
||||
|
||||
// 混合布局左侧菜单路由
|
||||
const sideMenuRoutes = computed(() => permissionStore.sideMenuRoutes);
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
|
||||
// 如果设置了activeMenu,则使用
|
||||
if (meta?.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
return {
|
||||
routes,
|
||||
sideMenuRoutes,
|
||||
activeMenu,
|
||||
activeTopMenuPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { watchEffect, computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useAppStore } from "@/store";
|
||||
import { DeviceEnum } from "@/enums/settings/device.enum";
|
||||
|
||||
/**
|
||||
* 布局响应式处理逻辑
|
||||
*/
|
||||
export function useLayoutResponsive() {
|
||||
const appStore = useAppStore();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// 定义响应式断点
|
||||
const WIDTH_DESKTOP = 992; // 桌面设备断点 (>=992px)
|
||||
|
||||
// 设置当前设备类型并调整侧边栏状态
|
||||
watchEffect(() => {
|
||||
const isDesktop = width.value >= WIDTH_DESKTOP;
|
||||
const deviceType = isDesktop ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE;
|
||||
|
||||
// 更新设备类型
|
||||
appStore.toggleDevice(deviceType);
|
||||
|
||||
// 根据设备类型调整侧边栏状态
|
||||
if (isDesktop) {
|
||||
appStore.openSideBar();
|
||||
} else {
|
||||
appStore.closeSideBar();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isDesktop: computed(() => width.value >= WIDTH_DESKTOP),
|
||||
isMobile: computed(() => appStore.device === DeviceEnum.MOBILE),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<component :is="currentLayoutComponent" />
|
||||
|
||||
<!-- 设置面板 - 独立于布局组件 -->
|
||||
<Settings v-if="isShowSettings" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useLayout } from "./composables/useLayout";
|
||||
import LeftLayout from "./views/LeftLayout.vue";
|
||||
import TopLayout from "./views/TopLayout.vue";
|
||||
import MixLayout from "./views/MixLayout.vue";
|
||||
import Settings from "./components/Settings/index.vue";
|
||||
import { LayoutMode } from "@/enums/settings/layout.enum";
|
||||
import { defaultSettings } from "@/settings";
|
||||
|
||||
const { currentLayout } = useLayout();
|
||||
|
||||
// 根据当前布局模式选择对应的组件
|
||||
const currentLayoutComponent = computed(() => {
|
||||
switch (currentLayout.value) {
|
||||
case LayoutMode.TOP:
|
||||
return TopLayout;
|
||||
case LayoutMode.MIX:
|
||||
return MixLayout;
|
||||
case LayoutMode.LEFT:
|
||||
default:
|
||||
return LeftLayout;
|
||||
}
|
||||
});
|
||||
|
||||
// 是否显示设置面板
|
||||
const isShowSettings = computed(() => defaultSettings.showSettings);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="layout" :class="layoutClass">
|
||||
<!-- 移动端遮罩层 -->
|
||||
<div v-if="isMobile && isSidebarOpen" class="layout__overlay" @click="closeSidebar" />
|
||||
|
||||
<!-- 布局内容插槽 -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- 返回顶部按钮 -->
|
||||
<el-backtop target=".app-main">
|
||||
<div class="i-svg:backtop w-6 h-6" />
|
||||
</el-backtop>
|
||||
|
||||
<!-- 新增:悬浮的系统设置按钮 -->
|
||||
<el-button
|
||||
v-if="defaultSettings.showSettings"
|
||||
class="floating-settings-button"
|
||||
type="primary"
|
||||
@click="handleSettingsClick"
|
||||
>
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "../composables/useLayout";
|
||||
import { useLayoutResponsive } from "../composables/useLayoutResponsive";
|
||||
import { defaultSettings } from "@/settings";
|
||||
import { useSettingsStore } from "@/store";
|
||||
|
||||
const settingStore = useSettingsStore();
|
||||
|
||||
/**
|
||||
* 打开系统设置页面
|
||||
*/
|
||||
function handleSettingsClick() {
|
||||
settingStore.settingsVisible = true;
|
||||
}
|
||||
|
||||
// 布局相关
|
||||
const { layoutClass, isSidebarOpen, closeSidebar } = useLayout();
|
||||
|
||||
// 响应式处理
|
||||
const { isMobile } = useLayoutResponsive();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// 新增:悬浮按钮样式
|
||||
.floating-settings-button {
|
||||
position: fixed; // 固定在页面上
|
||||
top: 50%; // 距离顶部20px
|
||||
right: 0px; // 距离右侧8px
|
||||
z-index: 999; // 层叠顺序
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-top-right-radius: 0; // 右侧顶部无圆角
|
||||
border-bottom-right-radius: 0; // 右侧底部无圆角
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 左侧菜单栏 -->
|
||||
<div class="layout__sidebar" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
|
||||
<div :class="{ 'has-logo': isShowLogo }" class="layout-sidebar">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="!isSidebarOpen" />
|
||||
<!-- 主菜单内容 -->
|
||||
<el-scrollbar>
|
||||
<BasicMenu :data="routes" base-path="" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div
|
||||
:class="{
|
||||
hasTagsView: isShowTagsView,
|
||||
'layout__main--collapsed': !isSidebarOpen,
|
||||
}"
|
||||
class="layout__main"
|
||||
>
|
||||
<NavBar />
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from "../composables/useLayout";
|
||||
import { useLayoutMenu } from "../composables/useLayoutMenu";
|
||||
import BaseLayout from "./BaseLayout.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";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo, isSidebarOpen } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
&__sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: $sidebar-width;
|
||||
background-color: $menu-background;
|
||||
transition: width 0.28s;
|
||||
|
||||
&--collapsed {
|
||||
width: $sidebar-width-collapsed;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background-color: var(--menu-background);
|
||||
transition: width 0.28s;
|
||||
|
||||
&.has-logo {
|
||||
.el-scrollbar {
|
||||
height: calc(100vh - $navbar-height);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
margin-left: $sidebar-width;
|
||||
overflow-y: auto;
|
||||
transition: margin-left 0.28s;
|
||||
|
||||
&--collapsed {
|
||||
margin-left: $sidebar-width-collapsed;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
transition: width 0.28s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
.mobile {
|
||||
.layout__sidebar {
|
||||
width: $sidebar-width !important;
|
||||
transition:
|
||||
transform 0.28s,
|
||||
width 0s;
|
||||
}
|
||||
|
||||
&.hideSidebar {
|
||||
.layout__sidebar {
|
||||
transform: translateX(-$sidebar-width);
|
||||
}
|
||||
}
|
||||
|
||||
&.openSidebar {
|
||||
.layout__sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.layout__main {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hasTagsView {
|
||||
:deep(.app-main) {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-content">
|
||||
|
||||
<!-- 顶部菜单区域 -->
|
||||
<div class="layout__header-menu">
|
||||
<!-- Logo区域 -->
|
||||
<div v-if="isShowLogo" class="layout__header-logo">
|
||||
<AppLogo :collapse="isLogoCollapsed" />
|
||||
</div>
|
||||
<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 } from "../composables/useLayout";
|
||||
import { useLayoutMenu } from "../composables/useLayoutMenu";
|
||||
import BaseLayout from "./BaseLayout.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 { computed, watch } from "vue";
|
||||
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 && typeof meta.activeMenu === "string") {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
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) => {
|
||||
console.log("📍 Route changed in MixLayout:", newPath);
|
||||
|
||||
// 获取顶级路径
|
||||
const topMenuPath =
|
||||
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
|
||||
|
||||
// 如果当前路径属于当前激活的顶部菜单
|
||||
if (newPath.startsWith(activeTopMenuPath.value)) {
|
||||
console.log("📍 Route is under active top menu, ensuring menu item is activated");
|
||||
}
|
||||
// 如果路径改变了顶级菜单,确保顶部菜单和左侧菜单都更新
|
||||
else if (topMenuPath !== activeTopMenuPath.value) {
|
||||
console.log(
|
||||
"📍 Top menu changed, updating active menu from:",
|
||||
activeTopMenuPath.value,
|
||||
"to:",
|
||||
topMenuPath
|
||||
);
|
||||
|
||||
// 主动更新顶部菜单和左侧菜单
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
appStore.activeTopMenu(topMenuPath);
|
||||
permissionStore.updateSideMenu(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>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<BaseLayout>
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div class="layout__header">
|
||||
<div class="layout__header-left">
|
||||
<!-- Logo -->
|
||||
<AppLogo v-if="isShowLogo" :collapse="isLogoCollapsed" />
|
||||
<!-- 菜单 -->
|
||||
<BasicMenu :data="routes" menu-mode="horizontal" base-path="" />
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="layout__header-right">
|
||||
<NavbarActions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div :class="{ hasTagsView: isShowTagsView }" class="layout__main">
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLayout } from "../composables/useLayout";
|
||||
import { useLayoutMenu } from "../composables/useLayoutMenu";
|
||||
import BaseLayout from "./BaseLayout.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";
|
||||
|
||||
// 布局相关参数
|
||||
const { isShowTagsView, isShowLogo } = useLayout();
|
||||
|
||||
// 菜单相关
|
||||
const { routes } = useLayoutMenu();
|
||||
|
||||
// 响应式窗口尺寸
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// 只有在小屏设备(移动设备)时才折叠Logo(只显示图标,隐藏文字)
|
||||
const isLogoCollapsed = computed(() => width.value < 768);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout {
|
||||
&__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: $navbar-height;
|
||||
background-color: $menu-background;
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0; // 允许flex收缩
|
||||
height: 100%;
|
||||
|
||||
// Logo样式由AppLogo组件的全局样式控制
|
||||
:deep(.logo) {
|
||||
flex-shrink: 0; // 防止Logo被压缩
|
||||
height: $navbar-height;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-shrink: 0; // 防止操作按钮被压缩
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
// 菜单样式
|
||||
:deep(.el-menu--horizontal) {
|
||||
flex: 1;
|
||||
min-width: 0; // 允许菜单收缩
|
||||
height: $navbar-height;
|
||||
overflow: hidden; // 防止菜单溢出
|
||||
line-height: $navbar-height;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
.el-menu-item {
|
||||
height: $navbar-height;
|
||||
line-height: $navbar-height;
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
.el-sub-menu__title {
|
||||
height: $navbar-height;
|
||||
line-height: $navbar-height;
|
||||
}
|
||||
|
||||
// 父菜单激活状态 - 水平布局专用
|
||||
&.has-active-child {
|
||||
.el-sub-menu__title {
|
||||
color: var(--el-color-primary) !important;
|
||||
border-bottom: 2px solid var(--el-color-primary) !important;
|
||||
|
||||
.menu-icon {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修复子菜单弹出位置
|
||||
.el-menu--popup {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
height: calc(100vh - $navbar-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 当存在TagsView时的样式调整
|
||||
.hasTagsView {
|
||||
:deep(.app-main) {
|
||||
height: calc(100vh - $navbar-height - $tags-view-height) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user