library-picturebook-activity/java-frontend/src/layouts/BasicLayout.vue
2026-04-01 19:30:33 +08:00

517 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>