发布v2.0.0分支

This commit is contained in:
zhangtao
2025-07-13 19:31:23 +08:00
parent 4150a39713
commit 6a8ca3becc
342 changed files with 39441 additions and 14737 deletions
@@ -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>