初始化项目

This commit is contained in:
2026-02-08 22:38:13 +08:00
commit 334d2c6312
201 changed files with 32724 additions and 0 deletions
@@ -0,0 +1,55 @@
<template>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
<component :is="item.meta?.icon || 'FileTextOutlined'" />
{{ item.meta.title }}
</span>
<a v-else @click.prevent="handleLink(item)">
<component :is="item.meta?.icon || 'FileTextOutlined'" />
{{ item.meta.title }}
</a>
</a-breadcrumb-item>
</a-breadcrumb>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import config from '@/config'
// 定义组件名称(多词命名)
defineOptions({
name: 'LayoutBreadcrumb'
})
const route = useRoute()
const breadcrumbList = ref([])
// 获取面包屑列表
const getBreadcrumb = () => {
let matched = route.matched.filter(item => item.meta && item.meta.title)
// 如果第一个不是首页,添加首页
const first = matched[0]
if (first && first.path !== config.DASHBOARD_URL) {
matched = [{ path: config.DASHBOARD_URL, meta: { title: '', icon: 'HomeOutlined' } }].concat(matched)
}
breadcrumbList.value = matched
}
// 处理点击面包屑
const handleLink = () => {
return
}
// 监听路由变化
watch(
() => route.path,
() => {
getBreadcrumb()
},
{ immediate: true }
)
</script>
@@ -0,0 +1,54 @@
<template>
<template v-for="item in menuItems" :key="item.path || item.name">
<!-- 有子菜单 - 使用递归 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`${item.path}`">
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
<template #title>{{ item.meta?.title || item.name }}</template>
<navMenu :menu-items="item.children" :active-path="activePath" :parent-path="item.path" />
</a-sub-menu>
<!-- 无子菜单的菜单项 -->
<a-menu-item v-else :key="item.path" :class="{ 'ant-menu-item-selected': item.path === activePath }"
@click="handleMenuClick(item)">
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
{{ item.meta?.title || item.name }}
</a-menu-item>
</template>
</template>
<script setup>
import { useRouter } from 'vue-router'
import * as icons from '@ant-design/icons-vue'
defineProps({
menuItems: {
type: Array,
default: () => []
},
activePath: {
type: String,
default: ''
},
parentPath: {
type: String,
default: ''
}
})
const router = useRouter()
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
// 处理菜单点击
const handleMenuClick = (item) => {
if (item.path) {
router.push(item.path)
}
}
</script>
@@ -0,0 +1,302 @@
<template>
<a-modal
v-model:open="visible"
:title="$t('common.searchMenu')"
:footer="null"
:width="600"
:destroyOnClose="true"
@cancel="handleClose"
>
<div class="menu-search">
<a-input
v-model:value="searchKeyword"
:placeholder="$t('common.searchPlaceholder')"
size="large"
allow-clear
@input="handleSearch"
@keydown="handleKeydown"
ref="searchInputRef"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div v-if="searchResults.length > 0" class="search-results">
<div
v-for="(item, index) in searchResults"
:key="item.path"
class="result-item"
:class="{ active: selectedIndex === index }"
@click="handleSelect(item)"
@mouseenter="selectedIndex = index"
>
<div class="result-icon">
<component :is="item.icon || 'MenuOutlined'" />
</div>
<div class="result-content">
<div class="result-title">{{ item.title }}</div>
<div v-if="item.breadcrumbs" class="result-path">{{ item.breadcrumbs }}</div>
</div>
</div>
</div>
<div v-else-if="searchKeyword" class="no-results">
<a-empty :description="$t('common.noResults')" />
</div>
<div v-else class="search-tips">
<div class="tip-title">{{ $t('common.searchTips') }}</div>
<div class="tip-list">
<div class="tip-item">
<kbd></kbd>
<kbd></kbd>
<span>{{ $t('common.navigateResults') }}</span>
</div>
<div class="tip-item">
<kbd>Enter</kbd>
<span>{{ $t('common.selectResult') }}</span>
</div>
<div class="tip-item">
<kbd>Esc</kbd>
<span>{{ $t('common.closeSearch') }}</span>
</div>
</div>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { SearchOutlined, MenuOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18n } from 'vue-i18n'
// 定义组件名称
defineOptions({
name: 'MenuSearch',
})
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const visible = defineModel('visible', { type: Boolean, default: false })
const searchKeyword = ref('')
const searchResults = ref([])
const selectedIndex = ref(0)
const searchInputRef = ref(null)
// 将扁平化的菜单数据转换为可搜索格式
function flattenMenus(menus, breadcrumbs = []) {
const result = []
menus.forEach((menu) => {
if (menu.hidden) return
const currentBreadcrumbs = [...breadcrumbs, menu.title]
// 如果有路径且不是外部链接,添加到搜索结果
if (menu.path && !menu.path.startsWith('http')) {
result.push({
title: menu.title,
path: menu.path,
icon: menu.icon,
breadcrumbs: currentBreadcrumbs.join(' / '),
})
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
const children = flattenMenus(menu.children, currentBreadcrumbs)
result.push(...children)
}
})
return result
}
// 获取所有菜单项
const allMenus = computed(() => {
const menus = userStore.menu || []
return flattenMenus(menus)
})
// 执行搜索
function handleSearch() {
if (!searchKeyword.value.trim()) {
searchResults.value = []
selectedIndex.value = 0
return
}
const keyword = searchKeyword.value.toLowerCase().trim()
searchResults.value = allMenus.value.filter((menu) => {
return menu.title.toLowerCase().includes(keyword) ||
menu.breadcrumbs.toLowerCase().includes(keyword)
})
selectedIndex.value = 0
}
// 键盘导航
function handleKeydown(e) {
if (!searchResults.value.length) return
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedIndex.value = selectedIndex.value > 0
? selectedIndex.value - 1
: searchResults.value.length - 1
break
case 'ArrowDown':
e.preventDefault()
selectedIndex.value = selectedIndex.value < searchResults.value.length - 1
? selectedIndex.value + 1
: 0
break
case 'Enter':
e.preventDefault()
if (searchResults.value[selectedIndex.value]) {
handleSelect(searchResults.value[selectedIndex.value])
}
break
case 'Escape':
e.preventDefault()
handleClose()
break
}
}
// 选择菜单项
function handleSelect(item) {
visible.value = false
router.push(item.path)
}
// 关闭搜索弹窗
function handleClose() {
visible.value = false
searchKeyword.value = ''
searchResults.value = []
selectedIndex.value = 0
}
// 监听弹窗显示,自动聚焦输入框
watch(visible, (newVal) => {
if (newVal) {
nextTick(() => {
searchInputRef.value?.focus()
})
} else {
handleClose()
}
})
</script>
<style scoped lang="scss">
.menu-search {
.search-results {
max-height: 400px;
overflow-y: auto;
margin-top: 16px;
border: 1px solid #f0f0f0;
border-radius: 4px;
.result-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover,
&.active {
background-color: #e6f7ff;
}
.result-icon {
margin-right: 12px;
font-size: 16px;
color: #1890ff;
}
.result-content {
flex: 1;
min-width: 0;
.result-title {
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-weight: 500;
}
.result-path {
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.no-results {
margin-top: 40px;
}
.search-tips {
margin-top: 20px;
.tip-title {
font-size: 14px;
color: #666;
margin-bottom: 16px;
text-align: center;
}
.tip-list {
display: flex;
flex-direction: row;
gap: 12px;
.tip-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background-color: #f5f5f5;
border-radius: 4px;
kbd {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
font-family: inherit;
line-height: 1;
color: #333;
background-color: #fff;
border: 1px solid #d9d9d9;
border-radius: 2px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
span {
font-size: 13px;
color: #666;
}
}
}
}
}
</style>
@@ -0,0 +1,350 @@
<template>
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
<div class="setting-content">
<div class="setting-item">
<div class="setting-title">布局模式</div>
<div class="layout-mode-list">
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
:class="{ active: layoutStore.layoutMode === mode.value }"
@click="handleLayoutChange(mode.value)">
<div class="layout-preview" :class="`preview-${mode.value}`">
<div class="preview-sidebar"></div>
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
<div class="preview-content">
<div class="preview-header"></div>
<div class="preview-body"></div>
</div>
</div>
<div class="layout-name">{{ mode.label }}</div>
<CheckOutlined v-if="layoutStore.layoutMode === mode.value" class="check-icon" />
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-title">主题颜色</div>
<div class="color-list">
<div v-for="color in themeColors" :key="color" class="color-item"
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
@click="changeThemeColor(color)">
<CheckOutlined v-if="themeColor === color" />
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-title">显示设置</div>
<div class="toggle-list">
<div class="toggle-item">
<span>显示标签栏</span>
<a-switch v-model:checked="showTags" @change="handleShowTagsChange" />
</div>
<div class="toggle-item">
<span>显示面包屑</span>
<a-switch v-model:checked="showBreadcrumb" @change="handleShowBreadcrumbChange" />
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-title">其他设置</div>
<div class="action-buttons">
<a-button type="primary" block @click="handleResetSettings">
<ReloadOutlined />
重置设置
</a-button>
</div>
</div>
</div>
</a-drawer>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useLayoutStore } from '@/stores/modules/layout'
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
// 定义组件名称(多词命名)
defineOptions({
name: 'LayoutSetting',
})
const layoutStore = useLayoutStore()
const open = ref(false)
const themeColor = ref('#1890ff')
const showTags = ref(true)
const showBreadcrumb = ref(true)
const layoutModes = [
{ value: 'default', label: '默认布局' },
{ value: 'menu', label: '菜单布局' },
{ value: 'top', label: '顶部布局' },
]
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
const openDrawer = () => {
open.value = true
}
const closeDrawer = () => {
open.value = false
}
defineExpose({
openDrawer,
closeDrawer,
})
// 切换布局
const handleLayoutChange = (mode) => {
layoutStore.setLayoutMode(mode)
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
message.success(`已切换到${modeLabel}`)
}
// 切换主题颜色
const changeThemeColor = (color) => {
themeColor.value = color
// 更新 CSS 变量
document.documentElement.style.setProperty('--primary-color', color)
message.success('主题颜色已更新')
}
// 切换标签栏显示
const handleShowTagsChange = (checked) => {
showTags.value = checked
// 触发自定义事件或更新状态
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
}
// 切换面包屑显示
const handleShowBreadcrumbChange = (checked) => {
showBreadcrumb.value = checked
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
}
// 重置设置
const handleResetSettings = () => {
themeColor.value = '#1890ff'
showTags.value = true
showBreadcrumb.value = true
layoutStore.setLayoutMode('default')
document.documentElement.style.setProperty('--primary-color', '#1890ff')
document.documentElement.style.setProperty('--show-tags', 'block')
message.success('设置已重置')
}
// 初始化
onMounted(() => {
// 从本地存储或其他地方恢复设置
const savedThemeColor = localStorage.getItem('themeColor')
if (savedThemeColor) {
themeColor.value = savedThemeColor
document.documentElement.style.setProperty('--primary-color', savedThemeColor)
}
const savedShowTags = localStorage.getItem('showTags')
if (savedShowTags !== null) {
showTags.value = savedShowTags === 'true'
document.documentElement.style.setProperty('--show-tags', savedShowTags === 'true' ? 'block' : 'none')
}
})
// 监听设置变化并保存到本地存储
watch(themeColor, (newVal) => {
localStorage.setItem('themeColor', newVal)
})
watch(showTags, (newVal) => {
localStorage.setItem('showTags', String(newVal))
})
</script>
<style scoped lang="scss">
.setting-content {
.setting-item {
margin-bottom: 32px;
.setting-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
color: #333;
}
.layout-mode-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.layout-mode-item {
position: relative;
border: 2px solid #e8e8e8;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: var(--primary-color, #1890ff);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.active {
border-color: var(--primary-color, #1890ff);
background-color: rgba(24, 144, 255, 0.05);
}
.layout-preview {
width: 100%;
height: 60px;
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
display: flex;
background-color: #f0f2f5;
.preview-sidebar {
background-color: #001529;
}
.preview-sidebar-2 {
background-color: #fff;
border-left: 1px solid #e8e8e8;
}
.preview-content {
flex: 1;
padding: 4px;
.preview-header {
height: 8px;
background-color: #fff;
margin-bottom: 4px;
}
.preview-body {
height: calc(100% - 12px);
background-color: #e8e8e8;
}
}
&.preview-default {
.preview-sidebar {
width: 20px;
}
.preview-sidebar-2 {
width: 24px;
}
}
&.preview-menu {
.preview-sidebar {
width: 30px;
background-color: #fff;
border-right: 1px solid #e8e8e8;
}
}
&.preview-top {
flex-direction: column;
.preview-sidebar {
width: 100%;
height: 12px;
background-color: #fff;
}
.preview-content {
.preview-header {
display: none;
}
.preview-body {
height: 100%;
}
}
}
}
.layout-name {
font-size: 12px;
color: #666;
text-align: center;
}
.check-icon {
position: absolute;
top: 4px;
right: 4px;
color: var(--primary-color, #1890ff);
font-size: 12px;
}
}
}
.color-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.color-item {
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
border: 2px solid transparent;
&:hover {
transform: scale(1.1);
}
&.active {
border-color: #fff;
box-shadow: 0 0 0 2px var(--primary-color, #1890ff);
.anticon {
color: #fff;
}
}
}
}
.toggle-list {
.toggle-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
span {
font-size: 14px;
color: #333;
}
}
}
.action-buttons {
:deep(.ant-btn) {
height: 40px;
font-size: 14px;
}
}
}
}
</style>
@@ -0,0 +1,172 @@
<template>
<a-menu mode="inline" :theme="theme" :collapsed="collapsed" :selected-keys="selectedKeys" :open-keys="openKeys"
@select="handleSelect" @open-change="handleOpenChange" class="side-menu">
<template v-for="item in menuList">
<!-- 有子菜单 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.path + '-submenu'">
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
<template #title>{{ item.meta?.title || item.name }}</template>
<a-menu-item v-for="child in item.children.filter(sub => !sub.children || sub.children.length === 0)"
:key="child.path">
<template #icon>
<component :is="child.meta?.icon || 'FileOutlined'" />
</template>
{{ child.meta?.title || child.name }}
</a-menu-item>
<a-sub-menu v-for="child in item.children.filter(sub => sub.children && sub.children.length > 0)"
:key="child.path">
<template #icon>
<component :is="child.meta?.icon || 'AppstoreOutlined'" />
</template>
<template #title>{{ child.meta?.title || child.name }}</template>
<a-menu-item v-for="grandChild in child.children" :key="grandChild.path">
<template #icon>
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
</template>
{{ grandChild.meta?.title || grandChild.name }}
</a-menu-item>
</a-sub-menu>
</a-sub-menu>
<!-- 无子菜单 -->
<a-menu-item v-else :key="item.path + '-item'">
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
{{ item.meta?.title || item.name }}
</a-menu-item>
</template>
</a-menu>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getUserMenu } from '@/api/menu'
const props = defineProps({
collapsed: {
type: Boolean,
default: false
},
theme: {
type: String,
default: 'light'
}
})
const route = useRoute()
const router = useRouter()
const menuList = ref([])
const selectedKeys = ref([])
const openKeys = ref([])
// 获取菜单数据
const getMenuList = async () => {
try {
const res = await getUserMenu()
if (res.code === 200) {
menuList.value = res.data || []
}
} catch (error) {
console.error('获取菜单失败:', error)
// 模拟数据
menuList.value = [
{
path: '/home',
name: 'Home',
meta: { title: '首页', icon: 'HomeOutlined' }
},
{
path: '/system',
name: 'System',
meta: { title: '系统管理', icon: 'SettingOutlined' },
children: [
{
path: '/system/user',
name: 'User',
meta: { title: '用户管理', icon: 'UserOutlined' }
},
{
path: '/system/role',
name: 'Role',
meta: { title: '角色管理', icon: 'TeamOutlined' }
},
{
path: '/system/menu',
name: 'Menu',
meta: { title: '菜单管理', icon: 'MenuOutlined' }
}
]
}
]
}
}
// 更新选中的菜单
const updateSelectedKeys = () => {
selectedKeys.value = [route.path]
// 获取父级菜单路径
const matched = route.matched
.filter(item => item.path !== '/' && item.path !== route.path)
.map(item => item.path)
// 折叠时不自动展开
if (!props.collapsed) {
openKeys.value = matched
}
}
// 处理菜单选择
const handleSelect = ({ key }) => {
router.push(key)
}
// 处理菜单展开/收起
const handleOpenChange = (keys) => {
openKeys.value = keys
}
// 监听路由变化
watch(() => route.path, () => {
updateSelectedKeys()
}, { immediate: true })
// 监听折叠状态
watch(() => props.collapsed, (val) => {
if (val) {
openKeys.value = []
} else {
updateSelectedKeys()
}
})
onMounted(() => {
getMenuList()
})
</script>
<style scoped lang="scss">
.side-menu {
height: calc(100% - 60px);
border-right: none;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
</style>
@@ -0,0 +1,487 @@
<template>
<div v-show="showTags" class="tags-view">
<div class="tags-wrapper" @contextmenu.prevent>
<a-space :size="4">
<a-tag
v-for="tag in visitedViews"
:key="tag.fullPath"
:closable="!tag.meta?.affix"
class="tag-item"
:class="{ active: isActive(tag), 'tag-affix': tag.meta?.affix }"
@click="clickTag(tag)"
@close="closeSelectedTag(tag)"
@contextmenu.prevent="handleContextMenu($event, tag)">
<template #icon v-if="tag.meta?.affix">
<PushpinFilled />
</template>
{{ tag.meta?.title || tag.name }}
</a-tag>
</a-space>
</div>
<div class="tags-actions">
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
<a-button size="small" type="text">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleActionMenuClick">
<a-menu-item key="refresh">
<ReloadOutlined />
<span>刷新当前页</span>
</a-menu-item>
<a-menu-item key="closeOthers">
<ColumnWidthOutlined />
<span>关闭其他</span>
</a-menu-item>
<a-menu-item key="closeLeft">
<LeftOutlined />
<span>关闭左侧</span>
</a-menu-item>
<a-menu-item key="closeRight">
<RightOutlined />
<span>关闭右侧</span>
</a-menu-item>
<a-menu-item key="closeAll">
<CloseCircleOutlined />
<span>关闭所有</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenu.visible"
:style="{
position: 'fixed',
left: contextMenu.x + 'px',
top: contextMenu.y + 'px',
zIndex: 9999
}"
class="context-menu"
@click="closeContextMenu">
<a-menu @click="handleMenuClick">
<a-menu-item key="refresh">
<ReloadOutlined />
<span>刷新</span>
</a-menu-item>
<a-menu-item v-if="selectedTag && !selectedTag.meta?.affix" key="close">
<CloseOutlined />
<span>关闭</span>
</a-menu-item>
<a-menu-item key="closeOthers">
<ColumnWidthOutlined />
<span>关闭其他</span>
</a-menu-item>
<a-menu-item key="closeAll">
<CloseCircleOutlined />
<span>关闭所有</span>
</a-menu-item>
</a-menu>
</div>
</teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import config from '@/config'
defineOptions({
name: 'TagsView',
})
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const showTags = ref(true)
const selectedTag = ref(null)
const visitedViews = computed(() => layoutStore.viewTags)
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0
})
// 顶部操作菜单状态
const actionMenuVisible = ref(false)
// 判断是否是当前激活的标签
const isActive = (tag) => {
return tag.fullPath === route.fullPath
}
// 添加标签
const addTags = () => {
const { name } = route
if (name && !route.meta?.noCache) {
layoutStore.updateViewTags({
fullPath: route.fullPath,
path: route.path,
name: name,
query: route.query,
params: route.params,
meta: route.meta
})
}
}
// 移除标签
const closeSelectedTag = (view) => {
// 如果是固定标签,不允许关闭
if (view.meta?.affix) {
return
}
layoutStore.removeViewTags(view.fullPath)
// 如果关闭的是当前激活的标签,需要跳转
if (isActive(view)) {
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
if (nextTag) {
router.push(nextTag.fullPath)
} else {
// 如果没有其他标签,跳转到首页
router.push(config.DASHBOARD_URL)
}
}
}
// 关闭其他标签
const closeOthersTags = () => {
if (!selectedTag.value || !selectedTag.value.fullPath) {
return
}
// 保留固定标签和当前选中的标签
const tagsToKeep = visitedViews.value.filter(
(tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath
)
// 更新标签列表
layoutStore.viewTags = tagsToKeep
// 如果当前不在选中的标签页,跳转到选中的标签
if (!isActive(selectedTag.value)) {
router.push(selectedTag.value.fullPath)
}
}
// 关闭所有标签
const closeAllTags = () => {
// 只保留固定标签
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
layoutStore.viewTags = affixTags
// 如果还有固定标签,跳转到第一个固定标签
if (affixTags.length > 0) {
router.push(affixTags[0].fullPath)
} else {
// 如果没有固定标签,跳转到首页
router.push(config.DASHBOARD_URL)
}
}
// 关闭左侧标签
const closeLeftTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其右侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index >= currentIndex
})
layoutStore.viewTags = tagsToKeep
}
// 关闭右侧标签
const closeRightTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其左侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index <= currentIndex
})
layoutStore.viewTags = tagsToKeep
}
// 点击标签
const clickTag = (tag) => {
if (!isActive(tag)) {
router.push(tag.fullPath)
}
}
// 刷新指定标签
const refreshTag = (tag) => {
// 如果刷新的是当前激活的标签
if (isActive(tag)) {
// 调用 store 的刷新方法,触发组件重新渲染
layoutStore.refreshTag()
} else {
// 如果刷新的是其他标签,先跳转到该标签
router.push(tag.fullPath)
}
}
// 刷新当前选中的标签(用于顶部操作按钮)
const refreshSelectedTag = () => {
// 找到当前激活的标签
const currentTag = visitedViews.value.find((tag) => isActive(tag))
if (currentTag) {
refreshTag(currentTag)
}
}
// 右键菜单处理
const handleContextMenu = (event, tag) => {
event.preventDefault()
event.stopPropagation()
selectedTag.value = tag
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY
}
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value.visible = false
}
// 菜单点击处理
const handleMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
if (selectedTag.value) {
refreshTag(selectedTag.value)
}
break
case 'close':
if (selectedTag.value && !selectedTag.value.meta?.affix) {
closeSelectedTag(selectedTag.value)
}
break
case 'closeOthers':
closeOthersTags()
break
case 'closeAll':
closeAllTags()
break
}
closeContextMenu()
}
// 顶部操作菜单点击处理
const handleActionMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
refreshSelectedTag()
break
case 'closeOthers':
closeOthersTags()
break
case 'closeLeft':
closeLeftTags()
break
case 'closeRight':
closeRightTags()
break
case 'closeAll':
closeAllTags()
break
}
actionMenuVisible.value = false
}
// 点击其他地方关闭右键菜单
const handleClickOutside = (event) => {
if (contextMenu.value.visible) {
const menuElement = document.querySelector('.context-menu')
if (menuElement && !menuElement.contains(event.target)) {
closeContextMenu()
}
}
}
// 监听路由变化,自动添加标签
watch(
() => route.fullPath,
() => {
addTags()
// 更新当前选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
},
{ immediate: true }
)
onMounted(() => {
addTags()
// 初始化选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
// 添加点击事件监听器
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
// 移除点击事件监听器
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped lang="scss">
.tags-view {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.tags-wrapper {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
&::-webkit-scrollbar {
height: 0;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 0;
}
&::-webkit-scrollbar-track {
background: transparent;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
line-height: 28px;
padding: 0 12px;
margin: 0 4px;
border: 1px solid #d9d9d9;
background-color: #fafafa;
border-radius: 2px;
cursor: pointer;
transition: all 0.3s;
user-select: none;
&:hover {
color: #1890ff;
border-color: #1890ff;
}
&.active {
background-color: #1890ff;
border-color: #1890ff;
color: #ffffff;
&:hover {
background-color: #40a9ff;
border-color: #40a9ff;
}
}
&.tag-affix {
background-color: #fff7e6;
border-color: #ffd591;
color: #fa8c16;
&.active {
background-color: #fa8c16;
border-color: #fa8c16;
color: #ffffff;
}
&:hover {
background-color: #ffe7ba;
border-color: #ffa940;
}
&.active:hover {
background-color: #d46b08;
border-color: #d46b08;
}
}
:deep(.ant-tag-close-icon) {
color: inherit;
&:hover {
background-color: rgba(255, 255, 255, 0.3);
}
}
}
}
.tags-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
flex-shrink: 0;
:deep(.ant-btn) {
font-size: 14px;
padding: 2px 6px;
&:hover {
color: #1890ff;
}
}
}
}
.context-menu {
background: #ffffff;
border-radius: 2px;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
padding: 4px 0;
min-width: 160px;
:deep(.ant-menu) {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
.ant-menu-item {
margin: 0;
padding: 8px 12px;
height: auto;
line-height: normal;
&:hover {
background-color: #f5f5f5;
}
}
}
}
</style>
@@ -0,0 +1,448 @@
<template>
<a-drawer
v-model:open="visible"
:title="$t('common.taskCenter')"
placement="right"
:width="400"
:destroyOnClose="true"
>
<div class="task-drawer">
<!-- 任务统计 -->
<div class="task-stats">
<div class="stat-item">
<div class="stat-number">{{ totalTasks }}</div>
<div class="stat-label">{{ $t('common.totalTasks') }}</div>
</div>
<div class="stat-item">
<div class="stat-number pending">{{ pendingTasks }}</div>
<div class="stat-label">{{ $t('common.pendingTasks') }}</div>
</div>
<div class="stat-item">
<div class="stat-number completed">{{ completedTasks }}</div>
<div class="stat-label">{{ $t('common.completedTasks') }}</div>
</div>
</div>
<!-- 操作栏 -->
<div class="task-actions">
<a-input
v-model:value="searchKeyword"
:placeholder="$t('common.searchTasks')"
allow-clear
@input="handleSearch"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div class="filter-buttons">
<a-button
:type="filterType === 'all' ? 'primary' : 'default'"
size="small"
@click="setFilter('all')"
>
{{ $t('common.all') }}
</a-button>
<a-button
:type="filterType === 'pending' ? 'primary' : 'default'"
size="small"
@click="setFilter('pending')"
>
{{ $t('common.pending') }}
</a-button>
<a-button
:type="filterType === 'completed' ? 'primary' : 'default'"
size="small"
@click="setFilter('completed')"
>
{{ $t('common.completed') }}
</a-button>
</div>
</div>
<!-- 任务列表 -->
<div class="task-list">
<div v-if="filteredTasks.length > 0">
<div
v-for="task in filteredTasks"
:key="task.id"
class="task-item"
:class="{ completed: task.completed }"
>
<div class="task-checkbox">
<a-checkbox
:checked="task.completed"
@change="toggleTask(task)"
/>
</div>
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">
<span class="task-priority" :class="task.priority">
{{ $t(`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`) }}
</span>
<span class="task-time">{{ task.time }}</span>
</div>
</div>
<div class="task-actions">
<a-popconfirm
:title="$t('common.confirmDelete')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@confirm="deleteTask(task.id)"
>
<DeleteOutlined class="action-icon" />
</a-popconfirm>
</div>
</div>
</div>
<a-empty v-else :description="$t('common.noTasks')" />
</div>
<!-- 底部操作 -->
<div class="drawer-footer">
<a-button @click="showAddTask">
<PlusOutlined />
{{ $t('common.addTask') }}
</a-button>
<a-button danger @click="clearAllTasks">
{{ $t('common.clearAll') }}
</a-button>
</div>
</div>
<!-- 添加任务弹窗 -->
<a-modal
v-model:open="addTaskVisible"
:title="$t('common.addTask')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@ok="confirmAddTask"
>
<a-form layout="vertical">
<a-form-item :label="$t('common.taskTitle')">
<a-input v-model:value="newTask.title" :placeholder="$t('common.enterTaskTitle')" />
</a-form-item>
<a-form-item :label="$t('common.taskPriority')">
<a-select v-model:value="newTask.priority">
<a-select-option value="low">{{ $t('common.priorityLow') }}</a-select-option>
<a-select-option value="medium">{{ $t('common.priorityMedium') }}</a-select-option>
<a-select-option value="high">{{ $t('common.priorityHigh') }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</a-drawer>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
// 定义组件名称
defineOptions({
name: 'TaskDrawer',
})
const { t } = useI18n()
const visible = defineModel('visible', { type: Boolean, default: false })
const tasks = defineModel('tasks', { type: Array, default: () => [] })
// 搜索关键词
const searchKeyword = ref('')
// 筛选类型:all, pending, completed
const filterType = ref('all')
// 添加任务弹窗
const addTaskVisible = ref(false)
const newTask = ref({
title: '',
priority: 'medium',
})
// 统计数据
const totalTasks = computed(() => tasks.value.length)
const pendingTasks = computed(() => tasks.value.filter(t => !t.completed).length)
const completedTasks = computed(() => tasks.value.filter(t => t.completed).length)
// 筛选后的任务列表
const filteredTasks = computed(() => {
let result = [...tasks.value]
// 按状态筛选
if (filterType.value === 'pending') {
result = result.filter(t => !t.completed)
} else if (filterType.value === 'completed') {
result = result.filter(t => t.completed)
}
// 按关键词搜索
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(t =>
t.title.toLowerCase().includes(keyword)
)
}
return result
})
// 切换任务状态
const toggleTask = (task) => {
task.completed = !task.completed
}
// 删除任务
const deleteTask = (id) => {
const index = tasks.value.findIndex(t => t.id === id)
if (index > -1) {
tasks.value.splice(index, 1)
message.success(t('common.deleted'))
}
}
// 清空所有任务
const clearAllTasks = () => {
tasks.value = []
message.success(t('common.cleared'))
}
// 显示添加任务弹窗
const showAddTask = () => {
newTask.value = {
title: '',
priority: 'medium',
}
addTaskVisible.value = true
}
// 确认添加任务
const confirmAddTask = () => {
if (!newTask.value.title.trim()) {
message.warning(t('common.pleaseEnterTaskTitle'))
return
}
tasks.value.unshift({
id: Date.now(),
title: newTask.value.title,
priority: newTask.value.priority,
completed: false,
time: t('common.justNow'),
})
addTaskVisible.value = false
message.success(t('common.added'))
}
// 设置筛选类型
const setFilter = (type) => {
filterType.value = type
}
// 搜索处理
const handleSearch = () => {
// 搜索逻辑在 computed 中自动处理
}
// 监听抽窗关闭,重置搜索和筛选
watch(visible, (newVal) => {
if (!newVal) {
searchKeyword.value = ''
filterType.value = 'all'
}
})
</script>
<style scoped lang="scss">
.task-drawer {
display: flex;
flex-direction: column;
height: 100%;
.task-stats {
display: flex;
gap: 16px;
margin-bottom: 20px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
.stat-item {
flex: 1;
text-align: center;
.stat-number {
font-size: 28px;
font-weight: bold;
color: #fff;
margin-bottom: 4px;
&.pending {
color: #ffd666;
}
&.completed {
color: #95de64;
}
}
.stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
}
}
}
.task-actions {
margin-bottom: 16px;
:deep(.ant-input-affix-wrapper) {
margin-bottom: 12px;
}
.filter-buttons {
display: flex;
gap: 8px;
.ant-btn {
flex: 1;
}
}
}
.task-list {
flex: 1;
overflow-y: auto;
margin-bottom: 16px;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
.task-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background-color: #f9f9f9;
border-radius: 6px;
transition: all 0.2s;
&:hover {
background-color: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
&.completed {
.task-title {
text-decoration: line-through;
color: #999;
}
.task-content {
opacity: 0.6;
}
}
.task-checkbox {
margin-right: 12px;
flex-shrink: 0;
}
.task-content {
flex: 1;
min-width: 0;
.task-title {
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.task-priority {
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.high {
background-color: #fff1f0;
color: #ff4d4f;
}
&.medium {
background-color: #fff7e6;
color: #fa8c16;
}
&.low {
background-color: #f6ffed;
color: #52c41a;
}
}
.task-time {
color: #999;
}
}
}
.task-actions {
margin-left: 8px;
flex-shrink: 0;
.action-icon {
font-size: 16px;
color: #999;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #ff4d4f;
}
}
}
}
}
.drawer-footer {
padding-top: 16px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 12px;
.ant-btn {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,380 @@
<template>
<div class="userbar">
<!-- 菜单搜索 -->
<a-tooltip :title="$t('common.search')">
<a-button type="text" @click="showSearch" class="action-btn">
<SearchOutlined />
</a-button>
</a-tooltip>
<!-- 消息通知 -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-badge :count="messageCount" :offset="[-5, 5]">
<a-button type="text" class="action-btn">
<BellOutlined />
</a-button>
</a-badge>
<template #overlay>
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
<template #extra>
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
</template>
<div class="message-list">
<div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ unread: !msg.read }">
<div class="message-content">
<div class="message-title">{{ msg.title }}</div>
<div class="message-time">{{ msg.time }}</div>
</div>
<a-badge v-if="!msg.read" dot />
</div>
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
</div>
</a-card>
</template>
</a-dropdown>
<!-- 任务列表 -->
<a-tooltip :title="$t('common.taskCenter')">
<a-badge :count="taskCount" :offset="[-5, 5]">
<a-button type="text" @click="taskVisible = true" class="action-btn">
<CheckSquareOutlined />
</a-button>
</a-badge>
</a-tooltip>
<!-- 语言切换 -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-button type="text" class="action-btn">
<GlobalOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleLanguageChange">
<a-menu-item v-for="locale in i18nStore.availableLocales" :key="locale.value"
:disabled="i18nStore.currentLocale === locale.value">
<span>{{ locale.label }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 全屏 -->
<a-tooltip :title="$t('common.fullscreen')">
<a-button type="text" @click="toggleFullscreen" class="action-btn">
<FullscreenOutlined v-if="!isFullscreen" />
<FullscreenExitOutlined v-else />
</a-button>
</a-tooltip>
<!-- 用户信息 -->
<a-dropdown :trigger="['click']">
<div class="user-info">
<a-avatar :size="32" :src="userStore.user?.avatar || ''">
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}
</a-avatar>
<span class="username">{{ userStore.user?.username || 'Admin' }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="profile">
<UserOutlined />
<span>{{ $t('common.personalCenter') }}</span>
</a-menu-item>
<a-menu-item key="settings">
<SettingOutlined />
<span>{{ $t('common.systemSettings') }}</span>
</a-menu-item>
<a-menu-item key="clearCache">
<DeleteOutlined />
<span>{{ $t('common.clearCache') }}</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
<span>{{ $t('common.logout') }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 菜单搜索弹窗 -->
<search v-model:visible="searchVisible" />
<!-- 任务抽屉 -->
<task v-model:visible="taskVisible" v-model:tasks="tasks" />
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18nStore } from '@/stores/modules/i18n'
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import search from './search.vue'
import task from './task.vue'
// 定义组件名称(多词命名)
defineOptions({
name: 'UserBar',
})
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const i18nStore = useI18nStore()
const isFullscreen = ref(false)
const searchVisible = ref(false)
const taskVisible = ref(false)
// 消息数据
const messages = ref([
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
])
const messageCount = computed(() => messages.value.filter((m) => !m.read).length)
// 任务数据
const tasks = ref([
{ id: 1, title: '完成用户审核', priority: 'high', completed: false, time: '今天' },
{ id: 2, title: '更新系统文档', priority: 'medium', completed: false, time: '明天' },
{ id: 3, title: '优化数据库查询', priority: 'low', completed: true, time: '昨天' },
])
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 监听全屏变化
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
// 显示搜索功能
const showSearch = () => {
searchVisible.value = true
}
// 清除消息
const clearMessages = () => {
messages.value = []
message.success(t('common.cleared'))
}
// 显示任务抽屉
const showTasks = () => {
taskVisible.value = true
}
// 切换语言
const handleLanguageChange = ({ key }) => {
i18nStore.setLocale(key)
message.success(t('common.languageChanged'))
}
// 处理菜单点击
const handleMenuClick = ({ key }) => {
switch (key) {
case 'profile':
router.push('/ucenter')
break
case 'settings':
router.push('/system/setting')
break
case 'clearCache':
handleClearCache()
break
case 'logout':
handleLogout()
break
}
}
// 清除缓存
const handleClearCache = () => {
Modal.confirm({
title: t('common.confirmClearCache'),
content: t('common.clearCacheConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
try {
// 清除 localStorage
localStorage.clear()
// 清除 sessionStorage
sessionStorage.clear()
// 清除所有缓存
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => {
caches.delete(name)
})
})
}
message.success(t('common.cacheCleared'))
// 延迟刷新页面以应用缓存清除
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (error) {
message.error(t('common.clearCacheFailed'))
console.error('清除缓存失败:', error)
}
},
})
}
// 退出登录
const handleLogout = () => {
Modal.confirm({
title: t('common.confirmLogout'),
content: t('common.logoutConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
await userStore.logout()
message.success(t('common.logoutSuccess'))
router.push('/login')
} catch {
message.error(t('common.logoutFailed'))
}
},
})
}
</script>
<style scoped lang="scss">
.userbar {
display: flex;
align-items: center;
gap: 12px;
.search-input {
width: 240px;
margin-right: 8px;
:deep(.ant-input) {
background-color: rgba(255, 255, 255, 0.9);
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 0 12px;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.username {
font-size: 14px;
color: #333;
}
}
.action-btn {
font-size: 16px;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
.lang-text {
font-size: 14px;
}
&:hover {
color: #1890ff;
}
}
.dropdown-card {
width: 320px;
max-height: 400px;
overflow: auto;
:deep(.ant-card-head) {
padding: 12px 16px;
min-height: auto;
}
:deep(.ant-card-body) {
padding: 12px 16px;
max-height: 320px;
overflow-y: auto;
}
}
.message-list,
.task-list {
.message-item,
.task-item {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
position: relative;
&:last-child {
border-bottom: none;
}
&.unread {
background-color: rgba(24, 144, 255, 0.04);
padding: 10px;
margin: 0 -10px;
border-radius: 4px;
}
}
.message-content {
.message-title {
font-size: 14px;
color: #333;
margin-bottom: 4px;
line-height: 1.4;
}
.message-time {
font-size: 12px;
color: #999;
}
}
.task-item {
.completed {
text-decoration: line-through;
color: #999;
}
}
}
}
</style>
+665
View File
@@ -0,0 +1,665 @@
<template>
<a-layout class="app-wrapper" :class="layoutClass">
<!-- 默认布局左侧双栏布局 -->
<template v-if="layoutMode === 'default'">
<!-- 第一个侧边栏显示一级菜单 -->
<a-layout-sider theme="dark" width="70" class="left-sidebar">
<div class="logo-box">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
</div>
<ul class="left-nav">
<li v-for="(item, index) in menuList" :key="index"
:class="{ active: selectedParentMenu?.path === item.path }"
@click="handleParentMenuClick(item)">
<component :is="getIconComponent(item.meta?.icon)" />
<span>{{ item.meta?.title }}</span>
</li>
</ul>
</a-layout-sider>
<!-- 第二个侧边栏显示选中的父菜单的子菜单 -->
<a-layout-sider
v-if="selectedParentMenu && selectedParentMenu.children && selectedParentMenu.children.length > 0"
theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" width="200"
:collapsed-width="64" class="right-sidebar">
<div class="parent-title">
<component :is="getIconComponent(selectedParentMenu.meta?.icon)" />
<span v-if="!sidebarCollapsed">{{ selectedParentMenu.meta?.title }}</span>
</div>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
:selected-keys="[route.path]">
<navMenu :menu-items="selectedParentMenu.children" :active-path="route.path" />
</a-menu>
</a-layout-sider>
<a-layout class="main-layout">
<a-layout-header class="app-header">
<div class="header-left">
<breadcrumb />
</div>
<userbar />
</a-layout-header>
<tags />
<a-layout-content class="app-main">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="refreshKey" />
</keep-alive>
</router-view>
</a-layout-content>
</a-layout>
</template>
<!-- Menu布局左侧菜单栏布局 -->
<template v-else-if="layoutMode === 'menu'">
<a-layout-sider theme="light" style="border-right: 1px solid #f0f0f0" :collapsed="sidebarCollapsed"
:collapsible="true" @collapse="handleCollapse" class="full-menu-sidebar" width="200"
:collapsed-width="64">
<div class="logo-box-full">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<span v-if="!sidebarCollapsed" class="app-name">{{ config.APP_NAME }}</span>
</div>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
:selected-keys="[route.path]">
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</a-layout-sider>
<a-layout class="main-layout">
<a-layout-header class="app-header">
<div class="header-left">
<breadcrumb />
</div>
<userbar />
</a-layout-header>
<tags />
<a-layout-content class="app-main">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</a-layout-content>
</a-layout>
</template>
<!-- Top布局顶部菜单栏布局 -->
<template v-else-if="layoutMode === 'top'">
<a-layout-header class="app-header top-header">
<div class="top-header-left">
<div class="logo-box-top">
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<span class="app-name">{{ config.APP_NAME }}</span>
</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]"
style="line-height: 60px">
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</div>
<userbar />
</a-layout-header>
<tags />
<a-layout-content class="app-main top-content">
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="refreshKey" />
</keep-alive>
</router-view>
</a-layout-content>
</template>
<!-- 漂浮的设置按钮 -->
<a-float-button type="primary" @click="openSetting">
<template #icon>
<SettingOutlined />
</template>
</a-float-button>
<!-- 布局设置组件 -->
<setting ref="settingRef" />
</a-layout>
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { useUserStore } from '@/stores/modules/user'
import { SettingOutlined } from '@ant-design/icons-vue'
import * as icons from '@ant-design/icons-vue'
import config from '@/config/index.js'
import userbar from './components/userbar.vue'
import navMenu from './components/navMenu.vue'
import breadcrumb from './components/breadcrumb.vue'
import tags from './components/tags.vue'
import setting from './components/setting.vue'
// 定义组件名称(多词命名)
defineOptions({
name: 'AppLayouts',
})
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const userStore = useUserStore()
const settingRef = ref(null)
const layoutMode = computed(() => layoutStore.layoutMode)
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
// 缓存的视图列表
const cachedViews = computed(() => {
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
})
// 布局类名
const layoutClass = computed(() => {
return {
'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'is-collapse': sidebarCollapsed.value,
}
})
// 获取刷新 key
const refreshKey = computed(() => layoutStore.refreshKey)
const openKeys = ref([])
const selectedKeys = ref([])
const menuList = computed(() => {
return userStore.menu
})
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
// 处理父菜单点击(默认布局的第一级菜单)
const handleParentMenuClick = (item) => {
// 设置选中的父菜单
layoutStore.setSelectedParentMenu(item)
// 如果没有子菜单,直接跳转
if (!item.children || item.children.length === 0) {
if (item.path) {
router.push(item.path)
}
} else {
// 默认展开第一个子菜单
if (item.children.length > 0 && item.children[0].path) {
router.push(item.children[0].path)
}
}
}
// 处理折叠
const handleCollapse = (collapsed) => {
layoutStore.sidebarCollapsed = collapsed
}
// 打开设置抽屉
const openSetting = () => {
settingRef.value?.openDrawer()
}
// 更新选中的菜单和展开的菜单
const updateMenuState = () => {
selectedKeys.value = [route.path]
// 获取所有父级路径
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
const parentPaths = matched.map((item) => item.path)
// 对于不同的布局模式,处理方式不同
if (layoutMode.value === 'default') {
// 默认布局:找到当前路由对应的父菜单
const currentMenu = findMenuByPath(menuList.value, route.path)
if (currentMenu) {
// 如果当前菜单有子菜单,设置为选中的父菜单
if (currentMenu.children && currentMenu.children.length > 0) {
layoutStore.setSelectedParentMenu(currentMenu)
} else {
// 如果当前菜单是子菜单,找到它的父菜单
const parentMenu = findParentMenu(menuList.value, route.path)
if (parentMenu) {
layoutStore.setSelectedParentMenu(parentMenu)
} else {
layoutStore.setSelectedParentMenu(currentMenu)
}
}
}
} else if (!sidebarCollapsed.value) {
// 其他布局模式:展开所有父级菜单
openKeys.value = parentPaths
}
}
// 根据路径查找菜单
const findMenuByPath = (menus, path) => {
for (const menu of menus) {
if (menu.path === path) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path)
if (found) {
return found
}
}
}
return null
}
// 查找父菜单
const findParentMenu = (menus, path) => {
for (const menu of menus) {
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
if (child.path === path) {
return menu
}
if (child.children && child.children.length > 0) {
const found = findParentMenu([child], path)
if (found) {
return menu
}
}
}
}
}
return null
}
// 监听路由变化,更新菜单状态
watch(
() => route.path,
(newPath) => {
console.log('路由变化:', newPath)
updateMenuState()
},
{ immediate: true },
)
// 监听布局模式变化,确保菜单状态正确
watch(
() => layoutMode.value,
() => {
updateMenuState()
},
)
// 监听折叠状态
watch(
() => sidebarCollapsed.value,
(val) => {
if (val) {
openKeys.value = []
} else {
updateMenuState()
}
},
)
// 初始化
onMounted(() => {
// 如果还没有选中的父菜单,默认选中第一个
if (layoutMode.value === 'default' && !selectedParentMenu.value && menuList.value.length > 0) {
layoutStore.setSelectedParentMenu(menuList.value[0])
}
updateMenuState()
})
</script>
<style scoped lang="scss">
.app-wrapper {
position: relative;
width: 100%;
height: 100vh;
display: flex;
overflow: hidden;
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
height: 60px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.collapse-btn {
font-size: 16px;
padding: 0 8px;
}
}
}
.app-main {
background-color: #f0f2f5;
overflow-y: auto;
flex: 1;
height: calc(100vh - 106px);
display: flex;
flex-direction: column;
}
/* 默认布局 - 双栏菜单 */
&.layout-default {
.left-sidebar {
background-color: #001529;
display: flex;
flex-direction: column;
z-index: 10;
.logo-box {
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.logo-image {
width: 40px;
height: 40px;
object-fit: contain;
}
}
.left-nav {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
li {
height: 70px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
transition: all 0.3s;
border-left: 3px solid transparent;
position: relative;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
}
&.active {
color: #ffffff;
background-color: #1890ff;
border-left-color: #ffffff;
}
:deep(.anticon) {
font-size: 20px;
margin-bottom: 4px;
}
span {
font-size: 12px;
text-align: center;
line-height: 1.2;
word-break: break-all;
padding: 0 4px;
}
}
}
}
.right-sidebar {
background-color: #ffffff;
box-shadow: 1px 0 6px rgba(0, 0, 0, 0.1);
z-index: 9;
.parent-title {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
gap: 5px;
height: 60px;
font-size: 16px;
font-weight: 500;
color: #262626;
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-menu) {
border-right: none;
.ant-menu-item {
&:hover {
color: #1890ff;
background-color: #e6f7ff;
}
&.ant-menu-item-selected {
background-color: #e6f7ff;
&::after {
border-color: #1890ff;
}
}
}
.ant-menu-submenu {
>.ant-menu-submenu-title {
&:hover {
color: #1890ff;
}
}
&.ant-menu-submenu-open {
>.ant-menu-submenu-title {
color: #1890ff;
}
}
}
}
}
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
padding: 0 20px;
}
.app-main {
height: calc(100vh - 106px);
}
}
/* Menu布局 */
&.layout-menu {
.full-menu-sidebar {
background-color: #ffffff;
z-index: 10;
transition: all 0.2s;
.logo-box-full {
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #f0f0f0;
transition: all 0.2s;
gap: 10px;
padding: 0 10px;
.logo-image {
width: 32px;
height: 32px;
object-fit: contain;
flex-shrink: 0;
}
.app-name {
color: #1890ff;
font-size: 18px;
font-weight: bold;
letter-spacing: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
:deep(.ant-menu) {
border-right: none;
height: calc(100% - 60px);
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.ant-menu-item {
margin: 0;
height: 44px;
line-height: 44px;
padding-left: 20px !important;
&:hover {
color: #1890ff;
background-color: #e6f7ff;
}
&.ant-menu-item-selected {
background-color: #e6f7ff;
&::after {
border-color: #1890ff;
}
}
}
.ant-menu-submenu {
>.ant-menu-submenu-title {
height: 44px;
line-height: 44px;
margin: 0;
padding-left: 20px !important;
&:hover {
color: #1890ff;
}
}
&.ant-menu-submenu-open {
>.ant-menu-submenu-title {
color: #1890ff;
}
}
.ant-menu-sub {
background-color: #fafafa;
.ant-menu-item {
padding-left: 40px !important;
}
}
}
}
}
.main-layout {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
padding: 0 20px;
}
.app-main {
height: calc(100vh - 106px);
}
}
/* Top布局 */
&.layout-top {
flex-direction: column;
.top-header {
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
border-bottom: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.top-header-left {
display: flex;
align-items: center;
gap: 30px;
.logo-box-top {
display: flex;
align-items: center;
gap: 10px;
.logo-image {
width: 36px;
height: 36px;
object-fit: contain;
}
.app-name {
font-size: 20px;
font-weight: bold;
letter-spacing: 1px;
color: #1890ff;
}
}
}
}
.top-content {
height: calc(100vh - 116px);
}
}
/* 通用菜单样式优化 */
:deep(.ant-menu-item-icon) {
font-size: 16px;
}
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<template>
<div class="not-found-container">
<div class="tech-decoration">
<div class="tech-circle"></div>
<div class="tech-circle"></div>
<div class="tech-circle"></div>
</div>
<div class="not-found-content">
<div class="error-code">404</div>
<div class="error-title">页面未找到</div>
<div class="error-description">抱歉您访问的页面不存在或已被移除</div>
<div class="action-buttons">
<a-button type="primary" size="large" @click="goBack">
<template #icon>
<ArrowLeftOutlined />
</template>
返回上一页
</a-button>
<a-button size="large" @click="goHome">
<template #icon>
<HomeOutlined />
</template>
返回首页
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
const router = useRouter()
// Go back to previous page
const goBack = () => {
router.back()
}
// Go to home page
const goHome = () => {
router.push('/')
}
</script>
<style scoped lang="scss">
.not-found-container {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
position: relative;
overflow: hidden;
.tech-decoration {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
overflow: hidden;
.tech-circle {
position: absolute;
border: 2px solid rgba(255, 107, 53, 0.1);
border-radius: 50%;
animation: pulse 4s ease-in-out infinite;
}
.tech-circle:nth-child(1) {
width: 300px;
height: 300px;
top: -150px;
left: -150px;
animation-delay: 0s;
}
.tech-circle:nth-child(2) {
width: 200px;
height: 200px;
bottom: -100px;
right: -100px;
animation-delay: 1s;
}
.tech-circle:nth-child(3) {
width: 150px;
height: 150px;
bottom: 20%;
left: -75px;
animation-delay: 2s;
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.05);
}
}
.not-found-content {
text-align: center;
padding: 40px;
position: relative;
z-index: 1;
.error-code {
font-size: 120px;
font-weight: 700;
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 20px;
line-height: 1;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.error-title {
font-size: 32px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.error-description {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 40px;
line-height: 1.6;
}
.action-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
.ant-btn {
height: 48px;
padding: 0 32px;
font-size: 16px;
font-weight: 600;
border-radius: 12px;
&.ant-btn-primary {
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
border: none;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
}
&:not(.ant-btn-primary) {
background: rgba(255, 255, 255, 0.9);
border: 2px solid var(--border-color);
color: var(--text-secondary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:hover {
border-color: var(--auth-primary);
color: var(--auth-primary);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.15);
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.not-found-content {
padding: 20px;
.error-code {
font-size: 80px;
}
.error-title {
font-size: 24px;
}
.error-description {
font-size: 14px;
}
.action-buttons {
.ant-btn {
width: 100%;
max-width: 200px;
}
}
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="empty-container">
<div class="tech-decoration">
<div class="tech-circle"></div>
<div class="tech-circle"></div>
<div class="tech-circle"></div>
</div>
<div class="empty-content">
<div class="empty-icon">
<InboxOutlined :style="{ fontSize: '120px', color: '#ff6b35' }" />
</div>
<div class="empty-title">暂无数据</div>
<div class="empty-description">
{{ description || '当前页面暂无数据,请稍后再试' }}
</div>
<a-button v-if="showButton" type="primary" size="large" @click="handleAction">
<template #icon v-if="buttonIcon">
<component :is="buttonIcon" />
</template>
{{ buttonText || '刷新页面' }}
</a-button>
</div>
</div>
</template>
<script setup>
import { InboxOutlined } from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
defineOptions({
name: 'EmptyPage',
})
const router = useRouter()
defineProps({
description: {
type: String,
default: '当前页面暂无数据,请稍后再试',
},
showButton: {
type: Boolean,
default: true,
},
buttonText: {
type: String,
default: '刷新页面',
},
buttonIcon: {
type: [String, Object],
default: null,
},
})
const emit = defineEmits(['action'])
const handleAction = () => {
emit('action')
// Default behavior: refresh page
router.go(0)
}
</script>
<style scoped lang="scss">
.empty-container {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
position: relative;
overflow: hidden;
.tech-decoration {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
overflow: hidden;
.tech-circle {
position: absolute;
border: 2px solid rgba(255, 107, 53, 0.1);
border-radius: 50%;
animation: pulse 4s ease-in-out infinite;
}
.tech-circle:nth-child(1) {
width: 300px;
height: 300px;
top: -150px;
left: -150px;
animation-delay: 0s;
}
.tech-circle:nth-child(2) {
width: 200px;
height: 200px;
bottom: -100px;
right: -100px;
animation-delay: 1s;
}
.tech-circle:nth-child(3) {
width: 150px;
height: 150px;
bottom: 20%;
left: -75px;
animation-delay: 2s;
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.05);
}
}
.empty-content {
text-align: center;
padding: 40px;
position: relative;
z-index: 1;
.empty-icon {
margin-bottom: 32px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.empty-title {
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.empty-description {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 40px;
line-height: 1.6;
max-width: 400px;
}
.ant-btn {
height: 48px;
padding: 0 40px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
border: none;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
}
}
// Responsive design
@media (max-width: 768px) {
.empty-content {
padding: 20px;
:deep(.anticon) {
font-size: 80px;
}
.empty-title {
font-size: 24px;
}
.empty-description {
font-size: 14px;
}
.ant-btn {
width: 100%;
max-width: 200px;
}
}
}
</style>