hookehuyr

feat(study): 添加学习记录功能并更新播放器组件

实现学习记录功能,包括添加记录API接口和前端交互逻辑
为VideoPlayer和AudioPlayer组件添加获取ID的方法
更新LearningRecordsPage使用真实API获取数据
在StudyDetailPage中实现播放时持续记录功能
1 +/*
2 + * @Date: 2025-06-11 13:24:46
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-06-11 13:49:42
5 + * @FilePath: /mlaj/src/api/record.js
6 + * @Description: 学习记录相关接口
7 + */
8 +import { fn, fetch } from './fn'
9 +
10 +const Api = {
11 + STUDY_RECORD_LIST: '/srv/?a=study_record&t=list',
12 + STUDY_RECORD_ADD: '/srv/?a=study_record&t=add',
13 +}
14 +
15 +/**
16 + * @description: 获取学习记录列表
17 + * @param: page 页码
18 + * @param: limit 每页数量
19 + * @param: keyword 搜索
20 + * @return: data: { id: 课程id, title: 课程名称, subtitle: 课程副标题, cover: 封面图, study_duration: 学习时长, recent_study_time: 最近学习时间, study_progress: 学习进度(小数) }
21 + */
22 +export const getStudyRecordListAPI = (params) => fn(fetch.get(Api.STUDY_RECORD_LIST, params))
23 +
24 +/**
25 + * @description: 添加记录
26 + * @param: schedule_id 课程章节ID
27 + * @param: meta_id 课程章节的视频、音频的ID
28 + * @param: media_duration 视频、音频的时长
29 + * @param: playback_position 视频、音频当前播放位置
30 + * @param: playback_id 某一轮播放的ID,需要区分不同轮次播放的开始和结束,最终用来统计播放时长
31 + * @return: data: { }
32 + */
33 +export const addStudyRecordAPI = (params) => fn(fetch.post(Api.STUDY_RECORD_ADD, params))
1 <!-- 1 <!--
2 * @Date: 2025-04-07 12:35:35 2 * @Date: 2025-04-07 12:35:35
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-05-30 18:03:29 4 + * @LastEditTime: 2025-06-11 11:58:22
5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue 5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue
6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能 6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
7 --> 7 -->
...@@ -480,6 +480,10 @@ defineExpose({ ...@@ -480,6 +480,10 @@ defineExpose({
480 }, 480 },
481 isPlaying: () => isPlaying.value, 481 isPlaying: () => isPlaying.value,
482 id: props.id, 482 id: props.id,
483 + getPlayer: () => audio.value,
484 + getId() {
485 + return currentSong.value.meta_id || 'meta_id'
486 + }
483 }) 487 })
484 </script> 488 </script>
485 489
......
...@@ -30,6 +30,10 @@ const props = defineProps({ ...@@ -30,6 +30,10 @@ const props = defineProps({
30 type: String, 30 type: String,
31 required: true, 31 required: true,
32 }, 32 },
33 + videoId: {
34 + type: String,
35 + required: true,
36 + },
33 autoplay: { 37 autoplay: {
34 type: Boolean, 38 type: Boolean,
35 required: false, 39 required: false,
...@@ -140,7 +144,10 @@ defineExpose({ ...@@ -140,7 +144,10 @@ defineExpose({
140 }, 144 },
141 getPlayer() { 145 getPlayer() {
142 return player.value; 146 return player.value;
143 - } 147 + },
148 + getId() {
149 + return props.videoId || "meta_id";
150 + },
144 }); 151 });
145 </script> 152 </script>
146 153
......
1 <!-- 1 <!--
2 * @Date: 2025-05-29 15:34:17 2 * @Date: 2025-05-29 15:34:17
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-10 16:13:24 4 + * @LastEditTime: 2025-06-11 13:38:38
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
4 <van-list 4 <van-list
5 v-model:loading="loading" 5 v-model:loading="loading"
6 :finished="finished" 6 :finished="finished"
7 - finished-text="没有更多了" 7 + :finished-text="finishText"
8 @load="onLoad" 8 @load="onLoad"
9 class="px-4 py-3 space-y-4" 9 class="px-4 py-3 space-y-4"
10 > 10 >
...@@ -18,8 +18,8 @@ ...@@ -18,8 +18,8 @@
18 class="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 mr-3" 18 class="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 mr-3"
19 > 19 >
20 <van-image 20 <van-image
21 - :src="record.course.coverImage" 21 + :src="record.cover"
22 - :alt="record.course.title" 22 + :alt="record.title"
23 class="w-full h-full" 23 class="w-full h-full"
24 fit="cover" 24 fit="cover"
25 error-icon="photo-fail" 25 error-icon="photo-fail"
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
30 </div> 30 </div>
31 <div class="flex-1"> 31 <div class="flex-1">
32 <h3 class="text-base font-medium mb-2 line-clamp-1"> 32 <h3 class="text-base font-medium mb-2 line-clamp-1">
33 - {{ record.course.title }} 33 + {{ record.title }}
34 </h3> 34 </h3>
35 <div class="flex items-center text-sm text-gray-500 mb-2"> 35 <div class="flex items-center text-sm text-gray-500 mb-2">
36 <svg 36 <svg
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
47 d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" 47 d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
48 /> 48 />
49 </svg> 49 </svg>
50 - <span>学习时长:{{ formatDuration(record.duration) }}</span> 50 + <span>学习时长:{{ formatDuration(record.study_duration) }}</span>
51 </div> 51 </div>
52 <div class="flex items-center text-sm text-gray-500 mb-3"> 52 <div class="flex items-center text-sm text-gray-500 mb-3">
53 <svg 53 <svg
...@@ -64,12 +64,12 @@ ...@@ -64,12 +64,12 @@
64 d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" 64 d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
65 /> 65 />
66 </svg> 66 </svg>
67 - <span>最近学习:{{ formatDate(record.lastStudyTime) }}</span> 67 + <span>最近学习:{{ formatDate(record.recent_study_time) }}</span>
68 </div> 68 </div>
69 <div class="flex items-center"> 69 <div class="flex items-center">
70 <div class="flex-1"> 70 <div class="flex-1">
71 <van-progress 71 <van-progress
72 - :percentage="record.progress" 72 + :percentage="record.study_progress"
73 :stroke-width="4" 73 :stroke-width="4"
74 color="#10B981" 74 color="#10B981"
75 /> 75 />
...@@ -112,6 +112,9 @@ import { useTitle } from '@vueuse/core'; ...@@ -112,6 +112,9 @@ import { useTitle } from '@vueuse/core';
112 import FrostedGlass from '@/components/ui/FrostedGlass.vue'; 112 import FrostedGlass from '@/components/ui/FrostedGlass.vue';
113 import { formatDate, formatDuration } from '@/utils/tools'; 113 import { formatDate, formatDuration } from '@/utils/tools';
114 114
115 +// 导入接口
116 +import { getStudyRecordListAPI } from "@/api/record";
117 +
115 const $route = useRoute(); 118 const $route = useRoute();
116 useTitle($route.meta.title); 119 useTitle($route.meta.title);
117 120
...@@ -119,50 +122,28 @@ const records = ref([]); ...@@ -119,50 +122,28 @@ const records = ref([]);
119 const loading = ref(false); 122 const loading = ref(false);
120 const finished = ref(false); 123 const finished = ref(false);
121 const page = ref(1); 124 const page = ref(1);
122 -const pageSize = 10; 125 +const limit = ref(10);
123 - 126 +const finishText = ref('没有更多了');
124 -// 模拟学习记录数据
125 -const mockRecords = [
126 - {
127 - id: 1,
128 - course: {
129 - title: '亲子教育必修课:如何培养孩子的学习兴趣',
130 - coverImage: 'https://cdn.ipadbiz.cn/mlaj/images/jbwr0qZvpD4.jpg'
131 - },
132 - duration: 3600, // 秒
133 - lastStudyTime: '2024-01-15T10:30:00',
134 - progress: 75
135 - },
136 - {
137 - id: 2,
138 - course: {
139 - title: '儿童心理发展指南:0-6岁关键期教育方法',
140 - coverImage: 'https://cdn.ipadbiz.cn/mlaj/images/27kCu7bXGEI.jpg'
141 - },
142 - duration: 7200,
143 - lastStudyTime: '2024-01-14T15:20:00',
144 - progress: 45
145 - }
146 -];
147 127
148 // 加载数据 128 // 加载数据
149 -const onLoad = () => { 129 +const onLoad = async () => {
150 loading.value = true; 130 loading.value = true;
151 // 模拟异步加载 131 // 模拟异步加载
152 - setTimeout(() => { 132 + const nextPage = page.value;
153 - const start = (page.value - 1) * pageSize; 133 + const { code, data } = await getStudyRecordListAPI({
154 - const end = start + pageSize; 134 + limit: limit.value,
155 - const newRecords = mockRecords.slice(start, end); 135 + page: nextPage,
156 - 136 + });
157 - records.value.push(...newRecords); 137 + if (code) {
138 + records.value.push(...data);
139 + finished.value = data.length < limit.value;
140 + page.value = nextPage + 1;
158 loading.value = false; 141 loading.value = false;
159 - 142 + } else {
160 - if (newRecords.length < pageSize) { 143 + finished.value = true;
161 - finished.value = true; 144 + finishText.value = '';
162 - } else { 145 + loading.value = false;
163 - page.value += 1; 146 + }
164 - }
165 - }, 1000);
166 }; 147 };
167 148
168 // 处理图片加载错误 149 // 处理图片加载错误
......
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
24 <!-- 视频播放器 --> 24 <!-- 视频播放器 -->
25 <VideoPlayer v-show="isPlaying" ref="videoPlayerRef" 25 <VideoPlayer v-show="isPlaying" ref="videoPlayerRef"
26 :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" :autoplay="false" 26 :video-url="courseFile?.list?.length ? courseFile['list'][0]['url'] : ''" :autoplay="false"
27 + :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''"
27 @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> 28 @onPlay="handleVideoPlay" @onPause="handleVideoPause" />
28 </div> 29 </div>
29 <div v-if="course.course_type === 'audio'" class="w-full relative" 30 <div v-if="course.course_type === 'audio'" class="w-full relative"
...@@ -256,6 +257,7 @@ import PdfPreview from '@/components/ui/PdfPreview.vue'; ...@@ -256,6 +257,7 @@ import PdfPreview from '@/components/ui/PdfPreview.vue';
256 257
257 // 导入接口 258 // 导入接口
258 import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course'; 259 import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course';
260 +import { addStudyRecordAPI } from "@/api/record";
259 261
260 const route = useRoute(); 262 const route = useRoute();
261 const router = useRouter(); 263 const router = useRouter();
...@@ -402,10 +404,16 @@ const pdfShow = ref(false); ...@@ -402,10 +404,16 @@ const pdfShow = ref(false);
402 const pdfTitle = ref(''); 404 const pdfTitle = ref('');
403 const pdfUrl = ref(''); 405 const pdfUrl = ref('');
404 406
405 -const showPdf = ({ title, url }) => { 407 +const showPdf = ({ title, url, meta_id }) => {
406 pdfTitle.value = title; 408 pdfTitle.value = title;
407 pdfUrl.value = url; 409 pdfUrl.value = url;
408 pdfShow.value = true; 410 pdfShow.value = true;
411 + // 新增记录
412 + let paramsObj = {
413 + schedule_id: courseId.value,
414 + meta_id
415 + }
416 + addRecord(paramsObj);
409 }; 417 };
410 418
411 const courseId = computed(() => { 419 const courseId = computed(() => {
...@@ -709,12 +717,16 @@ const onAudioPause = (audio) => { ...@@ -709,12 +717,16 @@ const onAudioPause = (audio) => {
709 const videoDuration = ref(0); 717 const videoDuration = ref(0);
710 const currentPosition = ref(0); 718 const currentPosition = ref(0);
711 719
720 +// 记录音频时长和当前播放位置的变量
721 +const audioDuration = ref(0);
722 +const audioPosition = ref(0);
723 +
712 /** 724 /**
713 * 开始操作 725 * 开始操作
714 * @param action 726 * @param action
715 * @param item 727 * @param item
716 */ 728 */
717 -const startAction = (action, item) => { 729 +const startAction = (item) => {
718 // 先清除可能存在的定时器 730 // 先清除可能存在的定时器
719 if (window.actionTimer) { 731 if (window.actionTimer) {
720 clearInterval(window.actionTimer); 732 clearInterval(window.actionTimer);
...@@ -725,6 +737,11 @@ const startAction = (action, item) => { ...@@ -725,6 +737,11 @@ const startAction = (action, item) => {
725 videoDuration.value = videoPlayerRef.value.getPlayer().duration(); 737 videoDuration.value = videoPlayerRef.value.getPlayer().duration();
726 } 738 }
727 739
740 + // 获取音频总时长(如果是音频播放)
741 + if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
742 + audioDuration.value = audioPlayerRef.value.getPlayer().duration;
743 + }
744 +
728 // 生成唯一标识符 745 // 生成唯一标识符
729 let uuid = uuidv4(); 746 let uuid = uuidv4();
730 console.warn('开始操作', uuid); 747 console.warn('开始操作', uuid);
...@@ -733,12 +750,37 @@ const startAction = (action, item) => { ...@@ -733,12 +750,37 @@ const startAction = (action, item) => {
733 window.actionTimer = setInterval(() => { 750 window.actionTimer = setInterval(() => {
734 console.warn('持续操作中', uuid); 751 console.warn('持续操作中', uuid);
735 752
753 + let paramsObj = {}
754 +
736 // 更新当前播放位置(如果是视频播放) 755 // 更新当前播放位置(如果是视频播放)
737 if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) { 756 if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) {
738 currentPosition.value = videoPlayerRef.value.getPlayer().currentTime(); 757 currentPosition.value = videoPlayerRef.value.getPlayer().currentTime();
739 - console.log('视频总时长:', videoDuration.value, '当前位置:', currentPosition.value); 758 + console.log('视频总时长:', videoDuration.value, '当前位置:', currentPosition.value, 'id:', videoPlayerRef.value.getId());
759 + paramsObj = {
760 + schedule_id: courseId.value,
761 + meta_id: videoPlayerRef.value.getId(),
762 + media_duration: videoDuration.value,
763 + playback_position: currentPosition.value,
764 + playback_id: uuid,
765 + }
766 + }
767 +
768 + // 更新当前播放位置(如果是音频播放)
769 + if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
770 + audioPosition.value = audioPlayerRef.value.getPlayer().currentTime;
771 + console.log('音频总时长:', audioDuration.value, '当前位置:', audioPosition.value, 'id:', audioPlayerRef.value.getId());
772 + paramsObj = {
773 + schedule_id: courseId.value,
774 + meta_id: audioPlayerRef.value.getId(),
775 + media_duration: audioDuration.value,
776 + playback_position: audioPosition.value,
777 + playback_id: uuid,
778 + }
740 } 779 }
741 780
781 + // 新增记录
782 + addRecord(paramsObj);
783 +
742 // 这里可以添加需要持续执行的具体操作 784 // 这里可以添加需要持续执行的具体操作
743 }, 1000); // 每秒执行一次,可以根据需求调整时间间隔 785 }, 1000); // 每秒执行一次,可以根据需求调整时间间隔
744 } 786 }
...@@ -748,11 +790,18 @@ const startAction = (action, item) => { ...@@ -748,11 +790,18 @@ const startAction = (action, item) => {
748 * @param action 790 * @param action
749 * @param item 791 * @param item
750 */ 792 */
751 -const endAction = (action, item) => { 793 +const endAction = (item) => {
752 // 在结束前记录最后的播放位置 794 // 在结束前记录最后的播放位置
753 if (videoPlayerRef.value && videoPlayerRef.value.player) { 795 if (videoPlayerRef.value && videoPlayerRef.value.player) {
754 - window.currentPosition = videoPlayerRef.value.player.currentTime(); 796 + currentPosition.value = videoPlayerRef.value.player.currentTime();
755 - console.log('结束时 - 视频总时长:', window.videoDuration, '最终位置:', window.currentPosition); 797 + console.log('结束时 - 视频总时长:', videoDuration.value, '最终位置:', currentPosition.value);
798 + }
799 +
800 + // 在结束前记录最后的音频播放位置
801 + if (course.value?.course_type === 'audio' && document.querySelector('audio')) {
802 + const audioElement = document.querySelector('audio');
803 + audioPosition.value = audioElement.currentTime || 0;
804 + console.log('结束时 - 音频总时长:', audioDuration.value, '最终位置:', audioPosition.value);
756 } 805 }
757 806
758 // 清除定时器,停止执行startAction 807 // 清除定时器,停止执行startAction
...@@ -762,6 +811,14 @@ const endAction = (action, item) => { ...@@ -762,6 +811,14 @@ const endAction = (action, item) => {
762 console.warn('操作已停止'); 811 console.warn('操作已停止');
763 } 812 }
764 } 813 }
814 +
815 +/**
816 + * 新增记录
817 + * @param paramsObj
818 + */
819 +const addRecord = async (paramsObj) => {
820 + await addStudyRecordAPI(paramsObj);
821 +}
765 </script> 822 </script>
766 823
767 <style lang="less" scoped> 824 <style lang="less" scoped>
......