guoyu/fronted_uniapp/pages/course/detail.vue
2025-12-03 18:58:36 +08:00

1478 lines
60 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>