移动端适配
This commit is contained in:
@@ -8,7 +8,7 @@ import type { RouteRecordRaw } from 'vue-router';
|
|||||||
import type { SysMenu } from '@/types';
|
import type { SysMenu } from '@/types';
|
||||||
import { MenuType } from '@/types/enums';
|
import { MenuType } from '@/types/enums';
|
||||||
import { routes } from '@/router';
|
import { routes } from '@/router';
|
||||||
import { getResponsiveLayout } from './routeAdapter';
|
import { getResponsiveLayout, createResponsiveRoute, type RouteAdapter } from './routeAdapter';
|
||||||
|
|
||||||
// 预注册所有视图组件,构建时由 Vite 解析并生成按需加载的 chunk
|
// 预注册所有视图组件,构建时由 Vite 解析并生成按需加载的 chunk
|
||||||
const VIEW_MODULES = import.meta.glob('../views/**/*.vue');
|
const VIEW_MODULES = import.meta.glob('../views/**/*.vue');
|
||||||
@@ -280,7 +280,7 @@ function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据组件名称获取组件
|
* 根据组件名称获取组件(支持响应式组件)
|
||||||
* @param componentName 组件名称/路径
|
* @param componentName 组件名称/路径
|
||||||
* @returns 组件异步加载函数
|
* @returns 组件异步加载函数
|
||||||
*/
|
*/
|
||||||
@@ -302,17 +302,23 @@ function getComponent(componentName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 将别名 @/ 转为相对于当前文件的路径,必须与 import.meta.glob 中的模式一致
|
// 将别名 @/ 转为相对于当前文件的路径,必须与 import.meta.glob 中的模式一致
|
||||||
componentPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView'
|
const originalPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView'
|
||||||
|
|
||||||
// 补全 .vue 后缀
|
// 补全 .vue 后缀
|
||||||
if (!componentPath.endsWith('.vue')) {
|
if (!originalPath.endsWith('.vue')) {
|
||||||
componentPath += '.vue';
|
componentPath = originalPath + '.vue';
|
||||||
|
} else {
|
||||||
|
componentPath = originalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 从 VIEW_MODULES 中查找对应的 loader
|
// 3. 检查是否有移动端版本
|
||||||
const loader = VIEW_MODULES[componentPath];
|
const mobileComponentPath = componentPath.replace('.vue', '.mobile.vue');
|
||||||
|
|
||||||
if (!loader) {
|
// 从 VIEW_MODULES 中查找对应的 loader
|
||||||
|
const originalLoader = VIEW_MODULES[componentPath];
|
||||||
|
const mobileLoader = VIEW_MODULES[mobileComponentPath];
|
||||||
|
|
||||||
|
if (!originalLoader) {
|
||||||
console.error('[路由生成] 未找到组件模块', {
|
console.error('[路由生成] 未找到组件模块', {
|
||||||
原始组件名: componentName,
|
原始组件名: componentName,
|
||||||
期望路径: componentPath,
|
期望路径: componentPath,
|
||||||
@@ -322,7 +328,17 @@ function getComponent(componentName: string) {
|
|||||||
return () => import('@/views/public/error/404.vue');
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<!-- 课程信息看板 -->
|
<!-- 课程信息看板 -->
|
||||||
<div class="course-info-panel">
|
<div class="course-info-panel">
|
||||||
<div class="panel-container">
|
<div class="panel-container">
|
||||||
<!-- 左侧:课程封面 -->
|
<!-- 课程封面 -->
|
||||||
<div class="course-cover">
|
<div class="course-cover">
|
||||||
<img
|
<img
|
||||||
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
|
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:课程信息 -->
|
<!-- 课程信息 -->
|
||||||
<div class="course-info">
|
<div class="course-info">
|
||||||
<div class="info-content">
|
<div class="info-content">
|
||||||
<!-- 课程标题 -->
|
<!-- 课程标题 -->
|
||||||
@@ -65,14 +65,6 @@
|
|||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
@click="handleStartLearning"
|
|
||||||
:loading="enrolling"
|
|
||||||
>
|
|
||||||
{{ isEnrolled ? '继续学习' : '开始学习' }}
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
<el-button
|
||||||
size="large"
|
size="large"
|
||||||
:plain="!isCollected"
|
:plain="!isCollected"
|
||||||
@@ -81,6 +73,15 @@
|
|||||||
<el-icon><Star /></el-icon>
|
<el-icon><Star /></el-icon>
|
||||||
收藏课程
|
收藏课程
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="handleStartLearning"
|
||||||
|
:loading="enrolling"
|
||||||
|
>
|
||||||
|
{{ isEnrolled ? '继续学习' : '开始学习' }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,6 +501,13 @@ function formatDuration(minutes?: number): string {
|
|||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
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%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端全宽
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-info {
|
.course-info {
|
||||||
@@ -529,6 +543,11 @@ function formatDuration(minutes?: number): string {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端样式调整
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.course-title {
|
.course-title {
|
||||||
font-family: "Source Han Sans SC";
|
font-family: "Source Han Sans SC";
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -634,6 +653,12 @@ function formatDuration(minutes?: number): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 27px;
|
gap: 27px;
|
||||||
|
|
||||||
|
// 移动端按钮布局 - 保持水平排列
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-button) {
|
:deep(.el-button) {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -651,6 +676,12 @@ function formatDuration(minutes?: number): string {
|
|||||||
background: #d32f2f;
|
background: #d32f2f;
|
||||||
border-color: #d32f2f;
|
border-color: #d32f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移动端等宽
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.el-button--default {
|
&.el-button--default {
|
||||||
@@ -658,6 +689,12 @@ function formatDuration(minutes?: number): string {
|
|||||||
border-color: #86909C;
|
border-color: #86909C;
|
||||||
color: #86909C;
|
color: #86909C;
|
||||||
|
|
||||||
|
// 移动端等宽
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<el-button circle :icon="Expand" />
|
<el-button circle :icon="Expand" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端遮罩层(侧边栏展开时显示) -->
|
||||||
|
<div v-if="isMobile && !sidebarCollapsed" class="mobile-overlay" @click="toggleSidebar"></div>
|
||||||
|
|
||||||
<!-- 左侧:章节目录 -->
|
<!-- 左侧:章节目录 -->
|
||||||
<div class="chapter-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
<div class="chapter-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -179,7 +182,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
@@ -230,6 +233,7 @@ const currentChapterIndex = ref(0);
|
|||||||
const currentNodeIndex = ref(0);
|
const currentNodeIndex = ref(0);
|
||||||
const sidebarCollapsed = ref(false);
|
const sidebarCollapsed = ref(false);
|
||||||
const activeChapters = ref<number[]>([0]);
|
const activeChapters = ref<number[]>([0]);
|
||||||
|
const isMobile = ref(false);
|
||||||
const articleData = ref<any>(null);
|
const articleData = ref<any>(null);
|
||||||
const contentAreaRef = ref<HTMLElement | null>(null);
|
const contentAreaRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -348,8 +352,31 @@ watch(currentNode, async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 不在这里启动定时器,等待学习记录加载完成后再启动
|
// 不在这里启动定时器,等待学习记录加载完成后再启动
|
||||||
// startLearningTimer(); 移到loadLearningRecord和createLearningRecord成功后
|
// 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(() => {
|
onBeforeUnmount(() => {
|
||||||
stopLearningTimer();
|
stopLearningTimer();
|
||||||
saveLearningProgress();
|
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 {
|
.chapter-sidebar {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -1086,6 +1128,22 @@ function handleBack() {
|
|||||||
overflow: hidden;
|
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 {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -898,26 +898,163 @@ function getItemStatusType(status?: number): 'info' | 'warning' | 'success' {
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式设计
|
// 移动端响应式设计
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.task-detail {
|
||||||
|
.back-header {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.task-content {
|
.task-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-info {
|
.task-info-card {
|
||||||
|
.task-info-section {
|
||||||
.task-title {
|
.task-title {
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-meta {
|
.task-meta {
|
||||||
.meta-row {
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 12px;
|
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 {
|
.task-stats {
|
||||||
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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-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 {
|
.resource-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
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,
|
.course-action,
|
||||||
.resource-action {
|
.resource-action {
|
||||||
@@ -932,6 +1100,8 @@ function getItemStatusType(status?: number): 'info' | 'warning' | 'success' {
|
|||||||
|
|
||||||
.el-button {
|
.el-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
>
|
>
|
||||||
<div class="resource-cover">
|
<div class="resource-cover">
|
||||||
<img
|
<img
|
||||||
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : defaultArticleImg"
|
:src="resource.coverImage ? (FILE_DOWNLOAD_URL + resource.coverImage) : staticAssets.defaultArticleImg"
|
||||||
alt="cover"
|
alt="cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,13 +27,19 @@
|
|||||||
<div v-if="resources.length === 0 && !loading" class="empty">暂无数据</div>
|
<div v-if="resources.length === 0 && !loading" class="empty">暂无数据</div>
|
||||||
</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
|
<el-pagination
|
||||||
v-model:current-page="currentPage"
|
v-model:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total="total"
|
:total="total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 15, 20, 50]"
|
||||||
@size-change="handleSizeChange"
|
@size-change="handleSizeChange"
|
||||||
@current-change="handlePageChange"
|
@current-change="handlePageChange"
|
||||||
/>
|
/>
|
||||||
@@ -42,12 +48,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { resourceApi } from '@/apis/resource';
|
||||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import type { PageParam } from '@/types';
|
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 {
|
interface Props {
|
||||||
tagID?: string;
|
tagID?: string;
|
||||||
@@ -67,20 +79,61 @@ const total = ref(0);
|
|||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const listContainerRef = ref<HTMLElement>();
|
const listContainerRef = ref<HTMLElement>();
|
||||||
|
const hasMoreData = ref(true);
|
||||||
|
const isLoadingMore = ref(false);
|
||||||
|
const hasTriggeredLoadMore = ref(false); // 标记是否触发过加载更多
|
||||||
|
|
||||||
|
// 设备检测
|
||||||
|
const { isMobileDevice } = useDevice();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadResources();
|
loadResources();
|
||||||
|
// 在移动端添加滚动监听
|
||||||
|
nextTick(() => {
|
||||||
|
if (isMobileDevice.value && listContainerRef.value) {
|
||||||
|
setupInfiniteScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理滚动监听
|
||||||
|
if (isMobileDevice.value && listContainerRef.value) {
|
||||||
|
listContainerRef.value.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => [props.tagID, props.searchKeyword], () => {
|
watch(() => [props.tagID, props.searchKeyword], () => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
|
resources.value = []; // 清空现有数据
|
||||||
|
hasMoreData.value = true;
|
||||||
|
hasTriggeredLoadMore.value = false; // 重置加载更多标记
|
||||||
loadResources();
|
loadResources();
|
||||||
}, { deep: true });
|
}, { 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;
|
loading.value = true;
|
||||||
|
if (isAppend) {
|
||||||
|
isLoadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filter: Resource = {
|
const filter: Resource = {
|
||||||
@@ -97,22 +150,35 @@ async function loadResources() {
|
|||||||
const res = await resourceApi.getResourcePage(pageParam, filter);
|
const res = await resourceApi.getResourcePage(pageParam, filter);
|
||||||
|
|
||||||
if (res.success && res.dataList) {
|
if (res.success && res.dataList) {
|
||||||
resources.value = res.dataList;
|
const newData = res.dataList;
|
||||||
total.value = res.pageDomain?.pageParam.totalElements || 0;
|
|
||||||
|
|
||||||
// 通知父组件列表已更新
|
|
||||||
emit('list-updated', res.dataList);
|
|
||||||
|
|
||||||
|
if (isAppend) {
|
||||||
|
// 追加模式:将新数据添加到现有列表
|
||||||
|
resources.value = [...resources.value, ...newData];
|
||||||
|
} else {
|
||||||
|
// 替换模式:重新加载数据
|
||||||
|
resources.value = newData;
|
||||||
// 重置滚动位置到顶部
|
// 重置滚动位置到顶部
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (listContainerRef.value) {
|
if (listContainerRef.value) {
|
||||||
listContainerRef.value.scrollTop = 0;
|
listContainerRef.value.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
total.value = res.pageDomain?.pageParam.totalElements || 0;
|
||||||
|
|
||||||
|
// 检查是否还有更多数据
|
||||||
|
const totalPages = Math.ceil(total.value / pageSize.value);
|
||||||
|
hasMoreData.value = currentPage.value < totalPages;
|
||||||
|
|
||||||
|
// 通知父组件列表已更新
|
||||||
|
emit('list-updated', resources.value);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载资源列表失败:', error);
|
console.error('加载资源列表失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
isLoadingMore.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +220,40 @@ function handleResourceClick(resource: Resource) {
|
|||||||
emit('resource-click', 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({
|
defineExpose({
|
||||||
loadResources,
|
loadResources,
|
||||||
getResources,
|
getResources,
|
||||||
@@ -181,6 +281,7 @@ defineExpose({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-item {
|
.resource-item {
|
||||||
@@ -275,7 +376,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-more,
|
.loading-more,
|
||||||
.empty {
|
.empty,
|
||||||
|
.no-more-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
font-family: "Source Han Sans SC";
|
font-family: "Source Han Sans SC";
|
||||||
@@ -283,5 +385,70 @@ defineExpose({
|
|||||||
color: #979797;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ function handleCategoryClick(category: Tag) {
|
|||||||
background: #C62828;
|
background: #C62828;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,5 +113,60 @@ function handleCategoryClick(category: Tag) {
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
transition: color 0.3s;
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
<el-empty description="暂无课程" />
|
<el-empty description="暂无课程" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 桌面端分页 -->
|
||||||
<div v-if="total > 0" class="pagination-container">
|
<div v-if="total > 0 && !isMobile" class="pagination-container">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="pageParam.pageNumber"
|
v-model:current-page="pageParam.pageNumber"
|
||||||
v-model:page-size="pageParam.pageSize"
|
v-model:page-size="pageParam.pageSize"
|
||||||
@@ -88,20 +88,28 @@
|
|||||||
@current-change="handleCurrentChange"
|
@current-change="handleCurrentChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</StudyPlanLayout>
|
</StudyPlanLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
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 { courseApi } from '@/apis/study';
|
||||||
import type { Course, PageParam } from '@/types';
|
import type { Course, PageParam } from '@/types';
|
||||||
import { StudyPlanLayout } from '@/views/user/study-plan';
|
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';
|
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -110,9 +118,15 @@ defineOptions({
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const courseList = ref<Course[]>([]);
|
const courseList = ref<Course[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
const isMobile = ref(false);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
|
||||||
|
// 默认封面图片
|
||||||
|
const defaultCover = defaultCoverImg;
|
||||||
|
|
||||||
// 分页参数
|
// 分页参数
|
||||||
const pageParam = ref<PageParam>({
|
const pageParam = ref<PageParam>({
|
||||||
@@ -121,11 +135,91 @@ const pageParam = ref<PageParam>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
checkMobile();
|
||||||
loadCourseList();
|
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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const filter: Course = {
|
const filter: Course = {
|
||||||
@@ -141,6 +235,11 @@ async function loadCourseList() {
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
courseList.value = res.dataList || [];
|
courseList.value = res.dataList || [];
|
||||||
total.value = res.pageParam?.totalElements || 0;
|
total.value = res.pageParam?.totalElements || 0;
|
||||||
|
|
||||||
|
// 移动端下检查是否还有更多数据
|
||||||
|
if (isMobile.value) {
|
||||||
|
hasMore.value = courseList.value.length < total.value;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('加载课程列表失败');
|
ElMessage.error('加载课程列表失败');
|
||||||
}
|
}
|
||||||
@@ -155,7 +254,8 @@ async function loadCourseList() {
|
|||||||
// 搜索
|
// 搜索
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
pageParam.value.pageNumber = 1;
|
pageParam.value.pageNumber = 1;
|
||||||
loadCourseList();
|
hasMore.value = true;
|
||||||
|
loadCourseList(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页大小改变
|
// 分页大小改变
|
||||||
@@ -323,14 +423,13 @@ function getCategoryName(): string {
|
|||||||
|
|
||||||
.course-info {
|
.course-info {
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.view-count {
|
.view-count {
|
||||||
position: absolute;
|
|
||||||
top: 232px;
|
|
||||||
right: 22px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(0, 0, 0, 0.3);
|
color: rgba(0, 0, 0, 0.3);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-title {
|
.course-title {
|
||||||
@@ -370,4 +469,22 @@ function getCategoryName(): string {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 40px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -682,4 +682,175 @@ function getDeadlineStatus(task: TaskItemVO): { show: boolean; text: string; typ
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 40px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user