|
|
@@ -0,0 +1,739 @@
|
|
|
+<!-- 学习课程 - 小程序webview版 -->
|
|
|
+<template>
|
|
|
+ <div class="learningCourse">
|
|
|
+ <!-- 顶部导航栏 -->
|
|
|
+ <div class="nav-bar">
|
|
|
+ <p class="nav-title">{{ newData.courseName }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 课件展示区 -->
|
|
|
+ <div class="courseware-box" :class="{ 'is-fullscreen': isFullscreen }">
|
|
|
+ <!-- 视频 -->
|
|
|
+ <div v-if="officeType === 'video'" class="video-wrap">
|
|
|
+ <video
|
|
|
+ ref="videoRef"
|
|
|
+ class="video-player"
|
|
|
+ controls
|
|
|
+ playsinline
|
|
|
+ webkit-playsinline
|
|
|
+ x5-playsinline
|
|
|
+ x5-video-player-type="h5"
|
|
|
+ x5-video-player-fullscreen="true"
|
|
|
+ ></video>
|
|
|
+ <div v-if="videoError" class="video-error-text">视频加载失败,请检查网络或联系管理员</div>
|
|
|
+ </div>
|
|
|
+ <!-- 富文本/文章 -->
|
|
|
+ <div v-else-if="officeType === 'text'" class="rich-text-box" v-html="lookUrl"></div>
|
|
|
+ <!-- 文档(docx/excel/pdf) -->
|
|
|
+ <div v-else-if="officeType === 'docx' || officeType === 'excel' || officeType === 'pdf'" class="office-box">
|
|
|
+ <vue-office-docx
|
|
|
+ v-if="officeType === 'docx' && !officeNullType"
|
|
|
+ :src="lookUrl"
|
|
|
+ @rendered="renderedHandler"
|
|
|
+ @error="errorHandler"
|
|
|
+ />
|
|
|
+ <vue-office-excel
|
|
|
+ v-if="officeType === 'excel' && !officeNullType"
|
|
|
+ :src="lookUrl"
|
|
|
+ @rendered="renderedHandler"
|
|
|
+ @error="errorHandler"
|
|
|
+ />
|
|
|
+ <vue-office-pdf
|
|
|
+ v-if="officeType === 'pdf' && !officeNullType"
|
|
|
+ :src="lookUrl"
|
|
|
+ @rendered="renderedHandler"
|
|
|
+ @error="errorHandler"
|
|
|
+ />
|
|
|
+ <p v-if="officeNullType" class="load-fail-text">加载失败...</p>
|
|
|
+ </div>
|
|
|
+ <!-- 空态 -->
|
|
|
+ <div v-else class="empty-box">
|
|
|
+ <p>课件加载中...</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部课件列表(固定高度) -->
|
|
|
+ <div class="bottom-panel">
|
|
|
+ <!-- 课程meta -->
|
|
|
+ <div class="info-meta">
|
|
|
+ <span>{{ secondsToMinutes(newData.totalDuration) }}分钟</span>
|
|
|
+ <span class="dot">·</span>
|
|
|
+ <span>{{ newData.totalCreditHours }}学时</span>
|
|
|
+ <span class="dot">·</span>
|
|
|
+ <span>{{ newData.totalPoints }}积分</span>
|
|
|
+ <span class="dot">·</span>
|
|
|
+ <span>已学 {{ newData.learnedCoursewareCount }} / {{ newData.totalCoursewareCount }} 课件</span>
|
|
|
+ </div>
|
|
|
+ <!-- 课件列表 -->
|
|
|
+ <div class="courseware-list">
|
|
|
+ <div
|
|
|
+ class="courseware-item"
|
|
|
+ v-for="(item, index) in newData.coursewares"
|
|
|
+ :key="index"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="item-card"
|
|
|
+ :class="checkLearningIndex === index ? 'item-card-active' : ''"
|
|
|
+ @click="checkLearningButton(index)"
|
|
|
+ >
|
|
|
+ <p class="item-name">{{ item.coursewareName }}</p>
|
|
|
+ <p class="item-time">已学:{{ formatTime(item.learnedDuration) }} / {{ formatTime(item.minLearningDuration) }}</p>
|
|
|
+ <span :class="['item-status',
|
|
|
+ item.learnStatus === 2 ? 'status-done' :
|
|
|
+ item.learnStatus === 1 ? 'status-ing' : 'status-none'
|
|
|
+ ]">
|
|
|
+ {{ item.learnStatus === 2 ? '已学完' : (item.learnStatus === 1 ? '学习中' : '未学完') }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="confirm-btn"
|
|
|
+ v-if="item.learnStatus !== 2 && item.learnedDuration >= item.minLearningDuration && item.minLearningDuration > 0 && checkLearningIndex === index"
|
|
|
+ @click="finishStudying(index)"
|
|
|
+ >
|
|
|
+ 若已完成学习,请点此确认
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 安全验证弹窗(挂载到 body 脱离父级 stacking context) -->
|
|
|
+ <van-popup
|
|
|
+ v-model="dialogVisible"
|
|
|
+ :close-on-click-overlay="false"
|
|
|
+ position="center"
|
|
|
+ :style="{ width: '80%', borderRadius: '12px', padding: '30px 24px', boxSizing: 'border-box' }"
|
|
|
+ :get-container="() => $el.ownerDocument.body"
|
|
|
+ >
|
|
|
+ <p class="verify-title">请完成安全验证</p>
|
|
|
+ <van-slider
|
|
|
+ v-model="sliderValue"
|
|
|
+ :min="0"
|
|
|
+ :max="100"
|
|
|
+ active-color="#0183fa"
|
|
|
+ @change="onSliderChange"
|
|
|
+ class="verify-slider"
|
|
|
+ />
|
|
|
+ <p class="verify-tip">{{ sliderValue === 100 ? '验证通过' : '请拖动滑块到最右边' }}</p>
|
|
|
+ </van-popup>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+<script>
|
|
|
+ import Hls from 'hls.js'
|
|
|
+ import VueOfficeDocx from '@vue-office/docx'
|
|
|
+ import '@vue-office/docx/lib/index.css'
|
|
|
+ import VueOfficeExcel from '@vue-office/excel'
|
|
|
+ import '@vue-office/excel/lib/index.css'
|
|
|
+ import VueOfficePdf from '@vue-office/pdf'
|
|
|
+ import { Toast, Dialog } from 'vant'
|
|
|
+ import {
|
|
|
+ examUserCourseLearningCoursewareList,
|
|
|
+ examUserCoursewareLearningReportProgress,
|
|
|
+ examUserCoursewareLearningConfirmCompletion,
|
|
|
+ } from '@/api/index.js'
|
|
|
+
|
|
|
+ export default {
|
|
|
+ name: 'learningCourse',
|
|
|
+ components: {
|
|
|
+ VueOfficeDocx,
|
|
|
+ VueOfficeExcel,
|
|
|
+ VueOfficePdf,
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ courseId: null,
|
|
|
+ examId: null,
|
|
|
+ newData: {
|
|
|
+ courseName: '',
|
|
|
+ totalDuration: 0,
|
|
|
+ totalCreditHours: 0,
|
|
|
+ totalPoints: 0,
|
|
|
+ learnedCoursewareCount: 0,
|
|
|
+ totalCoursewareCount: 0,
|
|
|
+ progress: 0,
|
|
|
+ coursewares: [],
|
|
|
+ popupVerifyEnabled: 0,
|
|
|
+ popupIntervalMinutes: 0,
|
|
|
+ },
|
|
|
+ // 当前选中课件下标
|
|
|
+ checkLearningIndex: 0,
|
|
|
+ // 课件展示
|
|
|
+ officeType: null,
|
|
|
+ lookUrl: '',
|
|
|
+ officeNullType: false,
|
|
|
+ videoError: false,
|
|
|
+ // hls 实例
|
|
|
+ hls: null,
|
|
|
+ _stallTimer: null,
|
|
|
+ _lastTime: 0,
|
|
|
+ isFullscreen: false,
|
|
|
+ // 学习定时器
|
|
|
+ studyTimer: null,
|
|
|
+ heartbeatValue: 30,
|
|
|
+ // 验证弹窗
|
|
|
+ dialogVisible: false,
|
|
|
+ sliderValue: 0,
|
|
|
+ verificationTime: 0,
|
|
|
+ _verifyTimer: null,
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ document.title = ''
|
|
|
+ this.initialization()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // 初始化
|
|
|
+ initialization() {
|
|
|
+ let text = decodeURIComponent(window.location.href)
|
|
|
+ let search = text.split('?')[1] || ''
|
|
|
+ let params = {}
|
|
|
+ search.split('&').forEach(item => {
|
|
|
+ let kv = item.split('=')
|
|
|
+ if (kv[0]) params[kv[0]] = kv[1]
|
|
|
+ })
|
|
|
+ this.$set(this, 'courseId', params.courseId)
|
|
|
+ this.$set(this, 'examId', params.examId || 0)
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.loadCoursewareList()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ // 获取课程下课件列表
|
|
|
+ loadCoursewareList() {
|
|
|
+ examUserCourseLearningCoursewareList({ courseId: this.courseId, examId: this.examId }).then(response => {
|
|
|
+ this.$set(this, 'newData', response.data)
|
|
|
+ if (response.data.popupVerifyEnabled === 1) {
|
|
|
+ this.$set(this, 'verificationTime', this.accMul(response.data.popupIntervalMinutes, 60))
|
|
|
+ this.startVerifyCountdown()
|
|
|
+ }
|
|
|
+ this.initTimer()
|
|
|
+ this.renderCourseware(this.checkLearningIndex)
|
|
|
+ })
|
|
|
+ },
|
|
|
+ // 课件渲染
|
|
|
+ renderCourseware(index) {
|
|
|
+ const item = this.newData.coursewares[index]
|
|
|
+ this.$set(this, 'officeNullType', false)
|
|
|
+ this.$set(this, 'videoError', false)
|
|
|
+ this.$set(this, 'officeType', null)
|
|
|
+ this.$set(this, 'lookUrl', '')
|
|
|
+ const base = localStorage.getItem('fileBrowseEnvironment') || ''
|
|
|
+ if (item.materialType === 1) {
|
|
|
+ this.$set(this, 'officeType', 'video')
|
|
|
+ this.$set(this, 'lookUrl', base + '/' + item.attachmentPath)
|
|
|
+ this.$nextTick(() => { this.initVideoPlayer(this.lookUrl) })
|
|
|
+ } else if (item.materialType === 3) {
|
|
|
+ let ext = item.attachmentType
|
|
|
+ if (ext === '.docx') this.$set(this, 'officeType', 'docx')
|
|
|
+ else if (ext === '.xlsx') this.$set(this, 'officeType', 'excel')
|
|
|
+ else if (ext === '.pdf') this.$set(this, 'officeType', 'pdf')
|
|
|
+ this.$set(this, 'lookUrl', base + '/' + item.attachmentPath)
|
|
|
+ } else if (item.materialType === 4) {
|
|
|
+ this.$set(this, 'officeType', 'text')
|
|
|
+ this.$set(this, 'lookUrl', item.articleContent)
|
|
|
+ }
|
|
|
+ if (this.newData.coursewares[index].learnStatus === 0) {
|
|
|
+ this.$set(this.newData.coursewares[index], 'learnStatus', 1)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ renderedHandler() {},
|
|
|
+ errorHandler() {
|
|
|
+ this.$set(this, 'officeNullType', true)
|
|
|
+ },
|
|
|
+ // 切换课件
|
|
|
+ checkLearningButton(index) {
|
|
|
+ if (this.checkLearningIndex === index) return
|
|
|
+ const current = this.newData.coursewares[this.checkLearningIndex]
|
|
|
+ if (current.learnStatus === 2) {
|
|
|
+ this._switchTo(index)
|
|
|
+ } else {
|
|
|
+ this._pauseStudy()
|
|
|
+ Dialog.confirm({
|
|
|
+ title: '提示',
|
|
|
+ message: '确认切换课件?',
|
|
|
+ confirmButtonText: '确认',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ }).then(() => {
|
|
|
+ this._switchTo(index)
|
|
|
+ }).catch(() => {
|
|
|
+ this._resumeStudy()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ _switchTo(index) {
|
|
|
+ this.$set(this, 'checkLearningIndex', index)
|
|
|
+ this.clearTimer()
|
|
|
+ this.initTimer()
|
|
|
+ this.renderCourseware(index)
|
|
|
+ },
|
|
|
+ // 确认完成学习
|
|
|
+ finishStudying() {
|
|
|
+ Dialog.confirm({
|
|
|
+ title: '提示',
|
|
|
+ message: '确认完成课件学习?',
|
|
|
+ confirmButtonText: '确认',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ }).then(() => {
|
|
|
+ this.confirmCompletion()
|
|
|
+ }).catch(() => {})
|
|
|
+ },
|
|
|
+ confirmCompletion() {
|
|
|
+ const item = this.newData.coursewares[this.checkLearningIndex]
|
|
|
+ examUserCoursewareLearningConfirmCompletion({
|
|
|
+ coursewareId: item.coursewareId,
|
|
|
+ courseId: this.courseId,
|
|
|
+ examId: this.examId,
|
|
|
+ }).then(response => {
|
|
|
+ // 自动跳到下一个未学完的课件
|
|
|
+ const list = this.newData.coursewares
|
|
|
+ for (let i = 0; i < list.length; i++) {
|
|
|
+ if (list[i].learnStatus !== 2 && i !== this.checkLearningIndex) {
|
|
|
+ this.$set(this, 'checkLearningIndex', i)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.loadCoursewareList()
|
|
|
+ Toast(response.message || '学习完成')
|
|
|
+ })
|
|
|
+ },
|
|
|
+ // 心跳上报
|
|
|
+ reportProgress() {
|
|
|
+ const item = this.newData.coursewares[this.checkLearningIndex]
|
|
|
+ examUserCoursewareLearningReportProgress({
|
|
|
+ coursewareId: item.coursewareId,
|
|
|
+ courseId: this.courseId,
|
|
|
+ examId: this.examId,
|
|
|
+ duration: this.heartbeatValue,
|
|
|
+ }).then(() => {})
|
|
|
+ },
|
|
|
+ /*============ 视频播放器(三段式兼容) ============*/
|
|
|
+ initVideoPlayer(url) {
|
|
|
+ const video = this.$refs.videoRef
|
|
|
+ if (!video) return
|
|
|
+ if (this.hls) { this.hls.destroy(); this.hls = null }
|
|
|
+ this._stopStallDetector()
|
|
|
+ // 监听全屏变化
|
|
|
+ this._onFullscreenChange = () => {
|
|
|
+ const full = !!(document.fullscreenElement || document.webkitFullscreenElement)
|
|
|
+ this.$set(this, 'isFullscreen', full)
|
|
|
+ }
|
|
|
+ document.addEventListener('fullscreenchange', this._onFullscreenChange)
|
|
|
+ document.addEventListener('webkitfullscreenchange', this._onFullscreenChange)
|
|
|
+ if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
+ video.src = url
|
|
|
+ } else if (Hls.isSupported()) {
|
|
|
+ this.hls = new Hls({
|
|
|
+ maxBufferLength: 60,
|
|
|
+ maxMaxBufferLength: 120,
|
|
|
+ maxBufferSize: 60 * 1000 * 1000,
|
|
|
+ lowLatencyMode: false,
|
|
|
+ enableWorker: true,
|
|
|
+ })
|
|
|
+ this.hls.loadSource(url)
|
|
|
+ this.hls.attachMedia(video)
|
|
|
+ this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
+ video.play().catch(() => {})
|
|
|
+ this._startStallDetector(video)
|
|
|
+ })
|
|
|
+ this.hls.on(Hls.Events.ERROR, (_, data) => {
|
|
|
+ if (data.fatal) {
|
|
|
+ switch (data.type) {
|
|
|
+ case Hls.ErrorTypes.NETWORK_ERROR:
|
|
|
+ this.hls.startLoad(); break
|
|
|
+ case Hls.ErrorTypes.MEDIA_ERROR:
|
|
|
+ this.hls.recoverMediaError(); break
|
|
|
+ default:
|
|
|
+ this.hls.destroy(); this.hls = null
|
|
|
+ this.$set(this, 'videoError', true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ video.src = url
|
|
|
+ }
|
|
|
+ },
|
|
|
+ _startStallDetector(video) {
|
|
|
+ this._stopStallDetector()
|
|
|
+ this._lastTime = video.currentTime
|
|
|
+ this._stallTimer = setInterval(() => {
|
|
|
+ if (video.paused || video.ended) return
|
|
|
+ const bufferedEnd = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0
|
|
|
+ if (bufferedEnd - video.currentTime > 1 && video.currentTime === this._lastTime) {
|
|
|
+ video.currentTime += 0.1
|
|
|
+ }
|
|
|
+ this._lastTime = video.currentTime
|
|
|
+ }, 1000)
|
|
|
+ },
|
|
|
+ _stopStallDetector() {
|
|
|
+ if (this._stallTimer) { clearInterval(this._stallTimer); this._stallTimer = null }
|
|
|
+ },
|
|
|
+ /*============ 学习定时器 ============*/
|
|
|
+ initTimer() {
|
|
|
+ const item = this.newData.coursewares[this.checkLearningIndex]
|
|
|
+ if (!item || item.learnedDuration >= item.minLearningDuration) return
|
|
|
+ this.clearTimer()
|
|
|
+ let elapsedTime = item.learnedDuration || 0
|
|
|
+ const maxSeconds = item.minLearningDuration
|
|
|
+ let isRunning = false
|
|
|
+ let isFinished = false
|
|
|
+ let timerId = null
|
|
|
+ let startTimeStamp = 0
|
|
|
+ const handleVisibility = () => {
|
|
|
+ if (isFinished) return
|
|
|
+ document.hidden ? pause() : resume()
|
|
|
+ }
|
|
|
+ document.addEventListener('visibilitychange', handleVisibility)
|
|
|
+ const resume = () => {
|
|
|
+ if (isRunning || isFinished) return
|
|
|
+ isRunning = true
|
|
|
+ startTimeStamp = Date.now() - elapsedTime * 1000
|
|
|
+ timerId = setInterval(() => {
|
|
|
+ const rawElapsed = (Date.now() - startTimeStamp) / 1000
|
|
|
+ if (rawElapsed >= maxSeconds) {
|
|
|
+ elapsedTime = maxSeconds
|
|
|
+ this.$set(this.newData.coursewares[this.checkLearningIndex], 'learnedDuration', elapsedTime)
|
|
|
+ this.reportProgress()
|
|
|
+ pause(); isFinished = true
|
|
|
+ document.removeEventListener('visibilitychange', handleVisibility)
|
|
|
+ } else {
|
|
|
+ elapsedTime = Math.floor(rawElapsed)
|
|
|
+ this.$set(this.newData.coursewares[this.checkLearningIndex], 'learnedDuration', elapsedTime)
|
|
|
+ if (elapsedTime % this.heartbeatValue === 0 && elapsedTime > 0) this.reportProgress()
|
|
|
+ }
|
|
|
+ }, 1000)
|
|
|
+ }
|
|
|
+ const pause = () => {
|
|
|
+ if (!isRunning) return
|
|
|
+ isRunning = false; clearInterval(timerId); timerId = null
|
|
|
+ }
|
|
|
+ this.studyTimer = {
|
|
|
+ pause, resume,
|
|
|
+ get isRunning() { return isRunning },
|
|
|
+ destroy() { pause(); document.removeEventListener('visibilitychange', handleVisibility) }
|
|
|
+ }
|
|
|
+ resume()
|
|
|
+ },
|
|
|
+ clearTimer() {
|
|
|
+ if (this.studyTimer) { this.studyTimer.destroy(); this.studyTimer = null }
|
|
|
+ },
|
|
|
+ _pauseStudy() {
|
|
|
+ if (this.studyTimer && this.studyTimer.isRunning) this.studyTimer.pause()
|
|
|
+ this._pauseVerifyCountdown()
|
|
|
+ },
|
|
|
+ _resumeStudy() {
|
|
|
+ if (this.studyTimer && !this.studyTimer.isRunning) this.studyTimer.resume()
|
|
|
+ this._resumeVerifyCountdown()
|
|
|
+ },
|
|
|
+ /*============ 验证倒计时 ============*/
|
|
|
+ startVerifyCountdown() {
|
|
|
+ if (this.verificationTime === 0) return
|
|
|
+ this._clearVerifyCountdown()
|
|
|
+ let elapsed = 0
|
|
|
+ this._verifyTimer = setInterval(() => {
|
|
|
+ elapsed++
|
|
|
+ if (elapsed >= this.verificationTime) {
|
|
|
+ this._clearVerifyCountdown()
|
|
|
+ this._pauseStudy()
|
|
|
+ this.$set(this, 'sliderValue', 0)
|
|
|
+ this.$set(this, 'dialogVisible', true)
|
|
|
+ // 暂停视频
|
|
|
+ const video = this.$refs.videoRef
|
|
|
+ if (video) video.pause()
|
|
|
+ }
|
|
|
+ }, 1000)
|
|
|
+ },
|
|
|
+ _pauseVerifyCountdown() {
|
|
|
+ if (this._verifyTimer) { clearInterval(this._verifyTimer); this._verifyTimer = null }
|
|
|
+ },
|
|
|
+ _resumeVerifyCountdown() {
|
|
|
+ this.startVerifyCountdown()
|
|
|
+ },
|
|
|
+ _clearVerifyCountdown() {
|
|
|
+ if (this._verifyTimer) { clearInterval(this._verifyTimer); this._verifyTimer = null }
|
|
|
+ },
|
|
|
+ // 滑块验证
|
|
|
+ onSliderChange(val) {
|
|
|
+ if (val === 100) {
|
|
|
+ this.$set(this, 'dialogVisible', false)
|
|
|
+ this._resumeStudy()
|
|
|
+ this.startVerifyCountdown()
|
|
|
+ // 继续视频
|
|
|
+ const video = this.$refs.videoRef
|
|
|
+ if (video && !video.ended) video.play().catch(() => {})
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /*============ 工具方法 ============*/
|
|
|
+ formatTime(totalSeconds) {
|
|
|
+ if (typeof totalSeconds !== 'number' || totalSeconds <= 0) return '0秒'
|
|
|
+ const minutes = Math.floor(totalSeconds / 60)
|
|
|
+ const seconds = totalSeconds % 60
|
|
|
+ if (minutes > 0 && seconds > 0) return `${minutes}分${seconds}秒`
|
|
|
+ if (minutes > 0) return `${minutes}分`
|
|
|
+ return `${seconds}秒`
|
|
|
+ },
|
|
|
+ secondsToMinutes(totalSeconds) {
|
|
|
+ if (typeof totalSeconds !== 'number' || totalSeconds <= 0) return 1
|
|
|
+ return Math.ceil(totalSeconds / 60)
|
|
|
+ },
|
|
|
+ accMul(arg1, arg2) {
|
|
|
+ let m = 0
|
|
|
+ const s1 = arg1.toString(), s2 = arg2.toString()
|
|
|
+ try { m += s1.split('.')[1].length } catch (e) {}
|
|
|
+ try { m += s2.split('.')[1].length } catch (e) {}
|
|
|
+ return Number(s1.replace('.', '')) * Number(s2.replace('.', '')) / Math.pow(10, m)
|
|
|
+ },
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ this.clearTimer()
|
|
|
+ this._stopStallDetector()
|
|
|
+ this._clearVerifyCountdown()
|
|
|
+ if (this.hls) { this.hls.destroy(); this.hls = null }
|
|
|
+ if (this._onFullscreenChange) {
|
|
|
+ document.removeEventListener('fullscreenchange', this._onFullscreenChange)
|
|
|
+ document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ }
|
|
|
+</script>
|
|
|
+<style scoped lang="scss">
|
|
|
+ .learningCourse {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100vh;
|
|
|
+ background: #f5f5f5;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .nav-bar {
|
|
|
+ height: 44px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #fff;
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+ flex-shrink: 0;
|
|
|
+ .nav-title {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #333;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ padding: 0 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .courseware-box {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ background: #000;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ // 全屏时视频旋转铺满(针对不支持原生横屏全屏的 WebView)
|
|
|
+ &.is-fullscreen .video-wrap {
|
|
|
+ position: fixed;
|
|
|
+ top: 0; left: 0;
|
|
|
+ width: 100vh;
|
|
|
+ height: 100vw;
|
|
|
+ transform: rotate(90deg) translateY(-100%);
|
|
|
+ transform-origin: top left;
|
|
|
+ z-index: 9999;
|
|
|
+ background: #000;
|
|
|
+ .video-player {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .video-wrap {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ position: relative;
|
|
|
+ background: #000;
|
|
|
+ min-height: 0;
|
|
|
+ .video-player {
|
|
|
+ width: 100%;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ object-fit: contain;
|
|
|
+ background: #000;
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+ .video-error-text {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ color: #ccc;
|
|
|
+ font-size: 14px;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .rich-text-box {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 16px;
|
|
|
+ background: #fff;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.7;
|
|
|
+ word-break: break-word;
|
|
|
+ ::v-deep img {
|
|
|
+ max-width: 100% !important;
|
|
|
+ height: auto !important;
|
|
|
+ display: block;
|
|
|
+ margin: 8px auto;
|
|
|
+ }
|
|
|
+ ::v-deep table { width: 100%; border-collapse: collapse; }
|
|
|
+ ::v-deep p { margin-bottom: 8px; }
|
|
|
+ }
|
|
|
+
|
|
|
+ .office-box {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+ background: #fff;
|
|
|
+ ::v-deep section.docx {
|
|
|
+ width: 100% !important;
|
|
|
+ padding: 16pt !important;
|
|
|
+ box-sizing: border-box !important;
|
|
|
+ min-height: unset !important;
|
|
|
+ margin-bottom: 20px !important;
|
|
|
+ }
|
|
|
+ ::v-deep section.docx img {
|
|
|
+ max-width: 100% !important;
|
|
|
+ width: auto !important;
|
|
|
+ height: auto !important;
|
|
|
+ }
|
|
|
+ ::v-deep section.docx div[style*="display: inline-block"] {
|
|
|
+ max-width: 100% !important;
|
|
|
+ width: auto !important;
|
|
|
+ height: auto !important;
|
|
|
+ }
|
|
|
+ ::v-deep section.docx table {
|
|
|
+ max-width: 100% !important;
|
|
|
+ table-layout: fixed;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+ ::v-deep section.docx td,
|
|
|
+ ::v-deep section.docx th { word-break: break-all; }
|
|
|
+ .load-fail-text {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ line-height: 200px;
|
|
|
+ color: #ccc;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .empty-box {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #fff;
|
|
|
+ color: #999;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .bottom-panel {
|
|
|
+ flex-shrink: 0;
|
|
|
+ height: 40vh;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+ border-top: 1px solid #eee;
|
|
|
+
|
|
|
+ .info-meta {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 10px 16px;
|
|
|
+ font-size: 22px;
|
|
|
+ color: #999;
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+ .dot { margin: 0 6px; }
|
|
|
+ }
|
|
|
+
|
|
|
+ .courseware-list {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 10px 16px 20px;
|
|
|
+
|
|
|
+ .courseware-item {
|
|
|
+ margin-bottom: 16px;
|
|
|
+
|
|
|
+ .item-card {
|
|
|
+ position: relative;
|
|
|
+ border: 1px solid #ddd;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 12px 70px 12px 12px;
|
|
|
+ background: #fff;
|
|
|
+ .item-name {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #333;
|
|
|
+ line-height: 1.4;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+ .item-time {
|
|
|
+ font-size: 22px;
|
|
|
+ color: #666;
|
|
|
+ line-height: 1.4;
|
|
|
+ }
|
|
|
+ .item-status {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ right: 12px;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ font-size: 22px;
|
|
|
+ padding: 3px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #999;
|
|
|
+ color: #fff;
|
|
|
+ white-space: nowrap;
|
|
|
+ &.status-done { background: #0183fa; }
|
|
|
+ &.status-ing { background: #fff; border: 1px solid #0183fa; color: #0183fa; }
|
|
|
+ }
|
|
|
+ &.item-card-active {
|
|
|
+ background: rgba(1, 131, 250, 0.06);
|
|
|
+ border-color: #0183fa;
|
|
|
+ .item-name, .item-time { color: #0183fa; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .confirm-btn {
|
|
|
+ margin-top: 10px;
|
|
|
+ background: #13ce66;
|
|
|
+ color: #fff;
|
|
|
+ text-align: center;
|
|
|
+ height: 60px;
|
|
|
+ line-height: 60px;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 26px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 验证弹窗内容样式(van-popup 挂载到 body,需用非 scoped 或 ::v-deep)
|
|
|
+ }
|
|
|
+ .verify-title {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ text-align: center;
|
|
|
+ margin-bottom: 30px;
|
|
|
+ color: #333;
|
|
|
+ }
|
|
|
+ .verify-slider {
|
|
|
+ margin: 0 10px 20px;
|
|
|
+ }
|
|
|
+ .verify-tip {
|
|
|
+ font-size: 24px;
|
|
|
+ text-align: center;
|
|
|
+ color: #0183fa;
|
|
|
+ }
|
|
|
+</style>
|