kindergarten/reading-platform-frontend/src/views/parent/LayoutView.vue
zhonghua 31d4ed76f0 feat(frontend): 响应式布局与移动端适配优化
1) 新增 useBreakpoints 统一断点管理;2) 管理/教师/园校/家长端布局支持移动端抽屉菜单与顶部导航;3) 全局 html/body/#app overflow 与 safe-area 处理,避免横向滚动和刘海遮挡;4) 各端内容区仅内部滚动,提升大屏与小屏的浏览体验

Made-with: Cursor
2026-03-02 14:01:51 +08:00

658 lines
15 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="parent-layout">
<!-- 桌面端侧边栏 -->
<a-layout-sider
v-if="!isMobile"
v-model:collapsed="collapsed"
:trigger="null"
collapsible
class="parent-sider"
:width="220"
>
<div class="sider-logo">
<img src="/logo.png" alt="Logo" class="logo-img" />
<div v-if="!collapsed" class="logo-text">
<span class="logo-title">少儿智慧阅读</span>
<span class="logo-subtitle">家长端</span>
</div>
</div>
<div class="sider-menu-wrap">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="light"
:inline-collapsed="collapsed"
@click="handleMenuClick"
class="side-menu"
>
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
<span>首页</span>
</a-menu-item>
<a-menu-item key="children">
<template #icon><TeamOutlined /></template>
<span>我的孩子</span>
</a-menu-item>
<a-menu-item key="lessons">
<template #icon><BookOutlined /></template>
<span>阅读记录</span>
</a-menu-item>
<a-menu-item key="tasks">
<template #icon><CheckSquareOutlined /></template>
<span>阅读任务</span>
</a-menu-item>
<a-menu-item key="growth">
<template #icon><FileImageOutlined /></template>
<span>成长档案</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<!-- 移动端抽屉菜单 -->
<a-drawer
v-if="isMobile"
v-model:open="drawerVisible"
placement="left"
:closable="false"
:width="260"
class="mobile-drawer"
>
<template #title>
<div class="drawer-header">
<img src="/logo.png" alt="Logo" class="drawer-logo" />
<div class="drawer-title">
<span class="title-main">少儿智慧阅读</span>
<span class="title-sub">家长端</span>
</div>
</div>
</template>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="light"
@click="handleMobileMenuClick"
class="drawer-menu"
>
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
<span>首页</span>
</a-menu-item>
<a-menu-item key="children">
<template #icon><TeamOutlined /></template>
<span>我的孩子</span>
</a-menu-item>
<a-menu-item key="lessons">
<template #icon><BookOutlined /></template>
<span>阅读记录</span>
</a-menu-item>
<a-menu-item key="tasks">
<template #icon><CheckSquareOutlined /></template>
<span>阅读任务</span>
</a-menu-item>
<a-menu-item key="growth">
<template #icon><FileImageOutlined /></template>
<span>成长档案</span>
</a-menu-item>
</a-menu>
<div class="drawer-footer">
<a-button block @click="handleLogout" class="logout-btn">
<LogoutOutlined /> 退出登录
</a-button>
</div>
</a-drawer>
<a-layout class="main-layout">
<!-- 桌面端顶部栏 -->
<a-layout-header v-if="!isMobile" class="parent-header">
<div class="header-left">
<MenuUnfoldOutlined
v-if="collapsed"
class="trigger"
@click="collapsed = !collapsed"
/>
<MenuFoldOutlined
v-else
class="trigger"
@click="collapsed = !collapsed"
/>
</div>
<div class="header-right">
<a-space>
<a-badge :count="notifications">
<BellOutlined style="font-size: 18px; cursor: pointer; color: #666;" />
</a-badge>
<a-dropdown>
<a-space class="user-info" style="cursor: pointer;">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="user-name">{{ userName }}</span>
<DownOutlined />
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="logout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</div>
</a-layout-header>
<!-- 移动端顶部栏 -->
<a-layout-header v-if="isMobile" class="mobile-header">
<div class="mobile-header-content">
<MenuOutlined class="menu-icon" @click="drawerVisible = true" />
<span class="page-title">{{ pageTitle }}</span>
<a-badge :count="notifications" class="notification-badge">
<BellOutlined style="font-size: 20px; color: #333;" />
</a-badge>
</div>
</a-layout-header>
<a-layout-content :class="['parent-content', { 'mobile-content': isMobile }]">
<router-view />
</a-layout-content>
<!-- 移动端底部导航 -->
<div v-if="isMobile" class="mobile-bottom-nav">
<div
v-for="nav in navItems"
:key="nav.key"
:class="['nav-item', { active: selectedKeys[0] === nav.key }]"
@click="handleNavClick(nav.key)"
>
<component :is="nav.icon" class="nav-icon" />
<span class="nav-text">{{ nav.text }}</span>
</div>
</div>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, watch, shallowRef } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
HomeOutlined,
TeamOutlined,
UserOutlined,
BookOutlined,
FileImageOutlined,
CheckSquareOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
BellOutlined,
LogoutOutlined,
DownOutlined,
MenuOutlined,
} from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user';
import { useBreakpoints } from '@/composables/useBreakpoints';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const { isMobile } = useBreakpoints();
const collapsed = ref(false);
const selectedKeys = ref(['dashboard']);
const notifications = ref(0);
const drawerVisible = ref(false);
const userName = computed(() => userStore.user?.name || '家长');
// 页面标题映射
const pageTitleMap: Record<string, string> = {
dashboard: '首页',
children: '我的孩子',
lessons: '阅读记录',
tasks: '阅读任务',
growth: '成长档案',
};
const pageTitle = computed(() => {
const key = selectedKeys.value[0];
return pageTitleMap[key] || '首页';
});
// 底部导航项
const navItems = [
{ key: 'dashboard', icon: shallowRef(HomeOutlined), text: '首页' },
{ key: 'tasks', icon: shallowRef(CheckSquareOutlined), text: '任务' },
{ key: 'lessons', icon: shallowRef(BookOutlined), text: '记录' },
{ key: 'growth', icon: shallowRef(FileImageOutlined), text: '档案' },
{ key: 'children', icon: shallowRef(TeamOutlined), text: '孩子' },
];
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
});
// 根据路由设置选中的菜单
watch(
() => route.path,
(path) => {
if (path.startsWith('/parent/children')) {
selectedKeys.value = ['children'];
} else if (path.startsWith('/parent/lessons')) {
selectedKeys.value = ['lessons'];
} else if (path.startsWith('/parent/tasks')) {
selectedKeys.value = ['tasks'];
} else if (path.startsWith('/parent/growth')) {
selectedKeys.value = ['growth'];
} else {
selectedKeys.value = ['dashboard'];
}
},
{ immediate: true }
);
const handleMenuClick = ({ key }: { key: string | number }) => {
router.push(`/parent/${key}`);
};
const handleMobileMenuClick = ({ key }: { key: string | number }) => {
router.push(`/parent/${key}`);
drawerVisible.value = false;
};
const handleNavClick = (key: string) => {
selectedKeys.value = [key];
router.push(`/parent/${key}`);
};
const handleUserMenuClick = ({ key }: { key: string | number }) => {
const keyStr = String(key);
if (keyStr === 'logout') {
handleLogout();
}
};
const handleLogout = () => {
userStore.logout();
message.success('退出成功');
router.push('/login');
};
</script>
<style scoped lang="scss">
$primary-color: #52c41a;
$primary-light: #f6ffed;
$primary-dark: #389e0d;
$text-color: #333333;
$text-secondary: #666666;
$border-color: #E8E8E8;
$bg-light: #FAFAFA;
.parent-layout {
height: 100vh;
min-height: 100vh;
background: $bg-light;
display: flex;
overflow: hidden;
}
.main-layout {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// 桌面端侧边栏样式
.parent-sider {
background: white !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sider-logo {
flex-shrink: 0;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
background: linear-gradient(135deg, $primary-light 0%, white 100%);
.logo-img {
width: 44px;
height: 44px;
object-fit: contain;
flex-shrink: 0;
}
.logo-text {
display: flex;
flex-direction: column;
margin-left: 12px;
line-height: 1.4;
.logo-title {
font-size: 14px;
font-weight: 600;
color: $text-color;
}
.logo-subtitle {
font-size: 11px;
color: $text-secondary;
}
}
}
.sider-menu-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.side-menu {
border-right: none !important;
padding: 8px 12px;
:deep(.ant-menu-item) {
margin: 4px 0;
padding-left: 12px !important;
padding-right: 12px !important;
border-radius: 8px;
height: 44px;
line-height: 44px;
color: $text-color;
transition: all 0.3s;
&:hover {
background: $primary-light;
color: $primary-color;
}
&.ant-menu-item-selected {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: white;
&::after {
display: none;
}
.anticon {
color: white;
}
}
}
}
}
// 桌面端顶部栏固定不随页面滚动
.parent-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
height: 64px;
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
color: $text-secondary;
&:hover {
color: $primary-color;
}
}
.user-info {
padding: 0 12px;
}
.user-avatar {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
}
.user-name {
color: $text-color;
font-weight: 500;
}
}
// 内容区域仅此区域滚动
.parent-content {
flex: 1;
min-height: 0;
margin: 20px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow-y: auto;
}
// =============== 移动端样式 ===============
// 移动端抽屉
.mobile-drawer {
.drawer-header {
display: flex;
align-items: center;
padding: 8px 0;
.drawer-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.drawer-title {
display: flex;
flex-direction: column;
margin-left: 12px;
.title-main {
font-size: 15px;
font-weight: 600;
color: $text-color;
}
.title-sub {
font-size: 12px;
color: $text-secondary;
}
}
}
.drawer-menu {
border-right: none !important;
:deep(.ant-menu-item) {
margin: 4px 0;
padding-left: 16px !important;
border-radius: 8px;
height: 48px;
line-height: 48px;
font-size: 15px;
&.ant-menu-item-selected {
background: $primary-light;
color: $primary-color;
&::after {
display: none;
}
}
}
}
.drawer-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
.logout-btn {
height: 44px;
border-radius: 8px;
color: #ff4d4f;
border-color: #ff4d4f;
&:hover {
color: #ff7875;
border-color: #ff7875;
}
}
}
}
// 移动端顶部栏
.mobile-header {
background: white;
padding: 0 16px;
height: 56px;
border-bottom: 1px solid $border-color;
position: sticky;
top: 0;
z-index: 100;
.mobile-header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
.menu-icon {
font-size: 22px;
color: $text-color;
padding: 8px;
cursor: pointer;
}
.page-title {
font-size: 17px;
font-weight: 600;
color: $text-color;
}
.notification-badge {
padding: 8px;
cursor: pointer;
}
}
}
// 移动端内容区域
.mobile-content {
margin: 12px;
padding: 16px;
border-radius: 12px;
margin-bottom: 70px;
}
// 移动端底部导航
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: white;
display: flex;
justify-content: space-around;
align-items: center;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.08);
border-top: 1px solid $border-color;
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
cursor: pointer;
transition: all 0.3s;
color: $text-secondary;
.nav-icon {
font-size: 22px;
margin-bottom: 2px;
}
.nav-text {
font-size: 11px;
}
&.active {
color: $primary-color;
.nav-icon {
transform: scale(1.1);
}
}
&:active {
background: rgba(0, 0, 0, 0.02);
}
}
}
// 响应式调整
@media screen and (max-width: 768px) {
.parent-layout {
.ant-layout-sider {
display: none;
}
}
}
@media screen and (min-width: 769px) {
.mobile-drawer {
display: none;
}
.mobile-header {
display: none;
}
.mobile-bottom-nav {
display: none;
}
}
// 平板适配
@media screen and (min-width: 769px) and (max-width: 1024px) {
.parent-content {
padding: 20px;
}
}
</style>