1478 lines
60 KiB
Vue
1478 lines
60 KiB
Vue
|
|
<template>
|
|||
|
|
<view class="course-detail-container" :class="{ 'landscape': isLandscape }" :style="{ paddingTop: !isLandscape ? (navbarHeight + 20) + 'px' : '0' }">
|
|||
|
|
<!-- 顶部导航栏(横屏时隐藏) -->
|
|||
|
|
<custom-navbar v-if="!isLandscape" :title="course.courseName || '课程详情'"></custom-navbar>
|
|||
|
|
|
|||
|
|
<!-- 视频播放区域 -->
|
|||
|
|
<view class="video-container" v-if="courseware && courseware.type === 'video'">
|
|||
|
|
<video
|
|||
|
|
:id="videoId"
|
|||
|
|
:src="videoUrl"
|
|||
|
|
:poster="videoPoster"
|
|||
|
|
:controls="true"
|
|||
|
|
:show-center-play-btn="true"
|
|||
|
|
:enable-play-gesture="true"
|
|||
|
|
:enable-progress-gesture="true"
|
|||
|
|
:show-fullscreen-btn="true"
|
|||
|
|
:show-play-btn="true"
|
|||
|
|
:show-loading="true"
|
|||
|
|
:object-fit="videoObjectFit"
|
|||
|
|
:initial-time="0"
|
|||
|
|
:enable-metadata="true"
|
|||
|
|
:play-strategy="0"
|
|||
|
|
:autoplay="false"
|
|||
|
|
:muted="false"
|
|||
|
|
:page-gesture="true"
|
|||
|
|
:enable-auto-rotation="true"
|
|||
|
|
:show-mute-btn="true"
|
|||
|
|
:vslide-gesture="true"
|
|||
|
|
:vslide-gesture-in-fullscreen="true"
|
|||
|
|
codec="hardware"
|
|||
|
|
http-cache="true"
|
|||
|
|
play-btn-position="center"
|
|||
|
|
class="course-video"
|
|||
|
|
@play="onVideoPlay"
|
|||
|
|
@pause="onVideoPause"
|
|||
|
|
@ended="onVideoEnded"
|
|||
|
|
@timeupdate="onVideoTimeUpdate"
|
|||
|
|
@waiting="onVideoWaiting"
|
|||
|
|
@error="onVideoError"
|
|||
|
|
@loadedmetadata="onVideoLoadedMetadata"
|
|||
|
|
@fullscreenchange="onFullscreenChange"
|
|||
|
|
></video>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 课件标签切换(多个课件时显示) -->
|
|||
|
|
<view class="courseware-tabs" v-if="coursewareList.length > 1 && !isLandscape">
|
|||
|
|
<view
|
|||
|
|
v-for="(cw, index) in coursewareList"
|
|||
|
|
:key="cw.id"
|
|||
|
|
class="courseware-tab"
|
|||
|
|
:class="{ active: currentCoursewareIndex === index }"
|
|||
|
|
@click="switchCourseware(index)"
|
|||
|
|
>
|
|||
|
|
<text class="tab-text">{{ cw.name || cw.title || `课件${index + 1}` }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 图文课件查看区域 -->
|
|||
|
|
<view class="courseware-container" v-if="courseware && displayType !== 'video'">
|
|||
|
|
<view class="courseware-viewer">
|
|||
|
|
<template v-if="displayType === 'pdf'">
|
|||
|
|
<view class="pdf-viewer">
|
|||
|
|
<view class="pdf-placeholder">
|
|||
|
|
<text class="pdf-icon">📄</text>
|
|||
|
|
<text class="pdf-text">PDF课件</text>
|
|||
|
|
<view class="open-pdf-btn" @click="openPdf">
|
|||
|
|
<text class="btn-text">打开课件</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
<template v-else-if="displayType === 'image'">
|
|||
|
|
<scroll-view class="image-viewer" scroll-y="true" :scroll-with-animation="true">
|
|||
|
|
<image
|
|||
|
|
v-if="imageUrl"
|
|||
|
|
:src="imageUrl"
|
|||
|
|
mode="widthFix"
|
|||
|
|
class="courseware-image"
|
|||
|
|
@click="previewImage"
|
|||
|
|
@error="onImageError"
|
|||
|
|
@load="onImageLoad"
|
|||
|
|
></image>
|
|||
|
|
<view v-else class="image-loading">
|
|||
|
|
<text>图片加载中...</text>
|
|||
|
|
</view>
|
|||
|
|
</scroll-view>
|
|||
|
|
</template>
|
|||
|
|
<template v-else-if="displayType === 'text'">
|
|||
|
|
<view class="text-viewer">
|
|||
|
|
<rich-text :nodes="courseware.description || '暂无内容'"></rich-text>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
<template v-else>
|
|||
|
|
<view class="unknown-viewer">
|
|||
|
|
<text class="unknown-icon">📁</text>
|
|||
|
|
<text class="unknown-text">暂不支持预览此类型文件</text>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 课程信息 -->
|
|||
|
|
<view class="course-info" v-if="!isLandscape">
|
|||
|
|
<view class="info-header">
|
|||
|
|
<text class="course-name">{{ course.courseName || '加载中...' }}</text>
|
|||
|
|
</view>
|
|||
|
|
<text class="course-desc">{{ course.description || '暂无描述' }}</text>
|
|||
|
|
|
|||
|
|
<!-- 学习进度信息 -->
|
|||
|
|
<view class="learning-progress" v-if="learningRecord">
|
|||
|
|
<view class="progress-header">
|
|||
|
|
<text class="progress-title">学习进度</text>
|
|||
|
|
<text class="progress-percent">{{ learningRecord.progress || 0 }}%</text>
|
|||
|
|
</view>
|
|||
|
|
<view class="progress-bar">
|
|||
|
|
<view class="progress-fill" :style="{ width: (learningRecord.progress || 0) + '%' }"></view>
|
|||
|
|
</view>
|
|||
|
|
<view class="progress-stats">
|
|||
|
|
<text>学习时长:{{ formatDuration(learningRecord.totalDuration) }}</text>
|
|||
|
|
<text>学习次数:{{ learningRecord.learnCount || 0 }}次</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
import request from '@/utils/request.js'
|
|||
|
|
import { getCourseInfo } from '@/api/study/course.js'
|
|||
|
|
import { getCoursewareList, getCoursewareDetail } from '@/api/study/courseware.js'
|
|||
|
|
import { getMyRecords, getMyLearningDetails } from '@/api/study/learningRecord.js'
|
|||
|
|
import config from '@/utils/config.js'
|
|||
|
|
import screenStreamClient from '@/utils/screenStream.js'
|
|||
|
|
import progressQueue from '@/utils/progressQueue.js'
|
|||
|
|
import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
|
|||
|
|
|
|||
|
|
export default {
|
|||
|
|
components: {
|
|||
|
|
CustomNavbar
|
|||
|
|
},
|
|||
|
|
data() {
|
|||
|
|
return {
|
|||
|
|
courseId: null,
|
|||
|
|
course: {},
|
|||
|
|
courseware: null,
|
|||
|
|
coursewareList: [], // 课件列表
|
|||
|
|
currentCoursewareIndex: 0, // 当前课件索引
|
|||
|
|
learningRecord: null,
|
|||
|
|
learningDetails: [], // 学习详情列表
|
|||
|
|
courseProgress: 0, // 课程总进度(0-100)
|
|||
|
|
loading: false,
|
|||
|
|
isLandscape: false,
|
|||
|
|
videoId: 'course-video-' + Date.now(),
|
|||
|
|
videoUrl: '',
|
|||
|
|
videoPoster: '',
|
|||
|
|
pdfUrl: '',
|
|||
|
|
imageUrl: '',
|
|||
|
|
// 学习进度上报相关
|
|||
|
|
progressTimer: null,
|
|||
|
|
progressRefreshTimer: null, // 进度刷新定时器
|
|||
|
|
lastReportTime: 0,
|
|||
|
|
reportInterval: 10000, // 每10秒上报一次
|
|||
|
|
videoStartTime: 0,
|
|||
|
|
videoCurrentTime: 0,
|
|||
|
|
videoTotalDuration: 0,
|
|||
|
|
isPlaying: false,
|
|||
|
|
lastReportedDuration: 0, // 上次已上报的累计时长
|
|||
|
|
accumulatedDuration: 0, // 累积观看时长(秒)
|
|||
|
|
currentPlayStartTime: 0, // 当前播放开始时间
|
|||
|
|
videoObjectFit: 'contain', // 视频显示模式
|
|||
|
|
videoRetryCount: 0, // 视频重试次数
|
|||
|
|
// 导航栏高度
|
|||
|
|
navbarHeight: 0
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
mounted() {
|
|||
|
|
// 计算导航栏总高度(状态栏 + 导航栏)
|
|||
|
|
const systemInfo = uni.getSystemInfoSync()
|
|||
|
|
const statusBarHeight = systemInfo.statusBarHeight || 0
|
|||
|
|
// 导航栏内容高度:88rpx (移动端) 或 80rpx (桌面端)
|
|||
|
|
const navbarContentHeight = systemInfo.windowWidth >= 768 ? 80 : 88
|
|||
|
|
// 转换为px(假设1rpx = 0.5px,实际需要根据设备计算)
|
|||
|
|
const rpxToPx = systemInfo.windowWidth / 750
|
|||
|
|
this.navbarHeight = statusBarHeight + (navbarContentHeight * rpxToPx)
|
|||
|
|
},
|
|||
|
|
onLoad(options) {
|
|||
|
|
this.courseId = options.id
|
|||
|
|
this.loadCourseDetail()
|
|||
|
|
},
|
|||
|
|
async onShow() {
|
|||
|
|
// 刷新课件列表、学习记录和进度(每次显示页面时)
|
|||
|
|
if (this.courseId) {
|
|||
|
|
console.log('[课程学习] 📊 刷新课件列表、学习记录和进度')
|
|||
|
|
// 重新加载课件列表(以获取新增的课件)
|
|||
|
|
await this.loadCoursewareList()
|
|||
|
|
// 刷新学习记录(包含后端计算的进度)
|
|||
|
|
await this.loadLearningRecord()
|
|||
|
|
// 刷新学习详情
|
|||
|
|
await this.loadLearningDetails()
|
|||
|
|
// 重新计算进度
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听屏幕方向变化
|
|||
|
|
this.checkOrientation()
|
|||
|
|
this.orientationListener = setInterval(() => {
|
|||
|
|
this.checkOrientation()
|
|||
|
|
}, 500)
|
|||
|
|
|
|||
|
|
// 启动实时监控(App环境使用WebSocket+截图方式)
|
|||
|
|
if (this.courseId) {
|
|||
|
|
try {
|
|||
|
|
// 获取用户ID
|
|||
|
|
const userInfo = uni.getStorageSync('userInfo')
|
|||
|
|
const userId = userInfo?.userId || userInfo?.id
|
|||
|
|
|
|||
|
|
if (userId) {
|
|||
|
|
// App环境:延迟启动,等待plus对象初始化
|
|||
|
|
// #ifdef APP-PLUS
|
|||
|
|
// 延迟2秒启动,确保plus对象已初始化
|
|||
|
|
setTimeout(async () => {
|
|||
|
|
try {
|
|||
|
|
await screenStreamClient.start(userId, 1000) // 1000ms间隔(1帧/秒),降低频率减少数据量
|
|||
|
|
console.log('✅ 实时监控已启动(截图方式)')
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('启动实时监控失败:', error)
|
|||
|
|
}
|
|||
|
|
}, 2000)
|
|||
|
|
// #endif
|
|||
|
|
|
|||
|
|
// #ifndef APP-PLUS
|
|||
|
|
await screenStreamClient.start(userId, 1000) // 1000ms间隔(1帧/秒)
|
|||
|
|
console.log('✅ 实时监控已启动(截图方式)')
|
|||
|
|
// #endif
|
|||
|
|
} else {
|
|||
|
|
console.warn('无法获取用户ID,跳过实时监控')
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('启动实时监控失败:', error)
|
|||
|
|
// 不显示错误提示,避免影响用户体验
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
onHide() {
|
|||
|
|
console.log('[课程学习] 页面隐藏,上报当前进度')
|
|||
|
|
// 页面隐藏时立即上报进度
|
|||
|
|
if (this.isPlaying) {
|
|||
|
|
// 先累加当前播放时长
|
|||
|
|
if (this.currentPlayStartTime) {
|
|||
|
|
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
|
|||
|
|
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
|
|||
|
|
}
|
|||
|
|
this.reportProgress(true) // 强制立即上报
|
|||
|
|
}
|
|||
|
|
this.clearProgressTimer()
|
|||
|
|
if (this.orientationListener) {
|
|||
|
|
clearInterval(this.orientationListener)
|
|||
|
|
}
|
|||
|
|
// 停止实时监控(页面隐藏时)
|
|||
|
|
screenStreamClient.stop()
|
|||
|
|
},
|
|||
|
|
onUnload() {
|
|||
|
|
console.log('[课程学习] 页面卸载,上报最终进度')
|
|||
|
|
// 页面卸载时立即上报最终进度
|
|||
|
|
if (this.isPlaying || this.accumulatedDuration > 0) {
|
|||
|
|
// 累加当前播放时长
|
|||
|
|
if (this.currentPlayStartTime) {
|
|||
|
|
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
|
|||
|
|
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
|
|||
|
|
}
|
|||
|
|
this.reportProgress(true) // 强制立即上报
|
|||
|
|
}
|
|||
|
|
this.clearProgressTimer()
|
|||
|
|
if (this.orientationListener) {
|
|||
|
|
clearInterval(this.orientationListener)
|
|||
|
|
}
|
|||
|
|
// 停止实时监控(页面卸载时)
|
|||
|
|
screenStreamClient.stop()
|
|||
|
|
},
|
|||
|
|
computed: {
|
|||
|
|
isPdf() {
|
|||
|
|
if (!this.courseware) return false
|
|||
|
|
const fileName = this.courseware.fileName || this.courseware.filePath || ''
|
|||
|
|
return fileName.toLowerCase().endsWith('.pdf')
|
|||
|
|
},
|
|||
|
|
isImage() {
|
|||
|
|
if (!this.courseware) return false
|
|||
|
|
const fileName = this.courseware.fileName || this.courseware.filePath || ''
|
|||
|
|
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
|||
|
|
return imageExts.some(ext => fileName.toLowerCase().endsWith(ext))
|
|||
|
|
},
|
|||
|
|
// 计算实际的课件显示类型
|
|||
|
|
displayType() {
|
|||
|
|
if (!this.courseware) return null
|
|||
|
|
if (this.courseware.type === 'video') return 'video'
|
|||
|
|
if (this.courseware.type === 'image' || this.isImage) return 'image'
|
|||
|
|
if (this.courseware.type === 'document' && this.isPdf) return 'pdf'
|
|||
|
|
if (this.courseware.type === 'text') return 'text'
|
|||
|
|
return 'unknown'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
methods: {
|
|||
|
|
checkOrientation() {
|
|||
|
|
if (typeof window !== 'undefined' && window.orientation !== undefined) {
|
|||
|
|
const orientation = window.orientation || 0
|
|||
|
|
this.isLandscape = Math.abs(orientation) === 90
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadCourseDetail() {
|
|||
|
|
if (!this.courseId) return
|
|||
|
|
|
|||
|
|
this.loading = true
|
|||
|
|
try {
|
|||
|
|
// 加载课程详情(使用封装的API接口)
|
|||
|
|
const courseResponse = await getCourseInfo(this.courseId)
|
|||
|
|
if (courseResponse.code === 200) {
|
|||
|
|
this.course = courseResponse.data || {}
|
|||
|
|
|
|||
|
|
// 加载课件列表
|
|||
|
|
await this.loadCoursewareList()
|
|||
|
|
|
|||
|
|
// 加载学习记录(包含后端计算的进度)
|
|||
|
|
await this.loadLearningRecord()
|
|||
|
|
|
|||
|
|
// 加载学习详情
|
|||
|
|
await this.loadLearningDetails()
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: error.message || '加载失败',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
} finally {
|
|||
|
|
this.loading = false
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadCoursewareList() {
|
|||
|
|
try {
|
|||
|
|
// 使用封装的API接口获取课件列表
|
|||
|
|
const response = await getCoursewareList(this.courseId)
|
|||
|
|
console.log('课件列表接口响应:', response)
|
|||
|
|
console.log('课程ID:', this.courseId)
|
|||
|
|
|
|||
|
|
if (response.code === 200) {
|
|||
|
|
let data = response.data || []
|
|||
|
|
|
|||
|
|
// 如果返回的是单个对象,转换为数组
|
|||
|
|
if (!Array.isArray(data)) {
|
|||
|
|
console.log('返回的是单个对象,转换为数组')
|
|||
|
|
data = [data]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.coursewareList = data
|
|||
|
|
console.log('课件列表数据:', this.coursewareList)
|
|||
|
|
console.log('课件数量:', this.coursewareList.length)
|
|||
|
|
|
|||
|
|
// 加载学习详情并计算进度
|
|||
|
|
await this.loadLearningDetails()
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
|
|||
|
|
// 如果有课件列表,加载第一个课件
|
|||
|
|
if (this.coursewareList.length > 0) {
|
|||
|
|
this.currentCoursewareIndex = 0
|
|||
|
|
const firstCourseware = this.coursewareList[0]
|
|||
|
|
console.log('准备加载第一个课件:', firstCourseware)
|
|||
|
|
console.log('课件类型:', firstCourseware.type)
|
|||
|
|
console.log('课件文件路径:', firstCourseware.filePath)
|
|||
|
|
await this.loadCourseware(firstCourseware)
|
|||
|
|
} else if (this.course.coursewareId) {
|
|||
|
|
// 如果没有课件列表,但课程有coursewareId,尝试加载单个课件
|
|||
|
|
console.log('课件列表为空,尝试通过coursewareId加载:', this.course.coursewareId)
|
|||
|
|
await this.loadCourseware(this.course.coursewareId)
|
|||
|
|
} else {
|
|||
|
|
console.warn('没有找到课件数据,courseId:', this.courseId)
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '该课程暂无课件',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.error('课件列表接口返回错误:', response)
|
|||
|
|
uni.showToast({
|
|||
|
|
title: response.msg || '加载课件列表失败',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载课件列表失败:', error)
|
|||
|
|
console.error('错误详情:', error.stack)
|
|||
|
|
uni.showToast({
|
|||
|
|
title: error.message || '加载课件列表失败',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
// 如果列表加载失败,尝试加载单个课件
|
|||
|
|
if (this.course.coursewareId) {
|
|||
|
|
await this.loadCourseware(this.course.coursewareId)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadCourseware(courseware) {
|
|||
|
|
try {
|
|||
|
|
let coursewareData = courseware
|
|||
|
|
|
|||
|
|
// 如果传入的是ID,需要先获取详情
|
|||
|
|
if (typeof courseware === 'number' || typeof courseware === 'string') {
|
|||
|
|
const response = await getCoursewareDetail(courseware)
|
|||
|
|
if (response.code === 200) {
|
|||
|
|
coursewareData = response.data || {}
|
|||
|
|
} else {
|
|||
|
|
console.error('获取课件详情失败:', response)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[课程学习] 📦 加载课件数据:', coursewareData)
|
|||
|
|
console.log('[课程学习] 课件ID:', coursewareData?.id)
|
|||
|
|
console.log('[课程学习] 课件类型:', coursewareData?.type)
|
|||
|
|
console.log('[课程学习] 课件文件路径:', coursewareData?.filePath)
|
|||
|
|
console.log('[课程学习] 课件时长:', coursewareData?.duration)
|
|||
|
|
this.courseware = coursewareData
|
|||
|
|
|
|||
|
|
// 如果是非视频课件,记录查看进度
|
|||
|
|
if (this.courseware && this.courseware.type !== 'video') {
|
|||
|
|
// 延迟一下,确保课件已加载完成
|
|||
|
|
setTimeout(() => {
|
|||
|
|
this.reportNonVideoProgress()
|
|||
|
|
}, 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 构建文件URL
|
|||
|
|
if (this.courseware.filePath) {
|
|||
|
|
// 处理文件路径:确保以 / 开头
|
|||
|
|
let filePath = this.courseware.filePath
|
|||
|
|
if (!filePath.startsWith('/')) {
|
|||
|
|
filePath = '/' + filePath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 只对文件名部分进行编码,路径部分保持不变
|
|||
|
|
const pathParts = filePath.split('/')
|
|||
|
|
const encodedPath = pathParts.map((part, index) => {
|
|||
|
|
// 第一个空字符串(因为split会在开头产生空字符串)保持为空
|
|||
|
|
if (index === 0) return ''
|
|||
|
|
// 只对最后一个部分(文件名)进行编码,其他路径部分保持原样
|
|||
|
|
if (index === pathParts.length - 1) {
|
|||
|
|
return encodeURIComponent(part)
|
|||
|
|
}
|
|||
|
|
// 路径部分保持原样
|
|||
|
|
return part
|
|||
|
|
}).join('/')
|
|||
|
|
|
|||
|
|
const fileUrl = config.FILE_BASE_URL + encodedPath
|
|||
|
|
console.log('[课程学习] 🔗 URL构建信息:')
|
|||
|
|
console.log(' - FILE_BASE_URL:', config.FILE_BASE_URL)
|
|||
|
|
console.log(' - 原始filePath:', this.courseware.filePath)
|
|||
|
|
console.log(' - 编码后路径:', encodedPath)
|
|||
|
|
console.log(' - 完整URL:', fileUrl)
|
|||
|
|
|
|||
|
|
// 验证URL格式
|
|||
|
|
if (!fileUrl.startsWith('http')) {
|
|||
|
|
console.error('[课程学习] ❌ URL格式错误,不是http协议:', fileUrl)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 测试文件是否可访问(仅在视频时测试)
|
|||
|
|
if (this.courseware.type === 'video') {
|
|||
|
|
this.testVideoUrl(fileUrl)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.courseware.type === 'video') {
|
|||
|
|
// 先清空视频URL,强制重新加载
|
|||
|
|
this.videoUrl = ''
|
|||
|
|
await this.$nextTick()
|
|||
|
|
|
|||
|
|
// 设置新的视频URL
|
|||
|
|
this.videoUrl = fileUrl
|
|||
|
|
console.log('[课程学习] ✅ 视频URL已设置:', this.videoUrl)
|
|||
|
|
|
|||
|
|
// 等待视频元素加载后,强制刷新视频
|
|||
|
|
this.$nextTick(() => {
|
|||
|
|
const videoContext = uni.createVideoContext(this.videoId)
|
|||
|
|
if (videoContext) {
|
|||
|
|
// 强制重新加载视频
|
|||
|
|
try {
|
|||
|
|
videoContext.play()
|
|||
|
|
videoContext.pause()
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('视频预加载失败:', e)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果有上次播放位置,设置初始播放位置
|
|||
|
|
if (this.learningRecord && this.learningRecord.lastVideoPosition) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
try {
|
|||
|
|
videoContext.seek(this.learningRecord.lastVideoPosition)
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('设置视频播放位置失败:', e)
|
|||
|
|
}
|
|||
|
|
}, 500)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else if (this.displayType === 'image') {
|
|||
|
|
// 图片类型(包括type='image'或document类型但文件是图片)
|
|||
|
|
this.imageUrl = fileUrl
|
|||
|
|
console.log('[课程学习] 🖼️ 设置图片URL:', this.imageUrl)
|
|||
|
|
console.log('[课程学习] 📋 课件类型:', this.courseware.type, '显示类型:', this.displayType)
|
|||
|
|
// 测试图片是否可访问
|
|||
|
|
this.testImageUrl(fileUrl)
|
|||
|
|
// 图片课件加载时上报学习进度
|
|||
|
|
this.reportNonVideoProgress()
|
|||
|
|
} else if (this.displayType === 'pdf') {
|
|||
|
|
// PDF类型
|
|||
|
|
this.pdfUrl = fileUrl
|
|||
|
|
console.log('[课程学习] 📄 设置PDF URL:', this.pdfUrl)
|
|||
|
|
// 测试PDF是否可访问
|
|||
|
|
this.testPdfUrl(fileUrl)
|
|||
|
|
// PDF课件加载时上报学习进度
|
|||
|
|
this.reportNonVideoProgress()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.warn('课件没有filePath:', this.courseware)
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载课件失败:', error)
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '加载课件失败',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 切换课件
|
|||
|
|
async switchCourseware(index) {
|
|||
|
|
if (index < 0 || index >= this.coursewareList.length) return
|
|||
|
|
|
|||
|
|
// 切换前先上报当前课件的进度
|
|||
|
|
if (this.isPlaying) {
|
|||
|
|
this.reportProgress(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.currentCoursewareIndex = index
|
|||
|
|
|
|||
|
|
// 重置学习时长统计
|
|||
|
|
this.accumulatedDuration = 0
|
|||
|
|
this.currentPlayStartTime = 0
|
|||
|
|
this.lastReportedDuration = 0
|
|||
|
|
this.lastReportTime = 0
|
|||
|
|
console.log('[课程学习] 切换课件,重置时长统计')
|
|||
|
|
|
|||
|
|
await this.loadCourseware(this.coursewareList[index])
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 加载学习详情列表
|
|||
|
|
*/
|
|||
|
|
async loadLearningDetails() {
|
|||
|
|
try {
|
|||
|
|
if (!this.courseId) return
|
|||
|
|
|
|||
|
|
const response = await getMyLearningDetails(this.courseId)
|
|||
|
|
if (response.code === 200) {
|
|||
|
|
this.learningDetails = response.data || []
|
|||
|
|
console.log('学习详情列表:', this.learningDetails)
|
|||
|
|
} else {
|
|||
|
|
console.error('加载学习详情失败:', response)
|
|||
|
|
this.learningDetails = []
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载学习详情失败:', error)
|
|||
|
|
this.learningDetails = []
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 加载学习记录(包含后端计算的进度)
|
|||
|
|
*/
|
|||
|
|
async loadLearningRecord() {
|
|||
|
|
try {
|
|||
|
|
if (!this.courseId) return
|
|||
|
|
|
|||
|
|
const response = await getMyRecords()
|
|||
|
|
if (response.code === 200) {
|
|||
|
|
const records = response.data || []
|
|||
|
|
// 找到当前课程的学习记录
|
|||
|
|
const record = records.find(r => r.courseId === this.courseId)
|
|||
|
|
if (record) {
|
|||
|
|
this.learningRecord = record
|
|||
|
|
console.log('[课程学习] ✅ 学习记录已加载:', {
|
|||
|
|
progress: record.progress,
|
|||
|
|
totalDuration: record.totalDuration,
|
|||
|
|
learnCount: record.learnCount
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
console.log('[课程学习] ℹ️ 当前课程还没有学习记录')
|
|||
|
|
this.learningRecord = null
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.error('[课程学习] ❌ 加载学习记录失败:', response)
|
|||
|
|
this.learningRecord = null
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[课程学习] ❌ 加载学习记录异常:', error)
|
|||
|
|
this.learningRecord = null
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 计算课程总进度(基于课件数量)
|
|||
|
|
* 每个课件占比 1/n
|
|||
|
|
* 视频:观看进度 >= 90% 视为完成
|
|||
|
|
* 图片/PDF:有学习详情记录视为完成
|
|||
|
|
*/
|
|||
|
|
calculateCourseProgress() {
|
|||
|
|
if (!this.coursewareList || this.coursewareList.length === 0) {
|
|||
|
|
this.courseProgress = 0
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const totalCount = this.coursewareList.length
|
|||
|
|
let completedCount = 0
|
|||
|
|
|
|||
|
|
// 构建学习详情映射(按课件ID分组,保留视频播放位置最大的记录)
|
|||
|
|
const detailMap = new Map()
|
|||
|
|
for (const detail of this.learningDetails) {
|
|||
|
|
if (detail.coursewareId) {
|
|||
|
|
const existing = detailMap.get(detail.coursewareId)
|
|||
|
|
if (!existing ||
|
|||
|
|
(detail.videoPosition && existing.videoPosition &&
|
|||
|
|
detail.videoPosition > existing.videoPosition)) {
|
|||
|
|
detailMap.set(detail.coursewareId, detail)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 遍历所有课件,判断是否完成
|
|||
|
|
console.log('[课程学习] 开始计算课件完成情况...')
|
|||
|
|
for (const cw of this.coursewareList) {
|
|||
|
|
const detail = detailMap.get(cw.id)
|
|||
|
|
|
|||
|
|
if (cw.type === 'video') {
|
|||
|
|
// 视频:判断是否观看完成(观看进度 >= 90%)
|
|||
|
|
if (detail && cw.duration && cw.duration > 0) {
|
|||
|
|
const videoPosition = detail.videoPosition || 0
|
|||
|
|
const duration = cw.duration
|
|||
|
|
const progress = Math.floor((videoPosition / duration) * 100)
|
|||
|
|
// 观看进度 >= 90% 视为完成
|
|||
|
|
if (videoPosition >= duration * 0.9) {
|
|||
|
|
completedCount++
|
|||
|
|
console.log(`[课程学习] ✅ 视频课件 ${cw.id}(${cw.name}) 已完成: ${videoPosition}秒 / ${duration}秒 (${progress}%)`)
|
|||
|
|
} else {
|
|||
|
|
console.log(`[课程学习] ⏳ 视频课件 ${cw.id}(${cw.name}) 未完成: ${videoPosition}秒 / ${duration}秒 (${progress}%)`)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
if (!detail) {
|
|||
|
|
console.log(`[课程学习] ❌ 视频课件 ${cw.id}(${cw.name}) 无学习记录`)
|
|||
|
|
} else if (!cw.duration || cw.duration <= 0) {
|
|||
|
|
console.warn(`[课程学习] ⚠️ 视频课件 ${cw.id}(${cw.name}) 时长异常: ${cw.duration}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 图片/PDF:只要有学习详情记录就视为完成
|
|||
|
|
if (detail) {
|
|||
|
|
completedCount++
|
|||
|
|
console.log(`[课程学习] ✅ 非视频课件 ${cw.id}(${cw.name}) 已完成`)
|
|||
|
|
} else {
|
|||
|
|
console.log(`[课程学习] ❌ 非视频课件 ${cw.id}(${cw.name}) 无学习记录`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算进度:已完成课件数 / 总课件数 * 100%
|
|||
|
|
this.courseProgress = Math.round((completedCount / totalCount) * 100)
|
|||
|
|
|
|||
|
|
// 限制在0-100之间
|
|||
|
|
if (this.courseProgress > 100) {
|
|||
|
|
this.courseProgress = 100
|
|||
|
|
}
|
|||
|
|
if (this.courseProgress < 0) {
|
|||
|
|
this.courseProgress = 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`课程进度计算完成: ${completedCount}个已完成 / ${totalCount}个总数 = ${this.courseProgress}%`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 视频播放事件
|
|||
|
|
onVideoPlay(e) {
|
|||
|
|
console.log('[课程学习] 视频开始播放')
|
|||
|
|
this.isPlaying = true
|
|||
|
|
// 记录本次播放的开始时间(不重置总时长)
|
|||
|
|
this.currentPlayStartTime = Date.now()
|
|||
|
|
this.startProgressTimer()
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoPause(e) {
|
|||
|
|
console.log('[课程学习] 视频暂停,当前播放时长:', Math.floor((Date.now() - this.currentPlayStartTime) / 1000), '秒')
|
|||
|
|
this.isPlaying = false
|
|||
|
|
// 累加本次播放时长
|
|||
|
|
if (this.currentPlayStartTime) {
|
|||
|
|
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
|
|||
|
|
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
|
|||
|
|
console.log('[课程学习] 累积观看时长:', this.accumulatedDuration, '秒')
|
|||
|
|
}
|
|||
|
|
this.reportProgress(true) // 暂停时立即上报
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoEnded(e) {
|
|||
|
|
console.log('[课程学习] 视频播放结束')
|
|||
|
|
this.isPlaying = false
|
|||
|
|
// 累加最后一次播放时长
|
|||
|
|
if (this.currentPlayStartTime) {
|
|||
|
|
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
|
|||
|
|
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
|
|||
|
|
console.log('[课程学习] 最终累积时长:', this.accumulatedDuration, '秒')
|
|||
|
|
}
|
|||
|
|
this.reportProgress(true) // 结束时立即上报
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoTimeUpdate(e) {
|
|||
|
|
this.videoCurrentTime = e.detail.currentTime
|
|||
|
|
this.videoTotalDuration = e.detail.duration || this.videoTotalDuration
|
|||
|
|
|
|||
|
|
// 备用进度上报机制:每30秒通过timeupdate上报一次
|
|||
|
|
// 这是为了兼容某些Android设备上pause/ended事件不触发的情况
|
|||
|
|
const now = Date.now()
|
|||
|
|
if (this.isPlaying && this.currentPlayStartTime) {
|
|||
|
|
const timeSinceLastReport = (now - this.lastReportTime) / 1000
|
|||
|
|
if (timeSinceLastReport >= 30) {
|
|||
|
|
console.log('[课程学习] ⏱️ 通过timeupdate触发备用上报(30秒)')
|
|||
|
|
this.reportProgress(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoWaiting(e) {
|
|||
|
|
// 视频缓冲中
|
|||
|
|
console.log('视频缓冲中...')
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoLoadedMetadata(e) {
|
|||
|
|
// 视频元数据加载完成
|
|||
|
|
console.log('[课程学习] ✅ 视频元数据加载完成')
|
|||
|
|
console.log('[课程学习] 视频URL:', this.videoUrl)
|
|||
|
|
console.log('[课程学习] 视频时长:', e.detail.duration, '秒')
|
|||
|
|
console.log('[课程学习] 视频宽度:', e.detail.width)
|
|||
|
|
console.log('[课程学习] 视频高度:', e.detail.height)
|
|||
|
|
console.log('[课程学习] 课件信息:', this.courseware)
|
|||
|
|
|
|||
|
|
// 如果视频时长存在,更新总时长
|
|||
|
|
if (e.detail.duration && e.detail.duration > 0) {
|
|||
|
|
this.videoTotalDuration = e.detail.duration
|
|||
|
|
console.log('[课程学习] ✅ 视频总时长已设置:', this.videoTotalDuration, '秒')
|
|||
|
|
} else {
|
|||
|
|
console.warn('[课程学习] ⚠️ 视频时长为0或未定义')
|
|||
|
|
// 尝试从课件信息中获取时长
|
|||
|
|
if (this.courseware && this.courseware.duration) {
|
|||
|
|
this.videoTotalDuration = this.courseware.duration
|
|||
|
|
console.log('[课程学习] 从课件信息获取时长:', this.videoTotalDuration, '秒')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查视频尺寸,如果宽度或高度为0,可能是黑屏问题
|
|||
|
|
if (e.detail.width === 0 || e.detail.height === 0) {
|
|||
|
|
console.warn('[课程学习] ⚠️ 检测到视频尺寸异常,尝试修复...')
|
|||
|
|
// 尝试重新加载视频
|
|||
|
|
this.$nextTick(() => {
|
|||
|
|
const videoContext = uni.createVideoContext(this.videoId)
|
|||
|
|
if (videoContext) {
|
|||
|
|
try {
|
|||
|
|
// 先暂停再播放,强制刷新
|
|||
|
|
videoContext.pause()
|
|||
|
|
setTimeout(() => {
|
|||
|
|
videoContext.play()
|
|||
|
|
}, 100)
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[课程学习] 视频刷新失败:', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onFullscreenChange(e) {
|
|||
|
|
console.log('[课程学习] 全屏状态变化:', e.detail)
|
|||
|
|
// 全屏切换时可能导致黑屏,尝试刷新
|
|||
|
|
if (e.detail.fullScreen) {
|
|||
|
|
console.log('[课程学习] 进入全屏模式')
|
|||
|
|
} else {
|
|||
|
|
console.log('[课程学习] 退出全屏模式')
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onVideoError(e) {
|
|||
|
|
console.error('[课程学习] ❌❌❌ 视频播放错误 ❌❌❌')
|
|||
|
|
console.error('[课程学习] 错误码:', e.detail?.errCode)
|
|||
|
|
console.error('[课程学习] 错误信息:', e.detail?.errMsg || e.detail?.message)
|
|||
|
|
console.error('[课程学习] 视频URL:', this.videoUrl)
|
|||
|
|
console.error('[课程学习] 课件信息:', this.courseware)
|
|||
|
|
console.error('[课程学习] 完整错误对象:', JSON.stringify(e.detail || e))
|
|||
|
|
|
|||
|
|
// 根据错误码提供具体的错误信息
|
|||
|
|
let errorMsg = '视频播放失败'
|
|||
|
|
if (e.detail?.errCode === -1) {
|
|||
|
|
errorMsg = '视频文件不存在或无法访问'
|
|||
|
|
} else if (e.detail?.errCode === -2) {
|
|||
|
|
errorMsg = '视频格式不支持'
|
|||
|
|
} else if (e.detail?.errMsg?.includes('404')) {
|
|||
|
|
errorMsg = '视频文件未找到(404)'
|
|||
|
|
} else if (e.detail?.errMsg?.includes('403')) {
|
|||
|
|
errorMsg = '没有权限访问视频文件(403)'
|
|||
|
|
} else if (e.detail?.errMsg?.includes('timeout')) {
|
|||
|
|
errorMsg = '视频加载超时,请检查网络'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 尝试使用原始路径(不编码)或检查是否是编码问题
|
|||
|
|
if (this.courseware && this.courseware.filePath) {
|
|||
|
|
let filePath = this.courseware.filePath
|
|||
|
|
if (!filePath.startsWith('/')) {
|
|||
|
|
filePath = '/' + filePath
|
|||
|
|
}
|
|||
|
|
const originalUrl = config.FILE_BASE_URL + filePath
|
|||
|
|
console.log('尝试原始URL(未编码):', originalUrl)
|
|||
|
|
|
|||
|
|
// 如果当前URL是编码的,尝试使用未编码的URL
|
|||
|
|
if (this.videoUrl !== originalUrl) {
|
|||
|
|
console.log('尝试切换到未编码的URL')
|
|||
|
|
this.videoUrl = originalUrl
|
|||
|
|
|
|||
|
|
// 等待一下后重新尝试播放
|
|||
|
|
this.$nextTick(() => {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const videoContext = uni.createVideoContext(this.videoId)
|
|||
|
|
if (videoContext) {
|
|||
|
|
try {
|
|||
|
|
videoContext.play()
|
|||
|
|
videoContext.pause()
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('重新加载视频失败:', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 500)
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '视频播放失败',
|
|||
|
|
content: errorMsg + '\n\n服务器: ' + config.FILE_BASE_URL + '\n路径: ' + (this.courseware?.filePath || '未知'),
|
|||
|
|
showCancel: false,
|
|||
|
|
confirmText: '知道了'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '视频播放失败',
|
|||
|
|
content: errorMsg + '\n\n请检查:\n1. 服务器是否运行\n2. 视频文件是否存在\n3. 网络连接是否正常',
|
|||
|
|
showCancel: false,
|
|||
|
|
confirmText: '知道了'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 测试图片URL是否可访问
|
|||
|
|
async testImageUrl(url) {
|
|||
|
|
console.log('[课程学习] 🧪 测试图片URL:', url)
|
|||
|
|
try {
|
|||
|
|
const res = await uni.request({
|
|||
|
|
url: url,
|
|||
|
|
method: 'HEAD',
|
|||
|
|
timeout: 5000
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (res.statusCode === 200) {
|
|||
|
|
console.log('[课程学习] ✅ 图片文件可访问')
|
|||
|
|
} else if (res.statusCode === 404) {
|
|||
|
|
console.error('[课程学习] ❌ 图片文件不存在(404)')
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '图片文件不存在',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
console.warn('[课程学习] ⚠️ 响应码:', res.statusCode)
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[课程学习] ❌ 图片URL测试失败:', error)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 图片加载成功
|
|||
|
|
onImageLoad(e) {
|
|||
|
|
console.log('[课程学习] ✅ 图片加载成功:', e)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 图片加载失败
|
|||
|
|
onImageError(e) {
|
|||
|
|
console.error('[课程学习] ❌ 图片加载失败:', e)
|
|||
|
|
console.error('[课程学习] 图片URL:', this.imageUrl)
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '图片加载失败',
|
|||
|
|
content: '无法加载图片,请检查网络连接或联系管理员。\n\nURL: ' + this.imageUrl,
|
|||
|
|
showCancel: false
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 测试PDF URL是否可访问
|
|||
|
|
async testPdfUrl(url) {
|
|||
|
|
console.log('[课程学习] 🧪 测试PDF URL:', url)
|
|||
|
|
try {
|
|||
|
|
const res = await uni.request({
|
|||
|
|
url: url,
|
|||
|
|
method: 'HEAD',
|
|||
|
|
timeout: 5000
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (res.statusCode === 200) {
|
|||
|
|
console.log('[课程学习] ✅ PDF文件可访问')
|
|||
|
|
console.log('[课程学习] Content-Length:', res.header['content-length'] || res.header['Content-Length'])
|
|||
|
|
} else if (res.statusCode === 404) {
|
|||
|
|
console.error('[课程学习] ❌ PDF文件不存在(404)')
|
|||
|
|
} else {
|
|||
|
|
console.warn('[课程学习] ⚠️ 响应码:', res.statusCode)
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[课程学习] ❌ PDF URL测试失败:', error)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 测试视频URL是否可访问
|
|||
|
|
async testVideoUrl(url) {
|
|||
|
|
console.log('[课程学习] 🧪 测试视频URL可访问性:', url)
|
|||
|
|
try {
|
|||
|
|
// 使用HEAD请求测试文件是否存在(不下载文件内容)
|
|||
|
|
const res = await uni.request({
|
|||
|
|
url: url,
|
|||
|
|
method: 'HEAD',
|
|||
|
|
timeout: 5000
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (res.statusCode === 200) {
|
|||
|
|
console.log('[课程学习] ✅ 视频文件可访问')
|
|||
|
|
console.log('[课程学习] Content-Type:', res.header['content-type'] || res.header['Content-Type'])
|
|||
|
|
console.log('[课程学习] Content-Length:', res.header['content-length'] || res.header['Content-Length'])
|
|||
|
|
} else if (res.statusCode === 404) {
|
|||
|
|
console.error('[课程学习] ❌ 视频文件不存在(404)')
|
|||
|
|
console.error('[课程学习] 请检查数据库中的filePath是否正确')
|
|||
|
|
} else if (res.statusCode === 403) {
|
|||
|
|
console.error('[课程学习] ❌ 没有权限访问视频文件(403)')
|
|||
|
|
} else {
|
|||
|
|
console.warn('[课程学习] ⚠️ 意外的响应码:', res.statusCode)
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[课程学习] ❌ 视频URL测试失败:', error)
|
|||
|
|
console.error('[课程学习] 可能原因: 服务器未启动或网络不通')
|
|||
|
|
console.error('[课程学习] 服务器地址:', config.FILE_BASE_URL)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 开始学习进度定时上报
|
|||
|
|
startProgressTimer() {
|
|||
|
|
this.clearProgressTimer()
|
|||
|
|
this.progressTimer = setInterval(() => {
|
|||
|
|
if (this.isPlaying) {
|
|||
|
|
this.reportProgress()
|
|||
|
|
}
|
|||
|
|
}, this.reportInterval)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 清除定时器
|
|||
|
|
clearProgressTimer() {
|
|||
|
|
if (this.progressTimer) {
|
|||
|
|
clearInterval(this.progressTimer)
|
|||
|
|
this.progressTimer = null
|
|||
|
|
}
|
|||
|
|
if (this.progressRefreshTimer) {
|
|||
|
|
clearInterval(this.progressRefreshTimer)
|
|||
|
|
this.progressRefreshTimer = null
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 上报学习进度(使用队列管理器)
|
|||
|
|
async reportProgress(immediate = false) {
|
|||
|
|
const now = Date.now()
|
|||
|
|
// 如果不是立即上报,检查是否到了上报时间
|
|||
|
|
if (!immediate && now - this.lastReportTime < this.reportInterval) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算本次播放的时长(如果正在播放)
|
|||
|
|
let currentSessionDuration = 0
|
|||
|
|
if (this.isPlaying && this.currentPlayStartTime) {
|
|||
|
|
currentSessionDuration = Math.floor((now - this.currentPlayStartTime) / 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算总时长:累积时长 + 当前播放时长
|
|||
|
|
const totalDuration = (this.accumulatedDuration || 0) + currentSessionDuration
|
|||
|
|
|
|||
|
|
// 计算增量时长(本次上报的时长增量)
|
|||
|
|
const duration = totalDuration - (this.lastReportedDuration || 0)
|
|||
|
|
|
|||
|
|
console.log('[课程学习] 上报进度 - 累积:', this.accumulatedDuration, '当前:', currentSessionDuration, '总计:', totalDuration, '增量:', duration)
|
|||
|
|
|
|||
|
|
// 如果增量时长太小且不是强制上报,跳过
|
|||
|
|
if (duration < 1 && !immediate) {
|
|||
|
|
console.log('[课程学习] 增量时长不足1秒,跳过上报')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const videoPosition = Math.floor(this.videoCurrentTime)
|
|||
|
|
const videoTotalDuration = Math.floor(this.videoTotalDuration)
|
|||
|
|
|
|||
|
|
// 计算当前视频播放进度百分比(仅用于显示,不作为课程总进度)
|
|||
|
|
let videoProgress = 0
|
|||
|
|
if (videoTotalDuration > 0) {
|
|||
|
|
videoProgress = Math.min(100, Math.floor((videoPosition / videoTotalDuration) * 100))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加到上报队列(队列管理器会自动处理网络异常和重连)
|
|||
|
|
progressQueue.addProgress({
|
|||
|
|
courseId: this.courseId,
|
|||
|
|
coursewareId: this.courseware ? this.courseware.id : null, // 添加课件ID
|
|||
|
|
duration: duration, // 上报增量时长
|
|||
|
|
videoPosition: videoPosition,
|
|||
|
|
videoTotalDuration: videoTotalDuration
|
|||
|
|
}, () => {
|
|||
|
|
// 上报成功后刷新学习记录、学习详情并重新计算进度
|
|||
|
|
this.loadLearningRecord().then(() => {
|
|||
|
|
return this.loadLearningDetails()
|
|||
|
|
}).then(() => {
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 每隔30秒刷新一次学习进度(从后端获取最新进度)
|
|||
|
|
if (!this.progressRefreshTimer) {
|
|||
|
|
this.progressRefreshTimer = setInterval(() => {
|
|||
|
|
this.refreshLearningProgress()
|
|||
|
|
}, 30000) // 30秒刷新一次
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.lastReportTime = now
|
|||
|
|
this.lastReportedDuration = totalDuration // 更新已上报的累计时长
|
|||
|
|
|
|||
|
|
console.log('[课程学习] ✅ 进度已上报 - courseId:', this.courseId, 'coursewareId:', this.courseware?.id, 'duration:', duration, '秒')
|
|||
|
|
|
|||
|
|
// 更新本地学习记录(注意:课程总进度由后端计算,这里只更新时长和位置)
|
|||
|
|
if (this.learningRecord) {
|
|||
|
|
this.learningRecord.totalDuration = (this.learningRecord.totalDuration || 0) + duration
|
|||
|
|
this.learningRecord.lastVideoPosition = videoPosition
|
|||
|
|
// 课程总进度不在前端计算,等待后端返回更新
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 刷新学习进度(从后端获取最新进度)
|
|||
|
|
async refreshLearningProgress() {
|
|||
|
|
try {
|
|||
|
|
// 刷新学习记录
|
|||
|
|
await this.loadLearningRecord()
|
|||
|
|
|
|||
|
|
// 刷新学习详情并重新计算进度
|
|||
|
|
await this.loadLearningDetails()
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('刷新学习进度失败:', error)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 预览图片
|
|||
|
|
async previewImage() {
|
|||
|
|
if (!this.imageUrl) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '图片路径为空',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[课程学习] 🖼️ 预览图片:', this.imageUrl)
|
|||
|
|
|
|||
|
|
// 记录图片查看进度
|
|||
|
|
await this.reportNonVideoProgress()
|
|||
|
|
|
|||
|
|
uni.previewImage({
|
|||
|
|
urls: [this.imageUrl],
|
|||
|
|
current: this.imageUrl,
|
|||
|
|
success: () => {
|
|||
|
|
console.log('[课程学习] ✅ 图片预览成功')
|
|||
|
|
},
|
|||
|
|
fail: (err) => {
|
|||
|
|
console.error('[课程学习] ❌ 图片预览失败:', err)
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '图片预览失败',
|
|||
|
|
content: '错误: ' + (err.errMsg || '未知错误') + '\n\nURL: ' + this.imageUrl,
|
|||
|
|
showCancel: false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 打开PDF(H5用新标签页,App用openDocument)
|
|||
|
|
async openPdf() {
|
|||
|
|
if (!this.pdfUrl) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: 'PDF路径为空',
|
|||
|
|
icon: 'none'
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[课程学习] 📄 打开PDF:', this.pdfUrl)
|
|||
|
|
|
|||
|
|
// 记录PDF查看进度
|
|||
|
|
await this.reportNonVideoProgress()
|
|||
|
|
|
|||
|
|
// #ifdef H5
|
|||
|
|
window.open(this.pdfUrl, '_blank')
|
|||
|
|
// #endif
|
|||
|
|
|
|||
|
|
// #ifndef H5
|
|||
|
|
uni.showLoading({ title: '下载中...' })
|
|||
|
|
uni.downloadFile({
|
|||
|
|
url: this.pdfUrl,
|
|||
|
|
timeout: 60000, // 60秒超时
|
|||
|
|
success: (res) => {
|
|||
|
|
console.log('[课程学习] PDF下载结果:', res.statusCode)
|
|||
|
|
uni.hideLoading()
|
|||
|
|
if (res.statusCode === 200) {
|
|||
|
|
uni.openDocument({
|
|||
|
|
filePath: res.tempFilePath,
|
|||
|
|
fileType: 'pdf',
|
|||
|
|
success: () => {
|
|||
|
|
console.log('[课程学习] ✅ 打开PDF成功')
|
|||
|
|
},
|
|||
|
|
fail: (err) => {
|
|||
|
|
console.error('[课程学习] ❌ openDocument错误:', err)
|
|||
|
|
uni.showModal({
|
|||
|
|
title: '打开PDF失败',
|
|||
|
|
content: '错误信息: ' + (err.errMsg || '未知错误') + '\n\n请确保设备安装了PDF阅读器',
|
|||
|
|
showCancel: false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
console.error('[课程学习] ❌ PDF下载失败,状态码:', res.statusCode)
|
|||
|
|
uni.showModal({
|
|||
|
|
title: 'PDF下载失败',
|
|||
|
|
content: 'HTTP状态码: ' + res.statusCode + '\n\nURL: ' + this.pdfUrl,
|
|||
|
|
showCancel: false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
fail: (err) => {
|
|||
|
|
console.error('[课程学习] ❌ 下载PDF错误:', err)
|
|||
|
|
uni.hideLoading()
|
|||
|
|
uni.showModal({
|
|||
|
|
title: 'PDF下载失败',
|
|||
|
|
content: '错误: ' + (err.errMsg || '网络错误') + '\n\nURL: ' + this.pdfUrl,
|
|||
|
|
showCancel: false
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
// #endif
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 上报非视频课件的学习进度(图片/PDF)
|
|||
|
|
async reportNonVideoProgress() {
|
|||
|
|
console.log('[课程学习] 📤 准备上报非视频课件进度')
|
|||
|
|
console.log('[课程学习] 课件信息:', this.courseware)
|
|||
|
|
|
|||
|
|
if (!this.courseware || !this.courseware.id) {
|
|||
|
|
console.warn('[课程学习] ⚠️ 课件信息不完整,跳过上报')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
console.log('[课程学习] 📤 上报非视频课件进度:', {
|
|||
|
|
courseId: this.courseId,
|
|||
|
|
coursewareId: this.courseware.id
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 上报学习进度(非视频课件,duration设为30秒,表示查看过)
|
|||
|
|
progressQueue.addProgress({
|
|||
|
|
courseId: this.courseId,
|
|||
|
|
coursewareId: this.courseware.id,
|
|||
|
|
duration: 30, // 非视频课件默认30秒
|
|||
|
|
videoPosition: null,
|
|||
|
|
videoTotalDuration: null
|
|||
|
|
}, () => {
|
|||
|
|
console.log('[课程学习] ✅ 非视频课件进度上报成功,刷新学习记录和详情')
|
|||
|
|
// 上报成功后刷新学习记录、学习详情并重新计算进度
|
|||
|
|
this.loadLearningRecord().then(() => {
|
|||
|
|
return this.loadLearningDetails()
|
|||
|
|
}).then(() => {
|
|||
|
|
this.calculateCourseProgress()
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[课程学习] ❌ 上报非视频课件进度失败:', error)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 格式化时长
|
|||
|
|
formatDuration(seconds) {
|
|||
|
|
if (!seconds) return '0分钟'
|
|||
|
|
const hours = Math.floor(seconds / 3600)
|
|||
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|||
|
|
if (hours > 0) {
|
|||
|
|
return `${hours}小时${minutes}分钟`
|
|||
|
|
}
|
|||
|
|
return `${minutes}分钟`
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="scss" scoped>
|
|||
|
|
.course-detail-container {
|
|||
|
|
padding: 20rpx;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background-color: #f8f8f8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 横屏样式 */
|
|||
|
|
.course-detail-container.landscape {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
width: 100vw;
|
|||
|
|
height: 100vh;
|
|||
|
|
padding: 0;
|
|||
|
|
padding-top: 0; // 横屏时不需要导航栏空间
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 视频容器 */
|
|||
|
|
.video-container {
|
|||
|
|
width: 100%;
|
|||
|
|
background-color: #000;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
border-radius: 12rpx;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.course-detail-container.landscape .video-container {
|
|||
|
|
width: 100vw;
|
|||
|
|
height: 100vh;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
border-radius: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.course-video {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 400rpx;
|
|||
|
|
background-color: #000;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.course-detail-container.landscape .course-video {
|
|||
|
|
height: 100vh;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 课件容器 */
|
|||
|
|
.courseware-container {
|
|||
|
|
width: 100%;
|
|||
|
|
background-color: #fff;
|
|||
|
|
border-radius: 12rpx;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 课件标签切换 */
|
|||
|
|
.courseware-tabs {
|
|||
|
|
display: flex;
|
|||
|
|
background: #fff;
|
|||
|
|
padding: 20rpx;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
border-radius: 12rpx;
|
|||
|
|
gap: 20rpx;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
|
|||
|
|
.courseware-tab {
|
|||
|
|
padding: 12rpx 24rpx;
|
|||
|
|
border-radius: 20rpx;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
|
|||
|
|
.tab-text {
|
|||
|
|
font-size: 26rpx;
|
|||
|
|
color: #666;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
&.active {
|
|||
|
|
background: rgb(55 140 224);
|
|||
|
|
|
|||
|
|
.tab-text {
|
|||
|
|
color: #fff;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.courseware-viewer {
|
|||
|
|
min-height: 600rpx;
|
|||
|
|
|
|||
|
|
.pdf-viewer {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 800rpx;
|
|||
|
|
|
|||
|
|
.pdf-placeholder {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
height: 100%;
|
|||
|
|
padding: 60rpx;
|
|||
|
|
|
|||
|
|
.pdf-icon {
|
|||
|
|
font-size: 120rpx;
|
|||
|
|
margin-bottom: 30rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pdf-text {
|
|||
|
|
font-size: 32rpx;
|
|||
|
|
color: #666;
|
|||
|
|
margin-bottom: 40rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.open-pdf-btn {
|
|||
|
|
background: rgb(55 140 224);
|
|||
|
|
color: #fff;
|
|||
|
|
padding: 20rpx 60rpx;
|
|||
|
|
border-radius: 50rpx;
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
font-weight: 500;
|
|||
|
|
|
|||
|
|
.btn-text {
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.placeholder {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
height: 100%;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.image-viewer {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 70vh;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
|
|||
|
|
.courseware-image {
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 200rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.image-loading {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
height: 400rpx;
|
|||
|
|
color: #999;
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.unknown-viewer {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
height: 400rpx;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
|
|||
|
|
.unknown-icon {
|
|||
|
|
font-size: 80rpx;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.unknown-text {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.text-viewer {
|
|||
|
|
padding: 30rpx;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 课程信息 */
|
|||
|
|
.course-info {
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 16rpx;
|
|||
|
|
padding: 30rpx;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
|
|||
|
|
.info-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.course-name {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 36rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #333;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.course-desc {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: #666;
|
|||
|
|
margin-bottom: 30rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.learning-progress {
|
|||
|
|
margin-top: 30rpx;
|
|||
|
|
padding-top: 30rpx;
|
|||
|
|
border-top: 1rpx solid #f0f0f0;
|
|||
|
|
|
|||
|
|
.progress-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
|
|||
|
|
.progress-title {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-percent {
|
|||
|
|
font-size: 32rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #4caf50;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-bar {
|
|||
|
|
height: 12rpx;
|
|||
|
|
background: #f0f0f0;
|
|||
|
|
border-radius: 6rpx;
|
|||
|
|
overflow: hidden;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
|
|||
|
|
.progress-fill {
|
|||
|
|
height: 100%;
|
|||
|
|
background: linear-gradient(90deg, #4caf50, #8bc34a);
|
|||
|
|
transition: width 0.3s;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-stats {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
font-size: 24rpx;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 横屏时隐藏课程信息 */
|
|||
|
|
.course-detail-container.landscape .course-info {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
</style>
|