517 lines
12 KiB
Vue
517 lines
12 KiB
Vue
<template>
|
||
<a-layout class="layout">
|
||
<a-layout-sider v-if="!hideSidebar" v-model:collapsed="collapsed" :width="210" class="custom-sider">
|
||
<div class="sider-content">
|
||
<div class="sider-top">
|
||
<div class="logo" :class="{ 'logo-collapsed': collapsed }">
|
||
<img src="../assets/images/logo-icon.png" alt="乐绘世界" class="logo-img" />
|
||
<div v-if="!collapsed" class="logo-text">
|
||
<span class="logo-title-main">乐绘世界</span>
|
||
<span class="logo-title-sub">创想活动乐园</span>
|
||
</div>
|
||
</div>
|
||
<a-menu v-model:selectedKeys="selectedKeys" v-model:openKeys="openKeys" mode="inline" class="custom-menu"
|
||
:items="menuItems" @click="handleMenuClick" />
|
||
</div>
|
||
<div class="sider-bottom" :class="{ 'sider-bottom-collapsed': collapsed }">
|
||
<a-dropdown placement="topRight">
|
||
<div class="user-info" :class="{ 'user-info-collapsed': collapsed }">
|
||
<a-avatar :size="32" :src="userAvatar" />
|
||
<span v-if="!collapsed" class="username">{{
|
||
authStore.user?.nickname
|
||
}}</span>
|
||
</div>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item @click="handleLogout">
|
||
<logout-outlined />
|
||
退出登录
|
||
</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
<div class="collapse-trigger" @click="collapsed = !collapsed">
|
||
<menu-unfold-outlined v-if="collapsed" />
|
||
<menu-fold-outlined v-else />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a-layout-sider>
|
||
<a-layout class="main-layout">
|
||
<a-layout-content class="content" :class="{ 'content-fullscreen': hideSidebar }">
|
||
<router-view />
|
||
</a-layout-content>
|
||
</a-layout>
|
||
</a-layout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from "vue"
|
||
import { useRouter, useRoute } from "vue-router"
|
||
import {
|
||
MenuFoldOutlined,
|
||
MenuUnfoldOutlined,
|
||
LogoutOutlined,
|
||
} from "@ant-design/icons-vue"
|
||
import { useAuthStore } from "@/stores/auth"
|
||
import { convertMenusToMenuItems } from "@/utils/menu"
|
||
import { getUserAvatar } from "@/utils/avatar"
|
||
import type { MenuProps } from "ant-design-vue"
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const authStore = useAuthStore()
|
||
|
||
const collapsed = ref(false)
|
||
const selectedKeys = ref<string[]>([])
|
||
const openKeys = ref<string[]>([])
|
||
|
||
// 生成用户头像URL(使用DiceBear API自动生成默认头像)
|
||
const userAvatar = computed(() => getUserAvatar(authStore.user))
|
||
|
||
// 根据路由 meta 判断是否隐藏侧边栏
|
||
const hideSidebar = computed(() => {
|
||
return route.meta?.hideSidebar === true
|
||
})
|
||
|
||
// 使用动态菜单
|
||
const menuItems = computed<MenuProps["items"]>(() => {
|
||
if (authStore.menus && authStore.menus.length > 0) {
|
||
return convertMenusToMenuItems(authStore.menus)
|
||
}
|
||
// 如果没有菜单数据,返回空数组(或者可以返回默认菜单)
|
||
return []
|
||
})
|
||
|
||
watch(
|
||
() => route.name,
|
||
(routeName) => {
|
||
if (routeName) {
|
||
selectedKeys.value = [routeName as string]
|
||
// 自动展开包含当前路由的父菜单
|
||
const findParentKeys = (
|
||
menus: any[],
|
||
targetName: string,
|
||
parentKeys: string[] = [],
|
||
): string[] => {
|
||
for (const menu of menus) {
|
||
const menuKey = menu.key
|
||
if (menuKey === targetName) {
|
||
return parentKeys
|
||
}
|
||
if (menu.children && menu.children.length > 0) {
|
||
const found = findParentKeys(menu.children, targetName, [
|
||
...parentKeys,
|
||
menuKey,
|
||
])
|
||
if (found.length > 0) return found
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
const parentKeys = findParentKeys(
|
||
menuItems.value || [],
|
||
routeName as string,
|
||
)
|
||
if (parentKeys.length > 0) {
|
||
openKeys.value = parentKeys
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const handleMenuClick = ({ key }: { key: string }) => {
|
||
const tenantCode = route.params.tenantCode as string
|
||
console.log("tenantCode:", tenantCode);
|
||
// 点击菜单
|
||
|
||
// 路由名称生成规则需与 src/utils/menu.ts 一致
|
||
const getRouteNameFromPath = (path: string | null | undefined, menuId: number): string => {
|
||
if (path) {
|
||
const baseName = path
|
||
.split("/")
|
||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||
.join("")
|
||
return `${baseName}${menuId}`
|
||
}
|
||
return `Menu${menuId}`
|
||
}
|
||
|
||
const findMenuByRouteName = (menus: any[], targetName: string): any | null => {
|
||
for (const m of menus) {
|
||
const rn = getRouteNameFromPath(m.path, m.id)
|
||
if (rn === targetName) return m
|
||
if (m.children?.length) {
|
||
const found = findMenuByRouteName(m.children, targetName)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const safePushByName = async () => {
|
||
if (tenantCode) {
|
||
await router.push({ name: key, params: { tenantCode } })
|
||
} else {
|
||
await router.push({ name: key })
|
||
}
|
||
}
|
||
|
||
const safePushByPath = async () => {
|
||
const rawMenus: any[] = (authStore.menus as any[]) || []
|
||
console.log("rawMenus:", rawMenus);
|
||
const matched = findMenuByRouteName(rawMenus, key)
|
||
const menuPath: string | undefined = matched?.path
|
||
if (!menuPath) {
|
||
await safePushByName()
|
||
return
|
||
}
|
||
const clean = menuPath.startsWith("/") ? menuPath.slice(1) : menuPath
|
||
const fullPath = tenantCode ? `/${tenantCode}/${clean}` : `/${clean}`
|
||
await router.push({ path: fullPath })
|
||
}
|
||
|
||
// 优先按 name 跳转(适配动态路由),失败则回退按 path 跳转
|
||
safePushByName().catch(() => safePushByPath())
|
||
}
|
||
|
||
const handleLogout = async () => {
|
||
await authStore.logout()
|
||
// 获取当前路由的租户编码,跳转到对应的登录页
|
||
const tenantCode = route.params.tenantCode as string
|
||
if (tenantCode) {
|
||
router.push(`/${tenantCode}/login`)
|
||
} else {
|
||
router.push("/login")
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
$primary: #6366f1;
|
||
$primary-dark: #4f46e5;
|
||
$primary-light: #818cf8;
|
||
$coral: #f97066;
|
||
$rose: #ec4899;
|
||
|
||
.layout {
|
||
min-height: 100vh;
|
||
}
|
||
|
||
// ========== 侧边栏 ==========
|
||
.custom-sider {
|
||
background: linear-gradient(180deg, #fefcfb 0%, #f8f5ff 100%) !important;
|
||
border-right: 1px solid rgba(99, 102, 241, 0.06) !important;
|
||
box-shadow: 2px 0 16px rgba(99, 102, 241, 0.06);
|
||
|
||
:deep(.ant-layout-sider-children) {
|
||
background: transparent;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.sider-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.sider-top {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: 0 10px;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 3px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: rgba(99, 102, 241, 0.15);
|
||
border-radius: 3px;
|
||
}
|
||
}
|
||
|
||
// ========== Logo 区域 ==========
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 26px 16px 22px;
|
||
margin-bottom: 4px;
|
||
cursor: default;
|
||
|
||
.logo-img {
|
||
width: 54px;
|
||
height: 54px;
|
||
object-fit: contain;
|
||
flex-shrink: 0;
|
||
transition: all 0.3s ease;
|
||
filter: drop-shadow(0 2px 6px rgba(99, 102, 241, 0.15));
|
||
}
|
||
|
||
.logo-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.logo-title-main {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
background: linear-gradient(135deg, #6366f1 0%, #ec4899 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
letter-spacing: 2px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.logo-title-sub {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #9ca3af;
|
||
letter-spacing: 3px;
|
||
line-height: 1.3;
|
||
}
|
||
}
|
||
|
||
.logo-collapsed {
|
||
justify-content: center;
|
||
padding: 26px 8px 22px;
|
||
|
||
.logo-img {
|
||
width: 42px;
|
||
height: 42px;
|
||
}
|
||
}
|
||
|
||
|
||
// ========== 底部用户区域 ==========
|
||
.sider-bottom {
|
||
padding: 14px 14px;
|
||
border-top: 1px solid rgba(99, 102, 241, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 6px 10px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.25s;
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
&:hover {
|
||
background: rgba($primary, 0.06);
|
||
}
|
||
|
||
:deep(.ant-avatar) {
|
||
border: 2px solid rgba($primary, 0.15);
|
||
box-shadow: 0 2px 6px rgba($primary, 0.1);
|
||
}
|
||
|
||
.username {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
|
||
.user-info-collapsed {
|
||
justify-content: center;
|
||
padding: 6px;
|
||
flex: unset;
|
||
}
|
||
|
||
.collapse-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: all 0.25s;
|
||
color: #9ca3af;
|
||
flex-shrink: 0;
|
||
|
||
&:hover {
|
||
background: rgba($primary, 0.06);
|
||
color: $primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
.sider-bottom-collapsed {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 14px 8px;
|
||
|
||
.collapse-trigger {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
// ========== 覆盖 Ant Design 默认边框 ==========
|
||
:deep(.ant-menu-light.ant-menu-root.ant-menu-inline),
|
||
:deep(.ant-menu-light.ant-menu-root.ant-menu-vertical) {
|
||
border-inline-end: none !important;
|
||
}
|
||
|
||
// ========== 菜单样式 ==========
|
||
.custom-menu {
|
||
background: transparent !important;
|
||
border-right: none !important;
|
||
border-inline-end: none !important;
|
||
padding: 4px 0;
|
||
|
||
:deep(.ant-menu-item) {
|
||
color: #374151;
|
||
margin: 3px 0;
|
||
border-radius: 12px;
|
||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
background: transparent !important;
|
||
height: 44px;
|
||
line-height: 44px;
|
||
font-weight: 500;
|
||
|
||
.anticon {
|
||
font-size: 16px;
|
||
transition: all 0.25s;
|
||
}
|
||
|
||
&:hover {
|
||
color: $primary !important;
|
||
background: rgba($primary, 0.06) !important;
|
||
transform: translateX(2px);
|
||
|
||
.anticon {
|
||
color: $primary;
|
||
}
|
||
}
|
||
|
||
&.ant-menu-item-selected {
|
||
color: $primary !important;
|
||
background: linear-gradient(135deg,
|
||
rgba($primary, 0.10) 0%,
|
||
rgba($rose, 0.05) 100%) !important;
|
||
font-weight: 600;
|
||
box-shadow: 0 2px 8px rgba($primary, 0.08);
|
||
|
||
.anticon {
|
||
color: $primary;
|
||
}
|
||
|
||
&::after {
|
||
display: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.ant-menu-submenu) {
|
||
.ant-menu-submenu-title {
|
||
color: #374151;
|
||
margin: 3px 0;
|
||
border-radius: 12px;
|
||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
background: transparent !important;
|
||
height: 44px;
|
||
line-height: 44px;
|
||
font-weight: 500;
|
||
|
||
.anticon {
|
||
font-size: 16px;
|
||
transition: all 0.25s;
|
||
}
|
||
|
||
&:hover {
|
||
color: $primary !important;
|
||
background: rgba($primary, 0.06) !important;
|
||
|
||
.anticon {
|
||
color: $primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
&.ant-menu-submenu-open>.ant-menu-submenu-title {
|
||
color: $primary;
|
||
background: rgba($primary, 0.06) !important;
|
||
|
||
.anticon {
|
||
color: $primary;
|
||
}
|
||
}
|
||
|
||
&.ant-menu-submenu-selected>.ant-menu-submenu-title {
|
||
color: $primary;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
:deep(.ant-menu-sub) {
|
||
background: transparent !important;
|
||
|
||
.ant-menu-item {
|
||
padding-left: 48px !important;
|
||
height: 40px;
|
||
line-height: 40px;
|
||
margin: 2px 0;
|
||
|
||
&.ant-menu-item-selected {
|
||
color: $primary !important;
|
||
background: rgba($primary, 0.10) !important;
|
||
box-shadow: none;
|
||
|
||
.anticon {
|
||
color: $primary;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.ant-menu-submenu-arrow) {
|
||
color: #9ca3af;
|
||
transition: all 0.25s;
|
||
}
|
||
|
||
:deep(.ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-submenu-arrow),
|
||
:deep(.ant-menu-submenu:hover > .ant-menu-submenu-title > .ant-menu-submenu-arrow) {
|
||
color: $primary;
|
||
}
|
||
}
|
||
|
||
// ========== 主内容区 ==========
|
||
.main-layout {
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.content {
|
||
padding: 24px;
|
||
background: #f8f7fc;
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.content-fullscreen {
|
||
padding: 0;
|
||
background: transparent;
|
||
}
|
||
</style>
|