1685 lines
70 KiB
Vue
1685 lines
70 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="initialTime"
|
||
: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"
|
||
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 class="video-progress-bar" v-if="!isLandscape">
|
||
<view class="progress-info">
|
||
<text class="progress-text">视频进度: {{ videoProgressPercent }}%</text>
|
||
<text class="time-text">{{ formatTime(videoCurrentTime) }} / {{ formatTime(videoTotalDuration) }}</text>
|
||
</view>
|
||
<view class="progress-track">
|
||
<view class="progress-active" :style="{ width: videoProgressPercent + '%' }"></view>
|
||
</view>
|
||
</view>
|
||
</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">{{ courseProgress || 0 }}%</text>
|
||
</view>
|
||
<view class="progress-bar">
|
||
<view class="progress-fill" :style="{ width: (courseProgress || 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, // 视频重试次数
|
||
initialTime: 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'
|
||
},
|
||
// 计算视频播放进度百分比
|
||
videoProgressPercent() {
|
||
if (!this.videoTotalDuration || this.videoTotalDuration === 0) {
|
||
return 0
|
||
}
|
||
const percent = Math.floor((this.videoCurrentTime / this.videoTotalDuration) * 100)
|
||
return Math.min(100, Math.max(0, percent))
|
||
}
|
||
},
|
||
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
|
||
|
||
// 确保路径以 / 开头(数据库存储格式:/profile/upload/2025/12/09/xxx.mp4)
|
||
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') {
|
||
// 查找该课件的所有学习详情记录,找出最新的播放位置
|
||
const coursewareDetails = this.learningDetails.filter(d => d.coursewareId === this.courseware.id)
|
||
if (coursewareDetails && coursewareDetails.length > 0) {
|
||
// 按学习时间降序排序,取最新的一条
|
||
coursewareDetails.sort((a, b) => {
|
||
const timeA = new Date(a.learnTime || a.createTime).getTime()
|
||
const timeB = new Date(b.learnTime || b.createTime).getTime()
|
||
return timeB - timeA // 降序
|
||
})
|
||
const latestDetail = coursewareDetails[0]
|
||
let lastPosition = latestDetail.videoPosition || 0
|
||
|
||
// 如果上次播放位置接近视频末尾(剩余<5秒),则从头开始
|
||
// 避免每次都从最后几秒开始然后立即结束
|
||
if (lastPosition > 0) {
|
||
// 获取视频总时长(优先使用配置的duration,如果没有则用lastPosition+5作为估算)
|
||
const estimatedDuration = this.courseware.duration || (lastPosition + 5)
|
||
const remainingTime = estimatedDuration - lastPosition
|
||
|
||
if (remainingTime < 5) {
|
||
// 剩余时间<5秒,从头开始
|
||
this.initialTime = 0
|
||
console.log('[课程学习] 📝 上次已接近结尾(剩余' + remainingTime + '秒),从头开始播放')
|
||
} else {
|
||
this.initialTime = lastPosition
|
||
console.log('[课程学习] 🔖 恢复上次播放位置:', this.initialTime, '秒 (共', coursewareDetails.length, '条记录)')
|
||
}
|
||
} else {
|
||
this.initialTime = 0
|
||
console.log('[课程学习] 📝 从头开始播放 (有', coursewareDetails.length, '条记录,但播放位置为0)')
|
||
}
|
||
} else {
|
||
this.initialTime = 0
|
||
console.log('[课程学习] 📝 从头开始播放 (无学习记录)')
|
||
}
|
||
|
||
// 先清空视频URL,强制重新加载
|
||
this.videoUrl = ''
|
||
await this.$nextTick()
|
||
|
||
// 设置新的视频URL
|
||
this.videoUrl = fileUrl
|
||
console.log('[课程学习] ✅ 视频URL已设置:', this.videoUrl)
|
||
console.log('[课程学习] ⏱️ 初始播放位置:', this.initialTime, '秒')
|
||
} 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
|
||
// ✅ 直接使用后端返回的进度值(已经过精确计算)
|
||
this.courseProgress = Math.round(record.progress || 0)
|
||
console.log('[课程学习] ✅✅✅ 使用后端进度(2025-12-05 18:10修复)✅✅✅')
|
||
console.log('[课程学习] ✅ 学习记录已加载:', {
|
||
progress: record.progress,
|
||
courseProgress: this.courseProgress,
|
||
totalDuration: record.totalDuration,
|
||
learnCount: record.learnCount
|
||
})
|
||
console.log('[课程学习] 📊 最终显示进度:', this.courseProgress + '%')
|
||
} else {
|
||
console.log('[课程学习] ℹ️ 当前课程还没有学习记录')
|
||
this.learningRecord = null
|
||
this.courseProgress = 0
|
||
}
|
||
} else {
|
||
console.error('[课程学习] ❌ 加载学习记录失败:', response)
|
||
this.learningRecord = null
|
||
this.courseProgress = 0
|
||
}
|
||
} catch (error) {
|
||
console.error('[课程学习] ❌ 加载学习记录异常:', error)
|
||
this.learningRecord = null
|
||
this.courseProgress = 0
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 计算课程总进度(已废弃 - 直接使用后端返回的progress)
|
||
*
|
||
* ⚠️ 已改为使用后端计算的进度值(loadLearningRecord方法中赋值)
|
||
* 前端不再重新计算,避免与后端不一致
|
||
*/
|
||
calculateCourseProgress() {
|
||
// ✅ 不再前端计算,直接使用后端返回的 learningRecord.progress
|
||
console.log('[课程学习] ℹ️ 使用后端计算的进度:', this.courseProgress + '%')
|
||
return
|
||
|
||
/* 已废弃的前端计算逻辑
|
||
if (!this.coursewareList || this.coursewareList.length === 0) {
|
||
this.courseProgress = 0
|
||
console.log('[课程学习] ⚠️ 没有课件列表,进度为0%')
|
||
return
|
||
}
|
||
|
||
const totalCount = this.coursewareList.length
|
||
let completedCount = 0
|
||
const enableDetailLog = false // 是否输出详细日志(关闭,避免日志刷屏)
|
||
|
||
// 构建学习详情映射(按课件ID分组,保留视频播放位置最大的记录)
|
||
const detailMap = new Map()
|
||
const realDurationMap = 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)
|
||
|
||
// 记录最大播放位置,作为视频的可能真实时长
|
||
const maxPosition = detail.videoPosition || 0
|
||
const existingMax = realDurationMap.get(detail.coursewareId) || 0
|
||
if (maxPosition > existingMax) {
|
||
realDurationMap.set(detail.coursewareId, maxPosition)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 遍历所有课件,判断是否完成
|
||
if (enableDetailLog) {
|
||
console.log('[课程学习] ⚙️ 开始前端计算课件完成情况...')
|
||
}
|
||
for (const cw of this.coursewareList) {
|
||
const detail = detailMap.get(cw.id)
|
||
|
||
if (cw.type === 'video') {
|
||
// 视频:判断是否观看完成
|
||
if (detail) {
|
||
const videoPosition = detail.videoPosition || 0
|
||
const maxPositionFromHistory = realDurationMap.get(cw.id) || 0
|
||
|
||
// 推断视频的真实时长
|
||
let realDuration = cw.duration // 默认使用配置的时长
|
||
let durationSource = '配置'
|
||
|
||
// 情况1:配置的duration无效
|
||
if (!cw.duration || cw.duration <= 0) {
|
||
realDuration = maxPositionFromHistory + 3 // 使用历史最大位置+3秒缓冲
|
||
durationSource = '推断'
|
||
}
|
||
// 情况2:配置的duration明显过长(历史最大播放位置超过配置时长的50%但不到90%)
|
||
// 说明用户已经播放到接近或完全结束,但配置的duration偏大
|
||
else if (maxPositionFromHistory > cw.duration * 0.5 && maxPositionFromHistory < cw.duration * 0.9) {
|
||
realDuration = maxPositionFromHistory + 3 // 使用历史最大位置+3秒缓冲
|
||
durationSource = '推断(配置过长)'
|
||
}
|
||
|
||
// 判断是否完成
|
||
let isCompleted = false
|
||
let progress = 0
|
||
|
||
// 特殊判断:如果当前播放位置等于历史最大位置(说明已经播放到视频结尾)
|
||
// 且播放位置>=3秒(避免误判极短的测试视频)
|
||
if (videoPosition === maxPositionFromHistory && videoPosition >= 3) {
|
||
isCompleted = true
|
||
progress = 100
|
||
} else {
|
||
// 常规判断:观看位置 >= 真实时长的90%
|
||
progress = realDuration > 0 ? Math.floor((videoPosition / realDuration) * 100) : 0
|
||
isCompleted = realDuration > 0 && videoPosition >= realDuration * 0.9
|
||
}
|
||
|
||
if (isCompleted) {
|
||
completedCount++
|
||
if (enableDetailLog) {
|
||
console.log(`[课程学习] ✅ 视频课件 ${cw.id}(${cw.name}) 已完成: ${videoPosition}秒 / ${realDuration}秒 (${progress}%) [时长来源:${durationSource}]`)
|
||
}
|
||
} else {
|
||
if (enableDetailLog) {
|
||
console.log(`[课程学习] ⏳ 视频课件 ${cw.id}(${cw.name}) 未完成: ${videoPosition}秒 / ${realDuration}秒 (${progress}%) [时长来源:${durationSource}]`)
|
||
}
|
||
}
|
||
} else {
|
||
if (enableDetailLog) {
|
||
console.log(`[课程学习] ❌ 视频课件 ${cw.id}(${cw.name}) 无学习记录`)
|
||
}
|
||
}
|
||
} else {
|
||
// 图片/PDF:只要有学习详情记录就视为完成
|
||
if (detail) {
|
||
completedCount++
|
||
if (enableDetailLog) {
|
||
console.log(`[课程学习] ✅ 非视频课件 ${cw.id}(${cw.name}) 已完成`)
|
||
}
|
||
} else {
|
||
if (enableDetailLog) {
|
||
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('[课程学习] ▶️ 视频开始播放')
|
||
console.log('[课程学习] 当前播放位置:', this.videoCurrentTime, '秒')
|
||
console.log('[课程学习] 视频总时长:', this.videoTotalDuration, '秒')
|
||
console.log('[课程学习] 已累积时长:', this.accumulatedDuration, '秒')
|
||
|
||
this.isPlaying = true
|
||
// 记录本次播放的开始时间(不重置总时长)
|
||
this.currentPlayStartTime = Date.now()
|
||
this.startProgressTimer()
|
||
|
||
console.log('[课程学习] ✅ 播放状态已设置,定时器已启动')
|
||
},
|
||
|
||
onVideoPause(e) {
|
||
const playDuration = this.currentPlayStartTime ? Math.floor((Date.now() - this.currentPlayStartTime) / 1000) : 0
|
||
console.log('[课程学习] ⏸️ 视频暂停')
|
||
console.log('[课程学习] 本次播放时长:', playDuration, '秒')
|
||
console.log('[课程学习] 当前位置:', this.videoCurrentTime, '秒')
|
||
|
||
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) // 暂停时立即上报
|
||
console.log('[课程学习] ✅ 暂停时进度已上报')
|
||
},
|
||
|
||
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
|
||
}
|
||
|
||
// 如果累积时长为0(可能从接近末尾开始播放立即结束),使用当前播放位置
|
||
if (this.accumulatedDuration === 0 && this.videoCurrentTime > 0) {
|
||
this.accumulatedDuration = Math.floor(this.videoCurrentTime)
|
||
console.log('[课程学习] ⚠️ 累积时长为0,使用当前播放位置作为时长:', this.accumulatedDuration, '秒')
|
||
} else {
|
||
console.log('[课程学习] 最终累积时长:', this.accumulatedDuration, '秒')
|
||
}
|
||
|
||
// 设置视频进度为100%
|
||
if (this.videoTotalDuration > 0) {
|
||
this.videoCurrentTime = this.videoTotalDuration
|
||
console.log('[课程学习] ✅ 视频已播放完毕,设置进度为100%')
|
||
}
|
||
|
||
// 结束时立即上报,并在上报成功后刷新进度
|
||
this.reportProgress(true)
|
||
|
||
// 延迟刷新进度,确保后端已处理完成(延长到2秒,确保事务提交)
|
||
setTimeout(async () => {
|
||
await this.loadLearningRecord()
|
||
await this.loadLearningDetails()
|
||
this.calculateCourseProgress()
|
||
console.log('[课程学习] 📊 进度已刷新')
|
||
|
||
// 如果学习详情还是空的,再尝试一次
|
||
if (this.learningDetails.length === 0) {
|
||
console.log('[课程学习] ⚠️ 学习详情为空,2秒后重试查询...')
|
||
setTimeout(async () => {
|
||
await this.loadLearningDetails()
|
||
this.calculateCourseProgress()
|
||
console.log('[课程学习] 📊 重试后进度已刷新,详情数量:', this.learningDetails.length)
|
||
}, 2000)
|
||
}
|
||
}, 2000)
|
||
},
|
||
|
||
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 (this.isPlaying && this.currentPlayStartTime) {
|
||
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
|
||
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
|
||
console.log('[课程学习] 全屏切换时累加时长:', thisDuration, '秒,总计:', this.accumulatedDuration, '秒')
|
||
// 重置当前播放开始时间
|
||
this.currentPlayStartTime = Date.now()
|
||
}
|
||
|
||
if (e.detail.fullScreen) {
|
||
console.log('[课程学习] ✅ 进入全屏模式,播放状态:', this.isPlaying ? '播放中' : '已暂停')
|
||
} else {
|
||
console.log('[课程学习] ✅ 退出全屏模式,播放状态:', this.isPlaying ? '播放中' : '已暂停')
|
||
}
|
||
|
||
// 全屏切换后,如果正在播放,立即上报一次进度
|
||
if (this.isPlaying) {
|
||
setTimeout(() => {
|
||
this.reportProgress(true)
|
||
}, 500)
|
||
}
|
||
},
|
||
|
||
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
|
||
|
||
// 确保路径以 / 开头(数据库格式:/profile/upload/...)
|
||
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
|
||
}, () => {
|
||
// 上报成功后刷新学习记录、学习详情并重新计算进度(延迟1秒,确保事务提交)
|
||
setTimeout(async () => {
|
||
await this.loadLearningRecord()
|
||
await this.loadLearningDetails()
|
||
this.calculateCourseProgress()
|
||
}, 1000)
|
||
})
|
||
|
||
// 每隔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('[课程学习] ✅ 非视频课件进度上报成功,刷新学习记录和详情')
|
||
// 上报成功后刷新学习记录、学习详情并重新计算进度(延迟1秒,确保事务提交)
|
||
setTimeout(async () => {
|
||
await this.loadLearningRecord()
|
||
await this.loadLearningDetails()
|
||
this.calculateCourseProgress()
|
||
}, 1000)
|
||
})
|
||
} 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}分钟`
|
||
},
|
||
|
||
// 格式化时间(用于视频播放时间显示:分:秒)
|
||
formatTime(seconds) {
|
||
if (!seconds || seconds < 0) return '00:00'
|
||
const min = Math.floor(seconds / 60)
|
||
const sec = Math.floor(seconds % 60)
|
||
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
||
}
|
||
}
|
||
}
|
||
</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;
|
||
}
|
||
|
||
/* 视频播放进度条 */
|
||
.video-progress-bar {
|
||
padding: 16rpx 20rpx;
|
||
background-color: #fff;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
.progress-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.progress-text {
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.time-text {
|
||
color: #999;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.progress-track {
|
||
width: 100%;
|
||
height: 8rpx;
|
||
background-color: #e0e0e0;
|
||
border-radius: 4rpx;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.progress-active {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #3c9cff 0%, #5cadff 100%);
|
||
border-radius: 4rpx;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
/* 课件容器 */
|
||
.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>
|