guoyu/fronted_uniapp/pages/course/detail.vue

1478 lines
60 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
: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
})
}
})
},
// 打开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
}
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>