Parcourir la source

修改视频播放、文档阅读的学习记录

余思翰 il y a 1 mois
Parent
révision
a8f493d0a0

+ 1
- 1
oa-back/ruoyi-system/src/main/resources/mapper/oa/CmcStudyMapper.xml Voir le fichier

@@ -47,7 +47,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
47 47
             <if test="resourceId != null "> and s.resource_id = #{resourceId}</if>
48 48
             <if test="userId != null "> and s.user_id = #{userId}</if>
49 49
             <if test="lastPoint != null  and lastPoint != ''"> and s.last_point = #{lastPoint}</if>
50
-            <if test="lastTime != null "> and s.last_time = #{lastTime}</if>
50
+            <if test="lastTime != null "> and YEAR(s.last_time) = YEAR(#{lastTime})</if>
51 51
             <if test="getHours != null "> and s.get_hours = #{getHours}</if>
52 52
         </where>
53 53
     </select>

+ 1
- 0
oa-ui/package.json Voir le fichier

@@ -61,6 +61,7 @@
61 61
     "jsencrypt": "3.0.0-rc.1",
62 62
     "nprogress": "0.2.0",
63 63
     "ol": "^7.1.0",
64
+    "pdfjs-dist": "^2.0.943",
64 65
     "quill": "1.3.7",
65 66
     "screenfull": "5.0.2",
66 67
     "sortablejs": "1.10.2",

+ 254
- 0
oa-ui/src/views/oa/study/components/pdfStudy.vue Voir le fichier

@@ -0,0 +1,254 @@
1
+<!--
2
+ * @Author: ysh
3
+ * @Date: 2025-03-06 14:38:35
4
+ * @LastEditors: Please set LastEditors
5
+ * @LastEditTime: 2025-03-11 16:16:33
6
+-->
7
+<template>
8
+  <div>
9
+    <div class="return-btn">
10
+      <div>
11
+        <el-button type="primary" size="mini" icon="el-icon-d-arrow-left"
12
+          @click="$router.push({ path: '/oa/study/myStudy' })">返回我的学习</el-button>
13
+      </div>
14
+      <div class="mt20">
15
+        学习进度:
16
+        <el-progress :text-inside="true" :status="formatStatus(percentage)" text-color="#fff" :stroke-width="26"
17
+          :percentage="percentage"></el-progress>
18
+      </div>
19
+      <div class="mt20" style="color:#E23D28;font-size:12px;line-height:30px;">
20
+        tips:鼠标滚动过快将导致进度计算失败,请隔2秒滚动一次页面。若一直未到100%,请返回我的学习重新进入课程。
21
+      </div>
22
+    </div>
23
+    <div id="pdf-container">
24
+
25
+      <div id="viewer" class="pdfViewer"></div>
26
+    </div>
27
+  </div>
28
+</template>
29
+
30
+<script>
31
+import { listStudy, getStudy, delStudy, addStudy, updateStudy } from "@/api/oa/study/myStudy";
32
+import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/build/pdf'
33
+import { PDFViewer } from 'pdfjs-dist/web/pdf_viewer'
34
+// 设置 PDF.js 的 Web Worker 路径
35
+GlobalWorkerOptions.workerSrc = require('pdfjs-dist/build/pdf.worker.min');
36
+export default {
37
+  async mounted() {
38
+    await this.getData();
39
+    this.initPDFViewer()
40
+  },
41
+  data() {
42
+    return {
43
+      baseUrl: process.env.VUE_APP_BASE_API,
44
+      studyId: '',
45
+      pdfPath: '',
46
+      pdfViewer: null,
47
+      resource: {},
48
+      totalPages: 0, //总页数
49
+      readPages: [], //已阅读页数
50
+      lastPoint: 0,
51
+      debounceTimer: null,
52
+      observers: new Map(),
53
+      isComplete: false,
54
+      percentage: 0,
55
+    }
56
+  },
57
+  methods: {
58
+    async getData() {
59
+      this.studyId = this.$route.params.id;
60
+      let resData = await getStudy(this.studyId);
61
+      let data = resData.data;
62
+      this.pdfPath = data.resource.sourcePath;
63
+      this.lastPoint = parseFloat(data.lastPoint);
64
+      this.resource = data.resource;
65
+      if (Number(this.lastPoint) == 100) {
66
+        this.isComplete = true;
67
+        this.$message.success('文档已阅读完毕')
68
+      }
69
+    },
70
+    // 初始化查看器
71
+    async initPDFViewer() {
72
+      this.pdfViewer = new PDFViewer({
73
+        container: document.getElementById('pdf-container'),
74
+        enhanceTextSelection: true,
75
+        textLayerMode: 1
76
+      })
77
+      // 先绑定监听页面变化事件
78
+      this.setupViewerEvents();
79
+      // 加载文档
80
+      const loadingTask = getDocument({
81
+        url: `/dev-api/profile/upload/${this.pdfPath}`,
82
+        cMapPacked: true
83
+      })
84
+      try {
85
+        const pdfDocument = await loadingTask.promise
86
+        this.pdfViewer.setDocument(pdfDocument);
87
+        this.totalPages = pdfDocument.numPages;
88
+        this.readPages = new Array(this.totalPages).fill(false);
89
+        let curProgress = Math.round((this.lastPoint / 100) * this.totalPages);
90
+        this.percentage = Math.round(curProgress / this.totalPages) * 100;
91
+        for (let i = 0; i < curProgress; i++) {
92
+          this.readPages[i] = true
93
+        }
94
+        this.$nextTick(() => {
95
+          this.setupPageObservers();
96
+        });
97
+      } catch (err) {
98
+        console.error('PDF加载失败:', err)
99
+      }
100
+      // 添加窗口缩放监听
101
+      window.addEventListener("resize", this.handleResize);
102
+    },
103
+    handleResize() {
104
+      if (this.pdfViewer) {
105
+        this.pdfViewer.update();
106
+      }
107
+    },
108
+    setupViewerEvents() {
109
+      const eventBus = this.pdfViewer.eventBus;
110
+      // 新增 pagesloaded 事件监听
111
+      eventBus.on('pagesloaded', () => {
112
+        this.scrollToLastPosition();
113
+        this.setupPageObservers()
114
+      });
115
+    },
116
+    scrollToLastPosition() {
117
+      if (!this.lastPoint || !this.totalPages) return
118
+      // 计算目标页码(向上取整)
119
+      const pageNumber = Math.min(
120
+        Math.ceil((this.lastPoint / 100) * this.totalPages),
121
+        this.totalPages
122
+      )
123
+      // 查找对应的页面元素
124
+      const pageElement = document.querySelector(
125
+        `.page[data-page-number="${pageNumber}"]`
126
+      )
127
+      if (pageElement) {
128
+        // 获取页面元素的垂直位置
129
+        const pageTop = pageElement.offsetTop
130
+        // 设置滚动位置(带20px顶部留白)
131
+        document.getElementById('pdf-container').scrollTo({
132
+          top: pageTop - 20,
133
+          behavior: 'auto'
134
+        })
135
+      }
136
+    },
137
+    setupPageObservers() {
138
+      // 清理旧观察者
139
+      this.observers.forEach((observer, page) => {
140
+        observer.disconnect();
141
+        delete page.dataset.timer;
142
+      });
143
+      this.observers.clear();
144
+      const pages = document.querySelectorAll('.page');
145
+      pages.forEach(page => {
146
+        const observer = new IntersectionObserver((entries) => {
147
+          entries.forEach(entry => {
148
+            const pageNumber = parseInt(page.dataset.pageNumber);
149
+            if (entry.isIntersecting) {
150
+              page.dataset.enterTime = Date.now();
151
+              page.dataset.timer = setTimeout(() => {
152
+                if (!this.readPages[pageNumber - 1]) {
153
+                  this.$set(this.readPages, pageNumber - 1, true);
154
+                  this.updateProgress();
155
+                }
156
+              }, 2000);
157
+            } else {
158
+              clearTimeout(page.dataset.timer);
159
+              delete page.dataset.enterTime;
160
+            }
161
+          });
162
+        }, { threshold: 0.5, root: this.pdfViewer.container });
163
+
164
+        this.observers.set(page, observer);
165
+        observer.observe(page);
166
+      });
167
+    },
168
+    updateProgress() {
169
+      if (!this.isComplete) {
170
+        const readCount = this.readPages.filter(Boolean).length;
171
+        const progress = Math.round(readCount / this.totalPages * 100);
172
+        this.percentage = progress;
173
+        // 防抖保存
174
+        if (!this.debounceTimer) {
175
+          this.debounceTimer = setTimeout(() => {
176
+            this.saveProgress(progress);
177
+            this.debounceTimer = null;
178
+          }, 2000);
179
+        }
180
+      }
181
+    },
182
+    async saveProgress(progress) {
183
+      try {
184
+        if (parseFloat(progress) == 100) {
185
+          this.$message.success('文档已阅读完毕!学时+' + this.resource.hours)
186
+          progress = 100
187
+        }
188
+        await updateStudy(
189
+          {
190
+            studyId: this.studyId,
191
+            lastPoint: progress,
192
+            getHours: Math.round((this.resource.hours * progress) / 100),
193
+            lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
194
+          });
195
+      } catch (error) {
196
+        console.error('保存进度失败:', error);
197
+      }
198
+    },
199
+    formatStatus(row) {
200
+      if (!row) {
201
+        row = 0
202
+        return 'exception'
203
+      }
204
+      if (row <= 20) {
205
+        return 'exception'
206
+      } else if (row > 20 && row <= 50) {
207
+        return 'warning'
208
+      } else if (row > 50 && row <= 80) {
209
+        return null
210
+      } else {
211
+        return 'success'
212
+      }
213
+    },
214
+  },
215
+  beforeDestroy() {
216
+    // 清理观察者和定时器
217
+    this.observers.forEach((observer, page) => {
218
+      observer.disconnect();
219
+      clearTimeout(page.dataset.timer);
220
+    });
221
+    window.removeEventListener("resize", this.handleResize);
222
+  }
223
+
224
+}
225
+</script>
226
+
227
+<style lang="scss" scoped>
228
+@import '~pdfjs-dist/web/pdf_viewer.css';
229
+
230
+/* 修改后 */
231
+#pdf-container {
232
+  position: relative;
233
+  /* 改为相对定位,避免脱离文档流 */
234
+  width: 100%;
235
+  height: calc(100vh - 88px);
236
+  /* 根据实际布局调整高度 */
237
+  overflow: auto;
238
+  background-color: #cdcdcd;
239
+}
240
+
241
+.pdfViewer .page {
242
+  margin: 1em auto;
243
+  border: 1px solid #ddd;
244
+  box-shadow: 0 10px 10px rgba(0, 0, 0, 0.1);
245
+}
246
+
247
+.return-btn {
248
+  position: absolute;
249
+  left: 20px;
250
+  top: 100px;
251
+  z-index: 9999;
252
+  width: 230px;
253
+}
254
+</style>

+ 19
- 2
oa-ui/src/views/oa/study/components/studyHead.vue Voir le fichier

@@ -2,7 +2,7 @@
2 2
  * @Author: ysh
3 3
  * @Date: 2025-03-05 14:19:02
4 4
  * @LastEditors: Please set LastEditors
5
- * @LastEditTime: 2025-03-06 14:26:32
5
+ * @LastEditTime: 2025-03-11 17:00:41
6 6
 -->
7 7
 <template>
8 8
   <div class="head-bg">
@@ -31,12 +31,29 @@
31 31
 </template>
32 32
 
33 33
 <script>
34
+import { listStudy, getStudy, delStudy, addStudy, updateStudy } from "@/api/oa/study/myStudy";
35
+
34 36
 export default {
35 37
   data() {
36 38
     return {
37 39
       hours: 0,
38 40
     }
39 41
   },
42
+  created() {
43
+    this.getYearHours();
44
+  },
45
+  methods: {
46
+    getYearHours() {
47
+      let lastTime = new Date().getFullYear() + '-01-01'
48
+      listStudy({ userId: this.$store.getters.userId, lastTime: lastTime }).then(res => {
49
+        console.log(res);
50
+        if (res.total > 0) {
51
+          this.hours = res.rows.reduce((sum, item) => sum + Number(item.getHours), 0)
52
+        }
53
+
54
+      })
55
+    }
56
+  },
40 57
 }
41 58
 </script>
42 59
 
@@ -93,7 +110,7 @@ export default {
93 110
       font-family: 'puhuiti';
94 111
       line-height: 28px;
95 112
       margin-top: 52px;
96
-      margin-left:20px;
113
+      margin-left: 20px;
97 114
     }
98 115
 
99 116
     .user-study {

+ 8
- 3
oa-ui/src/views/oa/study/components/studyLeft.vue Voir le fichier

@@ -2,7 +2,7 @@
2 2
  * @Author: ysh
3 3
  * @Date: 2025-03-05 15:01:52
4 4
  * @LastEditors: Please set LastEditors
5
- * @LastEditTime: 2025-03-07 15:03:55
5
+ * @LastEditTime: 2025-03-11 16:47:24
6 6
 -->
7 7
 <template>
8 8
   <div>
@@ -30,6 +30,9 @@
30 30
         </template>
31 31
       </el-table-column>
32 32
     </el-table>
33
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum"
34
+      :layout="'total, prev, pager, next,jumper'" :limit.sync="queryParams.pageSize" :autoScroll="false"
35
+      @pagination="getRecordsList" />
33 36
   </div>
34 37
 </template>
35 38
 
@@ -42,7 +45,10 @@ export default {
42 45
       loading: true,
43 46
       studyList: [],
44 47
       total: 0,
45
-      queryParams: {}
48
+      queryParams: {
49
+        pageNum: 1,
50
+        pageSize: 10
51
+      }
46 52
     }
47 53
   },
48 54
   created() {
@@ -60,7 +66,6 @@ export default {
60 66
     },
61 67
     startStudy(record) {
62 68
       const routeName = record.resource.type === '视频' ? 'VideoStudy' : 'PdfStudy';
63
-      console.log(record);
64 69
       this.$router.push({
65 70
         name: routeName,
66 71
         params: { id: record.studyId }

+ 1
- 17
oa-ui/src/views/oa/study/components/studyRight.vue Voir le fichier

@@ -2,25 +2,10 @@
2 2
  * @Author: ysh
3 3
  * @Date: 2025-03-05 15:36:17
4 4
  * @LastEditors: Please set LastEditors
5
- * @LastEditTime: 2025-03-06 16:00:55
5
+ * @LastEditTime: 2025-03-11 16:48:55
6 6
 -->
7 7
 <template>
8 8
   <div class="right-box">
9
-    <!-- <div class="material-list">
10
-      <el-card v-for="item in resourceList" :key="item.id" class="material-item">
11
-        <div class="material-content">
12
-          <i :class="item.type === '视频' ? 'el-icon-video-camera' : 'el-icon-document'"
13
-            style="font-size: 24px;margin-right: 15px" />
14
-          <div class="material-info">
15
-            <h4>名称:{{ item.title }}</h4>
16
-            <span>学时:{{ item.hours }}个学时</span>
17
-          </div>
18
-          <el-button type="primary" size="mini" @click="addToStudy(item)">
19
-            添加
20
-          </el-button>
21
-        </div>
22
-      </el-card>
23
-    </div> -->
24 9
     <div class="video">
25 10
       <el-tabs v-model="videoTab" type="card">
26 11
         <el-tab-pane label="视频" name="video">
@@ -103,7 +88,6 @@ export default {
103 88
       });
104 89
     },
105 90
     addToStudy(row) {
106
-      console.log(row);
107 91
       this.$modal.confirm('是否添加资料名称为"' + row.title + '"到我的学习记录?').then(res => {
108 92
         let obj = {
109 93
           resourceId: row.resourceId,

+ 70
- 22
oa-ui/src/views/oa/study/components/videoStudy.vue Voir le fichier

@@ -1,8 +1,19 @@
1 1
 <template>
2 2
   <div class="video-container">
3
-    <el-button type="primary" @click="$router.push({ path: '/oa/study/myStudy' })">返回我的学习</el-button>
4
-    <video ref="videoPlayer" class="video-js vjs-big-play-centered" @play="handlePlay" @pause="handlePause"
5
-      @ended="handleEnd">
3
+    <div class="return-btn">
4
+      <el-button type="primary" size="mini" icon="el-icon-d-arrow-left"
5
+        @click="$router.push({ path: '/oa/study/myStudy' })">返回我的学习</el-button>
6
+      <div class="mt20">
7
+        学习进度:
8
+        <el-progress :text-inside="true" :status="formatStatus(percentage)" text-color="#fff" :stroke-width="26"
9
+          :percentage="percentage"></el-progress>
10
+      </div>
11
+      <div class="mt20" style="color:#E23D28;font-size:12px;line-height:30px;">
12
+        tips:视频不可拖动进度条,每隔30秒会自动保存,暂停也会保存视频进度,若出现学习进度一直不到100%,请退出后重新进入课程。
13
+      </div>
14
+    </div>
15
+    <video ref="videoPlayer" width="1300" height="750" class="custom-video video-js vjs-big-play-centered"
16
+      @play="handlePlay" @pause="handlePause" @ended="handleEnd">
6 17
     </video>
7 18
     <el-alert v-if="showProgressTip" type="info" :closable="false" class="progress-alert">
8 19
       已为您定位到上次播放位置:{{ formatTime(lastPosition) }}
@@ -27,6 +38,7 @@ export default {
27 38
       getHours: '', //可获得的学时
28 39
       isComplete: false, //是否已经看完
29 40
       showProgressTip: false,
41
+      resource: {},
30 42
       // 禁用功能配置
31 43
       disabledControls: [
32 44
         'progressControl', // 隐藏进度条
@@ -35,6 +47,7 @@ export default {
35 47
         'playbackRateMenuButton'
36 48
       ],
37 49
       videoDuration: undefined,
50
+      percentage: 0,
38 51
     };
39 52
   },
40 53
   watch: {
@@ -67,14 +80,17 @@ export default {
67 80
   methods: {
68 81
     async getData() {
69 82
       let studyId = this.$route.params.id;
70
-      let resData = await getStudy(studyId)
83
+      let resData = await getStudy(studyId);
71 84
       let data = resData.data
85
+      this.resource = data.resource;
72 86
       this.videoPath = data.resource.sourcePath;
87
+      this.percentage = Number(data.lastPoint);
88
+      if (Number(data.lastPoint) > 0 && Number(data.lastPoint) < 100) {
89
+        this.showProgressTip = true;
90
+      }
73 91
       if (Number(data.lastPoint) == 100) {
74 92
         this.isComplete = true;
75 93
         this.getHours = data.resource.hours;
76
-        console.log(data.resource);
77
-
78 94
         this.$message.warning('视频已学习完毕,若要再次学习,请在学习记录里重新添加该课程。')
79 95
       }
80 96
     },
@@ -110,10 +126,10 @@ export default {
110 126
         }
111 127
       });
112 128
 
113
-      // 节流保存(每10秒保存一次)
129
+      // 节流保存(每30秒保存一次)
114 130
       this.player.on('timeupdate', _.throttle(() => {
115 131
         this.saveCurrentPosition();
116
-      }, 10000));
132
+      }, 30000));
117 133
     },
118 134
     // 加载上次播放位置
119 135
     async loadLastPosition() {
@@ -155,8 +171,14 @@ export default {
155 171
     },
156 172
 
157 173
     // 处理视频结束
158
-    handleEnd() {
159
-      this.saveCurrentPosition(true);
174
+    async handleEnd() {
175
+      this.$message.warning('视频已学习完毕,若要再次学习,请在学习记录里重新添加该课程。')
176
+      await updateStudy({
177
+        studyId: this.videoId,
178
+        lastPoint: 100,
179
+        getHours: this.resource.hours,
180
+        lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
181
+      });
160 182
     },
161 183
     // 保存当前进度
162 184
     async saveCurrentPosition(isEnd = false) {
@@ -166,18 +188,17 @@ export default {
166 188
       if (isNaN(percentage)) {
167 189
         return
168 190
       }
191
+      this.percentage = Number(percentage)
169 192
       try {
170
-        if (!this.isComplete) {
171
-          await updateStudy({
172
-            studyId: this.videoId,
173
-            lastPoint: percentage
174
-          });
175
-        } else {
176
-          await updateStudy({
177
-            studyId: this.videoId,
178
-            getHours: this.getHours
179
-          });
193
+        if (Number(percentage) == 100) {
194
+          return
180 195
         }
196
+        await updateStudy({
197
+          studyId: this.videoId,
198
+          lastPoint: percentage,
199
+          getHours: Math.round((this.resource.hours * percentage) / 100),
200
+          lastTime: this.parseTime(new Date(), '{y}-{m}-{d}')
201
+        });
181 202
         // 始终更新 lastPosition(即使播放结束)
182 203
         this.lastPosition = currentTime;
183 204
       } catch (error) {
@@ -191,6 +212,21 @@ export default {
191 212
       date.setSeconds(seconds);
192 213
       return date.toISOString().substr(11, 8);
193 214
     },
215
+    formatStatus(row) {
216
+      if (!row) {
217
+        row = 0
218
+        return 'exception'
219
+      }
220
+      if (row <= 20) {
221
+        return 'exception'
222
+      } else if (row > 20 && row <= 50) {
223
+        return 'warning'
224
+      } else if (row > 50 && row <= 80) {
225
+        return null
226
+      } else {
227
+        return 'success'
228
+      }
229
+    },
194 230
   },
195 231
   beforeDestroy() {
196 232
     if (this.player) {
@@ -203,8 +239,20 @@ export default {
203 239
 <style lang="scss" scoped>
204 240
 .video-container {
205 241
   position: relative;
206
-  width: 1000px;
207
-  // margin: 0 auto;
242
+
243
+  .return-btn {
244
+    position: absolute;
245
+    left: 20px;
246
+    top: 30px;
247
+    width: 130px;
248
+  }
249
+
250
+  .custom-video {
251
+    position: absolute;
252
+    left: 50%;
253
+    top: 50%;
254
+    transform: translate(-50%, 0%);
255
+  }
208 256
 
209 257
   .progress-alert {
210 258
     position: absolute;

Loading…
Annuler
Enregistrer