hookehuyr

feat(study-detail): 新增学习详情页相关组件和功能

新增学习目录弹窗组件、学习评论组件和学习资料弹窗组件
添加学习记录跟踪器和学习评论跟踪器组合式函数
优化音频播放器组件样式和交互逻辑
...@@ -43,6 +43,9 @@ declare module 'vue' { ...@@ -43,6 +43,9 @@ declare module 'vue' {
43 SearchBar: typeof import('./components/ui/SearchBar.vue')['default'] 43 SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
44 SharePoster: typeof import('./components/ui/SharePoster.vue')['default'] 44 SharePoster: typeof import('./components/ui/SharePoster.vue')['default']
45 StarryBackground: typeof import('./components/effects/StarryBackground.vue')['default'] 45 StarryBackground: typeof import('./components/effects/StarryBackground.vue')['default']
46 + StudyCatalogPopup: typeof import('./components/studyDetail/StudyCatalogPopup.vue')['default']
47 + StudyCommentsSection: typeof import('./components/studyDetail/StudyCommentsSection.vue')['default']
48 + StudyMaterialsPopup: typeof import('./components/studyDetail/StudyMaterialsPopup.vue')['default']
46 SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default'] 49 SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
47 TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default'] 50 TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default']
48 TaskCascaderFilter: typeof import('./components/teacher/TaskCascaderFilter.vue')['default'] 51 TaskCascaderFilter: typeof import('./components/teacher/TaskCascaderFilter.vue')['default']
......
1 +<template>
2 + <van-popup v-model:show="show_catalog_model" position="bottom" round closeable safe-area-inset-bottom
3 + style="height: 80%">
4 + <div class="flex flex-col h-full">
5 + <div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
6 + <div class="text-lg font-medium">课程目录</div>
7 + </div>
8 + <div ref="scroll_container_ref" class="flex-1 overflow-y-auto px-4 py-2" style="overflow-y: scroll;">
9 + <div v-if="lessons.length" class="space-y-4">
10 + <div v-for="(lesson, index) in lessons" :key="index" @click="$emit('lessonClick', lesson)"
11 + class="lesson-item bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"
12 + :class="{ 'is-active': String(courseId) === String(lesson.id) }"
13 + :data-lesson-id="String(lesson.id)">
14 + <div v-if="lesson.progress > 0 && lesson.progress < 100"
15 + class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
16 + 上次看到</div>
17 + <div class="text-black text-base mb-2"
18 + :class="{ 'text-green-600 font-medium': String(courseId) === String(lesson.id) }">
19 + {{ lesson.title }} •
20 + <span class="text-sm text-gray-500">{{ courseTypeMaps[lesson.course_type] }}</span>
21 + </div>
22 + <div class="flex items-center text-sm text-gray-500">
23 + <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无'
24 + }}</span>
25 + <span class="mx-2">|</span>
26 + <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
27 + </div>
28 + </div>
29 + </div>
30 + <van-empty v-else description="暂无目录" />
31 + </div>
32 + </div>
33 + </van-popup>
34 +</template>
35 +
36 +<script setup>
37 +import { computed, nextTick, ref, watch } from 'vue';
38 +import dayjs from 'dayjs';
39 +
40 +const props = defineProps({
41 + showCatalog: {
42 + type: Boolean,
43 + default: false
44 + },
45 + lessons: {
46 + type: Array,
47 + default: () => []
48 + },
49 + courseId: {
50 + type: [String, Number],
51 + default: ''
52 + },
53 + courseTypeMaps: {
54 + type: Object,
55 + default: () => ({})
56 + }
57 +});
58 +
59 +const emit = defineEmits([
60 + 'update:showCatalog',
61 + 'lessonClick'
62 +]);
63 +
64 +const scroll_container_ref = ref(null);
65 +
66 +const show_catalog_model = computed({
67 + get: () => props.showCatalog,
68 + set: (val) => emit('update:showCatalog', val)
69 +});
70 +
71 +watch(show_catalog_model, (newVal) => {
72 + if (!newVal) return;
73 +
74 + nextTick(() => {
75 + const container = scroll_container_ref.value;
76 + if (!container) return;
77 +
78 + const active_item = container.querySelector('.lesson-item.is-active');
79 + if (!active_item) return;
80 +
81 + const container_height = container.clientHeight;
82 + const item_top = active_item.offsetTop;
83 + const item_height = active_item.clientHeight;
84 + const scroll_top = item_top - (container_height / 2) + (item_height / 2);
85 +
86 + container.scrollTo({
87 + top: scroll_top,
88 + behavior: 'smooth'
89 + });
90 + });
91 +});
92 +</script>
1 +<template>
2 + <div id="comment" class="py-4 px-4 space-y-4" :style="{ paddingBottom: bottomWrapperHeight }">
3 + <div class="flex justify-between items-center mb-4">
4 + <div class="text-gray-900 font-medium text-sm">评论 ({{ commentCount }})</div>
5 + <div class="text-gray-500 cursor-pointer text-sm" @click="show_comment_popup_model = true">查看更多</div>
6 + </div>
7 + <div v-for="comment in commentList" :key="comment.id" class="border-b border-gray-100 last:border-b-0 py-4">
8 + <div class="flex">
9 + <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
10 + class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
11 + <div class="flex-1 ml-3">
12 + <div class="flex justify-between items-center mb-1">
13 + <span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
14 + <div class="flex items-center space-x-1">
15 + <span class="text-sm text-gray-500">{{ comment.like_count }}</span> &nbsp;
16 + <van-icon :name="comment.is_like ? 'like' : 'like-o'"
17 + :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
18 + @click="$emit('toggleLike', comment)" class="text-lg cursor-pointer" />
19 + </div>
20 + </div>
21 + <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
22 + <div class="flex items-center justify-between">
23 + <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
24 + <van-icon v-if="comment.is_my" name="ellipsis" class="text-gray-400"
25 + @click="show_action_sheet(comment)" />
26 + </div>
27 + </div>
28 + </div>
29 + </div>
30 + <van-popup v-model:show="show_comment_popup_model" position="bottom" round closeable safe-area-inset-bottom
31 + style="height: 80%">
32 + <div class="flex flex-col h-full">
33 + <div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
34 + <div class="text-lg font-medium">全部评论 ({{ commentCount }})</div>
35 + </div>
36 + <div class="flex-1 overflow-y-auto">
37 + <van-list v-model:loading="popup_loading_model" :finished="popupFinished" finished-text="没有更多评论了"
38 + @load="$emit('popupLoad')" class="px-4 py-2 pb-16">
39 + <div v-for="comment in popupCommentList" :key="comment.id"
40 + class="border-b border-gray-100 last:border-b-0 py-4">
41 + <div class="flex">
42 + <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
43 + class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
44 + <div class="flex-1 ml-3">
45 + <div class="flex justify-between items-center mb-1">
46 + <span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
47 + <div class="flex items-center space-x-1">
48 + <span class="text-sm text-gray-500">{{ comment.like_count }}</span>
49 + &nbsp;
50 + <van-icon :name="comment.is_like ? 'like' : 'like-o'"
51 + :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
52 + @click="$emit('toggleLike', comment)" class="text-lg cursor-pointer" />
53 + </div>
54 + </div>
55 + <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
56 + <div class="flex items-center justify-between">
57 + <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
58 + <van-icon v-if="comment.is_my" name="ellipsis" class="text-gray-400"
59 + @click="show_action_sheet(comment)" />
60 + </div>
61 + </div>
62 + </div>
63 + </div>
64 + </van-list>
65 + </div>
66 + <div class="flex-none border-t px-4 py-2 bg-white fixed bottom-0 left-0 right-0 z-10">
67 + <div class="flex items-center space-x-2">
68 + <van-field v-model="popup_comment_model" rows="1" autosize type="textarea" placeholder="请输入评论"
69 + class="flex-1 bg-gray-100 rounded-lg" />
70 + <van-button type="primary" size="small" @click="$emit('submitPopupComment')">发送</van-button>
71 + </div>
72 + </div>
73 + </div>
74 + </van-popup>
75 +
76 + <van-action-sheet v-model:show="show_actions" :actions="actions" cancel-text="取消" close-on-click-action
77 + @select="on_select_action" />
78 + </div>
79 +</template>
80 +
81 +<script setup>
82 +import { computed, ref } from 'vue';
83 +import { showConfirmDialog, showToast } from 'vant'
84 +import { formatDate } from '@/utils/tools'
85 +import { delGroupCommentAPI } from '@/api/course'
86 +
87 +const props = defineProps({
88 + commentCount: {
89 + type: Number,
90 + default: 0
91 + },
92 + commentList: {
93 + type: Array,
94 + default: () => []
95 + },
96 + showCommentPopup: {
97 + type: Boolean,
98 + default: false
99 + },
100 + popupCommentList: {
101 + type: Array,
102 + default: () => []
103 + },
104 + popupLoading: {
105 + type: Boolean,
106 + default: false
107 + },
108 + popupFinished: {
109 + type: Boolean,
110 + default: false
111 + },
112 + popupComment: {
113 + type: String,
114 + default: ''
115 + },
116 + bottomWrapperHeight: {
117 + type: String,
118 + default: '0px'
119 + }
120 +});
121 +
122 +const emit = defineEmits([
123 + 'update:showCommentPopup',
124 + 'update:popupLoading',
125 + 'update:popupComment',
126 + 'toggleLike',
127 + 'popupLoad',
128 + 'submitPopupComment',
129 + 'commentDeleted'
130 +]);
131 +
132 +const show_comment_popup_model = computed({
133 + get: () => props.showCommentPopup,
134 + set: (val) => emit('update:showCommentPopup', val)
135 +});
136 +
137 +const popup_loading_model = computed({
138 + get: () => props.popupLoading,
139 + set: (val) => emit('update:popupLoading', val)
140 +});
141 +
142 +const popup_comment_model = computed({
143 + get: () => props.popupComment,
144 + set: (val) => emit('update:popupComment', val)
145 +});
146 +
147 +const show_actions = ref(false)
148 +const current_comment = ref(null)
149 +const actions = [
150 + { name: '删除', color: '#ef4444' },
151 +]
152 +
153 +/**
154 + * @function show_action_sheet
155 + * @description 打开评论操作面板(目前仅支持删除)
156 + * @param {Object} comment - 当前选中的评论对象
157 + * @returns {void}
158 + */
159 +const show_action_sheet = (comment) => {
160 + current_comment.value = comment
161 + show_actions.value = true
162 +}
163 +
164 +/**
165 + * @function on_select_action
166 + * @description 处理评论操作面板选项点击
167 + * @param {Object} action - 选中的动作项
168 + * @returns {void}
169 + */
170 +const on_select_action = (action) => {
171 + if (action?.name === '删除') {
172 + confirm_delete_comment()
173 + }
174 +}
175 +
176 +/**
177 + * @function confirm_delete_comment
178 + * @description 二次确认删除评论并调用接口
179 + * @returns {Promise<void>}
180 + */
181 +const confirm_delete_comment = async () => {
182 + const comment_id = current_comment.value?.id
183 + if (!comment_id) return
184 +
185 + try {
186 + await showConfirmDialog({
187 + title: '温馨提示',
188 + message: '确定要删除这条评论吗?',
189 + })
190 + } catch (e) {
191 + return
192 + }
193 +
194 + const { code } = await delGroupCommentAPI({ i: comment_id })
195 + if (code) {
196 + showToast('评论删除成功')
197 + emit('commentDeleted', comment_id)
198 + }
199 +}
200 +</script>
1 +<template>
2 + <van-popup v-model:show="show_materials_popup_model" position="bottom" :style="{ width: '100%', height: '100%' }"
3 + :close-on-click-overlay="true" :lock-scroll="true">
4 + <div class="flex flex-col h-full bg-gray-50">
5 + <div class="bg-white shadow-sm border-b border-gray-100">
6 + <div class="flex items-center justify-between px-4 py-3">
7 + <div class="flex items-center gap-3">
8 + <div class="px-1">
9 + <h2 class="text-lg font-medium text-gray-900">学习资料</h2>
10 + <p class="text-xs text-gray-500">共 {{ files.length }} 个文件</p>
11 + </div>
12 + </div>
13 + <div class="px-2 py-1 rounded-full">
14 + <van-button @click="show_materials_popup_model = false" type="default" size="small" round
15 + class="w-8 h-8 p-0 bg-gray-100 border-0">
16 + <van-icon name="cross" size="16" class="text-gray-600" />
17 + </van-button>
18 + </div>
19 + </div>
20 + </div>
21 +
22 + <div class="flex-1 overflow-y-auto p-4 pb-safe">
23 + <div v-if="files.length > 0" class="space-y-4">
24 + <FrostedGlass v-for="(file, index) in files" :key="index" :bgOpacity="70" blurLevel="md"
25 + className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform">
26 + <div class="flex items-start gap-4 mb-4 p-2">
27 + <div
28 + class="w-12 h-12 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
29 + <van-icon :name="getFileIcon(file.title || file.name)" class="text-blue-600" :size="22" />
30 + </div>
31 + <div class="flex-1 min-w-0">
32 + <h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title ||
33 + file.name }}</h3>
34 + <div class="flex items-center justify-between gap-4 text-sm text-gray-600">
35 + <div class="flex items-center gap-1">
36 + <van-icon name="label-o" size="12" style="margin-right: 0.25rem;" />
37 + <span>{{ getFileType(file.title || file.name) }}</span>
38 + <span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) + 'MB' :
39 + '' }}</span>
40 + </div>
41 + </div>
42 + </div>
43 + </div>
44 +
45 + <div class="flex gap-2" style="margin: 1rem;">
46 + <button v-if="isPdfFile(file.url)" @click="$emit('openPdf', file)"
47 + class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
48 + <van-icon name="eye-o" size="16" />
49 + 在线查看
50 + </button>
51 + <button v-else-if="isAudioFile(file.url)" @click="$emit('openAudio', file)"
52 + class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
53 + <van-icon name="music-o" size="16" />
54 + 音频播放
55 + </button>
56 + <button v-else-if="isVideoFile(file.url)" @click="$emit('openVideo', file)"
57 + class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
58 + <van-icon name="video-o" size="16" />
59 + 视频播放
60 + </button>
61 + <button v-else-if="isImageFile(file.url)" @click="$emit('openImage', file)"
62 + class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
63 + <van-icon name="photo-o" size="16" />
64 + 图片预览
65 + </button>
66 + </div>
67 + </FrostedGlass>
68 + </div>
69 +
70 + <div v-else class="flex flex-col items-center justify-center py-16 px-4">
71 + <div class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
72 + <van-icon name="folder-o" :size="28" class="text-gray-400" />
73 + </div>
74 + <p class="text-gray-500 text-base sm:text-lg mb-2 text-center">暂无学习资料</p>
75 + <p class="text-gray-400 text-sm text-center">请联系老师上传相关资料</p>
76 + </div>
77 + </div>
78 + </div>
79 + </van-popup>
80 +</template>
81 +
82 +<script setup>
83 +import { computed } from 'vue';
84 +import FrostedGlass from '@/components/ui/FrostedGlass.vue';
85 +
86 +const props = defineProps({
87 + showMaterialsPopup: {
88 + type: Boolean,
89 + default: false
90 + },
91 + files: {
92 + type: Array,
93 + default: () => []
94 + }
95 +});
96 +
97 +const emit = defineEmits([
98 + 'update:showMaterialsPopup',
99 + 'openPdf',
100 + 'openAudio',
101 + 'openVideo',
102 + 'openImage'
103 +]);
104 +
105 +const show_materials_popup_model = computed({
106 + get: () => props.showMaterialsPopup,
107 + set: (val) => emit('update:showMaterialsPopup', val)
108 +});
109 +
110 +const isPdfFile = (url) => {
111 + if (!url || typeof url !== 'string') return false;
112 + return url.toLowerCase().includes('.pdf');
113 +}
114 +
115 +const isAudioFile = (fileName) => {
116 + if (!fileName || typeof fileName !== 'string') return false;
117 + const extension = fileName.split('.').pop().toLowerCase();
118 + const audioTypes = ['mp3', 'aac', 'wav', 'ogg'];
119 + return audioTypes.includes(extension);
120 +}
121 +
122 +const isVideoFile = (fileName) => {
123 + if (!fileName || typeof fileName !== 'string') return false;
124 + const extension = fileName.split('.').pop().toLowerCase();
125 + const videoTypes = ['mp4', 'avi', 'mov'];
126 + return videoTypes.includes(extension);
127 +}
128 +
129 +const isImageFile = (fileName) => {
130 + if (!fileName || typeof fileName !== 'string') return false;
131 + const extension = fileName.split('.').pop().toLowerCase();
132 + const imageTypes = ['jpg', 'jpeg', 'png', 'gif'];
133 + return imageTypes.includes(extension);
134 +}
135 +
136 +const getFileIcon = (fileName) => {
137 + if (!fileName || typeof fileName !== 'string') {
138 + return 'description';
139 + }
140 +
141 + const extension = fileName.split('.').pop().toLowerCase();
142 + const iconMap = {
143 + pdf: 'description',
144 + doc: 'description',
145 + docx: 'description',
146 + xls: 'description',
147 + xlsx: 'description',
148 + ppt: 'description',
149 + pptx: 'description',
150 + txt: 'description',
151 + zip: 'bag-o',
152 + rar: 'bag-o',
153 + '7z': 'bag-o',
154 + mp3: 'music-o',
155 + aac: 'music-o',
156 + wav: 'music-o',
157 + ogg: 'music-o',
158 + mp4: 'video-o',
159 + avi: 'video-o',
160 + mov: 'video-o',
161 + jpg: 'photo-o',
162 + jpeg: 'photo-o',
163 + png: 'photo-o',
164 + gif: 'photo-o'
165 + };
166 + return iconMap[extension] || 'description';
167 +}
168 +
169 +const getFileType = (fileName) => {
170 + if (!fileName || typeof fileName !== 'string') {
171 + return '未知文件';
172 + }
173 +
174 + const extension = fileName.split('.').pop().toLowerCase();
175 + const typeMap = {
176 + pdf: 'PDF文档',
177 + doc: 'Word文档',
178 + docx: 'Word文档',
179 + xls: 'Excel表格',
180 + xlsx: 'Excel表格',
181 + ppt: 'PPT演示',
182 + pptx: 'PPT演示',
183 + txt: '文本文件',
184 + zip: '压缩文件',
185 + rar: '压缩文件',
186 + '7z': '压缩文件',
187 + mp3: '音频文件',
188 + aac: '音频文件',
189 + wav: '音频文件',
190 + ogg: '音频文件',
191 + mp4: '视频文件',
192 + avi: '视频文件',
193 + mov: '视频文件',
194 + jpg: '图片文件',
195 + jpeg: '图片文件',
196 + png: '图片文件',
197 + gif: '图片文件'
198 + };
199 + return typeMap[extension] || '未知文件';
200 +}
201 +</script>
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-06-11 20:28:32 4 + * @LastEditTime: 2025-12-27 23:56:18
5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue 5 * @FilePath: /mlaj/src/components/ui/AudioPlayer.vue
6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能 6 * @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
7 --> 7 -->
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
58 @click="togglePlay" 58 @click="togglePlay"
59 :class="{'playing': isPlaying, 'opacity-50 cursor-not-allowed': isLoading}" 59 :class="{'playing': isPlaying, 'opacity-50 cursor-not-allowed': isLoading}"
60 :disabled="isLoading" 60 :disabled="isLoading"
61 - class="w-12 h-12 flex items-center justify-center rounded-full bg-blue-500 hover:bg-blue-600 transition-colors shadow-lg" 61 + class="w-12 h-12 flex items-center justify-center rounded-full transition-colors shadow-lg"
62 > 62 >
63 <font-awesome-icon 63 <font-awesome-icon
64 :icon="['fas' , isPlaying ? 'pause' : 'play']" 64 :icon="['fas' , isPlaying ? 'pause' : 'play']"
...@@ -79,8 +79,8 @@ ...@@ -79,8 +79,8 @@
79 <!-- 倍速播放和播放列表按钮 --> 79 <!-- 倍速播放和播放列表按钮 -->
80 <div class="flex justify-between items-center mt-4"> 80 <div class="flex justify-between items-center mt-4">
81 <!-- 倍速播放控件 --> 81 <!-- 倍速播放控件 -->
82 - <button 82 + <button
83 - @click="cycleSpeed" 83 + @click="cycleSpeed"
84 class="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors px-2 py-1 rounded-md hover:bg-gray-100" 84 class="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors px-2 py-1 rounded-md hover:bg-gray-100"
85 > 85 >
86 <span class="text-sm font-medium">{{ speed }}x</span> 86 <span class="text-sm font-medium">{{ speed }}x</span>
...@@ -506,7 +506,7 @@ const cycleSpeed = () => { ...@@ -506,7 +506,7 @@ const cycleSpeed = () => {
506 const currentIndex = speedOptions.indexOf(speed.value) 506 const currentIndex = speedOptions.indexOf(speed.value)
507 const nextIndex = (currentIndex + 1) % speedOptions.length 507 const nextIndex = (currentIndex + 1) % speedOptions.length
508 const newSpeed = speedOptions[nextIndex] 508 const newSpeed = speedOptions[nextIndex]
509 - 509 +
510 speed.value = newSpeed 510 speed.value = newSpeed
511 if (audio.value) { 511 if (audio.value) {
512 audio.value.playbackRate = newSpeed 512 audio.value.playbackRate = newSpeed
......
1 +import { ref, watch } from 'vue';
2 +import { getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI } from '@/api/course';
3 +
4 +/**
5 + * 学习评论跟踪器
6 + * @param {*} course 课程对象
7 + * @returns 学习评论跟踪器对象,包含 commentCount、commentList、newComment、showCommentPopup、popupComment、popupCommentList、popupLoading、popupFinished、popupLimit、popupPage、refreshComments、toggleLike、submitComment 等方法
8 + */
9 +export const useStudyComments = (course) => {
10 + const commentCount = ref(0);
11 + const commentList = ref([]);
12 +
13 + const newComment = ref('');
14 +
15 + const showCommentPopup = ref(false);
16 + const popupComment = ref('');
17 +
18 + const popupCommentList = ref([]);
19 + const popupLoading = ref(false);
20 + const popupFinished = ref(false);
21 + const popupLimit = ref(5);
22 + const popupPage = ref(0);
23 +
24 + const refreshComments = async () => {
25 + if (!course.value?.group_id || !course.value?.id) return;
26 +
27 + const comment = await getGroupCommentListAPI({
28 + group_id: course.value.group_id,
29 + schedule_id: course.value.id
30 + });
31 + if (comment.code) {
32 + commentList.value = comment.data.comment_list;
33 + commentCount.value = comment.data.comment_count;
34 + }
35 + };
36 +
37 + const toggleLike = async (comment) => {
38 + try {
39 + if (!comment.is_like) {
40 + const { code } = await addGroupCommentLikeAPI({ i: comment.id });
41 + if (code) {
42 + comment.is_like = true;
43 + comment.like_count += 1;
44 + }
45 + } else {
46 + const { code } = await delGroupCommentLikeAPI({ i: comment.id });
47 + if (code) {
48 + comment.is_like = false;
49 + comment.like_count -= 1;
50 + }
51 + }
52 + } catch (error) {
53 + console.error('点赞操作失败:', error);
54 + }
55 + };
56 +
57 + const submitComment = async () => {
58 + if (!newComment.value.trim()) return;
59 + if (!course.value?.group_id || !course.value?.id) return;
60 +
61 + try {
62 + const { code } = await addGroupCommentAPI({
63 + group_id: course.value.group_id,
64 + schedule_id: course.value.id,
65 + note: newComment.value
66 + });
67 +
68 + if (code) {
69 + await refreshComments();
70 + newComment.value = '';
71 + }
72 + } catch (error) {
73 + console.error('提交评论失败:', error);
74 + }
75 + };
76 +
77 + const onPopupLoad = async () => {
78 + if (!course.value?.group_id || !course.value?.id) {
79 + popupLoading.value = false;
80 + return;
81 + }
82 +
83 + const nextPage = popupPage.value;
84 + try {
85 + const res = await getGroupCommentListAPI({
86 + group_id: course.value.group_id,
87 + schedule_id: course.value.id,
88 + limit: popupLimit.value,
89 + page: nextPage
90 + });
91 + if (res.code) {
92 + const newComments = res.data.comment_list;
93 + const existingIds = new Set(popupCommentList.value.map(item => item.id));
94 + const uniqueNewComments = newComments.filter(item => !existingIds.has(item.id));
95 + popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments];
96 + popupFinished.value = res.data.comment_list.length < popupLimit.value;
97 + popupPage.value = nextPage + 1;
98 + }
99 + } catch (error) {
100 + console.error('加载评论失败:', error);
101 + }
102 + popupLoading.value = false;
103 + };
104 +
105 + const submitPopupComment = async () => {
106 + if (!popupComment.value.trim()) return;
107 + if (!course.value?.group_id || !course.value?.id) return;
108 +
109 + try {
110 + const { code, data } = await addGroupCommentAPI({
111 + group_id: course.value.group_id,
112 + schedule_id: course.value.id,
113 + note: popupComment.value
114 + });
115 +
116 + if (code) {
117 + popupCommentList.value = [];
118 + popupPage.value = 0;
119 + popupFinished.value = false;
120 + await onPopupLoad();
121 +
122 + commentCount.value = data.comment_count;
123 + await refreshComments();
124 + popupComment.value = '';
125 + }
126 + } catch (error) {
127 + console.error('提交评论失败:', error);
128 + }
129 + };
130 +
131 + watch(showCommentPopup, async (newVal) => {
132 + if (!newVal) return;
133 + if (!course.value?.group_id || !course.value?.id) return;
134 +
135 + popupCommentList.value = [];
136 + popupPage.value = 0;
137 + popupFinished.value = false;
138 + popupLoading.value = true;
139 +
140 + await refreshComments();
141 + onPopupLoad();
142 + });
143 +
144 + return {
145 + commentCount,
146 + commentList,
147 + newComment,
148 + showCommentPopup,
149 + popupComment,
150 + popupCommentList,
151 + popupLoading,
152 + popupFinished,
153 + refreshComments,
154 + toggleLike,
155 + submitComment,
156 + onPopupLoad,
157 + submitPopupComment
158 + };
159 +};
1 +import { ref, onUnmounted } from 'vue';
2 +import { v4 as uuidv4 } from 'uuid';
3 +import { addStudyRecordAPI } from '@/api/record';
4 +
5 +/**
6 + * 学习记录跟踪器
7 + * @param {*} course 课程对象
8 + * @param {*} courseId 课程ID
9 + * @param {*} videoPlayerRef 视频播放器引用
10 + * @param {*} audioPlayerRef 音频播放器引用
11 + * @returns 学习记录跟踪器对象,包含 startAction、addRecord 等方法
12 + */
13 +export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioPlayerRef }) => {
14 + const action_timer = ref(null);
15 + const playback_id = ref('');
16 +
17 + const clear_timer = () => {
18 + if (action_timer.value) {
19 + clearInterval(action_timer.value);
20 + action_timer.value = null;
21 + }
22 + };
23 +
24 + const addRecord = async (paramsObj) => {
25 + if (!paramsObj || !paramsObj.schedule_id) return;
26 + return await addStudyRecordAPI(paramsObj);
27 + };
28 +
29 + const get_schedule_id = () => {
30 + if (typeof courseId === 'object' && courseId && 'value' in courseId) return courseId.value;
31 + return courseId || '';
32 + };
33 +
34 + const get_video_payload = () => {
35 + const player = videoPlayerRef?.value?.getPlayer?.();
36 + if (!player) return null;
37 +
38 + const duration = typeof player.duration === 'function' ? player.duration() : (player.duration || 0);
39 + const position = typeof player.currentTime === 'function' ? player.currentTime() : (player.currentTime || 0);
40 + const meta_id = videoPlayerRef?.value?.getId?.();
41 + if (!meta_id) return null;
42 +
43 + return {
44 + meta_id,
45 + media_duration: duration,
46 + playback_position: position,
47 + playback_id: playback_id.value,
48 + };
49 + };
50 +
51 + const get_audio_payload = (item) => {
52 + const player = audioPlayerRef?.value?.getPlayer?.();
53 + if (!player) return null;
54 +
55 + const meta_id = item?.meta_id;
56 + if (!meta_id) return null;
57 +
58 + return {
59 + meta_id,
60 + media_duration: player.duration || 0,
61 + playback_position: player.currentTime || 0,
62 + playback_id: playback_id.value,
63 + };
64 + };
65 +
66 + const startAction = (item) => {
67 + clear_timer();
68 +
69 + const schedule_id = get_schedule_id();
70 + if (!schedule_id) return;
71 +
72 + playback_id.value = uuidv4();
73 +
74 + action_timer.value = setInterval(() => {
75 + const is_video = course?.value?.course_type === 'video';
76 + const is_audio = course?.value?.course_type === 'audio';
77 +
78 + let payload = null;
79 + if (is_video) payload = get_video_payload();
80 + if (is_audio) payload = get_audio_payload(item);
81 + if (!payload) payload = get_video_payload() || get_audio_payload(item);
82 + if (!payload) return;
83 +
84 + addRecord({
85 + schedule_id,
86 + ...payload,
87 + });
88 + }, 3000);
89 + };
90 +
91 + const endAction = () => {
92 + clear_timer();
93 + };
94 +
95 + onUnmounted(() => {
96 + clear_timer();
97 + });
98 +
99 + return {
100 + startAction,
101 + endAction,
102 + addRecord,
103 + };
104 +};
This diff is collapsed. Click to expand it.