Просмотр исходного кода

网页端:修改我的学习阅读pdf文档时出错的问题

余思翰 1 неделю назад
Родитель
Сommit
6d61ad70bf

+ 603
- 179
oa-ui/src/views/oa/study/components/pdfStudy.vue Просмотреть файл

@@ -5,261 +5,685 @@
5 5
  * @LastEditTime: 2025-03-12 11:01:28
6 6
 -->
7 7
 <template>
8
-  <div>
9
-    <div v-if="boxShow">
10
-      <div class="return-btn">
11
-        <div>
12
-          <el-button type="primary" size="mini" icon="el-icon-d-arrow-left"
13
-            @click="$router.push({ path: '/oa/study/myStudy' })">返回我的学习</el-button>
8
+  <div class="pdf-study-root">
9
+    <div
10
+      v-if="boxShow"
11
+      v-loading="loading"
12
+      class="pdf-study"
13
+      element-loading-text="正在加载文档..."
14
+    >
15
+      <aside class="study-sidebar">
16
+        <div class="sidebar-top">
17
+          <el-button
18
+            type="text"
19
+            icon="el-icon-arrow-left"
20
+            class="back-btn"
21
+            @click="$router.push({ path: '/oa/study/myStudy' })"
22
+          >返回我的学习</el-button>
14 23
         </div>
15
-        <div class="mt20">
16
-          学习进度:
17
-          <el-progress :text-inside="true" :status="formatStatus(percentage)" text-color="#fff" :stroke-width="26"
18
-            :percentage="percentage"></el-progress>
24
+
25
+        <div v-if="resource.title" class="resource-title" :title="resource.title">
26
+          {{ resource.title }}
27
+        </div>
28
+
29
+        <div class="progress-card">
30
+          <div class="progress-header">
31
+            <span class="progress-label">学习进度</span>
32
+            <span class="progress-percent" :class="progressPercentClass">{{ percentage }}%</span>
33
+          </div>
34
+          <el-progress
35
+            :percentage="percentage"
36
+            :status="formatStatus(percentage)"
37
+            :stroke-width="10"
38
+            :show-text="false"
39
+          />
40
+          <div v-if="totalPages" class="page-stats">
41
+            <span>已读 <strong>{{ readCount }}</strong> / {{ totalPages }} 页</span>
42
+            <el-tag v-if="isComplete" type="success" size="mini">已完成</el-tag>
43
+          </div>
44
+          <div v-if="resource.hours" class="hours-info">
45
+            可获得学时:<span>{{ resource.hours }}</span> 小时
46
+          </div>
19 47
         </div>
20
-        <div class="mt20" style="color:#E23D28;font-size:12px;line-height:30px;">
21
-          tips:鼠标滚动过快将导致进度计算失败,请隔2秒滚动一次页面。若一直未到100%,请返回我的学习重新进入课程。
48
+
49
+        <div class="tips-card">
50
+          <i class="el-icon-info" />
51
+          <p>每页累计可见约 2 秒计入进度,请逐页阅读至文档末尾;滚到底部后稍停片刻即可完成最后一页。</p>
52
+        </div>
53
+      </aside>
54
+
55
+      <main class="pdf-main">
56
+        <div ref="pdfContainer" class="pdf-container">
57
+          <div ref="pdfViewerEl" class="pdfViewer"></div>
22 58
         </div>
23
-      </div>
24
-      <div id="pdf-container">
25
-        <div id="viewer" class="pdfViewer"></div>
26
-      </div>
59
+      </main>
27 60
     </div>
28
-    <div class="mt20 text-center" v-else>
29
-      抱歉,暂不支持手机端,请前往电脑端观看学习
61
+
62
+    <div v-else class="mobile-tip">
63
+      <i class="el-icon-monitor" />
64
+      <p>抱歉,暂不支持手机端,请前往电脑端观看学习</p>
30 65
     </div>
31 66
   </div>
32 67
 </template>
33 68
 
34 69
 <script>
35
-import { listStudy, getStudy, delStudy, addStudy, updateStudy } from "@/api/oa/study/myStudy";
70
+import { getStudy, updateStudy } from '@/api/oa/study/myStudy'
36 71
 import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/build/pdf'
37 72
 import { PDFViewer } from 'pdfjs-dist/web/pdf_viewer'
38
-// 设置 PDF.js 的 Web Worker 路径
39
-GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.min');
73
+
74
+/** 页面累计可见多久视为已读(毫秒) */
75
+const PAGE_DWELL_MS = 2000
76
+/** 进度保存防抖(毫秒) */
77
+const SAVE_DEBOUNCE_MS = 2000
78
+/** 页面可见比例阈值(末页往往达不到 50%,故降低) */
79
+const VISIBILITY_RATIO = 0.15
80
+/** 判定滚动到底部的容差(像素) */
81
+const SCROLL_BOTTOM_OFFSET = 80
82
+
83
+GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.min')
84
+
40 85
 export default {
41
-  async mounted() {
42
-    if (this.$store.getters.device === 'mobile') {
43
-      this.boxShow = false;
44
-      return
45
-    } else {
46
-      this.boxShow = true;
47
-    }
48
-    await this.getData();
49
-    this.initPDFViewer()
50
-  },
86
+  name: 'PdfStudy',
51 87
   data() {
52 88
     return {
53 89
       baseUrl: process.env.VUE_APP_BASE_API,
90
+      boxShow: false,
91
+      loading: false,
54 92
       studyId: '',
55 93
       pdfPath: '',
56
-      pdfViewer: null,
57 94
       resource: {},
58
-      totalPages: 0, //总页数
59
-      readPages: [], //已阅读页数
60 95
       lastPoint: 0,
61
-      debounceTimer: null,
62
-      observers: new Map(),
63
-      isComplete: false,
64 96
       percentage: 0,
65
-      boxShow: false,
97
+      totalPages: 0,
98
+      readPages: [],
99
+      isComplete: false,
100
+      pdfViewer: null,
101
+      pdfLoadingTask: null,
102
+      pageObservers: [],
103
+      pageVisibleSince: new Map(),
104
+      pageDwellAccum: new Map(),
105
+      dwellCheckTimer: null,
106
+      saveTimer: null,
107
+      hasRestoredScroll: false,
108
+      onPagesLoaded: null,
109
+      onResize: null,
110
+      onScroll: null
111
+    }
112
+  },
113
+  computed: {
114
+    fullPdfPath() {
115
+      if (!this.pdfPath) return ''
116
+      const path = String(this.pdfPath).trim()
117
+      if (/^https?:\/\//i.test(path)) return path
118
+      if (path.indexOf('/profile/upload') === 0) {
119
+        return `${this.baseUrl}${path}`
120
+      }
121
+      return `${this.baseUrl}/profile/upload${path.startsWith('/') ? path : '/' + path}`
122
+    },
123
+    readCount() {
124
+      return this.readPages.filter(Boolean).length
125
+    },
126
+    progressPercentClass() {
127
+      if (this.percentage >= 100) return 'is-complete'
128
+      if (this.percentage >= 80) return 'is-high'
129
+      if (this.percentage >= 50) return 'is-mid'
130
+      return 'is-low'
131
+    }
132
+  },
133
+  created() {
134
+    this.boxShow = this.$store.getters.device !== 'mobile'
135
+  },
136
+  async mounted() {
137
+    if (!this.boxShow) return
138
+    try {
139
+      await this.loadStudyData()
140
+      await this.initPdfViewer()
141
+    } catch (err) {
142
+      console.error('初始化学习页失败:', err)
143
+      this.$message.error('加载学习资料失败,请稍后重试')
66 144
     }
67 145
   },
146
+  beforeDestroy() {
147
+    this.teardown()
148
+  },
68 149
   methods: {
69
-    async getData() {
70
-      this.studyId = this.$route.params.id;
71
-      let resData = await getStudy(this.studyId);
72
-      let data = resData.data;
73
-      this.pdfPath = data.resource.sourcePath;
74
-      this.lastPoint = parseFloat(data.lastPoint);
75
-      this.resource = data.resource;
76
-      if (Number(this.lastPoint) == 100) {
77
-        this.isComplete = true;
150
+    async loadStudyData() {
151
+      this.studyId = this.$route.params.id
152
+      const { data } = await getStudy(this.studyId)
153
+      this.pdfPath = data.resource.sourcePath
154
+      this.resource = data.resource
155
+      this.lastPoint = Number(data.lastPoint) || 0
156
+      this.percentage = Math.min(100, Math.round(this.lastPoint))
157
+      this.isComplete = this.percentage >= 100
158
+      if (this.isComplete) {
78 159
         this.$message.success('文档已阅读完毕')
79 160
       }
80 161
     },
81
-    // 初始化查看器
82
-    async initPDFViewer() {
162
+
163
+    async initPdfViewer() {
164
+      if (!this.fullPdfPath) {
165
+        this.$message.error('未找到 PDF 文件路径')
166
+        return
167
+      }
168
+
169
+      this.loading = true
170
+      const container = this.$refs.pdfContainer
171
+      const viewer = this.$refs.pdfViewerEl
172
+
83 173
       this.pdfViewer = new PDFViewer({
84
-        container: document.getElementById('pdf-container'),
174
+        container,
175
+        viewer,
85 176
         enhanceTextSelection: true,
86 177
         textLayerMode: 1
87 178
       })
88
-      // 先绑定监听页面变化事件
89
-      this.setupViewerEvents();
90
-      // 加载文档
91
-      const loadingTask = getDocument({
92
-        url: `/dev-api/profile/upload/${this.pdfPath}`,
179
+
180
+      this.bindViewerEvents()
181
+
182
+      this.pdfLoadingTask = getDocument({
183
+        url: this.fullPdfPath,
93 184
         cMapPacked: true
94 185
       })
186
+
95 187
       try {
96
-        const pdfDocument = await loadingTask.promise
97
-        this.pdfViewer.setDocument(pdfDocument);
98
-        this.totalPages = pdfDocument.numPages;
99
-        this.readPages = new Array(this.totalPages).fill(false);
100
-        let curProgress = Math.round((this.lastPoint / 100) * this.totalPages);
101
-        this.percentage = Math.round(curProgress / this.totalPages) * 100;
102
-        for (let i = 0; i < curProgress; i++) {
103
-          this.readPages[i] = true
104
-        }
105
-        this.$nextTick(() => {
106
-          this.setupPageObservers();
107
-        });
188
+        const pdfDocument = await this.pdfLoadingTask.promise
189
+        this.pdfViewer.setDocument(pdfDocument)
190
+        this.totalPages = pdfDocument.numPages
191
+        this.restoreReadProgress()
108 192
       } catch (err) {
109
-        console.error('PDF加载失败:', err)
193
+        console.error('PDF 加载失败:', err)
194
+        this.$message.error('PDF 加载失败,请稍后重试或联系管理员')
195
+        throw err
196
+      } finally {
197
+        this.loading = false
110 198
       }
111
-      // 添加窗口缩放监听
112
-      window.addEventListener("resize", this.handleResize);
113 199
     },
114
-    handleResize() {
115
-      if (this.pdfViewer) {
116
-        this.pdfViewer.update();
200
+
201
+    bindViewerEvents() {
202
+      const { eventBus } = this.pdfViewer
203
+      this.onPagesLoaded = () => {
204
+        this.scrollToLastPosition()
205
+        this.setupPageObservers()
206
+        this.startDwellCheckLoop()
207
+      }
208
+      eventBus.on('pagesloaded', this.onPagesLoaded)
209
+
210
+      this.onResize = () => {
211
+        if (this.pdfViewer) {
212
+          this.pdfViewer.update()
213
+        }
214
+      }
215
+      window.addEventListener('resize', this.onResize)
216
+
217
+      const container = this.$refs.pdfContainer
218
+      this.onScroll = () => {
219
+        this.checkScrollBottom()
220
+        this.flushVisibleDwell()
117 221
       }
222
+      container.addEventListener('scroll', this.onScroll, { passive: true })
118 223
     },
119
-    setupViewerEvents() {
120
-      const eventBus = this.pdfViewer.eventBus;
121
-      // 新增 pagesloaded 事件监听
122
-      eventBus.on('pagesloaded', () => {
123
-        this.scrollToLastPosition();
124
-        this.setupPageObservers()
125
-      });
224
+
225
+    /** 根据上次保存的百分比恢复已读页 */
226
+    restoreReadProgress() {
227
+      this.readPages = new Array(this.totalPages).fill(false)
228
+      if (!this.totalPages) return
229
+
230
+      const readCount = Math.min(
231
+        this.totalPages,
232
+        Math.floor((this.percentage / 100) * this.totalPages)
233
+      )
234
+      for (let i = 0; i < readCount; i++) {
235
+        this.readPages[i] = true
236
+      }
237
+      this.syncPercentageFromPages(false)
126 238
     },
239
+
127 240
     scrollToLastPosition() {
128
-      if (!this.lastPoint || !this.totalPages) return
129
-      // 计算目标页码(向上取整)
241
+      if (this.hasRestoredScroll || !this.lastPoint || !this.totalPages) return
242
+
130 243
       const pageNumber = Math.min(
131 244
         Math.ceil((this.lastPoint / 100) * this.totalPages),
132 245
         this.totalPages
133 246
       )
134
-      // 查找对应的页面元素
135
-      const pageElement = document.querySelector(
247
+      const pageElement = this.$refs.pdfContainer.querySelector(
136 248
         `.page[data-page-number="${pageNumber}"]`
137 249
       )
138 250
       if (pageElement) {
139
-        // 获取页面元素的垂直位置
140
-        const pageTop = pageElement.offsetTop
141
-        // 设置滚动位置(带20px顶部留白)
142
-        document.getElementById('pdf-container').scrollTo({
143
-          top: pageTop - 20,
251
+        this.$refs.pdfContainer.scrollTo({
252
+          top: pageElement.offsetTop - 20,
144 253
           behavior: 'auto'
145 254
         })
146 255
       }
256
+      this.hasRestoredScroll = true
147 257
     },
258
+
148 259
     setupPageObservers() {
149
-      // 清理旧观察者
150
-      this.observers.forEach((observer, page) => {
151
-        observer.disconnect();
152
-        delete page.dataset.timer;
153
-      });
154
-      this.observers.clear();
155
-      const pages = document.querySelectorAll('.page');
156
-      pages.forEach(page => {
157
-        const observer = new IntersectionObserver((entries) => {
158
-          entries.forEach(entry => {
159
-            const pageNumber = parseInt(page.dataset.pageNumber);
160
-            if (entry.isIntersecting) {
161
-              page.dataset.enterTime = Date.now();
162
-              page.dataset.timer = setTimeout(() => {
163
-                if (!this.readPages[pageNumber - 1]) {
164
-                  this.$set(this.readPages, pageNumber - 1, true);
165
-                  this.updateProgress();
166
-                }
167
-              }, 2000);
168
-            } else {
169
-              clearTimeout(page.dataset.timer);
170
-              delete page.dataset.enterTime;
171
-            }
172
-          });
173
-        }, { threshold: 0.5, root: this.pdfViewer.container });
174
-
175
-        this.observers.set(page, observer);
176
-        observer.observe(page);
177
-      });
260
+      this.clearPageObservers()
261
+      const container = this.$refs.pdfContainer
262
+      const pages = container.querySelectorAll('.page')
263
+
264
+      pages.forEach(pageEl => {
265
+        const observer = new IntersectionObserver(
266
+          entries => {
267
+            entries.forEach(entry => this.handlePageIntersection(pageEl, entry))
268
+          },
269
+          { threshold: [0, 0.1, 0.15, 0.25, 0.5, 0.75, 1], root: container }
270
+        )
271
+        observer.observe(pageEl)
272
+        this.pageObservers.push({ pageEl, observer })
273
+      })
274
+    },
275
+
276
+    handlePageIntersection(pageEl, entry) {
277
+      const pageNumber = parseInt(pageEl.dataset.pageNumber, 10)
278
+      if (!pageNumber || this.readPages[pageNumber - 1]) return
279
+
280
+      const ratio = entry.intersectionRatio
281
+      const visible = ratio >= VISIBILITY_RATIO
282
+
283
+      if (visible) {
284
+        if (!this.pageVisibleSince.has(pageEl)) {
285
+          this.pageVisibleSince.set(pageEl, Date.now())
286
+        }
287
+      } else if (this.pageVisibleSince.has(pageEl)) {
288
+        this.accumulateDwell(pageEl, pageNumber)
289
+      }
290
+    },
291
+
292
+    accumulateDwell(pageEl, pageNumber) {
293
+      const since = this.pageVisibleSince.get(pageEl)
294
+      if (!since) return
295
+
296
+      const elapsed = Date.now() - since
297
+      const total = (this.pageDwellAccum.get(pageNumber) || 0) + elapsed
298
+      this.pageDwellAccum.set(pageNumber, total)
299
+      this.pageVisibleSince.delete(pageEl)
300
+
301
+      if (total >= PAGE_DWELL_MS) {
302
+        this.markPageRead(pageNumber)
303
+      }
178 304
     },
179
-    updateProgress() {
180
-      if (!this.isComplete) {
181
-        const readCount = this.readPages.filter(Boolean).length;
182
-        const progress = Math.round(readCount / this.totalPages * 100);
183
-        this.percentage = progress;
184
-        // 防抖保存
185
-        if (!this.debounceTimer) {
186
-          this.debounceTimer = setTimeout(() => {
187
-            this.saveProgress(progress);
188
-            this.debounceTimer = null;
189
-          }, 2000);
305
+
306
+    flushVisibleDwell() {
307
+      this.pageVisibleSince.forEach((since, pageEl) => {
308
+        const pageNumber = parseInt(pageEl.dataset.pageNumber, 10)
309
+        if (!pageNumber || this.readPages[pageNumber - 1]) return
310
+
311
+        const elapsed = Date.now() - since
312
+        const total = (this.pageDwellAccum.get(pageNumber) || 0) + elapsed
313
+        this.pageDwellAccum.set(pageNumber, total)
314
+        this.pageVisibleSince.set(pageEl, Date.now())
315
+
316
+        if (total >= PAGE_DWELL_MS) {
317
+          this.markPageRead(pageNumber)
318
+        }
319
+      })
320
+    },
321
+
322
+    startDwellCheckLoop() {
323
+      this.stopDwellCheckLoop()
324
+      this.dwellCheckTimer = setInterval(() => {
325
+        if (this.isComplete) {
326
+          this.stopDwellCheckLoop()
327
+          return
328
+        }
329
+        this.flushVisibleDwell()
330
+        this.checkScrollBottom()
331
+      }, 500)
332
+    },
333
+
334
+    stopDwellCheckLoop() {
335
+      if (this.dwellCheckTimer) {
336
+        clearInterval(this.dwellCheckTimer)
337
+        this.dwellCheckTimer = null
338
+      }
339
+    },
340
+
341
+    checkScrollBottom() {
342
+      if (!this.totalPages || this.isComplete) return
343
+
344
+      const container = this.$refs.pdfContainer
345
+      const atBottom =
346
+        container.scrollTop + container.clientHeight >=
347
+        container.scrollHeight - SCROLL_BOTTOM_OFFSET
348
+
349
+      if (!atBottom) return
350
+
351
+      const lastPageNumber = this.totalPages
352
+      if (this.readPages[lastPageNumber - 1]) return
353
+
354
+      const lastPageEl = container.querySelector(
355
+        `.page[data-page-number="${lastPageNumber}"]`
356
+      )
357
+      if (!lastPageEl) return
358
+
359
+      const rect = lastPageEl.getBoundingClientRect()
360
+      const containerRect = container.getBoundingClientRect()
361
+      const visibleHeight =
362
+        Math.min(rect.bottom, containerRect.bottom) -
363
+        Math.max(rect.top, containerRect.top)
364
+
365
+      if (visibleHeight > 0) {
366
+        if (!this.pageVisibleSince.has(lastPageEl)) {
367
+          this.pageVisibleSince.set(lastPageEl, Date.now())
368
+        }
369
+        const accum = this.pageDwellAccum.get(lastPageNumber) || 0
370
+        const since = this.pageVisibleSince.get(lastPageEl)
371
+        const current = accum + (since ? Date.now() - since : 0)
372
+        if (current >= PAGE_DWELL_MS) {
373
+          this.markPageRead(lastPageNumber)
190 374
         }
191 375
       }
192 376
     },
377
+
378
+    markPageRead(pageNumber) {
379
+      const index = pageNumber - 1
380
+      if (index < 0 || index >= this.totalPages || this.readPages[index]) return
381
+      this.$set(this.readPages, index, true)
382
+      this.onReadProgressChanged()
383
+    },
384
+
385
+    onReadProgressChanged() {
386
+      if (this.isComplete) return
387
+
388
+      const progress = this.calcProgressFromPages()
389
+      if (progress < this.percentage) return
390
+
391
+      this.percentage = progress
392
+      this.scheduleSave(progress)
393
+
394
+      if (progress >= 100) {
395
+        this.tryCompleteStudy()
396
+      }
397
+    },
398
+
399
+    calcProgressFromPages() {
400
+      if (!this.totalPages) return 0
401
+      if (this.readCount >= this.totalPages) return 100
402
+      return Math.min(99, Math.floor((this.readCount / this.totalPages) * 100))
403
+    },
404
+
405
+    syncPercentageFromPages(allowComplete = true) {
406
+      const fromPages = this.calcProgressFromPages()
407
+      this.percentage = Math.max(this.percentage, fromPages)
408
+      if (allowComplete && fromPages >= 100) {
409
+        this.tryCompleteStudy()
410
+      }
411
+    },
412
+
413
+    tryCompleteStudy() {
414
+      if (this.isComplete || this.readCount < this.totalPages) return
415
+      this.percentage = 100
416
+      this.saveProgress(100)
417
+    },
418
+
419
+    scheduleSave(progress) {
420
+      if (this.saveTimer) {
421
+        clearTimeout(this.saveTimer)
422
+      }
423
+      this.saveTimer = setTimeout(() => {
424
+        this.saveTimer = null
425
+        this.saveProgress(progress)
426
+      }, SAVE_DEBOUNCE_MS)
427
+    },
428
+
429
+    /** 按进度计算已获得学时,保留一位小数,避免 0.5 被 Math.round 成 1 */
430
+    calcGetHours(progress) {
431
+      const hours = Number(this.resource.hours) || 0
432
+      const point = Math.min(100, Math.max(0, Number(progress) || 0))
433
+      return Math.round((hours * point / 100) * 10) / 10
434
+    },
435
+
193 436
     async saveProgress(progress) {
437
+      if (this.isComplete && progress < 100) return
438
+
439
+      const lastPoint = Math.min(100, Math.round(progress))
440
+      const payload = {
441
+        studyId: this.studyId,
442
+        lastPoint,
443
+        getHours: this.calcGetHours(lastPoint),
444
+        lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
445
+      }
446
+
194 447
       try {
195
-        if (parseFloat(progress) == 100) {
196
-          this.$message.success('文档已阅读完毕!学时+' + this.resource.hours)
197
-          progress = 100
448
+        await updateStudy(payload)
449
+        this.lastPoint = lastPoint
450
+
451
+        if (lastPoint >= 100 && !this.isComplete) {
452
+          this.isComplete = true
453
+          this.$message.success(`文档已阅读完毕!学时+${this.calcGetHours(100)}`)
198 454
         }
199
-        await updateStudy(
200
-          {
201
-            studyId: this.studyId,
202
-            lastPoint: progress,
203
-            getHours: Math.round((this.resource.hours * progress) / 100),
204
-            lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
205
-          });
206 455
       } catch (error) {
207
-        console.error('保存进度失败:', error);
456
+        console.error('保存进度失败:', error)
457
+        this.$message.error('保存学习进度失败')
208 458
       }
209 459
     },
210
-    formatStatus(row) {
211
-      if (!row) {
212
-        row = 0
213
-        return 'exception'
460
+
461
+    clearPageObservers() {
462
+      this.pageVisibleSince.clear()
463
+      this.pageDwellAccum.clear()
464
+      this.pageObservers.forEach(({ observer }) => observer.disconnect())
465
+      this.pageObservers = []
466
+    },
467
+
468
+    teardown() {
469
+      this.stopDwellCheckLoop()
470
+
471
+      if (this.saveTimer) {
472
+        clearTimeout(this.saveTimer)
473
+        this.saveTimer = null
474
+      }
475
+
476
+      if (!this.isComplete && this.readCount > 0) {
477
+        const progress = this.calcProgressFromPages()
478
+        if (progress > this.lastPoint) {
479
+          this.saveProgress(progress)
480
+        }
481
+      }
482
+
483
+      this.clearPageObservers()
484
+
485
+      if (this.pdfViewer && this.onPagesLoaded) {
486
+        this.pdfViewer.eventBus.off('pagesloaded', this.onPagesLoaded)
487
+      }
488
+
489
+      if (this.onResize) {
490
+        window.removeEventListener('resize', this.onResize)
214 491
       }
215
-      if (row <= 20) {
216
-        return 'exception'
217
-      } else if (row > 20 && row <= 50) {
218
-        return 'warning'
219
-      } else if (row > 50 && row <= 80) {
220
-        return null
221
-      } else {
222
-        return 'success'
492
+
493
+      if (this.onScroll && this.$refs.pdfContainer) {
494
+        this.$refs.pdfContainer.removeEventListener('scroll', this.onScroll)
495
+      }
496
+
497
+      if (this.pdfLoadingTask) {
498
+        this.pdfLoadingTask.destroy()
499
+        this.pdfLoadingTask = null
223 500
       }
501
+
502
+      this.pdfViewer = null
224 503
     },
225
-  },
226
-  beforeDestroy() {
227
-    // 清理观察者和定时器
228
-    this.observers.forEach((observer, page) => {
229
-      observer.disconnect();
230
-      clearTimeout(page.dataset.timer);
231
-    });
232
-    window.removeEventListener("resize", this.handleResize);
233
-  }
234 504
 
505
+    formatStatus(value) {
506
+      const row = value || 0
507
+      if (row <= 20) return 'exception'
508
+      if (row <= 50) return 'warning'
509
+      if (row <= 80) return null
510
+      return 'success'
511
+    }
512
+  }
235 513
 }
236 514
 </script>
237 515
 
238 516
 <style lang="scss" scoped>
239 517
 @import '~pdfjs-dist/web/pdf_viewer.css';
240 518
 
241
-/* 修改后 */
242
-#pdf-container {
243
-  position: relative;
244
-  /* 改为相对定位,避免脱离文档流 */
519
+.pdf-study-root {
520
+  height: calc(100vh - 84px);
521
+  min-height: 520px;
522
+  background: #f0f2f5;
523
+}
524
+
525
+.pdf-study {
526
+  display: flex;
527
+  height: 100%;
528
+  overflow: hidden;
529
+}
530
+
531
+.study-sidebar {
532
+  flex-shrink: 0;
533
+  width: 280px;
534
+  display: flex;
535
+  flex-direction: column;
536
+  gap: 16px;
537
+  padding: 20px 18px;
538
+  background: #fff;
539
+  border-right: 1px solid #e4e7ed;
540
+  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
541
+  overflow-y: auto;
542
+}
543
+
544
+.sidebar-top {
545
+  .back-btn {
546
+    padding: 0;
547
+    font-size: 14px;
548
+    color: #409eff;
549
+
550
+    &:hover {
551
+      color: #66b1ff;
552
+    }
553
+  }
554
+}
555
+
556
+.resource-title {
557
+  font-size: 15px;
558
+  font-weight: 600;
559
+  color: #303133;
560
+  line-height: 1.5;
561
+  display: -webkit-box;
562
+  -webkit-line-clamp: 2;
563
+  line-clamp: 2;
564
+  -webkit-box-orient: vertical;
565
+  overflow: hidden;
566
+  word-break: break-all;
567
+}
568
+
569
+.progress-card {
570
+  padding: 16px;
571
+  background: linear-gradient(135deg, #f5f9ff 0%, #f0f7ff 100%);
572
+  border: 1px solid #d9ecff;
573
+  border-radius: 8px;
574
+}
575
+
576
+.progress-header {
577
+  display: flex;
578
+  align-items: center;
579
+  justify-content: space-between;
580
+  margin-bottom: 12px;
581
+}
582
+
583
+.progress-label {
584
+  font-size: 13px;
585
+  color: #606266;
586
+}
587
+
588
+.progress-percent {
589
+  font-size: 22px;
590
+  font-weight: 700;
591
+  line-height: 1;
592
+
593
+  &.is-low { color: #f56c6c; }
594
+  &.is-mid { color: #e6a23c; }
595
+  &.is-high { color: #409eff; }
596
+  &.is-complete { color: #67c23a; }
597
+}
598
+
599
+.page-stats {
600
+  display: flex;
601
+  align-items: center;
602
+  justify-content: space-between;
603
+  margin-top: 12px;
604
+  font-size: 13px;
605
+  color: #606266;
606
+
607
+  strong {
608
+    color: #409eff;
609
+    font-size: 15px;
610
+  }
611
+}
612
+
613
+.hours-info {
614
+  margin-top: 10px;
615
+  padding-top: 10px;
616
+  border-top: 1px dashed #d9ecff;
617
+  font-size: 12px;
618
+  color: #909399;
619
+
620
+  span {
621
+    color: #409eff;
622
+    font-weight: 600;
623
+  }
624
+}
625
+
626
+.tips-card {
627
+  display: flex;
628
+  gap: 8px;
629
+  padding: 12px;
630
+  font-size: 12px;
631
+  line-height: 1.6;
632
+  color: #909399;
633
+  background: #fafafa;
634
+  border-radius: 6px;
635
+  border: 1px solid #ebeef5;
636
+
637
+  i {
638
+    flex-shrink: 0;
639
+    margin-top: 2px;
640
+    font-size: 14px;
641
+    color: #c0c4cc;
642
+  }
643
+
644
+  p {
645
+    margin: 0;
646
+  }
647
+}
648
+
649
+.pdf-main {
650
+  flex: 1;
651
+  min-width: 0;
652
+  display: flex;
653
+  flex-direction: column;
654
+  background: #525659;
655
+}
656
+
657
+.pdf-container {
658
+  flex: 1;
245 659
   width: 100%;
246
-  height: calc(100vh - 88px);
247
-  /* 根据实际布局调整高度 */
248 660
   overflow: auto;
249
-  background-color: #cdcdcd;
661
+  background: #525659;
250 662
 }
251 663
 
252
-.pdfViewer .page {
253
-  margin: 1em auto;
254
-  border: 1px solid #ddd;
255
-  box-shadow: 0 10px 10px rgba(0, 0, 0, 0.1);
664
+.pdf-main ::v-deep .pdfViewer .page {
665
+  margin: 16px auto;
666
+  border: none;
667
+  border-radius: 2px;
668
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
256 669
 }
257 670
 
258
-.return-btn {
259
-  position: absolute;
260
-  left: 20px;
261
-  top: 100px;
262
-  z-index: 9999;
263
-  width: 230px;
671
+.mobile-tip {
672
+  display: flex;
673
+  flex-direction: column;
674
+  align-items: center;
675
+  justify-content: center;
676
+  height: 100%;
677
+  color: #909399;
678
+
679
+  i {
680
+    font-size: 48px;
681
+    margin-bottom: 16px;
682
+  }
683
+
684
+  p {
685
+    margin: 0;
686
+    font-size: 14px;
687
+  }
264 688
 }
265
-</style>
689
+</style>

+ 257
- 39
oa-ui/src/views/oa/study/components/videoStudy.vue Просмотреть файл

@@ -1,27 +1,72 @@
1 1
 <template>
2
-  <div>
3
-    <div class="video-container" v-if="boxShow">
4
-      <div class="return-btn">
5
-        <el-button type="primary" size="mini" icon="el-icon-d-arrow-left"
6
-          @click="$router.push({ path: '/oa/study/myStudy' })">返回我的学习</el-button>
7
-        <div class="mt20">
8
-          学习进度:
9
-          <el-progress :text-inside="true" :status="formatStatus(percentage)" text-color="#fff" :stroke-width="26"
10
-            :percentage="percentage"></el-progress>
2
+  <div class="video-study-root">
3
+    <div v-if="boxShow" class="video-study">
4
+      <aside class="study-sidebar">
5
+        <div class="sidebar-top">
6
+          <el-button
7
+            type="text"
8
+            icon="el-icon-arrow-left"
9
+            class="back-btn"
10
+            @click="$router.push({ path: '/oa/study/myStudy' })"
11
+          >返回我的学习</el-button>
11 12
         </div>
12
-        <div class="mt20" style="color:#E23D28;font-size:12px;line-height:30px;">
13
-          tips:视频不可拖动进度条,每隔30秒会自动保存,暂停也会保存视频进度,若出现学习进度一直不到100%,请退出后重新进入课程。
13
+
14
+        <div v-if="resource.title" class="resource-title" :title="resource.title">
15
+          {{ resource.title }}
16
+        </div>
17
+
18
+        <div class="progress-card">
19
+          <div class="progress-header">
20
+            <span class="progress-label">学习进度</span>
21
+            <span class="progress-percent" :class="progressPercentClass">{{ percentage }}%</span>
22
+          </div>
23
+          <el-progress
24
+            :percentage="percentage"
25
+            :status="formatStatus(percentage)"
26
+            :stroke-width="10"
27
+            :show-text="false"
28
+          />
29
+          <div class="page-stats">
30
+            <span v-if="lastPosition > 0">上次播放 {{ formatTime(lastPosition) }}</span>
31
+            <span v-else>尚未开始播放</span>
32
+            <el-tag v-if="isComplete" type="success" size="mini">已完成</el-tag>
33
+          </div>
34
+          <div v-if="resource.hours" class="hours-info">
35
+            可获得学时:<span>{{ resource.hours }}</span> 小时
36
+          </div>
37
+        </div>
38
+
39
+        <div class="tips-card">
40
+          <i class="el-icon-info" />
41
+          <p>视频不可拖动进度条;每 30 秒自动保存,暂停时也会保存。若进度异常,请退出后重新进入课程。</p>
42
+        </div>
43
+      </aside>
44
+
45
+      <main class="video-main">
46
+        <div class="video-wrapper">
47
+          <video
48
+            ref="videoPlayer"
49
+            class="video-js vjs-big-play-centered custom-video"
50
+            @play="handlePlay"
51
+            @pause="handlePause"
52
+            @ended="handleEnd"
53
+          />
54
+          <el-alert
55
+            v-if="showProgressTip"
56
+            type="info"
57
+            :closable="false"
58
+            class="resume-alert"
59
+            show-icon
60
+          >
61
+            已为您定位到上次播放位置:{{ formatTime(lastPosition) }}
62
+          </el-alert>
14 63
         </div>
15
-      </div>
16
-      <video ref="videoPlayer" width="1300" height="750" class="custom-video video-js vjs-big-play-centered"
17
-        @play="handlePlay" @pause="handlePause" @ended="handleEnd">
18
-      </video>
19
-      <el-alert v-if="showProgressTip" type="info" :closable="false" class="progress-alert">
20
-        已为您定位到上次播放位置:{{ formatTime(lastPosition) }}
21
-      </el-alert>
64
+      </main>
22 65
     </div>
23
-    <div class="mt20 text-center" v-else>
24
-      抱歉,暂不支持手机端,请前往电脑端观看学习
66
+
67
+    <div v-else class="mobile-tip">
68
+      <i class="el-icon-monitor" />
69
+      <p>抱歉,暂不支持手机端,请前往电脑端观看学习</p>
25 70
     </div>
26 71
   </div>
27 72
 </template>
@@ -74,6 +119,12 @@ export default {
74 119
   computed: {
75 120
     fullVideoPath() {
76 121
       return `${process.env.VUE_APP_BASE_API}/profile/upload/${this.videoPath}`
122
+    },
123
+    progressPercentClass() {
124
+      if (this.percentage >= 100) return 'is-complete'
125
+      if (this.percentage >= 80) return 'is-high'
126
+      if (this.percentage >= 50) return 'is-mid'
127
+      return 'is-low'
77 128
     }
78 129
   },
79 130
   created() {
@@ -110,9 +161,9 @@ export default {
110 161
     initPlayer() {
111 162
       const self = this;
112 163
       this.player = videojs(this.$refs.videoPlayer, {
113
-        // 增加错误处理回调
114 164
         errorDisplay: false,
115 165
         autoplay: false,
166
+        fluid: true,
116 167
         controls: true,
117 168
         sources: [{ src: this.fullVideoPath, type: 'video/mp4' }],
118 169
         controlBar: {
@@ -208,7 +259,7 @@ export default {
208 259
         await updateStudy({
209 260
           studyId: this.videoId,
210 261
           lastPoint: percentage,
211
-          getHours: Math.round((this.resource.hours * percentage) / 100),
262
+          getHours: Math.round((Number(this.resource.hours) * percentage / 100) * 10) / 10,
212 263
           lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
213 264
         });
214 265
         // 始终更新 lastPosition(即使播放结束)
@@ -249,28 +300,195 @@ export default {
249 300
 </script>
250 301
 
251 302
 <style lang="scss" scoped>
252
-.video-container {
253
-  position: relative;
303
+.video-study-root {
304
+  height: calc(100vh - 84px);
305
+  min-height: 520px;
306
+  background: #f0f2f5;
307
+}
308
+
309
+.video-study {
310
+  display: flex;
311
+  height: 100%;
312
+  overflow: hidden;
313
+}
314
+
315
+.study-sidebar {
316
+  flex-shrink: 0;
317
+  width: 280px;
318
+  display: flex;
319
+  flex-direction: column;
320
+  gap: 16px;
321
+  padding: 20px 18px;
322
+  background: #fff;
323
+  border-right: 1px solid #e4e7ed;
324
+  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
325
+  overflow-y: auto;
326
+}
327
+
328
+.sidebar-top {
329
+  .back-btn {
330
+    padding: 0;
331
+    font-size: 14px;
332
+    color: #409eff;
254 333
 
255
-  .return-btn {
256
-    position: absolute;
257
-    left: 20px;
258
-    top: 30px;
259
-    width: 130px;
334
+    &:hover {
335
+      color: #66b1ff;
336
+    }
260 337
   }
338
+}
339
+
340
+.resource-title {
341
+  font-size: 15px;
342
+  font-weight: 600;
343
+  color: #303133;
344
+  line-height: 1.5;
345
+  display: -webkit-box;
346
+  -webkit-line-clamp: 2;
347
+  line-clamp: 2;
348
+  -webkit-box-orient: vertical;
349
+  overflow: hidden;
350
+  word-break: break-all;
351
+}
352
+
353
+.progress-card {
354
+  padding: 16px;
355
+  background: linear-gradient(135deg, #f5f9ff 0%, #f0f7ff 100%);
356
+  border: 1px solid #d9ecff;
357
+  border-radius: 8px;
358
+}
359
+
360
+.progress-header {
361
+  display: flex;
362
+  align-items: center;
363
+  justify-content: space-between;
364
+  margin-bottom: 12px;
365
+}
366
+
367
+.progress-label {
368
+  font-size: 13px;
369
+  color: #606266;
370
+}
371
+
372
+.progress-percent {
373
+  font-size: 22px;
374
+  font-weight: 700;
375
+  line-height: 1;
376
+
377
+  &.is-low { color: #f56c6c; }
378
+  &.is-mid { color: #e6a23c; }
379
+  &.is-high { color: #409eff; }
380
+  &.is-complete { color: #67c23a; }
381
+}
382
+
383
+.page-stats {
384
+  display: flex;
385
+  align-items: center;
386
+  justify-content: space-between;
387
+  margin-top: 12px;
388
+  font-size: 12px;
389
+  color: #606266;
390
+}
391
+
392
+.hours-info {
393
+  margin-top: 10px;
394
+  padding-top: 10px;
395
+  border-top: 1px dashed #d9ecff;
396
+  font-size: 12px;
397
+  color: #909399;
398
+
399
+  span {
400
+    color: #409eff;
401
+    font-weight: 600;
402
+  }
403
+}
404
+
405
+.tips-card {
406
+  display: flex;
407
+  gap: 8px;
408
+  padding: 12px;
409
+  font-size: 12px;
410
+  line-height: 1.6;
411
+  color: #909399;
412
+  background: #fafafa;
413
+  border-radius: 6px;
414
+  border: 1px solid #ebeef5;
415
+
416
+  i {
417
+    flex-shrink: 0;
418
+    margin-top: 2px;
419
+    font-size: 14px;
420
+    color: #c0c4cc;
421
+  }
422
+
423
+  p {
424
+    margin: 0;
425
+  }
426
+}
427
+
428
+.video-main {
429
+  flex: 1;
430
+  min-width: 0;
431
+  display: flex;
432
+  flex-direction: column;
433
+  background: #1a1a1a;
434
+}
435
+
436
+.video-wrapper {
437
+  position: relative;
438
+  flex: 1;
439
+  display: flex;
440
+  align-items: center;
441
+  justify-content: center;
442
+  padding: 24px 32px;
443
+  min-height: 0;
444
+}
445
+
446
+.custom-video {
447
+  width: 100%;
448
+  max-width: 1100px;
449
+  max-height: 100%;
450
+}
451
+
452
+.video-wrapper ::v-deep .video-js {
453
+  width: 100%;
454
+  max-width: 1100px;
455
+  max-height: calc(100vh - 84px - 48px);
456
+  border-radius: 8px;
457
+  overflow: hidden;
458
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
459
+}
460
+
461
+.video-wrapper ::v-deep .vjs-tech {
462
+  border-radius: 8px;
463
+}
464
+
465
+.resume-alert {
466
+  position: absolute;
467
+  left: 50%;
468
+  bottom: 32px;
469
+  transform: translateX(-50%);
470
+  width: auto;
471
+  max-width: 480px;
472
+  border-radius: 6px;
473
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
474
+}
475
+
476
+.mobile-tip {
477
+  display: flex;
478
+  flex-direction: column;
479
+  align-items: center;
480
+  justify-content: center;
481
+  height: 100%;
482
+  color: #909399;
261 483
 
262
-  .custom-video {
263
-    position: absolute;
264
-    left: 50%;
265
-    top: 50%;
266
-    transform: translate(-50%, 0%);
484
+  i {
485
+    font-size: 48px;
486
+    margin-bottom: 16px;
267 487
   }
268 488
 
269
-  .progress-alert {
270
-    position: absolute;
271
-    bottom: 40px;
272
-    width: 100%;
273
-    z-index: 1;
489
+  p {
490
+    margin: 0;
491
+    font-size: 14px;
274 492
   }
275 493
 }
276 494
 </style>

Загрузка…
Отмена
Сохранить