dedsudiyu 1 week ago
parent
commit
7ad290e506

+ 8 - 0
src/api/index.js

@@ -126,6 +126,14 @@ export function systemFileUpload(data) {
   })
   })
 }
 }
 
 
+//获取课程下的课件列表
+export function examUserCourseLearningCoursewareList(query) {
+  return request({
+    url: '/exam/user/course/learning/coursewareList',
+    method: 'get',
+    params: query
+  })
+}
 //根据课件ID查询课件信息
 //根据课件ID查询课件信息
 export function examUserCoursewareLearningDetail(query) {
 export function examUserCoursewareLearningDetail(query) {
   return request({
   return request({

+ 6 - 1
src/views/home.vue

@@ -10,6 +10,7 @@
       <chemicalsInstructionsVideo v-if="pageType == 'chemicalsInstructionsVideo'"></chemicalsInstructionsVideo>
       <chemicalsInstructionsVideo v-if="pageType == 'chemicalsInstructionsVideo'"></chemicalsInstructionsVideo>
       <alarmPage v-if="pageType == 'alarm'"></alarmPage>
       <alarmPage v-if="pageType == 'alarm'"></alarmPage>
       <!--课程学习-->
       <!--课程学习-->
+      <learningCourse v-if="pageType == 'learningCourse'"></learningCourse>
       <!--课件学习-->
       <!--课件学习-->
       <learningMaterials v-if="pageType == 'learningMaterials'"></learningMaterials>
       <learningMaterials v-if="pageType == 'learningMaterials'"></learningMaterials>
     </div>
     </div>
@@ -24,6 +25,7 @@
     import chemicalsInstructionsVideo from '@/views/chemicalsInstructionsVideo/index.vue';
     import chemicalsInstructionsVideo from '@/views/chemicalsInstructionsVideo/index.vue';
     import alarmPage from '@/views/alarmPage/index.vue';
     import alarmPage from '@/views/alarmPage/index.vue';
     import learningMaterials from '@/views/learning/learningMaterials.vue';
     import learningMaterials from '@/views/learning/learningMaterials.vue';
+    import learningCourse from '@/views/learning/learningCourse.vue';
     import {
     import {
       getConfigByType,
       getConfigByType,
     } from '@/api/index.js'
     } from '@/api/index.js'
@@ -40,6 +42,7 @@
           chemicalsInstructionsVideo,
           chemicalsInstructionsVideo,
           alarmPage,
           alarmPage,
           learningMaterials,
           learningMaterials,
+          learningCourse,
         },
         },
         data() {
         data() {
             return {
             return {
@@ -59,7 +62,9 @@
               this.getConfigByType(),
               this.getConfigByType(),
             ]).then((result) => {
             ]).then((result) => {
               //页面展示
               //页面展示
-              if(text.indexOf('learningMaterials') != -1){
+              if(text.indexOf('learningCourse') != -1){
+                this.$set(this,'pageType','learningCourse');
+              }else if(text.indexOf('learningMaterials') != -1){
                 this.$set(this,'pageType','learningMaterials');
                 this.$set(this,'pageType','learningMaterials');
               }else if (text.indexOf('videoBroadcastHk') != -1){
               }else if (text.indexOf('videoBroadcastHk') != -1){
                 this.$set(this,'pageType','videoBroadcastHk');
                 this.$set(this,'pageType','videoBroadcastHk');

+ 739 - 0
src/views/learning/learningCourse.vue

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

+ 29 - 1
src/views/learning/learningMaterials.vue

@@ -7,7 +7,7 @@
     </div>
     </div>
 
 
     <!-- 课件展示区 -->
     <!-- 课件展示区 -->
-    <div class="courseware-box">
+    <div class="courseware-box" :class="{ 'is-fullscreen': isFullscreen }">
       <!-- 视频 -->
       <!-- 视频 -->
       <div v-if="officeType === 'video'" class="video-wrap">
       <div v-if="officeType === 'video'" class="video-wrap">
         <video
         <video
@@ -150,6 +150,7 @@
         videoError: false,
         videoError: false,
         _stallTimer: null,
         _stallTimer: null,
         _lastTime: 0,
         _lastTime: 0,
+        isFullscreen: false,
       }
       }
     },
     },
     mounted() {
     mounted() {
@@ -225,6 +226,13 @@
           this.hls.destroy()
           this.hls.destroy()
           this.hls = null
           this.hls = null
         }
         }
+        // 监听全屏变化
+        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')) {
         if (video.canPlayType('application/vnd.apple.mpegurl')) {
           // iOS / Safari 原生支持 HLS
           // iOS / Safari 原生支持 HLS
           video.src = url
           video.src = url
@@ -409,6 +417,10 @@
         this.hls.destroy()
         this.hls.destroy()
         this.hls = null
         this.hls = null
       }
       }
+      if (this._onFullscreenChange) {
+        document.removeEventListener('fullscreenchange', this._onFullscreenChange)
+        document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange)
+      }
     },
     },
   }
   }
 </script>
 </script>
@@ -461,6 +473,22 @@
       overflow: hidden;
       overflow: hidden;
       background: #000;
       background: #000;
 
 
+      // 全屏时视频旋转铺满
+      &.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 {
       .video-wrap {
         flex: 1;
         flex: 1;
         display: flex;
         display: flex;