This commit is contained in:
2026-01-27 09:43:51 +08:00
parent 8b0e5a5642
commit bd07886ffa
10 changed files with 842 additions and 1549 deletions

View File

@@ -66,7 +66,8 @@ body {
} }
#app { #app {
min-height: 100vh; width: 100%;
height: 100vh;
} }
// ==================== 暗色主题样式 ==================== // ==================== 暗色主题样式 ====================

View File

@@ -1,12 +1,12 @@
<template> <template>
<el-breadcrumb separator="/"> <el-breadcrumb separator="/">
<transition-group name="breadcrumb"> <transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path"> <el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
<span v-if="index === breadcrumbs.length - 1" class="no-redirect"> <span v-if="item.redirect === 'noRedirect' || index === levelList.length - 1" class="no-redirect">
{{ item.meta?.title }} {{ item.meta.title }}
</span> </span>
<a v-else class="redirect" @click.prevent="handleLink(item)"> <a v-else class="redirect" @click.prevent="handleLink(item)">
{{ item.meta?.title }} {{ item.meta.title }}
</a> </a>
</el-breadcrumb-item> </el-breadcrumb-item>
</transition-group> </transition-group>
@@ -14,9 +14,8 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
// import { useI18n } from '@/hooks/useI18n'
defineOptions({ defineOptions({
name: 'LayoutBreadcrumb', name: 'LayoutBreadcrumb',
@@ -24,30 +23,36 @@ defineOptions({
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// const { t } = useI18n()
// 面包屑列表 const levelList = ref([])
const breadcrumbs = computed(() => {
const matched = route.matched.filter((item) => item.meta && item.meta.title) const getBreadcrumb = () => {
let matched = route.matched.filter((item) => {
if (item.meta && item.meta.title) {
return true
}
return false
})
const first = matched[0] const first = matched[0]
if (!first || !first.meta?.title) { if (!isDashboard(first)) {
return [] matched = [{ path: '/dashboard', meta: { title: '首页' } }].concat(matched)
} }
// 如果第一个不是首页,添加首页 levelList.value = matched.filter(
// if (first.path !== '/') { (item) => item.meta && item.meta.title && item.meta.breadcrumb !== false
// matched.unshift({ )
// path: '/', }
// meta: { title: '首页' },
// })
// }
return matched const isDashboard = (route) => {
}) const name = route && route.name
if (!name) {
return false
}
return name.toString().trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
}
// 处理点击
const handleLink = (item) => { const handleLink = (item) => {
const { redirect, path } = item const { redirect, path } = item
if (redirect) { if (redirect) {
@@ -56,52 +61,46 @@ const handleLink = (item) => {
} }
router.push(path) router.push(path)
} }
watch(
() => route.path,
() => {
getBreadcrumb()
},
{ immediate: true }
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.el-breadcrumb { .app-breadcrumb.el-breadcrumb {
display: inline-flex; display: inline-block;
align-items: center;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 50px;
margin-left: 8px;
:deep(.el-breadcrumb__item) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.el-breadcrumb__inner {
font-weight: 400;
transition: all 0.3s;
}
&:last-child .el-breadcrumb__inner {
font-weight: 500;
}
}
.no-redirect { .no-redirect {
color: var(--el-text-color-primary); color: #97a8be;
cursor: text; cursor: text;
font-weight: 500;
} }
.redirect { .redirect {
color: var(--el-text-color-regular); color: #666;
cursor: pointer; font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
color: var(--el-color-primary); color: var(--primary-color);
cursor: pointer;
} }
} }
} }
.breadcrumb-enter-active, .breadcrumb-enter-active,
.breadcrumb-leave-active { .breadcrumb-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.5s;
} }
.breadcrumb-enter-from, .breadcrumb-enter-from,
.breadcrumb-leave-to { .breadcrumb-leave-active {
opacity: 0; opacity: 0;
transform: translateX(20px); transform: translateX(20px);
} }

View File

@@ -13,7 +13,7 @@
</el-sub-menu> </el-sub-menu>
<!-- 无子菜单 --> <!-- 无子菜单 -->
<el-menu-item v-else-if="!menu.meta?.hidden" :index="menu.path"> <el-menu-item v-else-if="!menu.meta?.hidden" :index="menu.path" @click="handleMenuClick(menu)">
<el-icon v-if="menu.meta?.icon"> <el-icon v-if="menu.meta?.icon">
<component :is="menu.meta.icon" /> <component :is="menu.meta.icon" />
</el-icon> </el-icon>
@@ -25,8 +25,11 @@
</template> </template>
<script setup> <script setup>
import { defineProps } from 'vue'
import { useRouter } from 'vue-router'
defineOptions({ defineOptions({
name: 'MenuItem', name: 'LayoutMenuItem',
}) })
defineProps({ defineProps({
@@ -40,60 +43,25 @@ defineProps({
}, },
}) })
const router = useRouter()
// 判断是否有子菜单 // 判断是否有子菜单
const hasChildren = (menu) => { const hasChildren = (menu) => {
return menu.children && menu.children.length > 0 return menu.children && menu.children.length > 0
} }
// 处理菜单点击
const handleMenuClick = (menu) => {
if (menu.meta?.link) {
// 外部链接
window.open(menu.meta.link, '_blank')
} else if (menu.path) {
// 内部路由
router.push(menu.path)
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// 菜单项样式 // 菜单项样式继承自 Element Plus
:deep(.el-sub-menu) {
.el-sub-menu__title {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: var(--el-fill-color-light) !important;
}
}
// 子菜单内容区
.el-menu {
background-color: var(--el-bg-color);
}
// 菜单项样式
.el-menu-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: var(--el-fill-color-light) !important;
}
&.is-active {
background-color: var(--el-color-primary-light-9) !important;
color: var(--el-color-primary) !important;
font-weight: 500;
}
}
}
// 图标样式优化
:deep(.el-icon) {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// 箭头图标动画
:deep(.el-sub-menu__icon-arrow) {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.el-sub-menu.is-opened > .el-sub-menu__title & {
transform: rotateZ(180deg);
}
}
// 菜单文本样式
:deep(span) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
</style> </style>

View File

@@ -1,152 +0,0 @@
<template>
<el-menu :default-active="activeMenu" :mode="menuMode" :collapse="isCollapse" :unique-opened="uniqueOpened" :router="true" :collapse-transition="false" @select="handleSelect">
<template v-for="menu in menuList" :key="menu.path">
<!-- 有子菜单 -->
<el-sub-menu v-if="hasChildren(menu) && !menu.meta?.hidden" :index="menu.path">
<template #title>
<el-icon v-if="menu.meta?.icon">
<component :is="menu.meta.icon" />
</el-icon>
<span>{{ menu.meta?.title }}</span>
</template>
<!-- 递归渲染子菜单 -->
<menu-item :menu-list="menu.children" :parent-path="menu.path" />
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else-if="!menu.meta?.hidden" :index="menu.path">
<el-icon v-if="menu.meta?.icon">
<component :is="menu.meta.icon" />
</el-icon>
<template #title>
<span>{{ menu.meta?.title }}</span>
</template>
</el-menu-item>
</template>
</el-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { useUserStore } from '@/stores/modules/user'
import MenuItem from './menu-item.vue'
defineOptions({
name: 'LayoutMenu',
})
const props = defineProps({
mode: {
type: String,
default: 'vertical',
},
})
const route = useRoute()
const layoutStore = useLayoutStore()
const userStore = useUserStore()
// 菜单模式
const menuMode = computed(() => props.mode)
// 是否折叠
const isCollapse = computed(() => layoutStore.sidebarCollapsed)
// 是否只展开一个子菜单
const uniqueOpened = computed(() => true)
// 当前激活的菜单
const activeMenu = computed(() => {
const { meta, path } = route
if (meta?.activeMenu) {
return meta.activeMenu
}
return path
})
// 菜单列表
const menuList = computed(() => {
const menus = userStore.getMenu() || []
// 如果是默认布局(双栏布局),只显示当前选中的父菜单的子菜单
if (layoutStore.layoutMode === 'default' && layoutStore.selectedParentMenu) {
const selectedMenu = menus.find((m) => m.path === layoutStore.selectedParentMenu.path)
return selectedMenu?.children || []
}
// 其他布局显示所有菜单
return menus
})
// 判断是否有子菜单
const hasChildren = (menu) => {
return menu.children && menu.children.length > 0
}
// 菜单选择处理
const handleSelect = (index) => {
console.log('Menu selected:', index)
}
</script>
<style lang="scss" scoped>
.el-menu {
border-right: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// 菜单项样式
:deep(.el-menu-item) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: var(--el-fill-color-light) !important;
}
&.is-active {
background-color: var(--el-color-primary-light-9) !important;
color: var(--el-color-primary) !important;
font-weight: 500;
}
}
// 子菜单样式
:deep(.el-sub-menu__title) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: var(--el-fill-color-light) !important;
}
.el-sub-menu__icon-arrow {
transition: transform 0.3s;
}
}
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
transform: rotateZ(180deg);
}
// 图标样式
:deep(.el-icon) {
width: 18px;
height: 18px;
font-size: 18px;
vertical-align: middle;
margin-right: 8px;
}
// 折叠状态
&.el-menu--collapse {
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
padding: 0 20px;
}
:deep(.el-icon) {
margin-right: 0;
}
}
}
</style>

View File

@@ -1,141 +1,51 @@
<template> <template>
<div class="setting-container"> <div class="drawer-container">
<!-- 设置按钮 --> <el-drawer v-model="drawerVisible" title="布局设置" size="280px">
<div class="setting-btn" @click="showDrawer = true"> <el-scrollbar>
<el-icon> <el-form label-width="100px" label-position="left">
<Setting /> <el-form-item label="布局模式">
<el-select v-model="layoutMode" placeholder="请选择" @change="handleLayoutModeChange">
<el-option label="默认布局" value="default" />
<el-option label="菜单布局" value="menu" />
<el-option label="顶部布局" value="top" />
</el-select>
</el-form-item>
<el-form-item label="主题颜色">
<el-color-picker v-model="themeColor" @change="handleThemeChange" />
</el-form-item>
<el-divider>显示设置</el-divider>
<el-form-item label="显示标签栏">
<el-switch v-model="showTags" @change="handleShowTagsChange" />
</el-form-item>
<el-form-item label="显示面包屑">
<el-switch v-model="showBreadcrumb" @change="handleShowBreadcrumbChange" />
</el-form-item>
<el-divider>其他</el-divider>
<el-form-item>
<el-button type="primary" @click="handleReset">重置设置</el-button>
</el-form-item>
</el-form>
</el-scrollbar>
</el-drawer>
<div class="setting-btn" @click="drawerVisible = true">
<el-icon :size="24">
<component :is="'ElIconSetting'" />
</el-icon> </el-icon>
</div> </div>
<!-- 设置抽屉 -->
<el-drawer v-model="showDrawer" title="系统设置" :size="300" :close-on-click-modal="false">
<div class="setting-content">
<!-- 布局模式 -->
<div class="setting-item">
<div class="setting-title">布局模式</div>
<div class="setting-value">
<el-radio-group v-model="layoutMode" @change="handleLayoutModeChange">
<el-radio label="default">
<div class="layout-option">
<div class="layout-preview layout-default">
<div class="layout-aside-left"></div>
<div class="layout-aside-right"></div>
<div class="layout-main"></div>
</div>
<span>双栏布局</span>
</div>
</el-radio>
<el-radio label="menu">
<div class="layout-option">
<div class="layout-preview layout-menu">
<div class="layout-aside"></div>
<div class="layout-main"></div>
</div>
<span>菜单布局</span>
</div>
</el-radio>
<el-radio label="top">
<div class="layout-option">
<div class="layout-preview layout-top">
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
<span>顶部布局</span>
</div>
</el-radio>
</el-radio-group>
</div>
</div>
<!-- 主题色 -->
<div class="setting-item">
<div class="setting-title">主题色</div>
<div class="setting-value">
<div class="color-picker-wrapper">
<el-color-picker v-model="themeColor" show-alpha :predefine="predefineColors" @change="handleThemeColorChange" />
</div>
<div class="color-presets">
<div v-for="color in predefineColors" :key="color" class="color-preset" :class="{ active: themeColor === color }" :style="{ backgroundColor: color }" @click="handleColorPresetClick(color)"></div>
</div>
</div>
</div>
<!-- 主题模式 -->
<div class="setting-item">
<div class="setting-title">主题模式</div>
<div class="setting-value">
<el-radio-group v-model="isDark" @change="handleThemeModeChange">
<el-radio :label="false">
<el-icon>
<Sunny />
</el-icon>
浅色
</el-radio>
<el-radio :label="true">
<el-icon>
<Moon />
</el-icon>
深色
</el-radio>
</el-radio-group>
</div>
</div>
<!-- 标签栏 -->
<div class="setting-item">
<div class="setting-title">
<span>显示标签栏</span>
<el-tooltip content="开启后页面顶部显示标签页" placement="top">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<div class="setting-value">
<el-switch v-model="showTags" @change="handleShowTagsChange" />
</div>
</div>
<!-- 面包屑 -->
<div class="setting-item">
<div class="setting-title">
<span>显示面包屑</span>
<el-tooltip content="开启后页面顶部显示面包屑导航" placement="top">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<div class="setting-value">
<el-switch v-model="showBreadcrumb" @change="handleShowBreadcrumbChange" />
</div>
</div>
<!-- 操作按钮 -->
<div class="setting-actions">
<el-button type="primary" @click="handleSave">
<el-icon>
<Check />
</el-icon>
保存配置
</el-button>
<el-button @click="handleReset">
<el-icon>
<RefreshLeft />
</el-icon>
重置默认
</el-button>
</div>
</div>
</el-drawer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed } from 'vue'
import { useLayoutStore } from '../../stores/modules/layout'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Setting, Sunny, Moon, QuestionFilled, Check, RefreshLeft } from '@element-plus/icons-vue'
import { useLayoutStore } from '@/stores/modules/layout'
defineOptions({ defineOptions({
name: 'LayoutSetting', name: 'LayoutSetting',
@@ -143,350 +53,94 @@ defineOptions({
const layoutStore = useLayoutStore() const layoutStore = useLayoutStore()
// 抽屉显示状态 const drawerVisible = ref(false)
const showDrawer = ref(false)
// 预定义颜色
const predefineColors = [
'#1890ff',
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399',
'#722ed1',
'#eb2f96',
'#52c41a',
'#1890ff',
'#2f54eb',
'#722ed1',
'#f5222d',
'#fa541c',
'#fa8c16',
'#faad14',
'#fadb14',
'#a0d911',
'#52c41a',
'#13c2c2',
'#1890ff',
'#2f54eb',
'#722ed1',
]
// 布局模式 // 布局模式
const layoutMode = computed({ const layoutMode = computed({
get: () => layoutStore.layoutMode, get: () => layoutStore.layoutMode,
set: (val) => layoutStore.setLayoutMode(val), set: (value) => {
layoutStore.setLayoutMode(value)
},
}) })
// 主题色 // 主题
const themeColor = ref(layoutStore.themeColor) const themeColor = computed({
get: () => layoutStore.themeColor,
// 主题模式(深色/浅色) set: (value) => {
const isDark = ref(document.documentElement.classList.contains('dark')) layoutStore.setThemeColor(value)
},
})
// 显示标签栏 // 显示标签栏
const showTags = computed({ const showTags = computed({
get: () => layoutStore.showTags, get: () => layoutStore.showTags,
set: (val) => layoutStore.setShowTags(val), set: (value) => {
layoutStore.setShowTags(value)
},
}) })
// 显示面包屑 // 显示面包屑
const showBreadcrumb = computed({ const showBreadcrumb = computed({
get: () => layoutStore.showBreadcrumb, get: () => layoutStore.showBreadcrumb,
set: (val) => layoutStore.setShowBreadcrumb(val), set: (value) => {
layoutStore.setShowBreadcrumb(value)
},
}) })
// 布局模式变化 // 布局模式变化
const handleLayoutModeChange = (value) => { const handleLayoutModeChange = (value) => {
console.log('Layout mode changed:', value) layoutStore.setLayoutMode(value)
ElMessage.success('布局模式已切换')
} }
// 主题色变化 // 主题色变化
const handleThemeColorChange = (value) => { const handleThemeChange = (value) => {
if (value) { layoutStore.setThemeColor(value)
layoutStore.setThemeColor(value)
}
} }
// 预定义颜色点击 // 显示标签栏变化
const handleColorPresetClick = (color) => {
themeColor.value = color
layoutStore.setThemeColor(color)
}
// 主题模式变化
const handleThemeModeChange = (value) => {
if (value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 标签栏显示变化
const handleShowTagsChange = (value) => { const handleShowTagsChange = (value) => {
layoutStore.setShowTags(value) layoutStore.setShowTags(value)
} }
// 面包屑显示变化 // 显示面包屑变化
const handleShowBreadcrumbChange = (value) => { const handleShowBreadcrumbChange = (value) => {
layoutStore.setShowBreadcrumb(value) layoutStore.setShowBreadcrumb(value)
} }
// 保存配 // 重置设
const handleSave = () => {
ElMessage.success('配置已保存')
showDrawer.value = false
}
// 重置配置
const handleReset = () => { const handleReset = () => {
layoutStore.resetTheme() layoutStore.resetTheme()
themeColor.value = layoutStore.themeColor ElMessage.success('设置已重置')
isDark.value = false
document.documentElement.classList.remove('dark')
ElMessage.success('已重置为默认配置')
} }
// 初始化
onMounted(() => {
themeColor.value = layoutStore.themeColor
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.setting-container { .drawer-container {
// 设置按钮
.setting-btn { .setting-btn {
position: fixed; position: fixed;
top: 50%; top: 50%;
right: 0; right: 0;
transform: translateY(-50%); z-index: 9999;
width: 48px; width: 48px;
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--el-color-primary); background: var(--primary-color);
color: #fff; color: #fff;
cursor: pointer;
border-radius: 6px 0 0 6px; border-radius: 6px 0 0 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); cursor: pointer;
z-index: 9999; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s;
user-select: none;
&:hover {
background: var(--primary-color);
opacity: 0.9;
}
.el-icon { .el-icon {
font-size: 20px; font-size: 20px;
transition: transform 0.3s;
}
&:hover {
width: 56px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
.el-icon {
transform: rotate(90deg);
}
}
&:active {
transform: translateY(-50%) scale(0.95);
}
}
// 设置内容
.setting-content {
padding: 20px;
.setting-item {
margin-bottom: 24px;
.setting-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 4px;
.el-icon {
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: help;
}
}
.setting-value {
// 布局选项
:deep(.el-radio-group) {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
.el-radio {
margin-right: 0;
width: 100%;
}
.el-radio__label {
width: 100%;
padding-left: 0;
}
}
.layout-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 2px solid var(--el-border-color);
border-radius: 8px;
transition: all 0.3s;
cursor: pointer;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
span {
font-size: 14px;
color: var(--el-text-color-primary);
}
}
// 布局预览
.layout-preview {
width: 80px;
height: 60px;
background: var(--el-fill-color-blank);
border: 2px solid var(--el-border-color);
border-radius: 4px;
position: relative;
overflow: hidden;
&.layout-default {
.layout-aside-left {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 20px;
background: var(--el-color-primary-light-7);
}
.layout-aside-right {
position: absolute;
left: 20px;
top: 0;
bottom: 0;
width: 25px;
background: var(--el-color-primary-light-5);
}
.layout-main {
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 45px;
background: var(--el-fill-color-lighter);
}
}
&.layout-menu {
.layout-aside {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 25px;
background: var(--el-color-primary-light-5);
}
.layout-main {
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 25px;
background: var(--el-fill-color-lighter);
}
}
&.layout-top {
.layout-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12px;
background: var(--el-color-primary-light-5);
}
.layout-main {
position: absolute;
right: 0;
top: 12px;
bottom: 0;
left: 0;
background: var(--el-fill-color-lighter);
}
}
}
// 颜色选择器
.color-picker-wrapper {
margin-bottom: 12px;
:deep(.el-color-picker__trigger) {
width: 100%;
height: 40px;
}
}
.color-presets {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
.color-preset {
width: 100%;
padding-bottom: 100%;
position: relative;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
&:hover {
transform: scale(1.1);
}
&.active {
border-color: var(--el-color-primary);
transform: scale(1.1);
}
}
}
}
}
// 操作按钮
.setting-actions {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-light);
display: flex;
gap: 12px;
.el-button {
flex: 1;
}
} }
} }
} }

View File

@@ -1,214 +1,278 @@
<template> <template>
<div class="tags-view-container"> <div class="tags-view-container">
<el-scrollbar class="tags-view-wrapper"> <div class="tags-view-wrapper" @wheel="handleWheel">
<router-link v-for="tag in visibleTags" :key="tag.fullPath" :class="isActive(tag) ? 'active' : ''" class="tags-view-item" :to="{ path: tag.fullPath }" @click.middle="closeTag(tag)" @contextmenu.prevent="openMenu(tag, $event)"> <div
{{ tag.title || tag.meta?.title }} ref="scrollRef"
<el-icon v-if="!tag.meta?.affix" class="el-icon-close" @click.prevent.stop="closeTag(tag)"> class="scroll-content"
<Close /> :style="{ transform: `translateX(${scrollState.translateX}px)`, transition: scrollState.transition }"
</el-icon> >
</router-link> <router-link
</el-scrollbar> v-for="(tag, index) in visitedViews"
:id="`scroll-li-${index}`"
:key="tag.path"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
class="tags-view-item"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)"
>
{{ tag.title }}
<el-icon v-if="!isAffix(tag)" class="close-icon" @click.prevent.stop="closeSelectedTag(tag)">
<ElIconClose />
</el-icon>
</router-link>
</div>
</div>
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu"> <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"> <li @click="refreshSelectedTag(selectedTag)">
<el-icon> <el-icon><ElIconRefresh /></el-icon>
<Refresh />
</el-icon>
刷新 刷新
</li> </li>
<li v-if="!selectedTag?.meta?.affix" @click="closeSelectedTag(selectedTag)"> <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<el-icon> <el-icon><ElIconClose /></el-icon>
<Close />
</el-icon>
关闭 关闭
</li> </li>
<li @click="closeOthersTags"> <li @click="closeOthersTags">
<el-icon> <el-icon><ElIconCircleClose /></el-icon>
<CircleClose />
</el-icon>
关闭其他 关闭其他
</li> </li>
<li @click="closeAllTags(selectedTag)"> <li @click="closeAllTags(selectedTag)">
<el-icon> <el-icon><ElIconFolderDelete /></el-icon>
<CircleCloseFilled /> 关闭所有
</el-icon>
关闭全部
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout' import { useLayoutStore } from '../../stores/modules/layout'
import { Close, Refresh, CircleClose, CircleCloseFilled } from '@element-plus/icons-vue'
defineOptions({ defineOptions({
name: 'LayoutTags', name: 'TagsView',
}) })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const layoutStore = useLayoutStore() const layoutStore = useLayoutStore()
// 右键菜单
const visible = ref(false) const visible = ref(false)
const top = ref(0) const top = ref(0)
const left = ref(0) const left = ref(0)
const selectedTag = ref(null) const selectedTag = ref({})
const affixTags = ref([]) const affixTags = ref([])
const scrollRef = ref(null)
// 可见的标签(去重) const scrollState = ref({
const visibleTags = computed(() => { translateX: 0,
const tags = layoutStore.viewTags || [] transition: ''
// 使用 Map 根据 fullPath 去重,保留最后出现的 })
const tagMap = new Map()
tags.forEach((tag) => { const visitedViews = computed(() => layoutStore.viewTags)
if (tag.fullPath) { const activeTabIndex = computed(() => {
tagMap.set(tag.fullPath, tag) const index = visitedViews.value.findIndex(view => view.path === route.path)
} return index
})
return Array.from(tagMap.values())
}) })
// 判断是否激活
const isActive = (tag) => { const isActive = (tag) => {
return tag.fullPath === route.fullPath return tag.path === route.path
} }
// 过滤固定的标签 const isAffix = (tag) => {
const filterAffixTags = (routes, basePath = '/') => { return tag.meta && tag.meta.affix
let tags = [] }
routes.forEach((route) => {
if (route.meta?.affix) { // 滚动逻辑
const tagPath = basePath + route.path const setTransition = () => {
tags.push({ scrollState.value.transition = 'transform 0.5s cubic-bezier(0.15, 0, 0.15, 1)'
name: route.name, setTimeout(() => {
fullPath: tagPath, scrollState.value.transition = ''
path: tagPath, }, 250)
title: route.meta?.title, }
meta: route.meta,
}) const getCurrentTabElement = () => {
} const index = activeTabIndex.value
if (route.children) { return index >= 0 ? document.getElementById(`scroll-li-${index}`) : null
const tempTags = filterAffixTags(route.children, route.path + '/') }
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags] const calculateScrollPosition = () => {
} if (!scrollRef.value) return null
const scrollWrapper = scrollRef.value.parentElement
if (!scrollWrapper) return null
const scrollWidth = scrollWrapper.offsetWidth
const ulWidth = scrollRef.value.offsetWidth
const curTabEl = getCurrentTabElement()
if (!curTabEl) return null
const { offsetLeft, clientWidth } = curTabEl
const curTabRight = offsetLeft + clientWidth
const targetLeft = scrollWidth - curTabRight
return {
scrollWidth,
ulWidth,
offsetLeft,
clientWidth,
curTabRight,
targetLeft
}
}
const autoPositionTab = () => {
const positions = calculateScrollPosition()
if (!positions) return
const { scrollWidth, ulWidth, offsetLeft, curTabRight, targetLeft } = positions
const currentTranslateX = scrollState.value.translateX
if ((offsetLeft > Math.abs(currentTranslateX) && curTabRight <= scrollWidth) ||
(currentTranslateX < targetLeft && targetLeft < 0)) {
return
}
requestAnimationFrame(() => {
if (curTabRight > scrollWidth) {
scrollState.value.translateX = Math.max(targetLeft - 6, scrollWidth - ulWidth)
} else if (offsetLeft < Math.abs(currentTranslateX)) {
scrollState.value.translateX = -offsetLeft
} }
}) })
return tags
} }
// 初始化标签 const adjustPositionAfterClose = () => {
const initTags = () => { const positions = calculateScrollPosition()
affixTags.value = filterAffixTags(route.matched) if (!positions) return
for (const tag of affixTags.value) {
if (tag.name) { const { scrollWidth, ulWidth, offsetLeft, clientWidth } = positions
layoutStore.updateViewTags(tag) const curTabLeft = offsetLeft + clientWidth
}
} requestAnimationFrame(() => {
scrollState.value.translateX = curTabLeft > scrollWidth ? scrollWidth - ulWidth : 0
})
} }
// 添加标签
const addTags = () => { const addTags = () => {
if (route.name && !route.meta?.hidden) { const { name } = route
if (name) {
layoutStore.updateViewTags({ layoutStore.updateViewTags({
name: route.name,
path: route.path, path: route.path,
fullPath: route.fullPath, fullPath: route.fullPath,
title: route.meta?.title, name: name,
title: route.meta.title,
meta: route.meta, meta: route.meta,
query: route.query,
}) })
} }
return false
} }
// 关闭标签 const closeSelectedTag = (view) => {
const closeTag = (tag) => { layoutStore.removeViewTags(view.fullPath)
layoutStore.removeViewTags(tag.fullPath) setTransition()
if (isActive(tag)) { nextTick(() => {
toLastView(tag) adjustPositionAfterClose()
})
if (isActive(view)) {
toLastView(visitedViews.value, view)
} }
} }
// 关闭选中的标签 const closeOthersTags = () => {
const closeSelectedTag = (tag) => { router.push(selectedTag.value)
closeTag(tag) layoutStore.viewTags = visitedViews.value.filter((tag) => {
return tag.meta && tag.meta.affix || tag.fullPath === selectedTag.value.fullPath
})
setTransition()
} }
// 跳转到最后一个标签 const closeAllTags = (view) => {
const toLastView = (tag) => { layoutStore.viewTags = visitedViews.value.filter((tag) => tag.meta && tag.meta.affix)
const views = visibleTags.value setTransition()
const latestView = views.slice(-1)[0] if (view && view.fullPath) {
toLastView(visitedViews.value, view)
} else {
toLastView(layoutStore.viewTags)
}
}
const toLastView = (visitedViews, view) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView) { if (latestView) {
router.push(latestView.fullPath) router.push(latestView.fullPath)
} else { } else {
// 如果没有标签,跳转到首页 if (view.name === 'Dashboard') {
router.push('/') router.replace({ path: '/redirect' + view.fullPath })
} else {
router.push('/')
}
} }
} }
// 关闭其他标签 const refreshSelectedTag = (view) => {
const closeOthersTags = () => { layoutStore.removeViewTags(view.fullPath)
router.push(selectedTag.value.fullPath) const { fullPath } = view
layoutStore.clearViewTags() layoutStore.updateViewTags({
addTags() path: view.path,
for (const tag of affixTags.value) { fullPath: view.fullPath,
layoutStore.updateViewTags(tag) name: view.name,
} title: view.meta.title,
meta: view.meta,
query: view.query,
})
router.replace({
path: '/redirect' + fullPath
})
} }
// 关闭所有标签
const closeAllTags = () => {
layoutStore.clearViewTags()
for (const tag of affixTags.value) {
layoutStore.updateViewTags(tag)
}
if (affixTags.value.length > 0) {
router.push(affixTags.value[0].fullPath)
} else {
router.push('/')
}
}
// 刷新选中的标签
const refreshSelectedTag = (tag) => {
layoutStore.refreshTag()
router.replace({ path: tag.fullPath })
}
// 打开右键菜单
const openMenu = (tag, e) => { const openMenu = (tag, e) => {
const scrollWrapper = scrollRef.value?.parentElement
if (!scrollWrapper) return
const menuMinWidth = 105 const menuMinWidth = 105
const offsetLeft = e.clientX - container.getBoundingClientRect().left // container margin left const offsetLeft = scrollWrapper.getBoundingClientRect().left || 0
const offsetWidth = container.offsetWidth - 100 const offsetWidth = scrollWrapper.offsetWidth || 0
const maxLeft = offsetWidth - menuMinWidth const maxLeft = offsetWidth - menuMinWidth
left.value = offsetLeft > maxLeft ? maxLeft : offsetLeft left.value = e.clientX - offsetLeft + 15
if (left.value > maxLeft) {
left.value = maxLeft
}
top.value = e.clientY top.value = e.clientY
visible.value = true visible.value = true
selectedTag.value = tag selectedTag.value = tag
} }
// 关闭右键菜单
const closeMenu = () => { const closeMenu = () => {
visible.value = false visible.value = false
} }
// 容器引用 const handleWheel = (e) => {
let container = null const scrollWrapper = scrollRef.value?.parentElement
if (!scrollWrapper) return
// 监听路由变化 const scrollWidth = scrollWrapper.offsetWidth
watch( const tagElements = scrollRef.value?.querySelectorAll('.tags-view-item')
() => route.path, if (!tagElements || tagElements.length === 0) return
() => {
addTags() const ulWidth = scrollRef.value.offsetWidth
}, const eventDelta = e.wheelDelta || -e.deltaY * 40
{ immediate: true }, const currentScroll = -scrollState.value.translateX
) const newScroll = Math.max(0, Math.min(currentScroll + eventDelta, scrollWidth - ulWidth))
scrollState.value.translateX = -newScroll
closeMenu()
}
watch(route, () => {
addTags()
nextTick(() => {
setTransition()
autoPositionTab()
})
})
// 监听右键菜单可见性
watch(visible, (value) => { watch(visible, (value) => {
if (value) { if (value) {
document.body.addEventListener('click', closeMenu) document.body.addEventListener('click', closeMenu)
@@ -219,8 +283,10 @@ watch(visible, (value) => {
onMounted(() => { onMounted(() => {
addTags() addTags()
initTags() nextTick(() => {
container = document.querySelector('.tags-view-container') setTransition()
autoPositionTab()
})
}) })
</script> </script>
@@ -228,152 +294,125 @@ onMounted(() => {
.tags-view-container { .tags-view-container {
height: 34px; height: 34px;
width: 100%; width: 100%;
background: var(--el-fill-color-blank); background: #fff;
border-bottom: 1px solid var(--el-border-color-light); border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
position: relative; position: relative;
z-index: 9;
.tags-view-wrapper { .tags-view-wrapper {
white-space: nowrap; height: 100%;
width: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: 100%; white-space: nowrap;
:deep(.el-scrollbar__wrap) { .scroll-content {
height: 34px; display: inline-block;
overflow-x: auto; white-space: nowrap;
scrollbar-width: thin; will-change: transform;
height: 100%;
display: flex;
align-items: center;
&::-webkit-scrollbar { .tags-view-item {
height: 4px; display: inline-flex;
} align-items: center;
position: relative;
&::-webkit-scrollbar-thumb { cursor: pointer;
background-color: var(--el-border-color-darker); height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
border-radius: 2px; border-radius: 2px;
} transition: all 0.3s;
text-decoration: none;
user-select: none;
&::-webkit-scrollbar-track { &:first-of-type {
background-color: transparent; margin-left: 5px;
} }
}
:deep(.el-scrollbar__bar.is-horizontal) { &:hover {
height: 0; color: #409eff;
background: #ecf5ff;
border-color: #b3d8ff;
}
&.active {
background-color: #409eff;
color: #fff;
border-color: #409eff;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 4px;
}
}
.close-icon {
width: 14px;
height: 14px;
font-size: 14px;
margin-left: 5px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
vertical-align: middle;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background-color: #ff4d4f;
color: #fff;
}
}
}
} }
} }
.tags-view-item { .contextmenu {
display: inline-flex; margin: 0;
position: relative; background: #fff;
align-items: center; z-index: 3000;
cursor: pointer; position: absolute;
height: 26px; list-style-type: none;
line-height: 26px; padding: 5px 0;
border: 1px solid var(--el-border-color-light); border-radius: 4px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-blank);
padding: 0 12px;
font-size: 12px; font-size: 12px;
margin-left: 5px; font-weight: 400;
margin-top: 4px; color: #333;
border-radius: 3px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); min-width: 100px;
user-select: none;
&:first-of-type { li {
margin-left: 10px; margin: 0;
} padding: 8px 16px;
cursor: pointer;
&:last-of-type { display: flex;
margin-right: 10px;
}
&:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-7);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
&.active {
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
font-weight: 500;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(var(--el-color-primary-rgb), 0.3);
&::before {
content: '';
background: #fff;
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
position: relative;
margin-right: 6px;
flex-shrink: 0;
}
}
.el-icon-close {
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
border-radius: 50%; transition: all 0.3s;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
font-size: 12px;
margin-left: 6px;
width: 16px;
height: 16px;
line-height: 16px;
vertical-align: middle;
flex-shrink: 0;
opacity: 0.7;
&:hover { &:hover {
background-color: #fff; background: #ecf5ff;
color: var(--el-color-danger); color: #409eff;
opacity: 1;
transform: rotate(90deg);
} }
}
}
}
.contextmenu { .el-icon {
margin: 0; font-size: 14px;
background: var(--el-bg-color); }
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-regular);
box-shadow: var(--el-box-shadow-light);
li {
margin: 0;
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.el-icon {
font-size: 14px;
} }
} }
} }

View File

@@ -1,311 +1,187 @@
<template> <template>
<div class="userbar-container"> <div class="navbar-right">
<!-- 全屏切换 --> <div class="right-item">
<el-tooltip content="全屏" placement="bottom"> <el-tooltip content="全屏" placement="bottom">
<div class="icon-btn" @click="toggleFullScreen"> <el-icon class="icon-btn" @click="toggleFullScreen">
<el-icon v-if="!isFullScreen"> <component :is="isFullscreen ? 'ElIconFullScreen' : 'ElIconAim'" />
<FullScreen />
</el-icon> </el-icon>
<el-icon v-else> </el-tooltip>
<Aim /> </div>
</el-icon>
</div>
</el-tooltip>
<!-- 语言切换 --> <div class="right-item">
<el-dropdown trigger="click" @command="handleLanguageChange"> <el-tooltip content="刷新" placement="bottom">
<div class="icon-btn"> <el-icon class="icon-btn" @click="refreshPage">
<el-icon> <component :is="'ElIconRefresh'" />
<Location />
</el-icon> </el-icon>
</div> </el-tooltip>
<template #dropdown> </div>
<el-dropdown-menu>
<el-dropdown-item :disabled="currentLanguage === 'zh-CN'" command="zh-CN"> 简体中文 </el-dropdown-item>
<el-dropdown-item :disabled="currentLanguage === 'en-US'" command="en-US"> English </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 主题设置 --> <div class="right-item">
<el-dropdown trigger="click"> <el-dropdown trigger="click" @command="handleCommand">
<div class="icon-btn"> <span class="el-dropdown-link">
<el-icon> <el-avatar :size="32" :src="userInfo.avatar || ''">
<Sunny /> <el-icon><component :is="'ElIconUser'" /></el-icon>
</el-icon> </el-avatar>
</div> <span class="username">{{ userInfo.username || '用户' }}</span>
<template #dropdown> <el-icon class="el-icon--right"><component :is="'ElIconArrowDown'" /></el-icon>
<el-dropdown-menu> </span>
<el-dropdown-item @click="setTheme('light')"> <template #dropdown>
<el-icon> <el-dropdown-menu>
<Sunny /> <el-dropdown-item command="profile">
</el-icon> <el-icon><component :is="'ElIconUser'" /></el-icon>
浅色主题 个人中心
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click="setTheme('dark')"> <el-dropdown-item command="settings">
<el-icon> <el-icon><component :is="'ElIconSetting'" /></el-icon>
<Moon /> 设置
</el-icon> </el-dropdown-item>
深色主题 <el-dropdown-item divided command="logout">
</el-dropdown-item> <el-icon><component :is="'ElIconSwitchButton'" /></el-icon>
</el-dropdown-menu> 退出登录
</template> </el-dropdown-item>
</el-dropdown> </el-dropdown-menu>
</template>
<!-- 消息通知 --> </el-dropdown>
<el-tooltip content="消息" placement="bottom"> </div>
<div class="icon-btn">
<el-badge :value="messageCount" :hidden="messageCount === 0" :max="99">
<el-icon>
<Bell />
</el-icon>
</el-badge>
</div>
</el-tooltip>
<el-dropdown trigger="click" @command="handleCommand">
<div class="avatar-wrapper">
<el-avatar v-if="userStore.userInfo?.avatar" :src="userStore.userInfo.avatar" />
<el-avatar v-else>
<UserFilled />
</el-avatar>
<span class="username">{{ userStore.userInfo?.username || 'Admin' }}</span>
<el-icon class="el-icon-caret-bottom">
<ArrowDown />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="home">
<el-icon>
<HomeFilled />
</el-icon>
首页
</el-dropdown-item>
<el-dropdown-item command="profile">
<el-icon>
<User />
</el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item divided command="settings">
<el-icon>
<Setting />
</el-icon>
系统设置
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon>
<SwitchButton />
</el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus' import { useUserStore } from '../../stores/modules/user'
import { ArrowDown, HomeFilled, User, Setting, SwitchButton, FullScreen, Aim, Location, Sunny, Moon, Bell, UserFilled } from '@element-plus/icons-vue' import { useLayoutStore } from '../../stores/modules/layout'
import { useUserStore } from '@/stores/modules/user' import { ElMessage } from 'element-plus'
import { useI18nStore } from '@/stores/modules/i18n'
defineOptions({ defineOptions({
name: 'LayoutUserbar', name: 'UserBar',
}) })
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const i18nStore = useI18nStore() const layoutStore = useLayoutStore()
// 全屏状态 const isFullscreen = ref(false)
const isFullScreen = ref(false) const userInfo = ref({
username: '',
avatar: '',
})
// 消息数量 // 切换全屏
const messageCount = ref(0) const toggleFullScreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
isFullscreen.value = false
}
}
}
// 当前语言 // 刷新页面
const currentLanguage = computed(() => i18nStore.locale) const refreshPage = () => {
layoutStore.refreshTag()
router.replace({
path: '/redirect' + router.currentRoute.value.fullPath
})
}
// 处理下拉菜单命令 // 处理下拉菜单命令
const handleCommand = (command) => { const handleCommand = (command) => {
switch (command) { switch (command) {
case 'home':
router.push('/')
break
case 'profile': case 'profile':
router.push('/profile') router.push('/ucenter/profile')
break break
case 'settings': case 'settings':
router.push('/settings') router.push('/ucenter/settings')
break break
case 'logout': case 'logout':
handleLogout() handleLogout()
break break
default:
break
} }
} }
// 退出登录 // 退出登录
const handleLogout = () => { const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', { userStore.logout()
confirmButtonText: '确定', ElMessage.success('退出登录成功')
cancelButtonText: '取消', router.push('/login')
type: 'warning',
})
.then(() => {
userStore.logout()
router.push('/login')
ElMessage.success('退出成功')
})
.catch(() => {
// 取消退出
})
} }
// 切换全屏 // 获取用户信息
const toggleFullScreen = () => { const getUserInfo = () => {
if (!document.fullscreenElement) { const info = userStore.userInfo
document.documentElement.requestFullscreen() if (info) {
isFullScreen.value = true userInfo.value = {
} else { username: info.username || info.nickName || '用户',
if (document.exitFullscreen) { avatar: info.avatar || '',
document.exitFullscreen()
isFullScreen.value = false
} }
} }
} }
// 监听全屏变化 // 监听全屏变化
const handleFullScreenChange = () => { const handleFullscreenChange = () => {
isFullScreen.value = !!document.fullscreenElement isFullscreen.value = !!document.fullscreenElement
}
// 语言切换
const handleLanguageChange = (lang) => {
i18nStore.setLocale(lang)
ElMessage.success('语言切换成功')
}
// 设置主题
const setTheme = (theme) => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
ElMessage.success('主题切换成功')
} }
onMounted(() => { onMounted(() => {
document.addEventListener('fullscreenchange', handleFullScreenChange) getUserInfo()
document.addEventListener('fullscreenchange', handleFullscreenChange)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullScreenChange) document.removeEventListener('fullscreenchange', handleFullscreenChange)
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.userbar-container { .navbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; height: 100%;
.avatar-wrapper { .right-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; padding: 0 8px;
cursor: pointer; cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { .icon-btn {
background-color: var(--el-fill-color-light); font-size: 20px;
transform: translateY(-1px); color: #5a5e66;
} vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
.username { &:hover {
font-size: 14px; color: var(--primary-color);
font-weight: 500;
color: var(--el-text-color-primary);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-icon-caret-bottom {
font-size: 12px;
color: var(--el-text-color-regular);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.avatar-wrapper:hover & {
transform: rotate(180deg);
color: var(--el-color-primary);
}
}
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
.el-icon {
font-size: 18px;
color: var(--el-text-color-regular);
transition: all 0.3s;
}
&:hover {
background-color: var(--el-fill-color-light);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.el-icon {
color: var(--el-color-primary);
} }
} }
&:active { .el-dropdown-link {
transform: translateY(0); display: flex;
align-items: center;
cursor: pointer;
color: #5a5e66;
transition: color 0.3s;
&:hover {
color: var(--primary-color);
}
.username {
margin: 0 8px;
font-size: 14px;
}
.el-icon--right {
font-size: 12px;
}
} }
:deep(.el-badge__content) {
transform: translateY(-8px) translateX(8px);
border: 2px solid var(--el-bg-color);
}
:deep(.el-badge__content.is-fixed) {
transform: translateY(-8px) translateX(8px);
}
}
}
// 下拉菜单样式优化
:deep(.el-dropdown-menu__item) {
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
.el-icon {
font-size: 16px;
}
&:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
} }
} }
</style> </style>

View File

@@ -8,7 +8,13 @@
<img :src="logo" alt="logo" /> <img :src="logo" alt="logo" />
</div> </div>
<div class="menu-list"> <div class="menu-list">
<div v-for="menu in parentMenus" :key="menu.path" class="menu-item" :class="{ active: selectedParentMenu?.path === menu.path }" @click="handleParentMenuClick(menu)"> <div
v-for="menu in parentMenus"
:key="menu.path"
class="menu-item"
:class="{ active: selectedParentMenu?.path === menu.path }"
@click="handleParentMenuClick(menu)"
>
<el-icon v-if="menu.meta?.icon"> <el-icon v-if="menu.meta?.icon">
<component :is="menu.meta.icon" /> <component :is="menu.meta.icon" />
</el-icon> </el-icon>
@@ -19,13 +25,15 @@
<!-- 二级菜单栏 --> <!-- 二级菜单栏 -->
<el-aside :width="sidebarCollapsed ? '60px' : '220px'" class="second-sidebar"> <el-aside :width="sidebarCollapsed ? '60px' : '220px'" class="second-sidebar">
<div v-if="!sidebarCollapsed" class="logo logo-only"> <div v-if="!sidebarCollapsed && selectedParentMenu" class="second-sidebar-header">
<img v-if="logo" :src="logo" alt="logo" /> <el-icon v-if="selectedParentMenu.meta?.icon" class="menu-icon">
<span class="logo-text hidden">{{ logoText }}</span> <component :is="selectedParentMenu.meta.icon" />
</el-icon>
<span class="menu-title">{{ selectedParentMenu.meta?.title }}</span>
</div> </div>
<el-scrollbar class="menu-scrollbar"> <el-menu :default-active="activeMenu" :collapse="sidebarCollapsed" :collapse-transition="false">
<menu-component /> <layout-menu-item :menu-list="childMenus" />
</el-scrollbar> </el-menu>
</el-aside> </el-aside>
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -35,14 +43,14 @@
<el-icon class="collapse-icon" @click="toggleSidebar"> <el-icon class="collapse-icon" @click="toggleSidebar">
<component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" /> <component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" />
</el-icon> </el-icon>
<breadcrumb v-if="showBreadcrumb" /> <layout-breadcrumb v-if="showBreadcrumb" />
</div> </div>
<div class="header-right"> <div class="header-right">
<userbar /> <userbar />
</div> </div>
</el-header> </el-header>
<tags v-if="showTags" />
<el-main class="app-main"> <el-main class="app-main">
<tags v-if="showTags" />
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews"> <keep-alive :include="cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="refreshKey" /> <component v-if="!route.meta.link" :is="Component" :key="refreshKey" />
@@ -60,9 +68,9 @@
<img v-if="logo" :src="logo" alt="logo" /> <img v-if="logo" :src="logo" alt="logo" />
<span class="logo-text">{{ logoText }}</span> <span class="logo-text">{{ logoText }}</span>
</div> </div>
<el-scrollbar class="menu-scrollbar"> <el-menu :default-active="activeMenu" :collapse="sidebarCollapsed" :collapse-transition="false">
<menu-component /> <layout-menu-item :menu-list="allMenus" />
</el-scrollbar> </el-menu>
</el-aside> </el-aside>
<el-container class="main-container"> <el-container class="main-container">
<el-header class="app-header"> <el-header class="app-header">
@@ -70,13 +78,13 @@
<el-icon class="collapse-icon" @click="toggleSidebar"> <el-icon class="collapse-icon" @click="toggleSidebar">
<component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" /> <component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" />
</el-icon> </el-icon>
<breadcrumb v-if="showBreadcrumb" /> <layout-breadcrumb v-if="showBreadcrumb" />
</div> </div>
<div class="header-right"> <div class="header-right">
<tags v-if="showTags" />
<userbar /> <userbar />
</div> </div>
</el-header> </el-header>
<tags v-if="showTags" />
<el-main class="app-main"> <el-main class="app-main">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews"> <keep-alive :include="cachedViews">
@@ -97,14 +105,16 @@
<img v-if="logo" :src="logo" alt="logo" /> <img v-if="logo" :src="logo" alt="logo" />
<span class="logo-text">{{ logoText }}</span> <span class="logo-text">{{ logoText }}</span>
</div> </div>
<menu-component mode="horizontal" /> <el-menu :default-active="activeMenu" mode="horizontal" :ellipsis="false">
<layout-menu-item :menu-list="allMenus" />
</el-menu>
</div> </div>
<div class="header-right"> <div class="header-right">
<userbar /> <userbar />
</div> </div>
</el-header> </el-header>
<tags v-if="showTags" />
<el-main class="app-main"> <el-main class="app-main">
<tags v-if="showTags" />
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews"> <keep-alive :include="cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="refreshKey" /> <component v-if="!route.meta.link" :is="Component" :key="refreshKey" />
@@ -117,60 +127,87 @@
</el-container> </el-container>
<!-- 设置组件 --> <!-- 设置组件 -->
<setting /> <layout-setting />
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout' import { useUserStore } from '../stores/modules/user'
import { useUserStore } from '@/stores/modules/user' import { useLayoutStore } from '../stores/modules/layout'
import MenuComponent from './components/menu.vue' import LayoutMenuItem from './components/menu-item.vue'
import Breadcrumb from './components/breadcrumb.vue' import LayoutBreadcrumb from './components/breadcrumb.vue'
import Tags from './components/tags.vue' import Tags from './components/tags.vue'
import Userbar from './components/userbar.vue' import Userbar from './components/userbar.vue'
import Setting from './components/setting.vue' import LayoutSetting from './components/setting.vue'
import config from '@/config'
defineOptions({ defineOptions({
name: 'AppLayouts', name: 'AppLayouts',
}) })
const route = useRoute() const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const userStore = useUserStore() const userStore = useUserStore()
const layoutStore = useLayoutStore()
// Logo 配置 // Logo配置
const logo = config.LOGO const logo = computed(() => {
const logoText = config.APP_NAME return new URL('../assets/logo.png', import.meta.url).href
})
// 布局相关状态 const logoText = computed(() => {
return import.meta.env.VITE_APP_TITLE || 'Admin'
})
// 布局相关
const layoutMode = computed(() => layoutStore.layoutMode) const layoutMode = computed(() => layoutStore.layoutMode)
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed) const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
const showTags = computed(() => layoutStore.showTags) const showTags = computed(() => layoutStore.showTags)
const showBreadcrumb = computed(() => layoutStore.showBreadcrumb) const showBreadcrumb = computed(() => layoutStore.showBreadcrumb)
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
const refreshKey = computed(() => layoutStore.refreshKey) const refreshKey = computed(() => layoutStore.refreshKey)
const viewTags = computed(() => layoutStore.viewTags)
// 布局样式类 // 菜单数据
const layoutClass = computed(() => { const allMenus = computed(() => {
return { return userStore.getMenu() || []
[`layout-${layoutMode.value}`]: true, })
'sidebar-collapsed': sidebarCollapsed.value,
const parentMenus = computed(() => {
return allMenus.value.filter((menu) => !menu.meta?.hidden)
})
const childMenus = computed(() => {
if (!selectedParentMenu.value) {
return []
} }
return selectedParentMenu.value.children || []
})
const selectedParentMenu = computed({
get: () => layoutStore.selectedParentMenu,
set: (value) => layoutStore.setSelectedParentMenu(value),
})
// 当前激活的菜单
const activeMenu = computed(() => {
const { meta, path } = route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
}) })
// 缓存的视图 // 缓存的视图
const cachedViews = computed(() => { const cachedViews = computed(() => {
return viewTags.value.filter((tag) => tag.meta?.keepAlive).map((tag) => tag.name) return layoutStore.viewTags.map((tag) => tag.name).filter((name) => name)
}) })
// 父级菜单(一级菜单) // 布局样式类
const parentMenus = computed(() => { const layoutClass = computed(() => {
const menus = userStore.getMenu() || [] return {
return menus.filter((menu) => !menu.meta?.hidden && menu.children && menu.children.length > 0) 'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'sidebar-collapsed': sidebarCollapsed.value,
}
}) })
// 切换侧边栏 // 切换侧边栏
@@ -178,380 +215,254 @@ const toggleSidebar = () => {
layoutStore.toggleSidebar() layoutStore.toggleSidebar()
} }
// 处理菜单点击 // 处理一级菜单点击
const handleParentMenuClick = (menu) => { const handleParentMenuClick = (menu) => {
// 如果点击的是当前选中的父菜单,不重复操作 selectedParentMenu.value = menu
if (selectedParentMenu.value?.path === menu.path) {
return
}
// 设置选中的父菜单
layoutStore.setSelectedParentMenu(menu)
// 如果父菜单有 redirect跳转
if (menu.redirect) {
router.push(menu.redirect)
return
}
// 如果有子菜单,跳转到第一个子菜单
if (menu.children && menu.children.length > 0) {
const firstChild = menu.children.find((child) => !child.meta?.hidden) || menu.children[0]
if (firstChild) {
router.push(firstChild.path)
}
}
} }
// 监听路由变化,自动更新选中的父菜单 // 监听路由变化,自动选择父菜单
watch( watch(
() => route.path, () => route.path,
() => { () => {
if (layoutMode.value === 'default') { if (layoutMode.value === 'default') {
const menus = userStore.getMenu() || [] const path = route.path
for (const parentMenu of menus) { const parent = parentMenus.value.find((menu) => {
if (parentMenu.children && parentMenu.children.some((child) => route.path.startsWith(child.path))) { if (menu.path === path || path.startsWith(menu.path + '/')) {
layoutStore.setSelectedParentMenu(parentMenu) return true
break
} }
if (menu.children) {
return menu.children.some((child) => child.path === path || path.startsWith(child.path + '/'))
}
return false
})
if (parent && (!selectedParentMenu.value || !path.startsWith(selectedParentMenu.value.path + '/'))) {
selectedParentMenu.value = parent
} }
} }
// 更新视图标签
if (route.name && !route.meta?.hidden) {
layoutStore.updateViewTags({
name: route.name,
path: route.path,
fullPath: route.fullPath,
title: route.meta?.title,
meta: route.meta,
})
}
}, },
{ immediate: true }, { immediate: true }
) )
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-wrapper { .app-wrapper {
position: relative;
height: 100%;
width: 100%; width: 100%;
height: 100vh;
overflow: hidden;
// 通用侧边栏样式 &.layout-default {
.el-aside {
transition: width 0.28s;
display: flex; display: flex;
flex-direction: column;
background-color: var(--el-menu-bg-color);
border-right: 1px solid var(--el-border-color-light);
}
// Logo 样式 .first-sidebar {
.logo { width: 60px;
height: 60px; height: 100%;
display: flex; background: #001529;
align-items: center; overflow-x: hidden;
justify-content: center;
padding: 0 20px;
border-bottom: 1px solid var(--el-border-color-light);
img {
height: 32px;
max-width: 100%;
object-fit: contain;
}
.logo-text {
margin-left: 10px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 双栏布局只显示logo隐藏名称
&.logo-only .logo-text.hidden {
display: none;
}
&.logo-only {
justify-content: center;
img {
margin: 0;
}
}
}
.logo-mini {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--el-border-color-light);
img {
height: 32px;
width: 32px;
object-fit: contain;
}
.logo-text-mini {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
// Header 样式
.el-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background-color: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-icon {
font-size: 20px;
cursor: pointer;
color: var(--el-text-color-regular);
transition: color 0.3s;
&:hover {
color: var(--el-color-primary);
}
}
}
// Main 样式
.el-main {
padding: 0;
background-color: var(--el-bg-color-page);
overflow-y: auto;
}
.iframe-container {
width: 100%;
height: 100%;
border: none;
}
}
// 默认布局样式
.layout-default {
.first-sidebar {
.menu-list {
flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 10px 0;
.menu-item { .logo-mini {
height: 50px; height: 50px;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; background: #002140;
color: var(--el-text-color-regular);
transition: all 0.3s;
&:hover { img {
background-color: var(--el-fill-color-light); width: 32px;
height: 32px;
}
}
.menu-list {
.menu-item {
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(255, 255, 255, 0.65);
transition: all 0.3s;
&:hover {
color: #fff;
background: #1890ff;
}
&.active {
color: #fff;
background: #1890ff;
}
.el-icon {
font-size: 24px;
margin-bottom: 4px;
}
.menu-text {
font-size: 12px;
text-align: center;
}
}
}
}
.second-sidebar {
background: #fff;
border-right: 1px solid #e6e6e6;
overflow-x: hidden;
overflow-y: auto;
transition: width 0.3s;
display: flex;
flex-direction: column;
.second-sidebar-header {
height: 50px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #e6e6e6;
background: #fafafa;
flex-shrink: 0;
gap: 8px;
.menu-icon {
font-size: 18px;
color: #333;
} }
&.active { .menu-title {
color: var(--el-color-primary); font-size: 15px;
background-color: var(--el-color-primary-light-9); font-weight: 500;
} color: #333;
white-space: nowrap;
.el-icon { overflow: hidden;
font-size: 20px; text-overflow: ellipsis;
margin-bottom: 4px;
}
.menu-text {
font-size: 12px;
line-height: 1;
} }
} }
} }
} }
.second-sidebar { &.layout-menu {
.menu-scrollbar { display: flex;
flex: 1;
overflow: hidden;
}
}
}
// Menu 布局样式 .menu-sidebar {
.layout-menu { background: #fff;
.menu-sidebar { border-right: 1px solid #e6e6e6;
.menu-scrollbar { overflow-x: hidden;
flex: 1; overflow-y: auto;
overflow: hidden; transition: width 0.3s;
}
}
}
// Top 布局样式
.layout-top {
.top-header {
.header-left {
display: flex;
align-items: center;
gap: 20px;
.logo { .logo {
border: none; height: 50px;
padding: 0; display: flex;
height: 60px; align-items: center;
} padding: 0 20px;
} border-bottom: 1px solid #e6e6e6;
}
}
// 侧边栏折叠状态 img {
.sidebar-collapsed { width: 32px;
.logo-text { height: 32px;
display: none !important; margin-right: 8px;
} }
}
// 响应式设计 .logo-text {
@media screen and (max-width: 768px) {
// 小屏幕优化
.layout-default {
.first-sidebar {
width: 50px !important;
.menu-item {
height: 45px;
.el-icon {
font-size: 18px; font-size: 18px;
font-weight: 600;
color: #333;
} }
}
}
}
.menu-text { &.layout-top {
font-size: 10px; .top-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
.logo {
display: flex;
align-items: center;
margin-right: 40px;
img {
width: 32px;
height: 32px;
margin-right: 8px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: #333;
}
} }
} }
.logo-mini img { .el-menu {
height: 28px; border-bottom: none;
width: 28px;
}
}
.second-sidebar {
position: fixed;
left: 50px;
top: 0;
bottom: 0;
z-index: 1000;
background: var(--el-bg-color);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
}
.layout-menu {
.menu-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
background: var(--el-bg-color);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
}
// Header 优化
.el-header {
padding: 0 12px;
.header-left,
.header-right {
gap: 8px;
}
.collapse-icon {
font-size: 18px;
}
:deep(.breadcrumb) {
display: none;
}
}
// Tags 优化
.tags-view-item {
padding: 0 8px;
font-size: 11px;
}
}
@media screen and (max-width: 480px) {
// 超小屏幕优化
.layout-default {
.first-sidebar {
width: 45px !important;
}
.second-sidebar {
left: 45px;
}
}
.layout-menu {
.menu-sidebar {
width: 200px !important;
}
}
// Header 优化
.el-header {
padding: 0 8px;
.header-left,
.header-right {
gap: 6px;
}
.icon-btn {
width: 32px;
height: 32px;
}
.avatar-wrapper {
padding: 4px 8px;
.username {
display: none;
} }
} }
} }
// Logo 优化 .main-container {
.logo { flex: 1;
img { display: flex;
height: 28px; flex-direction: column;
overflow: hidden;
.app-header {
height: 50px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
.collapse-icon {
font-size: 20px;
cursor: pointer;
color: #666;
margin-right: 16px;
&:hover {
color: var(--primary-color);
}
}
}
.header-right {
display: flex;
align-items: center;
}
} }
.logo-text { .app-main {
font-size: 16px; flex: 1;
background: #f0f2f5;
overflow: auto;
padding: 20px;
.iframe-container {
width: 100%;
height: 100%;
border: none;
}
} }
} }
} }

View File

@@ -48,9 +48,8 @@ const goHome = () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/assets/style/auth.scss';
.not-found-container { .not-found-container {
width: 100%;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -30,7 +30,6 @@
<script setup> <script setup>
import { Box } from '@element-plus/icons-vue' import { Box } from '@element-plus/icons-vue'
import '@/assets/style/auth.scss'
defineProps({ defineProps({
description: { description: {
@@ -59,9 +58,8 @@ const handleAction = () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/assets/style/auth.scss';
.empty-container { .empty-container {
width: 100%;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;