learningCourse.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. <!-- 学习课程 - 小程序webview版 -->
  2. <template>
  3. <div class="learningCourse">
  4. <!-- 顶部导航栏 -->
  5. <div class="nav-bar">
  6. <p class="nav-title">{{ newData.courseName }}</p>
  7. </div>
  8. <!-- 课件展示区 -->
  9. <div class="courseware-box" :class="{ 'is-fullscreen': isFullscreen }">
  10. <!-- 视频 -->
  11. <div v-if="officeType === 'video'" class="video-wrap">
  12. <video
  13. ref="videoRef"
  14. class="video-player"
  15. controls
  16. playsinline
  17. webkit-playsinline
  18. x5-playsinline
  19. x5-video-player-type="h5"
  20. x5-video-player-fullscreen="true"
  21. ></video>
  22. <div v-if="videoError" class="video-error-text">视频加载失败,请检查网络或联系管理员</div>
  23. </div>
  24. <!-- 富文本/文章 -->
  25. <div v-else-if="officeType === 'text'" class="rich-text-box" v-html="lookUrl"></div>
  26. <!-- 文档(docx/excel/pdf) -->
  27. <div v-else-if="officeType === 'docx' || officeType === 'excel' || officeType === 'pdf'" class="office-box">
  28. <vue-office-docx
  29. v-if="officeType === 'docx' && !officeNullType"
  30. :src="lookUrl"
  31. @rendered="renderedHandler"
  32. @error="errorHandler"
  33. />
  34. <vue-office-excel
  35. v-if="officeType === 'excel' && !officeNullType"
  36. :src="lookUrl"
  37. @rendered="renderedHandler"
  38. @error="errorHandler"
  39. />
  40. <vue-office-pdf
  41. v-if="officeType === 'pdf' && !officeNullType"
  42. :src="lookUrl"
  43. @rendered="renderedHandler"
  44. @error="errorHandler"
  45. />
  46. <p v-if="officeNullType" class="load-fail-text">加载失败...</p>
  47. </div>
  48. <!-- 空态 -->
  49. <div v-else class="empty-box">
  50. <p>课件加载中...</p>
  51. </div>
  52. </div>
  53. <!-- 底部课件列表(固定高度) -->
  54. <div class="bottom-panel">
  55. <!-- 课程meta -->
  56. <div class="info-meta">
  57. <span>{{ secondsToMinutes(newData.totalDuration) }}分钟</span>
  58. <span class="dot">·</span>
  59. <span>{{ newData.totalCreditHours }}学时</span>
  60. <span class="dot">·</span>
  61. <span>{{ newData.totalPoints }}积分</span>
  62. <span class="dot">·</span>
  63. <span>已学 {{ newData.learnedCoursewareCount }} / {{ newData.totalCoursewareCount }} 课件</span>
  64. </div>
  65. <!-- 课件列表 -->
  66. <div class="courseware-list">
  67. <div
  68. class="courseware-item"
  69. v-for="(item, index) in newData.coursewares"
  70. :key="index"
  71. >
  72. <div
  73. class="item-card"
  74. :class="checkLearningIndex === index ? 'item-card-active' : ''"
  75. @click="checkLearningButton(index)"
  76. >
  77. <p class="item-name">{{ item.coursewareName }}</p>
  78. <p class="item-time">已学:{{ formatTime(item.learnedDuration) }} / {{ formatTime(item.minLearningDuration) }}</p>
  79. <span :class="['item-status',
  80. item.learnStatus === 2 ? 'status-done' :
  81. item.learnStatus === 1 ? 'status-ing' : 'status-none'
  82. ]">
  83. {{ item.learnStatus === 2 ? '已学完' : (item.learnStatus === 1 ? '学习中' : '未学完') }}
  84. </span>
  85. </div>
  86. <div
  87. class="confirm-btn"
  88. v-if="item.learnStatus !== 2 && item.learnedDuration >= item.minLearningDuration && item.minLearningDuration > 0 && checkLearningIndex === index"
  89. @click="finishStudying(index)"
  90. >
  91. 若已完成学习,请点此确认
  92. </div>
  93. </div>
  94. </div>
  95. </div>
  96. <!-- 安全验证弹窗(挂载到 body 脱离父级 stacking context) -->
  97. <van-popup
  98. v-model="dialogVisible"
  99. :close-on-click-overlay="false"
  100. position="center"
  101. :style="{ width: '80%', borderRadius: '12px', padding: '30px 24px', boxSizing: 'border-box' }"
  102. :get-container="() => $el.ownerDocument.body"
  103. >
  104. <p class="verify-title">请完成安全验证</p>
  105. <van-slider
  106. v-model="sliderValue"
  107. :min="0"
  108. :max="100"
  109. active-color="#0183fa"
  110. @change="onSliderChange"
  111. class="verify-slider"
  112. />
  113. <p class="verify-tip">{{ sliderValue === 100 ? '验证通过' : '请拖动滑块到最右边' }}</p>
  114. </van-popup>
  115. </div>
  116. </template>
  117. <script>
  118. import Hls from 'hls.js'
  119. import VueOfficeDocx from '@vue-office/docx'
  120. import '@vue-office/docx/lib/index.css'
  121. import VueOfficeExcel from '@vue-office/excel'
  122. import '@vue-office/excel/lib/index.css'
  123. import VueOfficePdf from '@vue-office/pdf'
  124. import { Toast, Dialog } from 'vant'
  125. import {
  126. examUserCourseLearningCoursewareList,
  127. examUserCoursewareLearningReportProgress,
  128. examUserCoursewareLearningConfirmCompletion,
  129. } from '@/api/index.js'
  130. export default {
  131. name: 'learningCourse',
  132. components: {
  133. VueOfficeDocx,
  134. VueOfficeExcel,
  135. VueOfficePdf,
  136. },
  137. data() {
  138. return {
  139. courseId: null,
  140. examId: null,
  141. newData: {
  142. courseName: '',
  143. totalDuration: 0,
  144. totalCreditHours: 0,
  145. totalPoints: 0,
  146. learnedCoursewareCount: 0,
  147. totalCoursewareCount: 0,
  148. progress: 0,
  149. coursewares: [],
  150. popupVerifyEnabled: 0,
  151. popupIntervalMinutes: 0,
  152. },
  153. // 当前选中课件下标
  154. checkLearningIndex: 0,
  155. // 课件展示
  156. officeType: null,
  157. lookUrl: '',
  158. officeNullType: false,
  159. videoError: false,
  160. // hls 实例
  161. hls: null,
  162. _stallTimer: null,
  163. _lastTime: 0,
  164. isFullscreen: false,
  165. // 学习定时器
  166. studyTimer: null,
  167. heartbeatValue: 30,
  168. // 验证弹窗
  169. dialogVisible: false,
  170. sliderValue: 0,
  171. verificationTime: 0,
  172. _verifyTimer: null,
  173. }
  174. },
  175. mounted() {
  176. document.title = ''
  177. this.initialization()
  178. },
  179. methods: {
  180. // 初始化
  181. initialization() {
  182. let text = decodeURIComponent(window.location.href)
  183. let search = text.split('?')[1] || ''
  184. let params = {}
  185. search.split('&').forEach(item => {
  186. let kv = item.split('=')
  187. if (kv[0]) params[kv[0]] = kv[1]
  188. })
  189. this.$set(this, 'courseId', params.courseId)
  190. this.$set(this, 'examId', params.examId || 0)
  191. this.$nextTick(() => {
  192. this.loadCoursewareList()
  193. })
  194. },
  195. // 获取课程下课件列表
  196. loadCoursewareList() {
  197. examUserCourseLearningCoursewareList({ courseId: this.courseId, examId: this.examId }).then(response => {
  198. this.$set(this, 'newData', response.data)
  199. if (response.data.popupVerifyEnabled === 1) {
  200. this.$set(this, 'verificationTime', this.accMul(response.data.popupIntervalMinutes, 60))
  201. this.startVerifyCountdown()
  202. }
  203. this.initTimer()
  204. this.renderCourseware(this.checkLearningIndex)
  205. })
  206. },
  207. // 课件渲染
  208. renderCourseware(index) {
  209. const item = this.newData.coursewares[index]
  210. this.$set(this, 'officeNullType', false)
  211. this.$set(this, 'videoError', false)
  212. this.$set(this, 'officeType', null)
  213. this.$set(this, 'lookUrl', '')
  214. const base = localStorage.getItem('fileBrowseEnvironment') || ''
  215. if (item.materialType === 1) {
  216. this.$set(this, 'officeType', 'video')
  217. this.$set(this, 'lookUrl', base + '/' + item.attachmentPath)
  218. this.$nextTick(() => { this.initVideoPlayer(this.lookUrl) })
  219. } else if (item.materialType === 3) {
  220. let ext = item.attachmentType
  221. if (ext === '.docx') this.$set(this, 'officeType', 'docx')
  222. else if (ext === '.xlsx') this.$set(this, 'officeType', 'excel')
  223. else if (ext === '.pdf') this.$set(this, 'officeType', 'pdf')
  224. this.$set(this, 'lookUrl', base + '/' + item.attachmentPath)
  225. } else if (item.materialType === 4) {
  226. this.$set(this, 'officeType', 'text')
  227. this.$set(this, 'lookUrl', item.articleContent)
  228. }
  229. if (this.newData.coursewares[index].learnStatus === 0) {
  230. this.$set(this.newData.coursewares[index], 'learnStatus', 1)
  231. }
  232. },
  233. renderedHandler() {},
  234. errorHandler() {
  235. this.$set(this, 'officeNullType', true)
  236. },
  237. // 切换课件
  238. checkLearningButton(index) {
  239. if (this.checkLearningIndex === index) return
  240. const current = this.newData.coursewares[this.checkLearningIndex]
  241. if (current.learnStatus === 2) {
  242. this._switchTo(index)
  243. } else {
  244. this._pauseStudy()
  245. Dialog.confirm({
  246. title: '提示',
  247. message: '确认切换课件?',
  248. confirmButtonText: '确认',
  249. cancelButtonText: '取消',
  250. }).then(() => {
  251. this._switchTo(index)
  252. }).catch(() => {
  253. this._resumeStudy()
  254. })
  255. }
  256. },
  257. _switchTo(index) {
  258. this.$set(this, 'checkLearningIndex', index)
  259. this.clearTimer()
  260. this.initTimer()
  261. this.renderCourseware(index)
  262. },
  263. // 确认完成学习
  264. finishStudying() {
  265. Dialog.confirm({
  266. title: '提示',
  267. message: '确认完成课件学习?',
  268. confirmButtonText: '确认',
  269. cancelButtonText: '取消',
  270. }).then(() => {
  271. this.confirmCompletion()
  272. }).catch(() => {})
  273. },
  274. confirmCompletion() {
  275. const item = this.newData.coursewares[this.checkLearningIndex]
  276. examUserCoursewareLearningConfirmCompletion({
  277. coursewareId: item.coursewareId,
  278. courseId: this.courseId,
  279. examId: this.examId,
  280. }).then(response => {
  281. // 自动跳到下一个未学完的课件
  282. const list = this.newData.coursewares
  283. for (let i = 0; i < list.length; i++) {
  284. if (list[i].learnStatus !== 2 && i !== this.checkLearningIndex) {
  285. this.$set(this, 'checkLearningIndex', i)
  286. break
  287. }
  288. }
  289. this.loadCoursewareList()
  290. Toast(response.message || '学习完成')
  291. })
  292. },
  293. // 心跳上报
  294. reportProgress() {
  295. const item = this.newData.coursewares[this.checkLearningIndex]
  296. examUserCoursewareLearningReportProgress({
  297. coursewareId: item.coursewareId,
  298. courseId: this.courseId,
  299. examId: this.examId,
  300. duration: this.heartbeatValue,
  301. }).then(() => {})
  302. },
  303. /*============ 视频播放器(三段式兼容) ============*/
  304. initVideoPlayer(url) {
  305. const video = this.$refs.videoRef
  306. if (!video) return
  307. if (this.hls) { this.hls.destroy(); this.hls = null }
  308. this._stopStallDetector()
  309. // 监听全屏变化
  310. this._onFullscreenChange = () => {
  311. const full = !!(document.fullscreenElement || document.webkitFullscreenElement)
  312. this.$set(this, 'isFullscreen', full)
  313. }
  314. document.addEventListener('fullscreenchange', this._onFullscreenChange)
  315. document.addEventListener('webkitfullscreenchange', this._onFullscreenChange)
  316. if (video.canPlayType('application/vnd.apple.mpegurl')) {
  317. video.src = url
  318. } else if (Hls.isSupported()) {
  319. this.hls = new Hls({
  320. maxBufferLength: 60,
  321. maxMaxBufferLength: 120,
  322. maxBufferSize: 60 * 1000 * 1000,
  323. lowLatencyMode: false,
  324. enableWorker: true,
  325. })
  326. this.hls.loadSource(url)
  327. this.hls.attachMedia(video)
  328. this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
  329. video.play().catch(() => {})
  330. this._startStallDetector(video)
  331. })
  332. this.hls.on(Hls.Events.ERROR, (_, data) => {
  333. if (data.fatal) {
  334. switch (data.type) {
  335. case Hls.ErrorTypes.NETWORK_ERROR:
  336. this.hls.startLoad(); break
  337. case Hls.ErrorTypes.MEDIA_ERROR:
  338. this.hls.recoverMediaError(); break
  339. default:
  340. this.hls.destroy(); this.hls = null
  341. this.$set(this, 'videoError', true)
  342. }
  343. }
  344. })
  345. } else {
  346. video.src = url
  347. }
  348. },
  349. _startStallDetector(video) {
  350. this._stopStallDetector()
  351. this._lastTime = video.currentTime
  352. this._stallTimer = setInterval(() => {
  353. if (video.paused || video.ended) return
  354. const bufferedEnd = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0
  355. if (bufferedEnd - video.currentTime > 1 && video.currentTime === this._lastTime) {
  356. video.currentTime += 0.1
  357. }
  358. this._lastTime = video.currentTime
  359. }, 1000)
  360. },
  361. _stopStallDetector() {
  362. if (this._stallTimer) { clearInterval(this._stallTimer); this._stallTimer = null }
  363. },
  364. /*============ 学习定时器 ============*/
  365. initTimer() {
  366. const item = this.newData.coursewares[this.checkLearningIndex]
  367. if (!item || item.learnedDuration >= item.minLearningDuration) return
  368. this.clearTimer()
  369. let elapsedTime = item.learnedDuration || 0
  370. const maxSeconds = item.minLearningDuration
  371. let isRunning = false
  372. let isFinished = false
  373. let timerId = null
  374. let startTimeStamp = 0
  375. const handleVisibility = () => {
  376. if (isFinished) return
  377. document.hidden ? pause() : resume()
  378. }
  379. document.addEventListener('visibilitychange', handleVisibility)
  380. const resume = () => {
  381. if (isRunning || isFinished) return
  382. isRunning = true
  383. startTimeStamp = Date.now() - elapsedTime * 1000
  384. timerId = setInterval(() => {
  385. const rawElapsed = (Date.now() - startTimeStamp) / 1000
  386. if (rawElapsed >= maxSeconds) {
  387. elapsedTime = maxSeconds
  388. this.$set(this.newData.coursewares[this.checkLearningIndex], 'learnedDuration', elapsedTime)
  389. this.reportProgress()
  390. pause(); isFinished = true
  391. document.removeEventListener('visibilitychange', handleVisibility)
  392. } else {
  393. elapsedTime = Math.floor(rawElapsed)
  394. this.$set(this.newData.coursewares[this.checkLearningIndex], 'learnedDuration', elapsedTime)
  395. if (elapsedTime % this.heartbeatValue === 0 && elapsedTime > 0) this.reportProgress()
  396. }
  397. }, 1000)
  398. }
  399. const pause = () => {
  400. if (!isRunning) return
  401. isRunning = false; clearInterval(timerId); timerId = null
  402. }
  403. this.studyTimer = {
  404. pause, resume,
  405. get isRunning() { return isRunning },
  406. destroy() { pause(); document.removeEventListener('visibilitychange', handleVisibility) }
  407. }
  408. resume()
  409. },
  410. clearTimer() {
  411. if (this.studyTimer) { this.studyTimer.destroy(); this.studyTimer = null }
  412. },
  413. _pauseStudy() {
  414. if (this.studyTimer && this.studyTimer.isRunning) this.studyTimer.pause()
  415. this._pauseVerifyCountdown()
  416. },
  417. _resumeStudy() {
  418. if (this.studyTimer && !this.studyTimer.isRunning) this.studyTimer.resume()
  419. this._resumeVerifyCountdown()
  420. },
  421. /*============ 验证倒计时 ============*/
  422. startVerifyCountdown() {
  423. if (this.verificationTime === 0) return
  424. this._clearVerifyCountdown()
  425. let elapsed = 0
  426. this._verifyTimer = setInterval(() => {
  427. elapsed++
  428. if (elapsed >= this.verificationTime) {
  429. this._clearVerifyCountdown()
  430. this._pauseStudy()
  431. this.$set(this, 'sliderValue', 0)
  432. // 若当前全屏,先退出全屏再显示弹窗
  433. this._exitFullscreenThen(() => {
  434. this.$set(this, 'dialogVisible', true)
  435. const video = this.$refs.videoRef
  436. if (video) video.pause()
  437. })
  438. }
  439. }, 1000)
  440. },
  441. _exitFullscreenThen(callback) {
  442. const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement)
  443. if (isFs) {
  444. const exit = document.exitFullscreen || document.webkitExitFullscreen
  445. if (exit) {
  446. exit.call(document).then(() => { callback() }).catch(() => { callback() })
  447. } else {
  448. this.$set(this, 'isFullscreen', false)
  449. callback()
  450. }
  451. } else if (this.isFullscreen) {
  452. // CSS 旋转模拟全屏,直接退出
  453. this.$set(this, 'isFullscreen', false)
  454. callback()
  455. } else {
  456. callback()
  457. }
  458. },
  459. _pauseVerifyCountdown() {
  460. if (this._verifyTimer) { clearInterval(this._verifyTimer); this._verifyTimer = null }
  461. },
  462. _resumeVerifyCountdown() {
  463. this.startVerifyCountdown()
  464. },
  465. _clearVerifyCountdown() {
  466. if (this._verifyTimer) { clearInterval(this._verifyTimer); this._verifyTimer = null }
  467. },
  468. // 滑块验证
  469. onSliderChange(val) {
  470. if (val === 100) {
  471. this.$set(this, 'dialogVisible', false)
  472. this._resumeStudy()
  473. this.startVerifyCountdown()
  474. // 继续视频
  475. const video = this.$refs.videoRef
  476. if (video && !video.ended) video.play().catch(() => {})
  477. }
  478. },
  479. /*============ 工具方法 ============*/
  480. formatTime(totalSeconds) {
  481. if (typeof totalSeconds !== 'number' || totalSeconds <= 0) return '0秒'
  482. const minutes = Math.floor(totalSeconds / 60)
  483. const seconds = totalSeconds % 60
  484. if (minutes > 0 && seconds > 0) return `${minutes}分${seconds}秒`
  485. if (minutes > 0) return `${minutes}分`
  486. return `${seconds}秒`
  487. },
  488. secondsToMinutes(totalSeconds) {
  489. if (typeof totalSeconds !== 'number' || totalSeconds <= 0) return 1
  490. return Math.ceil(totalSeconds / 60)
  491. },
  492. accMul(arg1, arg2) {
  493. let m = 0
  494. const s1 = arg1.toString(), s2 = arg2.toString()
  495. try { m += s1.split('.')[1].length } catch (e) {}
  496. try { m += s2.split('.')[1].length } catch (e) {}
  497. return Number(s1.replace('.', '')) * Number(s2.replace('.', '')) / Math.pow(10, m)
  498. },
  499. },
  500. beforeDestroy() {
  501. this.clearTimer()
  502. this._stopStallDetector()
  503. this._clearVerifyCountdown()
  504. if (this.hls) { this.hls.destroy(); this.hls = null }
  505. if (this._onFullscreenChange) {
  506. document.removeEventListener('fullscreenchange', this._onFullscreenChange)
  507. document.removeEventListener('webkitfullscreenchange', this._onFullscreenChange)
  508. }
  509. },
  510. }
  511. </script>
  512. <style scoped lang="scss">
  513. .learningCourse {
  514. display: flex;
  515. flex-direction: column;
  516. height: 100vh;
  517. background: #f5f5f5;
  518. overflow: hidden;
  519. .nav-bar {
  520. height: 44px;
  521. display: flex;
  522. align-items: center;
  523. justify-content: center;
  524. background: #fff;
  525. border-bottom: 1px solid #eee;
  526. flex-shrink: 0;
  527. .nav-title {
  528. font-size: 24px;
  529. font-weight: 700;
  530. color: #333;
  531. overflow: hidden;
  532. text-overflow: ellipsis;
  533. white-space: nowrap;
  534. padding: 0 16px;
  535. }
  536. }
  537. .courseware-box {
  538. flex: 1;
  539. min-height: 0;
  540. background: #000;
  541. display: flex;
  542. flex-direction: column;
  543. // 全屏时视频旋转铺满(针对不支持原生横屏全屏的 WebView)
  544. &.is-fullscreen .video-wrap {
  545. position: fixed;
  546. top: 0; left: 0;
  547. width: 100vh;
  548. height: 100vw;
  549. transform: rotate(90deg) translateY(-100%);
  550. transform-origin: top left;
  551. z-index: 9999;
  552. background: #000;
  553. .video-player {
  554. width: 100%;
  555. height: 100%;
  556. }
  557. }
  558. .video-wrap {
  559. flex: 1;
  560. display: flex;
  561. flex-direction: column;
  562. position: relative;
  563. background: #000;
  564. min-height: 0;
  565. .video-player {
  566. width: 100%;
  567. flex: 1;
  568. min-height: 0;
  569. object-fit: contain;
  570. background: #000;
  571. display: block;
  572. }
  573. .video-error-text {
  574. position: absolute;
  575. top: 50%;
  576. left: 50%;
  577. transform: translate(-50%, -50%);
  578. color: #ccc;
  579. font-size: 14px;
  580. text-align: center;
  581. }
  582. }
  583. .rich-text-box {
  584. flex: 1;
  585. overflow-y: auto;
  586. padding: 16px;
  587. background: #fff;
  588. font-size: 14px;
  589. line-height: 1.7;
  590. word-break: break-word;
  591. ::v-deep img {
  592. max-width: 100% !important;
  593. height: auto !important;
  594. display: block;
  595. margin: 8px auto;
  596. }
  597. ::v-deep table { width: 100%; border-collapse: collapse; }
  598. ::v-deep p { margin-bottom: 8px; }
  599. }
  600. .office-box {
  601. flex: 1;
  602. display: flex;
  603. flex-direction: column;
  604. overflow-y: auto;
  605. overflow-x: hidden;
  606. background: #fff;
  607. ::v-deep section.docx {
  608. width: 100% !important;
  609. padding: 16pt !important;
  610. box-sizing: border-box !important;
  611. min-height: unset !important;
  612. margin-bottom: 20px !important;
  613. }
  614. ::v-deep section.docx img {
  615. max-width: 100% !important;
  616. width: auto !important;
  617. height: auto !important;
  618. }
  619. ::v-deep section.docx div[style*="display: inline-block"] {
  620. max-width: 100% !important;
  621. width: auto !important;
  622. height: auto !important;
  623. }
  624. ::v-deep section.docx table {
  625. max-width: 100% !important;
  626. table-layout: fixed;
  627. word-break: break-all;
  628. }
  629. ::v-deep section.docx td,
  630. ::v-deep section.docx th { word-break: break-all; }
  631. .load-fail-text {
  632. flex: 1;
  633. text-align: center;
  634. line-height: 200px;
  635. color: #ccc;
  636. font-size: 14px;
  637. }
  638. }
  639. .empty-box {
  640. flex: 1;
  641. display: flex;
  642. align-items: center;
  643. justify-content: center;
  644. background: #fff;
  645. color: #999;
  646. font-size: 14px;
  647. }
  648. }
  649. .bottom-panel {
  650. flex-shrink: 0;
  651. height: 30vh;
  652. display: flex;
  653. flex-direction: column;
  654. overflow: hidden;
  655. background: #fff;
  656. border-top: 1px solid #eee;
  657. .info-meta {
  658. flex-shrink: 0;
  659. padding: 10px 16px;
  660. font-size: 26px;
  661. color: #999;
  662. border-bottom: 1px solid #eee;
  663. .dot { margin: 0 6px; }
  664. }
  665. .courseware-list {
  666. flex: 1;
  667. overflow-y: auto;
  668. padding: 10px 16px 20px;
  669. .courseware-item {
  670. margin-bottom: 16px;
  671. .item-card {
  672. position: relative;
  673. border: 1px solid #ddd;
  674. border-radius: 8px;
  675. padding: 12px 70px 12px 12px;
  676. background: #fff;
  677. .item-name {
  678. font-size: 26px;
  679. font-weight: 700;
  680. color: #333;
  681. line-height: 1.4;
  682. margin-bottom: 6px;
  683. overflow: hidden;
  684. text-overflow: ellipsis;
  685. white-space: nowrap;
  686. }
  687. .item-time {
  688. font-size: 22px;
  689. color: #666;
  690. line-height: 1.4;
  691. }
  692. .item-status {
  693. position: absolute;
  694. top: 50%;
  695. right: 12px;
  696. transform: translateY(-50%);
  697. font-size: 22px;
  698. padding: 3px 8px;
  699. border-radius: 4px;
  700. background: #999;
  701. color: #fff;
  702. white-space: nowrap;
  703. &.status-done { background: #0183fa; }
  704. &.status-ing { background: #fff; border: 1px solid #0183fa; color: #0183fa; }
  705. }
  706. &.item-card-active {
  707. background: rgba(1, 131, 250, 0.06);
  708. border-color: #0183fa;
  709. .item-name, .item-time { color: #0183fa; }
  710. }
  711. }
  712. .confirm-btn {
  713. margin-top: 10px;
  714. background: #13ce66;
  715. color: #fff;
  716. text-align: center;
  717. height: 60px;
  718. line-height: 60px;
  719. border-radius: 6px;
  720. font-size: 26px;
  721. }
  722. }
  723. }
  724. }
  725. // 验证弹窗内容样式(van-popup 挂载到 body,需用非 scoped 或 ::v-deep)
  726. }
  727. .verify-title {
  728. font-size: 28px;
  729. font-weight: 700;
  730. text-align: center;
  731. margin-bottom: 30px;
  732. color: #333;
  733. }
  734. .verify-slider {
  735. margin: 0 10px 20px;
  736. }
  737. .verify-tip {
  738. font-size: 24px;
  739. text-align: center;
  740. color: #0183fa;
  741. }
  742. </style>