feat(frontend): 响应式布局与移动端适配优化

1) 新增 useBreakpoints 统一断点管理;2) 管理/教师/园校/家长端布局支持移动端抽屉菜单与顶部导航;3) 全局 html/body/#app overflow 与 safe-area 处理,避免横向滚动和刘海遮挡;4) 各端内容区仅内部滚动,提升大屏与小屏的浏览体验

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-02 14:01:51 +08:00
parent 1b566be4dc
commit 31d4ed76f0
8 changed files with 496 additions and 85 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>幼儿阅读教学服务平台</title> <title>幼儿阅读教学服务平台</title>
</head> </head>
<body> <body>

View File

@ -12,16 +12,26 @@ const AConfigProvider = ConfigProvider;
</script> </script>
<style> <style>
html {
overflow-x: hidden;
}
body {
margin: 0;
padding: 0;
overflow-x: hidden;
/* 安全区域:刘海屏/横屏时留出边距 */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
#app { #app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji', sans-serif; 'Noto Color Emoji', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} min-height: 100vh;
overflow-x: hidden;
body {
margin: 0;
padding: 0;
} }
</style> </style>

View File

@ -7,75 +7,31 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar'] AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge'] ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
ACard: typeof import('ant-design-vue/es')['Card'] ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col'] ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer'] ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown'] ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty'] AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
AInput: typeof import('ant-design-vue/es')['Input'] AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout'] ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader'] ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider'] ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu'] AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker'] ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ARate: typeof import('ant-design-vue/es')['Rate']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space'] ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu'] ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
AUpload: typeof import('ant-design-vue/es')['Upload']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default'] FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default']
FileUploader: typeof import('./components/course/FileUploader.vue')['default'] FileUploader: typeof import('./components/course/FileUploader.vue')['default']
LessonConfigPanel: typeof import('./components/course/LessonConfigPanel.vue')['default'] LessonConfigPanel: typeof import('./components/course/LessonConfigPanel.vue')['default']

View File

@ -0,0 +1,47 @@
import { ref, onMounted, onUnmounted } from 'vue';
/** 统一断点(与 CSS 媒体查询一致) */
export const BREAKPOINTS = {
/** 手机竖屏 */
MOBILE: 768,
/** 平板 / 小屏 */
TABLET: 1024,
/** 桌面 */
DESKTOP: 1200,
} as const;
export type BreakpointKey = keyof typeof BREAKPOINTS;
/**
* isMobile / isTablet / isDesktop
* resize 使 768
*/
export function useBreakpoints() {
const width = ref(typeof window !== 'undefined' ? window.innerWidth : 1024);
const isMobile = ref(width.value < BREAKPOINTS.MOBILE);
const isTablet = ref(
width.value >= BREAKPOINTS.MOBILE && width.value < BREAKPOINTS.TABLET
);
const isDesktop = ref(width.value >= BREAKPOINTS.TABLET);
const update = () => {
if (typeof window === 'undefined') return;
const w = window.innerWidth;
width.value = w;
isMobile.value = w < BREAKPOINTS.MOBILE;
isTablet.value = w >= BREAKPOINTS.MOBILE && w < BREAKPOINTS.TABLET;
isDesktop.value = w >= BREAKPOINTS.TABLET;
};
onMounted(() => {
update();
window.addEventListener('resize', update);
});
onUnmounted(() => {
window.removeEventListener('resize', update);
});
return { width, isMobile, isTablet, isDesktop, BREAKPOINTS };
}

View File

@ -1,6 +1,8 @@
<template> <template>
<a-layout class="admin-layout"> <a-layout class="admin-layout">
<!-- 桌面端侧边栏 -->
<a-layout-sider <a-layout-sider
v-if="!isMobile"
v-model:collapsed="collapsed" v-model:collapsed="collapsed"
:trigger="null" :trigger="null"
collapsible collapsible
@ -75,8 +77,63 @@
</div> </div>
</a-layout-sider> </a-layout-sider>
<!-- 移动端抽屉菜单 -->
<a-drawer
v-if="isMobile"
v-model:open="drawerVisible"
placement="left"
:closable="false"
:width="280"
class="admin-drawer"
:body-style="{ padding: 0 }"
>
<template #title>
<div class="drawer-header">
<img src="/logo.png" alt="Logo" class="drawer-logo" />
<span class="drawer-title">服务管理后台</span>
</div>
</template>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="light"
@select="handleDrawerMenuSelect"
class="drawer-menu"
>
<a-menu-item key="dashboard">
<template #icon><LayoutDashboard :size="18" :stroke-width="1.5" /></template>
<span>数据看板</span>
</a-menu-item>
<a-menu-item key="courses">
<template #icon><BookOpen :size="18" :stroke-width="1.5" /></template>
<span>课程包管理</span>
</a-menu-item>
<a-menu-item key="packages">
<template #icon><DatabaseOutlined /></template>
<span>套餐管理</span>
</a-menu-item>
<a-menu-item key="themes">
<template #icon><FormatPainterOutlined /></template>
<span>主题字典</span>
</a-menu-item>
<a-menu-item key="tenants">
<template #icon><Building2 :size="18" :stroke-width="1.5" /></template>
<span>租户管理</span>
</a-menu-item>
<a-menu-item key="resources">
<template #icon><FolderOpen :size="18" :stroke-width="1.5" /></template>
<span>资源库</span>
</a-menu-item>
<a-menu-item key="settings">
<template #icon><Settings :size="18" :stroke-width="1.5" /></template>
<span>系统设置</span>
</a-menu-item>
</a-menu>
</a-drawer>
<a-layout class="admin-layout-right"> <a-layout class="admin-layout-right">
<a-layout-header class="admin-header"> <!-- 桌面端顶部 -->
<a-layout-header v-if="!isMobile" class="admin-header">
<div class="header-left"> <div class="header-left">
<MenuUnfoldOutlined <MenuUnfoldOutlined
v-if="collapsed" v-if="collapsed"
@ -122,7 +179,27 @@
</div> </div>
</a-layout-header> </a-layout-header>
<a-layout-content class="admin-content"> <!-- 移动端顶部 -->
<a-layout-header v-if="isMobile" class="admin-mobile-header">
<MenuOutlined class="menu-trigger" @click="drawerVisible = true" />
<span class="mobile-title">少儿智慧阅读</span>
<a-dropdown>
<a-space class="user-info" style="cursor: pointer;">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile"><UserOutlined /> 个人信息</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout"><LogoutOutlined /> 退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content :class="['admin-content', { 'admin-content-mobile': isMobile }]">
<router-view /> <router-view />
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
@ -135,6 +212,7 @@ import { useRouter, useRoute } from 'vue-router';
import { import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuOutlined,
BellOutlined, BellOutlined,
UserOutlined, UserOutlined,
DownOutlined, DownOutlined,
@ -150,13 +228,20 @@ import {
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { DatabaseOutlined, FormatPainterOutlined } from '@ant-design/icons-vue'; import { DatabaseOutlined, FormatPainterOutlined } from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useBreakpoints } from '@/composables/useBreakpoints';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { isMobile } = useBreakpoints();
const collapsed = ref(false); const collapsed = ref(false);
const selectedKeys = ref<string[]>(['dashboard']); const selectedKeys = ref<string[]>(['dashboard']);
const drawerVisible = ref(false);
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
});
// //
watch( watch(
@ -181,20 +266,25 @@ watch(
{ immediate: true } { immediate: true }
); );
const routeMap: Record<string, string> = {
dashboard: '/admin/dashboard',
courses: '/admin/courses',
packages: '/admin/packages',
themes: '/admin/themes',
tenants: '/admin/tenants',
resources: '/admin/resources',
settings: '/admin/settings',
};
const handleMenuSelect = ({ key }: { key: string | number }) => { const handleMenuSelect = ({ key }: { key: string | number }) => {
const keyStr = String(key); const keyStr = String(key);
const routeMap: Record<string, string> = { if (routeMap[keyStr]) router.push(routeMap[keyStr]);
dashboard: '/admin/dashboard', };
courses: '/admin/courses',
packages: '/admin/packages',
themes: '/admin/themes',
tenants: '/admin/tenants',
resources: '/admin/resources',
settings: '/admin/settings',
};
if (routeMap[keyStr]) { const handleDrawerMenuSelect = ({ key }: { key: string | number }) => {
router.push(routeMap[keyStr]); if (routeMap[String(key)]) {
router.push(routeMap[String(key)]);
drawerVisible.value = false;
} }
}; };
@ -395,5 +485,76 @@ $bg-dark: #111827;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
}
.admin-content-mobile {
margin: 12px;
padding: 16px;
border-radius: 12px;
}
//
.admin-drawer {
.drawer-header {
display: flex;
align-items: center;
gap: 12px;
.drawer-logo {
width: 36px;
height: 36px;
object-fit: contain;
}
.drawer-title {
font-size: 16px;
font-weight: 600;
color: $text-color;
}
}
.drawer-menu {
border-right: none !important;
padding: 8px 0;
:deep(.ant-menu-item) {
margin: 4px 8px;
border-radius: 8px;
height: 48px;
line-height: 48px;
}
}
}
.admin-mobile-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 0 16px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
.menu-trigger {
font-size: 22px;
color: $text-secondary;
padding: 8px;
cursor: pointer;
}
.mobile-title {
font-size: 17px;
font-weight: 600;
color: $text-color;
}
.user-avatar {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
}
}
@media (max-width: 768px) {
.admin-layout :deep(.ant-layout-sider) {
display: none;
}
} }
</style> </style>

View File

@ -178,7 +178,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, shallowRef } from 'vue'; import { ref, computed, watch, shallowRef } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { import {
@ -196,15 +196,16 @@ import {
MenuOutlined, MenuOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useBreakpoints } from '@/composables/useBreakpoints';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { isMobile } = useBreakpoints();
const collapsed = ref(false); const collapsed = ref(false);
const selectedKeys = ref(['dashboard']); const selectedKeys = ref(['dashboard']);
const notifications = ref(0); const notifications = ref(0);
const isMobile = ref(false);
const drawerVisible = ref(false); const drawerVisible = ref(false);
const userName = computed(() => userStore.user?.name || '家长'); const userName = computed(() => userStore.user?.name || '家长');
@ -232,13 +233,9 @@ const navItems = [
{ key: 'children', icon: shallowRef(TeamOutlined), text: '孩子' }, { key: 'children', icon: shallowRef(TeamOutlined), text: '孩子' },
]; ];
// watch(isMobile, (mobile) => {
const checkMobile = () => { if (!mobile) drawerVisible.value = false;
isMobile.value = window.innerWidth < 768; });
if (!isMobile.value) {
drawerVisible.value = false;
}
};
// //
watch( watch(
@ -286,14 +283,6 @@ const handleLogout = () => {
router.push('/login'); router.push('/login');
}; };
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -1,6 +1,7 @@
<template> <template>
<a-layout class="school-layout"> <a-layout class="school-layout">
<a-layout-sider <a-layout-sider
v-if="!isMobile"
v-model:collapsed="collapsed" v-model:collapsed="collapsed"
:trigger="null" :trigger="null"
collapsible collapsible
@ -118,8 +119,58 @@
</div> </div>
</a-layout-sider> </a-layout-sider>
<a-drawer
v-if="isMobile"
v-model:open="drawerVisible"
placement="left"
:closable="false"
:width="280"
class="school-drawer"
:body-style="{ padding: 0 }"
>
<template #title>
<div class="drawer-header">
<img src="/logo.png" alt="Logo" class="drawer-logo" />
<span class="drawer-title">管理后台</span>
</div>
</template>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="light"
@click="handleDrawerMenuClick"
class="drawer-menu"
>
<a-menu-item key="dashboard"><template #icon><DashboardOutlined /></template><span>数据概览</span></a-menu-item>
<a-sub-menu key="staff"><template #icon><TeamOutlined /></template><template #title>人员管理</template>
<a-menu-item key="teachers"><template #icon><SolutionOutlined /></template><span>教师管理</span></a-menu-item>
<a-menu-item key="students"><template #icon><UserOutlined /></template><span>学生管理</span></a-menu-item>
<a-menu-item key="parents"><template #icon><IdcardOutlined /></template><span>家长管理</span></a-menu-item>
<a-menu-item key="classes"><template #icon><HomeOutlined /></template><span>班级管理</span></a-menu-item>
</a-sub-menu>
<a-sub-menu key="teaching"><template #icon><BookOutlined /></template><template #title>教学管理</template>
<a-menu-item key="courses"><template #icon><ReadOutlined /></template><span>课程管理</span></a-menu-item>
<a-menu-item key="school-courses"><template #icon><FolderAddOutlined /></template><span>校本课程包</span></a-menu-item>
<a-menu-item key="schedule"><template #icon><CalendarOutlined /></template><span>课程排期</span></a-menu-item>
<a-menu-item key="tasks"><template #icon><CheckSquareOutlined /></template><span>阅读任务</span></a-menu-item>
<a-menu-item key="task-templates"><template #icon><CopyOutlined /></template><span>任务模板</span></a-menu-item>
<a-menu-item key="feedback"><template #icon><MessageOutlined /></template><span>课程反馈</span></a-menu-item>
</a-sub-menu>
<a-sub-menu key="data"><template #icon><BarChartOutlined /></template><template #title>数据中心</template>
<a-menu-item key="reports"><template #icon><FileTextOutlined /></template><span>数据报告</span></a-menu-item>
<a-menu-item key="growth"><template #icon><FileImageOutlined /></template><span>成长档案</span></a-menu-item>
</a-sub-menu>
<a-sub-menu key="system"><template #icon><SettingOutlined /></template><template #title>系统管理</template>
<a-menu-item key="packages"><template #icon><GiftOutlined /></template><span>套餐管理</span></a-menu-item>
<a-menu-item key="operation-logs"><template #icon><HistoryOutlined /></template><span>操作日志</span></a-menu-item>
<a-menu-item key="settings"><template #icon><ToolOutlined /></template><span>系统设置</span></a-menu-item>
</a-sub-menu>
</a-menu>
</a-drawer>
<a-layout class="school-layout-right"> <a-layout class="school-layout-right">
<a-layout-header class="school-header"> <a-layout-header v-if="!isMobile" class="school-header">
<div class="header-left"> <div class="header-left">
<MenuUnfoldOutlined <MenuUnfoldOutlined
v-if="collapsed" v-if="collapsed"
@ -165,7 +216,26 @@
</div> </div>
</a-layout-header> </a-layout-header>
<a-layout-content class="school-content"> <a-layout-header v-if="isMobile" class="school-mobile-header">
<MenuOutlined class="menu-trigger" @click="drawerVisible = true" />
<span class="mobile-title">少儿智慧阅读</span>
<a-dropdown>
<a-space class="user-info" style="cursor: pointer;">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile"><UserOutlined /> 个人信息</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout"><LogoutOutlined /> 退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content :class="['school-content', { 'school-content-mobile': isMobile }]">
<router-view /> <router-view />
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
@ -190,6 +260,7 @@ import {
MessageOutlined, MessageOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuOutlined,
BellOutlined, BellOutlined,
LogoutOutlined, LogoutOutlined,
DownOutlined, DownOutlined,
@ -204,19 +275,26 @@ import {
FolderAddOutlined, FolderAddOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useBreakpoints } from '@/composables/useBreakpoints';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { isMobile } = useBreakpoints();
const collapsed = ref(false); const collapsed = ref(false);
const selectedKeys = ref(['dashboard']); const selectedKeys = ref(['dashboard']);
const openKeys = ref<string[]>(['staff', 'teaching', 'data', 'system']); const openKeys = ref<string[]>(['staff', 'teaching', 'data', 'system']);
const notifications = ref(0); const notifications = ref(0);
const drawerVisible = ref(false);
const userName = computed(() => userStore.user?.name || '管理员'); const userName = computed(() => userStore.user?.name || '管理员');
const tenantName = computed(() => userStore.user?.tenantName || ''); const tenantName = computed(() => userStore.user?.tenantName || '');
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
});
// //
const routeToMenuMap: Record<string, { key: string; parentKey: string }> = { const routeToMenuMap: Record<string, { key: string; parentKey: string }> = {
'/school/teachers': { key: 'teachers', parentKey: 'staff' }, '/school/teachers': { key: 'teachers', parentKey: 'staff' },
@ -261,11 +339,22 @@ const handleMenuClick = ({ key }: { key: string | number }) => {
} else if (keyStr === 'operation-logs') { } else if (keyStr === 'operation-logs') {
router.push('/school/operation-logs'); router.push('/school/operation-logs');
} else if (!['staff', 'teaching', 'data', 'system'].includes(keyStr)) { } else if (!['staff', 'teaching', 'data', 'system'].includes(keyStr)) {
//
router.push(`/school/${keyStr}`); router.push(`/school/${keyStr}`);
} }
}; };
const handleDrawerMenuClick = ({ key }: { key: string | number }) => {
const keyStr = String(key);
if (keyStr === 'packages') {
router.push('/school/package');
} else if (keyStr === 'operation-logs') {
router.push('/school/operation-logs');
} else if (!['staff', 'teaching', 'data', 'system'].includes(keyStr)) {
router.push(`/school/${keyStr}`);
drawerVisible.value = false;
}
};
const handleUserMenuClick = ({ key }: { key: string | number }) => { const handleUserMenuClick = ({ key }: { key: string | number }) => {
const keyStr = String(key); const keyStr = String(key);
if (keyStr === 'logout') { if (keyStr === 'logout') {
@ -500,5 +589,51 @@ $bg-light: #FAFAFA;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
}
.school-content-mobile {
margin: 12px;
padding: 16px;
border-radius: 12px;
}
.school-drawer {
.drawer-header {
display: flex;
align-items: center;
gap: 12px;
.drawer-logo { width: 36px; height: 36px; object-fit: contain; }
.drawer-title { font-size: 16px; font-weight: 600; color: $text-color; }
}
.drawer-menu {
border-right: none !important;
padding: 8px 0;
:deep(.ant-menu-item),
:deep(.ant-menu-submenu-title) { margin: 4px 8px; border-radius: 8px; height: 48px; line-height: 48px; }
:deep(.ant-menu-sub) .ant-menu-item { height: 44px; line-height: 44px; }
}
}
.school-mobile-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 0 16px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
.menu-trigger { font-size: 22px; color: $text-secondary; padding: 8px; cursor: pointer; }
.mobile-title { font-size: 17px; font-weight: 600; color: $text-color; }
.user-avatar { background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%); }
}
@media (max-width: 768px) {
.school-layout :deep(.ant-layout-sider) { display: none; }
} }
</style> </style>

View File

@ -1,6 +1,7 @@
<template> <template>
<a-layout class="teacher-layout"> <a-layout class="teacher-layout">
<a-layout-sider <a-layout-sider
v-if="!isMobile"
v-model:collapsed="collapsed" v-model:collapsed="collapsed"
:trigger="null" :trigger="null"
collapsible collapsible
@ -64,8 +65,42 @@
</div> </div>
</a-layout-sider> </a-layout-sider>
<a-drawer
v-if="isMobile"
v-model:open="drawerVisible"
placement="left"
:closable="false"
:width="280"
class="teacher-drawer"
:body-style="{ padding: 0 }"
>
<template #title>
<div class="drawer-header">
<img src="/logo.png" alt="Logo" class="drawer-logo" />
<span class="drawer-title">服务平台</span>
</div>
</template>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
theme="light"
@click="handleDrawerMenuClick"
class="drawer-menu"
>
<a-menu-item key="dashboard"><template #icon><HomeOutlined /></template><span>首页</span></a-menu-item>
<a-menu-item key="classes"><template #icon><TeamOutlined /></template><span>我的班级</span></a-menu-item>
<a-menu-item key="courses"><template #icon><BookOutlined /></template><span>课程中心</span></a-menu-item>
<a-menu-item key="school-courses"><template #icon><FolderAddOutlined /></template><span>校本课程包</span></a-menu-item>
<a-menu-item key="lessons"><template #icon><CalendarOutlined /></template><span>上课记录</span></a-menu-item>
<a-menu-item key="schedule"><template #icon><ScheduleOutlined /></template><span>我的课表</span></a-menu-item>
<a-menu-item key="tasks"><template #icon><CheckSquareOutlined /></template><span>阅读任务</span></a-menu-item>
<a-menu-item key="feedback"><template #icon><FileTextOutlined /></template><span>课程反馈</span></a-menu-item>
<a-menu-item key="growth"><template #icon><CameraOutlined /></template><span>成长档案</span></a-menu-item>
</a-menu>
</a-drawer>
<a-layout class="teacher-layout-right"> <a-layout class="teacher-layout-right">
<a-layout-header class="teacher-header"> <a-layout-header v-if="!isMobile" class="teacher-header">
<div class="header-left"> <div class="header-left">
<MenuUnfoldOutlined <MenuUnfoldOutlined
v-if="collapsed" v-if="collapsed"
@ -111,7 +146,26 @@
</div> </div>
</a-layout-header> </a-layout-header>
<a-layout-content class="teacher-content"> <a-layout-header v-if="isMobile" class="teacher-mobile-header">
<MenuOutlined class="menu-trigger" @click="drawerVisible = true" />
<span class="mobile-title">少儿智慧阅读</span>
<a-dropdown>
<a-space class="user-info" style="cursor: pointer;">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
</a-space>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile"><UserOutlined /> 个人信息</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout"><LogoutOutlined /> 退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content :class="['teacher-content', { 'teacher-content-mobile': isMobile }]">
<router-view /> <router-view />
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
@ -130,6 +184,7 @@ import {
FileTextOutlined, FileTextOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuOutlined,
BellOutlined, BellOutlined,
UserOutlined, UserOutlined,
LogoutOutlined, LogoutOutlined,
@ -140,18 +195,25 @@ import {
FolderAddOutlined, FolderAddOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useBreakpoints } from '@/composables/useBreakpoints';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { isMobile } = useBreakpoints();
const collapsed = ref(false); const collapsed = ref(false);
const selectedKeys = ref(['dashboard']); const selectedKeys = ref(['dashboard']);
const notifications = ref(0); const notifications = ref(0);
const drawerVisible = ref(false);
const userName = computed(() => userStore.user?.name || '教师'); const userName = computed(() => userStore.user?.name || '教师');
const tenantName = computed(() => userStore.user?.tenantName || ''); const tenantName = computed(() => userStore.user?.tenantName || '');
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
});
// //
watch( watch(
() => route.path, () => route.path,
@ -183,6 +245,13 @@ const handleMenuClick = ({ key }: { key: string | number }) => {
router.push(`/teacher/${key}`); router.push(`/teacher/${key}`);
}; };
const handleDrawerMenuClick = ({ key }: { key: string | number }) => {
const keyStr = String(key);
if (!['dashboard', 'classes', 'courses', 'school-courses', 'lessons', 'schedule', 'tasks', 'feedback', 'growth'].includes(keyStr)) return;
router.push(`/teacher/${keyStr}`);
drawerVisible.value = false;
};
const handleUserMenuClick = ({ key }: { key: string | number }) => { const handleUserMenuClick = ({ key }: { key: string | number }) => {
const keyStr = String(key); const keyStr = String(key);
if (keyStr === 'logout') { if (keyStr === 'logout') {
@ -380,5 +449,49 @@ $bg-light: #FAFAFA;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
}
.teacher-content-mobile {
margin: 12px;
padding: 16px;
border-radius: 12px;
}
.teacher-drawer {
.drawer-header {
display: flex;
align-items: center;
gap: 12px;
.drawer-logo { width: 36px; height: 36px; object-fit: contain; }
.drawer-title { font-size: 16px; font-weight: 600; color: $text-color; }
}
.drawer-menu {
border-right: none !important;
padding: 8px 0;
:deep(.ant-menu-item) { margin: 4px 8px; border-radius: 8px; height: 48px; line-height: 48px; }
}
}
.teacher-mobile-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 0 16px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
.menu-trigger { font-size: 22px; color: $text-secondary; padding: 8px; cursor: pointer; }
.mobile-title { font-size: 17px; font-weight: 600; color: $text-color; }
.user-avatar { background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%); }
}
@media (max-width: 768px) {
.teacher-layout :deep(.ant-layout-sider) { display: none; }
} }
</style> </style>