guoyu/fronted_uniapp/pages/course/detail.vue

1753 lines
74 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<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
2025-12-11 23:28:07 +08:00
id="course-video"
:key="'video-' + (courseware ? courseware.id : 0)"
2025-12-03 18:58:36 +08:00
: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"
2025-12-03 18:58:36 +08:00
: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>
2025-12-03 18:58:36 +08:00
</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>
2025-12-03 18:58:36 +08:00
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: (courseProgress || 0) + '%' }"></view>
2025-12-03 18:58:36 +08:00
</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, // 当前课件索引
2025-12-11 23:28:07 +08:00
currentCoursewareId: null, // 当前课件ID用于判断是否切换课件
2025-12-03 18:58:36 +08:00
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,
2025-12-11 23:28:07 +08:00
reportInterval: 30000, // 每30秒上报一次减少上报频率
reportedNonVideoInSession: null, // 已上报的非视频课件(会话去重)
2025-12-03 18:58:36 +08:00
videoStartTime: 0,
videoCurrentTime: 0,
videoTotalDuration: 0,
isPlaying: false,
lastReportedDuration: 0, // 上次已上报的累计时长
accumulatedDuration: 0, // 累积观看时长(秒)
currentPlayStartTime: 0, // 当前播放开始时间
videoObjectFit: 'contain', // 视频显示模式
videoRetryCount: 0, // 视频重试次数
initialTime: 0, // 视频初始播放位置(秒)
2025-12-03 18:58:36 +08:00
// 导航栏高度
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))
2025-12-03 18:58:36 +08:00
}
},
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) {
2025-12-11 23:28:07 +08:00
// ✅ 防止快速切换导致竞态问题
if (this.loading) {
console.log('[课程学习] ⏳ 正在加载中,忽略重复请求')
return
}
this.loading = true
2025-12-03 18:58:36 +08:00
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)
2025-12-11 23:28:07 +08:00
this.loading = false
2025-12-03 18:58:36 +08:00
return
}
}
console.log('[课程学习] 📦 加载课件数据:', coursewareData)
console.log('[课程学习] 课件ID:', coursewareData?.id)
console.log('[课程学习] 课件类型:', coursewareData?.type)
console.log('[课程学习] 课件文件路径:', coursewareData?.filePath)
console.log('[课程学习] 课件时长:', coursewareData?.duration)
2025-12-11 23:28:07 +08:00
// ✅ 保存当前课件ID用于判断是否切换课件
const previousCoursewareId = this.currentCoursewareId
this.currentCoursewareId = coursewareData?.id
2025-12-03 18:58:36 +08:00
this.courseware = coursewareData
2025-12-11 23:28:07 +08:00
console.log('[课程学习] 🔄 课件切换:', previousCoursewareId, '->', this.currentCoursewareId)
2025-12-03 18:58:36 +08:00
// 如果是非视频课件,记录查看进度
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
2025-12-03 18:58:36 +08:00
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('[课程学习] 📝 从头开始播放 (无学习记录)')
}
2025-12-11 23:28:07 +08:00
// ✅ 只在课件ID变化时才重新生成videoId避免不必要的重新渲染
if (!this.videoId || previousCoursewareId !== coursewareData.id) {
this.videoId = 'course-video-' + coursewareData.id
console.log('[课程学习] 🔄 课件切换生成新的videoId:', this.videoId)
} else {
console.log('[课程学习] 相同课件复用videoId:', this.videoId)
}
2025-12-03 18:58:36 +08:00
2025-12-11 23:28:07 +08:00
// ✅ 只在URL变化时才更新避免不必要的重新加载
if (this.videoUrl !== fileUrl) {
this.videoUrl = fileUrl
console.log('[课程学习] ✅ 视频URL已更新:', this.videoUrl)
}
console.log('[课程学习] ⏱️ 初始播放位置:', this.initialTime, '秒')
2025-12-03 18:58:36 +08:00
} 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'
})
2025-12-11 23:28:07 +08:00
} finally {
// ✅ 无论成功或失败都重置loading状态
this.loading = false
console.log('[课程学习] ✅ 课件加载完成loading重置')
2025-12-03 18:58:36 +08:00
}
},
// 切换课件
async switchCourseware(index) {
if (index < 0 || index >= this.coursewareList.length) return
// 切换前先上报当前课件的进度
if (this.isPlaying) {
this.reportProgress(true)
}
this.currentCoursewareIndex = index
2025-12-11 23:28:07 +08:00
// ✅ 重置学习时长统计
2025-12-03 18:58:36 +08:00
this.accumulatedDuration = 0
this.currentPlayStartTime = 0
this.lastReportedDuration = 0
this.lastReportTime = 0
2025-12-10 22:53:20 +08:00
this.videoRetryCount = 0 // 重置视频重试计数器
2025-12-03 18:58:36 +08:00
2025-12-11 23:28:07 +08:00
// ✅ 清空视频URL确保切换时组件重新加载
this.videoUrl = ''
this.videoId = ''
console.log('[课程学习] 切换课件,重置所有状态')
// ✅ 等待DOM更新后再加载新课件
await this.$nextTick()
2025-12-03 18:58:36 +08:00
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修复✅✅✅')
2025-12-03 18:58:36 +08:00
console.log('[课程学习] ✅ 学习记录已加载:', {
progress: record.progress,
courseProgress: this.courseProgress,
2025-12-03 18:58:36 +08:00
totalDuration: record.totalDuration,
learnCount: record.learnCount
})
console.log('[课程学习] 📊 最终显示进度:', this.courseProgress + '%')
2025-12-03 18:58:36 +08:00
} else {
console.log('[课程学习] 当前课程还没有学习记录')
this.learningRecord = null
this.courseProgress = 0
2025-12-03 18:58:36 +08:00
}
} else {
console.error('[课程学习] ❌ 加载学习记录失败:', response)
this.learningRecord = null
this.courseProgress = 0
2025-12-03 18:58:36 +08:00
}
} catch (error) {
console.error('[课程学习] ❌ 加载学习记录异常:', error)
this.learningRecord = null
this.courseProgress = 0
2025-12-03 18:58:36 +08:00
}
},
/**
* 计算课程总进度已废弃 - 直接使用后端返回的progress
*
* 已改为使用后端计算的进度值loadLearningRecord方法中赋值
* 前端不再重新计算避免与后端不一致
2025-12-03 18:58:36 +08:00
*/
calculateCourseProgress() {
// ✅ 不再前端计算,直接使用后端返回的 learningRecord.progress
console.log('[课程学习] 使用后端计算的进度:', this.courseProgress + '%')
return
/*
2025-12-03 18:58:36 +08:00
if (!this.coursewareList || this.coursewareList.length === 0) {
this.courseProgress = 0
console.log('[课程学习] ⚠️ 没有课件列表进度为0%')
2025-12-03 18:58:36 +08:00
return
}
const totalCount = this.coursewareList.length
let completedCount = 0
const enableDetailLog = false // 是否输出详细日志(关闭,避免日志刷屏)
2025-12-03 18:58:36 +08:00
// 构建学习详情映射按课件ID分组保留视频播放位置最大的记录
const detailMap = new Map()
const realDurationMap = new Map() // 记录推断的真实时长
2025-12-03 18:58:36 +08:00
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)
}
2025-12-03 18:58:36 +08:00
}
}
}
// 遍历所有课件,判断是否完成
if (enableDetailLog) {
console.log('[课程学习] ⚙️ 开始前端计算课件完成情况...')
}
2025-12-03 18:58:36 +08:00
for (const cw of this.coursewareList) {
const detail = detailMap.get(cw.id)
if (cw.type === 'video') {
// 视频:判断是否观看完成
if (detail) {
2025-12-03 18:58:36 +08:00
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) {
2025-12-03 18:58:36 +08:00
completedCount++
if (enableDetailLog) {
console.log(`[课程学习] ✅ 视频课件 ${cw.id}(${cw.name}) 已完成: ${videoPosition}秒 / ${realDuration}秒 (${progress}%) [时长来源:${durationSource}]`)
}
2025-12-03 18:58:36 +08:00
} else {
if (enableDetailLog) {
console.log(`[课程学习] ⏳ 视频课件 ${cw.id}(${cw.name}) 未完成: ${videoPosition}秒 / ${realDuration}秒 (${progress}%) [时长来源:${durationSource}]`)
}
2025-12-03 18:58:36 +08:00
}
} else {
if (enableDetailLog) {
2025-12-03 18:58:36 +08:00
console.log(`[课程学习] ❌ 视频课件 ${cw.id}(${cw.name}) 无学习记录`)
}
}
} else {
// 图片/PDF只要有学习详情记录就视为完成
if (detail) {
completedCount++
if (enableDetailLog) {
console.log(`[课程学习] ✅ 非视频课件 ${cw.id}(${cw.name}) 已完成`)
}
2025-12-03 18:58:36 +08:00
} else {
if (enableDetailLog) {
console.log(`[课程学习] ❌ 非视频课件 ${cw.id}(${cw.name}) 无学习记录`)
}
2025-12-03 18:58:36 +08:00
}
}
}
// 计算进度:已完成课件数 / 总课件数 * 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}%`)
*/
2025-12-03 18:58:36 +08:00
},
// 视频播放事件
onVideoPlay(e) {
console.log('[课程学习] ▶️ 视频开始播放')
console.log('[课程学习] 当前播放位置:', this.videoCurrentTime, '秒')
console.log('[课程学习] 视频总时长:', this.videoTotalDuration, '秒')
console.log('[课程学习] 已累积时长:', this.accumulatedDuration, '秒')
2025-12-03 18:58:36 +08:00
this.isPlaying = true
// 记录本次播放的开始时间(不重置总时长)
this.currentPlayStartTime = Date.now()
this.startProgressTimer()
console.log('[课程学习] ✅ 播放状态已设置,定时器已启动')
2025-12-03 18:58:36 +08:00
},
onVideoPause(e) {
const playDuration = this.currentPlayStartTime ? Math.floor((Date.now() - this.currentPlayStartTime) / 1000) : 0
console.log('[课程学习] ⏸️ 视频暂停')
console.log('[课程学习] 本次播放时长:', playDuration, '秒')
console.log('[课程学习] 当前位置:', this.videoCurrentTime, '秒')
2025-12-03 18:58:36 +08:00
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('[课程学习] ✅ 暂停时进度已上报')
2025-12-03 18:58:36 +08:00
},
onVideoEnded(e) {
console.log('[课程学习] 🎬 视频播放结束')
2025-12-03 18:58:36 +08:00
this.isPlaying = false
2025-12-03 18:58:36 +08:00
// 累加最后一次播放时长
if (this.currentPlayStartTime) {
const thisDuration = Math.floor((Date.now() - this.currentPlayStartTime) / 1000)
this.accumulatedDuration = (this.accumulatedDuration || 0) + thisDuration
2025-12-11 23:28:07 +08:00
console.log('[课程学习] 本次播放时长:', thisDuration, '秒')
}
2025-12-11 23:28:07 +08:00
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)
2025-12-03 18:58:36 +08:00
},
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) {
2025-12-10 22:53:20 +08:00
console.warn('[课程学习] ⚠️ 检测到视频尺寸异常(黑屏),重试次数:', this.videoRetryCount)
// 限制重试次数,避免无限循环
if (this.videoRetryCount < 3) {
this.videoRetryCount++
console.log('[课程学习] 🔄 第', this.videoRetryCount, '次尝试修复黑屏')
// 强制重新加载视频重新生成videoId
setTimeout(async () => {
const currentUrl = this.videoUrl
const currentInitialTime = this.initialTime
// 重新生成videoId强制组件重建
this.videoId = 'course-video-' + Date.now()
this.videoUrl = ''
await this.$nextTick()
// 恢复视频URL和初始时间
this.videoUrl = currentUrl
this.initialTime = currentInitialTime
console.log('[课程学习] ✅ 已重新加载视频新videoId:', this.videoId)
}, 300)
} else {
console.error('[课程学习] ❌ 已达到最大重试次数,放弃修复')
uni.showToast({
title: '视频加载异常,请退出重试',
icon: 'none',
duration: 3000
})
}
} else {
// 视频尺寸正常,重置重试计数器
this.videoRetryCount = 0
console.log('[课程学习] ✅ 视频尺寸正常,重置重试计数器')
2025-12-03 18:58:36 +08:00
}
},
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()
}
2025-12-03 18:58:36 +08:00
if (e.detail.fullScreen) {
console.log('[课程学习] ✅ 进入全屏模式,播放状态:', this.isPlaying ? '播放中' : '已暂停')
2025-12-03 18:58:36 +08:00
} else {
console.log('[课程学习] ✅ 退出全屏模式,播放状态:', this.isPlaying ? '播放中' : '已暂停')
}
// 全屏切换后,如果正在播放,立即上报一次进度
if (this.isPlaying) {
setTimeout(() => {
this.reportProgress(true)
}, 500)
2025-12-03 18:58:36 +08:00
}
},
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/...
2025-12-03 18:58:36 +08:00
if (!filePath.startsWith('/')) {
filePath = '/' + filePath
}
2025-12-03 18:58:36 +08:00
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(() => {
2025-12-11 23:28:07 +08:00
const videoContext = uni.createVideoContext('course-video')
2025-12-03 18:58:36 +08:00
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)
2025-12-11 23:28:07 +08:00
console.log('[课程学习] 上报进度 - 累积:', this.accumulatedDuration, '当前:', currentSessionDuration, '总计:', totalDuration, '增量:', duration, '强制:', immediate)
2025-12-03 18:58:36 +08:00
2025-12-11 23:28:07 +08:00
// ✅ 如果增量时长太小且不是强制上报,跳过
// 注意视频结束时会强制上报immediate=true即使增量为0也会上报以便后端重新计算进度
2025-12-03 18:58:36 +08:00
if (duration < 1 && !immediate) {
2025-12-11 23:28:07 +08:00
console.log('[课程学习] ⏭️ 增量时长不足1秒且非强制上报跳过')
2025-12-03 18:58:36 +08:00
return
}
2025-12-11 23:28:07 +08:00
// ✅ 强制上报时即使增量为0也要上报确保后端更新进度
if (immediate && duration === 0) {
console.log('[课程学习] ⚠️ 强制上报但增量为0仍然上报以触发进度计算')
}
2025-12-03 18:58:36 +08:00
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()
2025-12-03 18:58:36 +08:00
this.calculateCourseProgress()
}, 1000)
2025-12-03 18:58:36 +08:00
})
// 每隔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
})
}
})
},
// 打开PDFH5用新标签页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
}
2025-12-11 23:28:07 +08:00
// ✅ 会话去重:同一课件在同一会话中只上报一次
const sessionKey = `nonvideo_${this.courseId}_${this.courseware.id}`
if (this.reportedNonVideoInSession && this.reportedNonVideoInSession === sessionKey) {
console.log('[课程学习] ⏭️ 该非视频课件已在本次会话中上报过,跳过')
return
}
2025-12-03 18:58:36 +08:00
try {
console.log('[课程学习] 📤 上报非视频课件进度:', {
courseId: this.courseId,
2025-12-11 23:28:07 +08:00
coursewareId: this.courseware.id,
type: this.courseware.type
2025-12-03 18:58:36 +08:00
})
2025-12-11 23:28:07 +08:00
// ✅ 上报学习进度非视频课件duration为0不计入学习时长
2025-12-03 18:58:36 +08:00
progressQueue.addProgress({
courseId: this.courseId,
coursewareId: this.courseware.id,
2025-12-11 23:28:07 +08:00
duration: 0, // ✅ 图片/PDF不计入学习时长
2025-12-03 18:58:36 +08:00
videoPosition: null,
videoTotalDuration: null
}, () => {
console.log('[课程学习] ✅ 非视频课件进度上报成功,刷新学习记录和详情')
2025-12-11 23:28:07 +08:00
// 标记已上报
this.reportedNonVideoInSession = sessionKey
// 上报成功后刷新学习记录、学习详情并重新计算进度延迟1秒确保事务提交
setTimeout(async () => {
await this.loadLearningRecord()
await this.loadLearningDetails()
2025-12-03 18:58:36 +08:00
this.calculateCourseProgress()
}, 1000)
2025-12-03 18:58:36 +08:00
})
} 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')}`
2025-12-03 18:58:36 +08:00
}
}
}
</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;
}
2025-12-03 18:58:36 +08:00
/* 课件容器 */
.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>