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:
@@ -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>
|
||||
Reference in New Issue
Block a user