移动端适配

This commit is contained in:
2025-12-09 14:59:41 +08:00
parent 490e8e70f1
commit 242a263daa
9 changed files with 1063 additions and 58 deletions

View File

@@ -8,7 +8,7 @@ import type { RouteRecordRaw } from 'vue-router';
import type { SysMenu } from '@/types';
import { MenuType } from '@/types/enums';
import { routes } from '@/router';
import { getResponsiveLayout } from './routeAdapter';
import { getResponsiveLayout, createResponsiveRoute, type RouteAdapter } from './routeAdapter';
// 预注册所有视图组件,构建时由 Vite 解析并生成按需加载的 chunk
const VIEW_MODULES = import.meta.glob('../views/**/*.vue');
@@ -280,7 +280,7 @@ function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
}
/**
* 根据组件名称获取组件
* 根据组件名称获取组件(支持响应式组件)
* @param componentName 组件名称/路径
* @returns 组件异步加载函数
*/
@@ -302,17 +302,23 @@ function getComponent(componentName: string) {
}
// 将别名 @/ 转为相对于当前文件的路径,必须与 import.meta.glob 中的模式一致
componentPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView'
const originalPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView'
// 补全 .vue 后缀
if (!componentPath.endsWith('.vue')) {
componentPath += '.vue';
if (!originalPath.endsWith('.vue')) {
componentPath = originalPath + '.vue';
} else {
componentPath = originalPath;
}
// 3. 从 VIEW_MODULES 中查找对应的 loader
const loader = VIEW_MODULES[componentPath];
// 3. 检查是否有移动端版本
const mobileComponentPath = componentPath.replace('.vue', '.mobile.vue');
// 从 VIEW_MODULES 中查找对应的 loader
const originalLoader = VIEW_MODULES[componentPath];
const mobileLoader = VIEW_MODULES[mobileComponentPath];
if (!loader) {
if (!originalLoader) {
console.error('[路由生成] 未找到组件模块', {
原始组件名: componentName,
期望路径: componentPath,
@@ -322,7 +328,17 @@ function getComponent(componentName: string) {
return () => import('@/views/public/error/404.vue');
}
return loader as () => Promise<any>;
// 4. 如果有移动端版本,创建响应式路由适配器
if (mobileLoader) {
const adapter: RouteAdapter = {
original: originalLoader as () => Promise<any>,
mobile: mobileLoader as () => Promise<any>
};
return createResponsiveRoute(adapter);
}
// 5. 没有移动端版本,直接返回原始组件
return originalLoader as () => Promise<any>;
}
/**

View File

@@ -15,7 +15,7 @@
<!-- 课程信息看板 -->
<div class="course-info-panel">
<div class="panel-container">
<!-- 左侧课程封面 -->
<!-- 课程封面 -->
<div class="course-cover">
<img
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
@@ -24,7 +24,7 @@
/>
</div>
<!-- 右侧课程信息 -->
<!-- 课程信息 -->
<div class="course-info">
<div class="info-content">
<!-- 课程标题 -->
@@ -65,14 +65,6 @@
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
<el-button
size="large"
:plain="!isCollected"
@@ -81,6 +73,15 @@
<el-icon><Star /></el-icon>
收藏课程
</el-button>
<el-button
type="primary"
size="large"
@click="handleStartLearning"
:loading="enrolling"
>
{{ isEnrolled ? '继续学习' : '开始学习' }}
</el-button>
</div>
</div>
</div>
@@ -500,6 +501,13 @@ function formatDuration(minutes?: number): string {
background: #FFFFFF;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
// 移动端垂直布局
@media (max-width: 768px) {
flex-direction: column;
gap: 20px;
padding: 20px 16px;
}
}
}
@@ -515,6 +523,12 @@ function formatDuration(minutes?: number): string {
height: 100%;
object-fit: cover;
}
// 移动端全宽
@media (max-width: 768px) {
width: 100%;
height: 200px;
}
}
.course-info {
@@ -529,6 +543,11 @@ function formatDuration(minutes?: number): string {
gap: 10px;
}
// 移动端样式调整
@media (max-width: 768px) {
gap: 16px;
}
.course-title {
font-family: "Source Han Sans SC";
font-weight: 600;
@@ -634,6 +653,12 @@ function formatDuration(minutes?: number): string {
align-items: center;
gap: 27px;
// 移动端按钮布局 - 保持水平排列
@media (max-width: 768px) {
gap: 12px;
justify-content: space-between;
}
:deep(.el-button) {
height: 42px;
border-radius: 8px;
@@ -651,6 +676,12 @@ function formatDuration(minutes?: number): string {
background: #d32f2f;
border-color: #d32f2f;
}
// 移动端等宽
@media (max-width: 768px) {
flex: 1;
width: auto;
}
}
&.el-button--default {
@@ -658,6 +689,12 @@ function formatDuration(minutes?: number): string {
border-color: #86909C;
color: #86909C;
// 移动端等宽
@media (max-width: 768px) {
flex: 1;
width: auto;
}
img {
width: 20px;
height: 20px;

View File

@@ -22,6 +22,9 @@
<el-button circle :icon="Expand" />
</div>
<!-- 移动端遮罩层侧边栏展开时显示 -->
<div v-if="isMobile && !sidebarCollapsed" class="mobile-overlay" @click="toggleSidebar"></div>
<!-- 左侧章节目录 -->
<div class="chapter-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
@@ -179,7 +182,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
@@ -230,6 +233,7 @@ const currentChapterIndex = ref(0);
const currentNodeIndex = ref(0);
const sidebarCollapsed = ref(false);
const activeChapters = ref<number[]>([0]);
const isMobile = ref(false);
const articleData = ref<any>(null);
const contentAreaRef = ref<HTMLElement | null>(null);
@@ -348,8 +352,31 @@ watch(currentNode, async () => {
onMounted(() => {
// 不在这里启动定时器,等待学习记录加载完成后再启动
// startLearningTimer(); 移到loadLearningRecord和createLearningRecord成功后
// 检查移动端并设置侧边栏默认状态
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// 检查是否为移动端
function checkMobile() {
const wasMobile = isMobile.value;
isMobile.value = window.innerWidth < 768;
// 如果是初始化或从桌面端切换到移动端,默认收起侧边栏
if (isMobile.value && (!wasMobile || wasMobile === undefined)) {
sidebarCollapsed.value = true;
}
// 如果从移动端切换到桌面端,默认展开侧边栏
else if (!isMobile.value && wasMobile) {
sidebarCollapsed.value = false;
}
}
onBeforeUnmount(() => {
stopLearningTimer();
saveLearningProgress();
@@ -1074,6 +1101,21 @@ function handleBack() {
}
}
.mobile-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
@media (max-width: 768px) {
display: block;
}
}
.chapter-sidebar {
width: 320px;
background: #fff;
@@ -1086,6 +1128,22 @@ function handleBack() {
overflow: hidden;
}
// 移动端适配
@media (max-width: 768px) {
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
width: 280px; // 移动端稍窄一些
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
&.collapsed {
left: -280px; // 滑出屏幕外
width: 280px; // 保持宽度用于动画
}
}
.sidebar-header {
display: flex;
justify-content: space-between;

View File

@@ -898,26 +898,163 @@ function getItemStatusType(status?: number): 'info' | 'warning' | 'success' {
padding: 40px;
}
// 响应式设计
// 移动端响应式设计
@media (max-width: 768px) {
.task-detail {
.back-header {
padding: 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
}
.task-content {
padding: 16px;
}
.task-info {
.task-title {
font-size: 22px;
}
.task-info-card {
.task-info-section {
.task-title {
font-size: 20px;
line-height: 1.4;
margin-bottom: 12px;
}
.task-meta {
.meta-row {
flex-direction: column;
.task-description {
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
}
.task-meta {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px 16px; // 垂直间距8px水平间距16px
align-items: center;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap; // 防止单个meta-item内部换行
span {
font-size: 13px;
color: #6B7280;
}
.creator-avatar,
.meta-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.meta-divider {
display: none; // 移动端隐藏分隔线
}
}
// 关键修改统计卡片改为2x2网格布局
.task-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 20px;
.stat-card {
width: 100%;
height: 88px;
padding: 16px 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
.stat-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
flex: 1;
.stat-label {
font-size: 13px;
color: #6B7280;
font-weight: 500;
line-height: 1.3;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #1F2937;
line-height: 1.1;
}
}
.meta-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
object-fit: contain;
opacity: 0.8;
}
}
}
.progress-section {
padding: 16px 20px;
height: auto;
margin-top: 16px;
.progress-header {
.progress-label,
.progress-value {
font-size: 14px;
}
}
.progress-bar-container .progress-bar {
height: 6px;
}
}
}
}
.task-stats {
grid-template-columns: repeat(2, 1fr);
.task-content-section {
margin-top: 16px;
.course-card,
.resource-card {
margin-top: 16px;
:deep(.el-card__header) {
padding: 16px;
.card-header {
.header-title {
font-size: 16px;
}
.item-count {
font-size: 12px;
}
}
}
:deep(.el-card__body) {
padding: 16px;
}
}
}
@@ -925,6 +1062,37 @@ function getItemStatusType(status?: number): 'info' | 'warning' | 'success' {
.resource-item {
flex-direction: column;
align-items: flex-start;
padding: 16px;
gap: 12px;
.course-index,
.resource-index {
width: 32px;
height: 32px;
font-size: 14px;
}
.course-info,
.resource-info {
width: 100%;
.course-name-row,
.resource-name-row {
.course-name,
.resource-name {
font-size: 16px;
}
}
.course-meta,
.resource-meta {
margin-top: 8px;
.progress-text {
font-size: 12px;
}
}
}
.course-action,
.resource-action {
@@ -932,6 +1100,8 @@ function getItemStatusType(status?: number): 'info' | 'warning' | 'success' {
.el-button {
width: 100%;
height: 36px;
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,215 @@
<template>
<div class="resource-center-view">
<CenterHead
title="资源中心"
:category-name="currentCategoryName"
:show-article-mode="showArticle"
/>
<!-- <div class="search-wrapper">
<Search @search="handleSearch" />
</div> -->
<div class="content-wrapper">
<div class="content-container">
<ResourceSideBar
:activeTagID="currentCategoryId"
@category-change="handleCategoryChange"
/>
<ResourceList
v-show="!showArticle"
ref="resourceListRef"
:tagID="currentCategoryId"
:search-keyword="searchKeyword"
@resource-click="handleResourceClick"
@list-updated="handleListUpdated"
/>
<ResourceArticle
v-if="showArticle"
:resource-id="currentResourceId"
:tagID="currentCategoryId"
:resource-list="resourceList"
@resource-change="handleResourceChange"
@navigate="handleArticleNavigate"
@back-to-list="backToList"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ResourceSideBar, ResourceList, ResourceArticle } from './components';
import { Search, CenterHead } from '@/components/base';
import type { Resource, Tag } from '@/types/resource';
import { resourceApi, resourceTagApi } from '@/apis/resource';
const route = useRoute();
const router = useRouter();
const showArticle = ref(false);
const currentCategoryId = ref('tag_article_001');
const currentCategoryName = ref('党史学习');
const currentResourceId = ref('');
const searchKeyword = ref('');
const resourceListRef = ref();
const resourceList = ref<Resource[]>([]);
// 组件加载时检查 URL 参数
onMounted(async () => {
const tagID = route.query.tagID as string;
if (tagID) {
await loadTagInfo(tagID);
}
});
// 监听路由参数变化
watch(() => route.query.tagID, async (newTagID) => {
if (newTagID && typeof newTagID === 'string') {
await loadTagInfo(newTagID);
}
});
// 加载标签信息
async function loadTagInfo(tagID: string) {
try {
const res = await resourceTagApi.getTagsByType(1); // 1 = 文章分类标签
if (res.success && res.dataList) {
const tag = res.dataList.find((t: Tag) => t.tagID === tagID);
if (tag) {
currentCategoryId.value = tag.tagID || '';
currentCategoryName.value = tag.name || '';
searchKeyword.value = '';
showArticle.value = false;
}
}
} catch (error) {
console.error('加载标签信息失败:', error);
}
}
function handleCategoryChange(category: Tag) {
currentCategoryId.value = category.tagID || category.id || '';
currentCategoryName.value = category.name || '';
searchKeyword.value = '';
showArticle.value = false;
// 清除 URL 中的 tagID 参数,确保 URL 与实际显示的分类一致
if (route.query.tagID) {
router.replace({ path: route.path, query: {} });
}
}
function handleSearch(keyword: string) {
searchKeyword.value = keyword;
showArticle.value = false;
// 清除 URL 中的 tagID 参数
if (route.query.tagID) {
router.replace({ path: route.path, query: {} });
}
}
function handleListUpdated(list: Resource[]) {
resourceList.value = list;
}
function handleResourceClick(resource: Resource) {
// 增加浏览次数
resourceApi.incrementViewCount(resource.resourceID || '');
currentResourceId.value = resource.resourceID || '';
showArticle.value = true;
}
function handleResourceChange(resourceId: string) {
currentResourceId.value = resourceId;
// ArticleShowView 会自动重新加载
}
function backToList() {
showArticle.value = false;
currentResourceId.value = '';
}
// 文章内前后切换时,靠近列表头部或尾部触发列表翻页
async function handleArticleNavigate(direction: 'prev' | 'next', resourceId: string) {
const list = resourceListRef.value?.getResources?.() || [];
const index = list.findIndex((r: any) => r.resourceID === resourceId);
if (index === -1) return;
const nearHead = index <= 2;
const nearTail = index >= list.length - 3;
if (nearHead && direction === 'prev') {
await resourceListRef.value?.loadPrevPage?.();
} else if (nearTail && direction === 'next') {
await resourceListRef.value?.loadNextPage?.();
}
}
</script>
<style scoped lang="scss">
.resource-center-view {
background: #F9F9F9;
height: 100%;
overflow-y: auto;
}
.search-wrapper {
width: 100%;
display: flex;
justify-content: center;
padding: 20px 0;
:deep(.resource-search) {
// width: 1200px;
width: 90%;
height: 60px;
padding: 0;
.search-box {
height: 60px;
input {
font-size: 20px;
padding: 0 100px 0 40px;
}
.search-button {
width: 72px;
height: 60px;
img {
width: 24px;
height: 24px;
}
}
}
}
}
.content-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.content-container {
width: 100%;
display: flex;
flex-direction: column;
height: 100%;
:deep(.resource-sidebar) {
margin-bottom: 16px;
}
:deep(.resource-list) {
width: 100%;
flex: 1;
}
:deep(.resource-article) {
width: 100%;
flex: 1;
}
}
</style>

View File

@@ -9,7 +9,7 @@
>
<div class="resource-cover">
<img
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : defaultArticleImg"
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : staticAssets.defaultArticleImg"
alt="cover"
/>
</div>
@@ -27,13 +27,19 @@
<div v-if="resources.length === 0 && !loading" class="empty">暂无数据</div>
</div>
<div v-if="total > 0" class="pagination-container">
<!-- 移动端显示加载更多提示桌面端显示分页 -->
<div v-if="isMobileDevice">
<div v-if="isLoadingMore" class="loading-more">正在加载更多...</div>
<div v-else-if="!hasMoreData && resources.length > 0 && hasTriggeredLoadMore" class="no-more-data">已加载全部数据</div>
</div>
<div v-if="!isMobileDevice && total > 0" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:page-sizes="[10, 15, 20, 50]"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
@@ -42,12 +48,18 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue';
import { ref, reactive, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { resourceApi } from '@/apis/resource';
import { FILE_DOWNLOAD_URL } from '@/config';
import type { Resource } from '@/types/resource';
import type { PageParam } from '@/types';
import defaultArticleImg from '@/assets/imgs/article-default.png';
import defaultArticleImgUrl from '@/assets/imgs/article-default.png';
import { useDevice } from '@/utils/deviceUtils';
// 创建响应式数据对象,包含静态资源
const staticAssets = reactive({
defaultArticleImg: defaultArticleImgUrl
});
interface Props {
tagID?: string;
@@ -67,20 +79,61 @@ const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const listContainerRef = ref<HTMLElement>();
const hasMoreData = ref(true);
const isLoadingMore = ref(false);
const hasTriggeredLoadMore = ref(false); // 标记是否触发过加载更多
// 设备检测
const { isMobileDevice } = useDevice();
onMounted(() => {
loadResources();
// 在移动端添加滚动监听
nextTick(() => {
if (isMobileDevice.value && listContainerRef.value) {
setupInfiniteScroll();
}
});
});
onUnmounted(() => {
// 清理滚动监听
if (isMobileDevice.value && listContainerRef.value) {
listContainerRef.value.removeEventListener('scroll', handleScroll);
}
});
watch(() => [props.tagID, props.searchKeyword], () => {
currentPage.value = 1;
resources.value = []; // 清空现有数据
hasMoreData.value = true;
hasTriggeredLoadMore.value = false; // 重置加载更多标记
loadResources();
}, { deep: true });
async function loadResources() {
if (loading.value) return;
// 监听设备变化
watch(isMobileDevice, (isMobile) => {
if (isMobile) {
nextTick(() => {
if (listContainerRef.value) {
setupInfiniteScroll();
}
});
} else {
// 切换到桌面端时移除滚动监听
if (listContainerRef.value) {
listContainerRef.value.removeEventListener('scroll', handleScroll);
}
}
});
async function loadResources(isAppend = false) {
if (loading.value || (isAppend && !hasMoreData.value)) return;
loading.value = true;
if (isAppend) {
isLoadingMore.value = true;
}
try {
const filter: Resource = {
@@ -97,22 +150,35 @@ async function loadResources() {
const res = await resourceApi.getResourcePage(pageParam, filter);
if (res.success && res.dataList) {
resources.value = res.dataList;
const newData = res.dataList;
if (isAppend) {
// 追加模式:将新数据添加到现有列表
resources.value = [...resources.value, ...newData];
} else {
// 替换模式:重新加载数据
resources.value = newData;
// 重置滚动位置到顶部
await nextTick();
if (listContainerRef.value) {
listContainerRef.value.scrollTop = 0;
}
}
total.value = res.pageDomain?.pageParam.totalElements || 0;
// 通知父组件列表已更新
emit('list-updated', res.dataList);
// 检查是否还有更多数据
const totalPages = Math.ceil(total.value / pageSize.value);
hasMoreData.value = currentPage.value < totalPages;
// 重置滚动位置到顶部
await nextTick();
if (listContainerRef.value) {
listContainerRef.value.scrollTop = 0;
}
// 通知父组件列表已更新
emit('list-updated', resources.value);
}
} catch (error) {
console.error('加载资源列表失败:', error);
} finally {
loading.value = false;
isLoadingMore.value = false;
}
}
@@ -154,6 +220,40 @@ function handleResourceClick(resource: Resource) {
emit('resource-click', resource);
}
// 无限滚动相关函数
function setupInfiniteScroll() {
if (!listContainerRef.value) return;
listContainerRef.value.addEventListener('scroll', handleScroll, { passive: true });
}
function handleScroll() {
if (!listContainerRef.value || !isMobileDevice.value || isLoadingMore.value || !hasMoreData.value) {
return;
}
const container = listContainerRef.value;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
// 计算距离底部的像素距离
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
// 当距离底部小于等于2像素时加载更多数据
if (distanceFromBottom <= 2) {
loadMoreData();
}
}
async function loadMoreData() {
if (!hasMoreData.value || isLoadingMore.value) return;
hasTriggeredLoadMore.value = true; // 标记用户已触发加载更多
currentPage.value++;
await loadResources(true); // true 表示追加模式
}
defineExpose({
loadResources,
getResources,
@@ -181,6 +281,7 @@ defineExpose({
flex-direction: column;
gap: 30px;
min-height: 400px;
overflow-y: auto;
}
.resource-item {
@@ -275,7 +376,8 @@ defineExpose({
}
.loading-more,
.empty {
.empty,
.no-more-data {
text-align: center;
padding: 20px;
font-family: "Source Han Sans SC";
@@ -283,5 +385,70 @@ defineExpose({
color: #979797;
}
.no-more-data {
color: #999999;
background-color: #f5f5f5;
border-radius: 8px;
margin: 16px;
}
.pagination-container {
padding: 20px 30px;
border-top: 1px solid #EEEEEE;
:deep(.el-pagination) {
justify-content: center;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.resource-list {
border-radius: 8px;
margin: 0;
}
.list-container {
padding: 16px;
gap: 16px;
max-height: calc(100vh - 200px); // 确保有足够高度可以滚动
}
.resource-item {
flex-direction: column;
gap: 12px;
padding-bottom: 16px;
}
.resource-cover {
width: 100%;
height: 160px;
}
.resource-title {
font-size: 16px;
line-height: 20px;
}
.resource-summary {
font-size: 14px;
line-height: 18px;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.pagination-container {
padding: 12px 16px;
:deep(.el-pagination) {
.el-pagination__sizes,
.el-pagination__total,
.el-pagination__jump {
display: none;
}
}
}
}
</style>

View File

@@ -101,7 +101,6 @@ function handleCategoryClick(category: Tag) {
background: #C62828;
border-radius: 8px;
z-index: 1;
}
}
}
@@ -114,5 +113,60 @@ function handleCategoryClick(category: Tag) {
color: #334155;
transition: color 0.3s;
}
</style>
// 移动端样式
@media (max-width: 768px) {
.resource-sidebar {
width: 100%;
background: transparent;
border-radius: 0;
padding: 0;
}
.sidebar-content {
flex-direction: row;
overflow-x: auto;
gap: 12px;
padding: 16px;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: #CCCCCC;
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
.sidebar-item {
flex-shrink: 0;
height: auto;
padding: 8px 16px;
background: #F5F5F5;
border-radius: 20px;
white-space: nowrap;
&:hover {
background: #E0E0E0;
}
&.active {
background: #C62828;
.active-overlay {
display: none;
}
}
}
.category-name {
font-size: 14px;
line-height: 20px;
}
}</style>

View File

@@ -76,8 +76,8 @@
<el-empty description="暂无课程" />
</div>
<!-- 分页 -->
<div v-if="total > 0" class="pagination-container">
<!-- 桌面端分页 -->
<div v-if="total > 0 && !isMobile" class="pagination-container">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
@@ -88,20 +88,28 @@
@current-change="handleCurrentChange"
/>
</div>
<!-- 移动端加载更多 -->
<div v-if="isMobile && hasMore && courseList.length > 0" class="mobile-loading">
<div v-if="loadingMore" class="loading-more">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
</div>
</div>
</div>
</StudyPlanLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { Search, VideoPlay } from '@element-plus/icons-vue';
import { Search, VideoPlay, Loading } from '@element-plus/icons-vue';
import { courseApi } from '@/apis/study';
import type { Course, PageParam } from '@/types';
import { StudyPlanLayout } from '@/views/user/study-plan';
import defaultCover from '@/assets/imgs/default-course-bg.png'
import defaultCoverImg from '@/assets/imgs/default-course-bg.png'
import { FILE_DOWNLOAD_URL } from '@/config';
defineOptions({
@@ -110,9 +118,15 @@ defineOptions({
const router = useRouter();
const loading = ref(false);
const loadingMore = ref(false);
const searchKeyword = ref('');
const courseList = ref<Course[]>([]);
const total = ref(0);
const isMobile = ref(false);
const hasMore = ref(true);
// 默认封面图片
const defaultCover = defaultCoverImg;
// 分页参数
const pageParam = ref<PageParam>({
@@ -121,11 +135,91 @@ const pageParam = ref<PageParam>({
});
onMounted(() => {
checkMobile();
loadCourseList();
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
});
// 检查是否为移动端
function checkMobile() {
isMobile.value = window.innerWidth < 768;
// 移动端使用更大的页面大小以减少请求次数
if (isMobile.value) {
pageParam.value.pageSize = 12;
}
}
// 窗口大小变化处理
function handleResize() {
checkMobile();
}
// 滚动事件处理
function handleScroll() {
if (!isMobile.value || loadingMore.value || !hasMore.value) return;
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const clientHeight = document.documentElement.clientHeight;
// 滚动到底部-2px时触发加载
if (scrollHeight - scrollTop - clientHeight <= 2) {
loadMoreCourses();
}
}
// 加载更多课程(移动端)
async function loadMoreCourses() {
if (loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
const nextPage = pageParam.value.pageNumber + 1;
try {
const filter: Course = {
status: 1
};
if (searchKeyword.value) {
filter.name = searchKeyword.value;
}
const res = await courseApi.getCoursePage(
{ ...pageParam.value, pageNumber: nextPage },
filter
);
if (res.success && res.dataList) {
courseList.value = [...courseList.value, ...res.dataList];
pageParam.value.pageNumber = nextPage;
total.value = res.pageParam?.totalElements || 0;
// 检查是否还有更多数据
hasMore.value = courseList.value.length < total.value;
} else {
ElMessage.error('加载更多课程失败');
}
} catch (error) {
console.error('加载更多课程失败:', error);
ElMessage.error('加载更多课程失败');
} finally {
loadingMore.value = false;
}
}
// 加载课程列表
async function loadCourseList() {
async function loadCourseList(isRefresh = false) {
if (isRefresh) {
pageParam.value.pageNumber = 1;
hasMore.value = true;
}
loading.value = true;
try {
const filter: Course = {
@@ -141,6 +235,11 @@ async function loadCourseList() {
if (res.success) {
courseList.value = res.dataList || [];
total.value = res.pageParam?.totalElements || 0;
// 移动端下检查是否还有更多数据
if (isMobile.value) {
hasMore.value = courseList.value.length < total.value;
}
} else {
ElMessage.error('加载课程列表失败');
}
@@ -155,7 +254,8 @@ async function loadCourseList() {
// 搜索
function handleSearch() {
pageParam.value.pageNumber = 1;
loadCourseList();
hasMore.value = true;
loadCourseList(true);
}
// 分页大小改变
@@ -323,14 +423,13 @@ function getCategoryName(): string {
.course-info {
padding: 22px;
position: relative;
.view-count {
position: absolute;
top: 232px;
right: 22px;
font-size: 14px;
color: rgba(0, 0, 0, 0.3);
margin-bottom: 8px;
text-align: right;
}
.course-title {
@@ -370,4 +469,22 @@ function getCategoryName(): string {
justify-content: center;
margin-top: 40px;
}
.mobile-loading {
display: flex;
justify-content: center;
padding: 20px 0 40px;
.loading-more {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 14px;
.el-icon {
font-size: 16px;
}
}
}
</style>

View File

@@ -682,4 +682,175 @@ function getDeadlineStatus(task: TaskItemVO): { show: boolean; text: string; typ
justify-content: center;
margin-top: 40px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.main-content {
.container {
padding: 16px;
}
}
.progress-card {
.progress-info {
.progress-header {
gap: 16px;
.progress-text {
font-size: 14px;
}
}
.progress-bar-container {
.progress-bar {
height: 8px;
}
}
}
.task-stats {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
.stat-card {
width: 100%;
padding: 20px 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
.stat-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex: 1;
.stat-title {
width: auto !important;
font-size: 14px;
font-weight: 500;
text-align: left;
color: #666666;
margin: 0;
}
.stat-number {
width: auto !important;
font-size: 28px;
font-weight: 600;
color: #141F38;
margin: 0;
}
}
.stat-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
margin-left: 16px;
display: flex;
align-items: center;
justify-content: center;
:deep(.el-icon) {
font-size: 28px !important;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
}
}
&.pending {
.stat-content .stat-number {
color: #F53F3F;
}
.stat-icon {
:deep(.el-icon) {
img {
transform: scale(0.9); // 红色图标稍微缩小
}
}
}
}
&.completed {
.stat-content .stat-number {
color: #00B42A;
}
.stat-icon {
:deep(.el-icon) {
img {
transform: scale(1.2); // 蓝色图标放大以补偿白边
}
}
}
}
}
}
}
.task-card {
padding: 20px 16px;
height: auto;
.task-content {
gap: 12px;
.task-header {
gap: 12px;
margin-bottom: 12px;
}
.task-title {
font-size: 18px;
}
.task-desc {
font-size: 13px;
margin-bottom: 12px;
-webkit-line-clamp: 2;
line-clamp: 2;
}
}
}
.pagination-container {
margin-top: 24px;
:deep(.el-pagination) {
.el-pagination__sizes,
.el-pagination__total,
.el-pagination__jump {
display: none;
}
.el-pager li {
min-width: 28px;
height: 28px;
font-size: 12px;
}
.btn-prev,
.btn-next {
min-width: 28px;
height: 28px;
}
}
}
}
</style>