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 +};
...@@ -27,8 +27,7 @@ ...@@ -27,8 +27,7 @@
27 :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''" 27 :video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''"
28 @onPlay="handleVideoPlay" @onPause="handleVideoPause" /> 28 @onPlay="handleVideoPlay" @onPause="handleVideoPause" />
29 </div> 29 </div>
30 - <div v-if="course.course_type === 'audio'" class="w-full relative" 30 + <div v-if="course.course_type === 'audio'" class="w-full relative border-b border-gray-200">
31 - style="border-bottom: 1px solid #F3F4F6;">
32 <!-- 音频播放器 --> 31 <!-- 音频播放器 -->
33 <AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay" 32 <AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay"
34 @pause="onAudioPause" /> 33 @pause="onAudioPause" />
...@@ -122,85 +121,13 @@ ...@@ -122,85 +121,13 @@
122 </div> 121 </div>
123 122
124 <div class="h-2 bg-gray-100"></div> 123 <div class="h-2 bg-gray-100"></div>
125 - 124 + <!-- 评论区 -->
126 - <div id="comment" class="py-4 px-4 space-y-4" :style="{ paddingBottom: bottomWrapperHeight }"> 125 + <StudyCommentsSection :comment-count="commentCount" :comment-list="commentList"
127 - <div class="flex justify-between items-center mb-4"> 126 + :popup-comment-list="popupCommentList" :popup-finished="popupFinished"
128 - <div class="text-gray-900 font-medium text-sm">评论 ({{ commentCount }})</div> 127 + :bottom-wrapper-height="bottomWrapperHeight" v-model:showCommentPopup="showCommentPopup"
129 - <div class="text-gray-500 cursor-pointer text-sm" @click="showCommentPopup = true">查看更多</div> 128 + v-model:popupLoading="popupLoading" v-model:popupComment="popupComment" @toggleLike="toggleLike"
130 - </div> 129 + @popupLoad="onPopupLoad" @submitPopupComment="submitPopupComment"
131 - <!-- 显示几条评论 --> 130 + @commentDeleted="handleCommentDeleted" />
132 - <div v-for="comment in commentList" :key="comment.id"
133 - class="border-b border-gray-100 last:border-b-0 py-4">
134 - <div class="flex">
135 - <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
136 - class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
137 - <div class="flex-1 ml-3">
138 - <div class="flex justify-between items-center mb-1">
139 - <span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
140 - <div class="flex items-center space-x-1">
141 - <span class="text-sm text-gray-500">{{ comment.like_count }}</span> &nbsp;
142 - <van-icon :name="comment.is_like ? 'like' : 'like-o'"
143 - :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
144 - @click="toggleLike(comment)" class="text-lg cursor-pointer" />
145 - </div>
146 - </div>
147 - <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
148 - <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
149 - </div>
150 - </div>
151 - </div>
152 - <van-popup v-model:show="showCommentPopup" position="bottom" round closeable safe-area-inset-bottom
153 - style="height: 80%">
154 - <div class="flex flex-col h-full">
155 - <!-- 固定头部 -->
156 - <div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
157 - <div class="text-lg font-medium">全部评论 ({{ commentCount }})</div>
158 - </div>
159 -
160 - <!-- 可滚动的评论列表 -->
161 - <div class="flex-1 overflow-y-auto">
162 - <van-list v-model:loading="popupLoading" :finished="popupFinished"
163 - finished-text="没有更多评论了" @load="onPopupLoad" class="px-4 py-2 pb-16">
164 - <div v-for="comment in popupCommentList" :key="comment.id"
165 - class="border-b border-gray-100 last:border-b-0 py-4">
166 - <div class="flex">
167 - <img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
168 - class="w-10 h-10 rounded-full flex-shrink-0"
169 - style="margin-right: 0.5rem;" />
170 - <div class="flex-1 ml-3">
171 - <div class="flex justify-between items-center mb-1">
172 - <span class="font-medium text-gray-900">{{ comment.name || '匿名用户'
173 - }}</span>
174 - <div class="flex items-center space-x-1">
175 - <span class="text-sm text-gray-500">{{ comment.like_count
176 - }}</span>
177 - &nbsp;
178 - <van-icon :name="comment.is_like ? 'like' : 'like-o'"
179 - :class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
180 - @click="toggleLike(comment)"
181 - class="text-lg cursor-pointer" />
182 - </div>
183 - </div>
184 - <p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
185 - <div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time)
186 - }}</div>
187 - </div>
188 - </div>
189 - </div>
190 - </van-list>
191 - </div>
192 -
193 - <!-- 固定底部输入框 -->
194 - <div class="flex-none border-t px-4 py-2 bg-white fixed bottom-0 left-0 right-0 z-10">
195 - <div class="flex items-center space-x-2">
196 - <van-field v-model="popupComment" rows="1" autosize type="textarea"
197 - placeholder="请输入评论" class="flex-1 bg-gray-100 rounded-lg" />
198 - <van-button type="primary" size="small" @click="submitPopupComment">发送</van-button>
199 - </div>
200 - </div>
201 - </div>
202 - </van-popup>
203 - </div>
204 </div> 131 </div>
205 132
206 <!-- 底部操作栏 --> 133 <!-- 底部操作栏 -->
...@@ -212,13 +139,15 @@ ...@@ -212,13 +139,15 @@
212 <span class="text-xs text-gray-600">课程目录</span> 139 <span class="text-xs text-gray-600">课程目录</span>
213 </div> 140 </div>
214 <div class="flex-grow flex-1 min-w-0"> 141 <div class="flex-grow flex-1 min-w-0">
215 - <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言" 142 + <!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言"
216 class="bg-gray-100 rounded-lg !p-0"> 143 class="bg-gray-100 rounded-lg !p-0">
217 <template #input> 144 <template #input>
218 <textarea v-model="newComment" rows="1" placeholder="请输入留言" 145 <textarea v-model="newComment" rows="1" placeholder="请输入留言"
219 class="w-full h-full bg-transparent outline-none resize-none" /> 146 class="w-full h-full bg-transparent outline-none resize-none" />
220 </template> 147 </template>
221 - </van-field> 148 + </van-field> -->
149 + <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入评论"
150 + class="flex-1 bg-gray-100 rounded-lg" />
222 </div> 151 </div>
223 <van-button type="primary" size="small" @click="submitComment">发送</van-button> 152 <van-button type="primary" size="small" @click="submitComment">发送</van-button>
224 </div> 153 </div>
...@@ -238,51 +167,20 @@ ...@@ -238,51 +167,20 @@
238 </template> 167 </template>
239 </van-image-preview> 168 </van-image-preview>
240 169
241 - <!-- 课程目录弹出层 --> 170 + <!-- 课程目录弹窗 -->
242 - <van-popup v-model:show="showCatalog" position="bottom" round closeable safe-area-inset-bottom 171 + <StudyCatalogPopup v-model:showCatalog="showCatalog" :lessons="course_lessons" :course-id="courseId"
243 - style="height: 80%"> 172 + :course-type-maps="course_type_maps" @lessonClick="handleLessonClick" />
244 - <div class="flex flex-col h-full">
245 - <!-- 固定头部 -->
246 - <div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
247 - <div class="text-lg font-medium">课程目录</div>
248 - </div>
249 -
250 - <!-- 可滚动的目录列表 -->
251 - <div ref="scrollContainer" class="flex-1 overflow-y-auto px-4 py-2" style="overflow-y: scroll;">
252 - <div v-if="course_lessons.length" class="space-y-4">
253 - <div v-for="(lesson, index) in course_lessons" :key="index" @click="handleLessonClick(lesson)"
254 - class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
255 - <div v-if="lesson.progress > 0 && lesson.progress < 100"
256 - class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
257 - 上次看到</div>
258 - <div class="text-black text-base mb-2"
259 - :class="{ 'text-green-600 font-medium': courseId == lesson.id }">{{ lesson.title }} •
260 - <span class="text-sm text-gray-500">{{ course_type_maps[lesson.course_type] }}</span>
261 - </div>
262 - <div class="flex items-center text-sm text-gray-500">
263 - <span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') :
264 - '暂无'
265 - }}</span>
266 - <span class="mx-2">|</span>
267 - <span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
268 - </div>
269 - </div>
270 - </div>
271 - <van-empty v-else description="暂无目录" />
272 - </div>
273 - </div>
274 - </van-popup>
275 173
276 <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview --> 174 <!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview -->
277 175
278 <!-- Office 文档预览弹窗 --> 176 <!-- Office 文档预览弹窗 -->
279 - <van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }"> 177 + <!--<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }">
280 <div class="h-full flex flex-col"> 178 <div class="h-full flex flex-col">
281 <div class="p-4 border-b border-gray-200"> 179 <div class="p-4 border-b border-gray-200">
282 <h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3> 180 <h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3>
283 </div> 181 </div>
284 <div class="flex-1 overflow-auto"> 182 <div class="flex-1 overflow-auto">
285 - <!-- <OfficeViewer 183 + <!~~ <OfficeViewer
286 v-if="officeShow && officeUrl && officeFileType" 184 v-if="officeShow && officeUrl && officeFileType"
287 :src="officeUrl" 185 :src="officeUrl"
288 :file-type="officeFileType" 186 :file-type="officeFileType"
...@@ -290,10 +188,10 @@ ...@@ -290,10 +188,10 @@
290 @rendered="onOfficeRendered" 188 @rendered="onOfficeRendered"
291 @error="onOfficeError" 189 @error="onOfficeError"
292 @retry="onOfficeRetry" 190 @retry="onOfficeRetry"
293 - /> --> 191 + /> ~~>
294 </div> 192 </div>
295 </div> 193 </div>
296 - </van-popup> 194 + </van-popup>-->
297 195
298 <!-- 音频播放器弹窗 --> 196 <!-- 音频播放器弹窗 -->
299 <van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }"> 197 <van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }">
...@@ -332,198 +230,9 @@ ...@@ -332,198 +230,9 @@
332 <CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list" 230 <CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list"
333 @check-in-success="handleCheckInSuccess" /> 231 @check-in-success="handleCheckInSuccess" />
334 232
335 - <!-- 下载失败提示弹窗 --> 233 + <!-- 学习资源弹窗 -->
336 - <van-popup v-model:show="showDownloadFailDialog" position="center" round closeable 234 + <StudyMaterialsPopup v-model:showMaterialsPopup="showMaterialsPopup" :files="courseFile?.list || []"
337 - :style="{ width: '85%', maxWidth: '400px' }"> 235 + @openPdf="showPdf" @openAudio="showAudio" @openVideo="showVideo" @openImage="showImage" />
338 - <div class="p-6">
339 - <div class="text-center mb-4">
340 - <van-icon name="warning-o" size="48" color="#ff6b6b" class="mb-2" />
341 - <h3 class="text-lg font-medium text-gray-800">下载失败</h3>
342 - </div>
343 -
344 - <div class="text-center text-gray-600 mb-4">
345 - <p class="mb-2">暂时无法自动下载文件</p>
346 - <p class="text-sm">请复制下方链接手动下载</p>
347 - </div>
348 -
349 - <div class="mb-4">
350 - <div class="text-sm text-gray-500 mb-2">文件名:</div>
351 - <div class="bg-gray-50 p-3 rounded-lg text-sm break-all">
352 - {{ downloadFailInfo.fileName }}
353 - </div>
354 - </div>
355 -
356 - <div class="mb-6">
357 - <div class="text-sm text-gray-500 mb-2">文件链接:</div>
358 - <div class="bg-gray-50 p-3 rounded-lg text-sm break-all max-h-20 overflow-y-auto">
359 - {{ downloadFailInfo.fileUrl }}
360 - </div>
361 - </div>
362 -
363 - <div class="flex gap-3">
364 - <van-button block type="default" @click="showDownloadFailDialog = false" class="flex-1">
365 - 关闭
366 - </van-button>
367 - <van-button block type="primary" @click="copyToClipboard(downloadFailInfo.fileUrl)" class="flex-1">
368 - 复制链接
369 - </van-button>
370 - </div>
371 - </div>
372 - </van-popup>
373 -
374 - <!-- 学习资料全屏弹窗 -->
375 - <van-popup v-model:show="showMaterialsPopup" position="bottom" :style="{ width: '100%', height: '100%' }"
376 - :close-on-click-overlay="true" :lock-scroll="true">
377 - <div class="flex flex-col h-full bg-gray-50">
378 - <!-- 头部导航栏 -->
379 - <div class="bg-white shadow-sm border-b border-gray-100">
380 - <div class="flex items-center justify-between px-4 py-3">
381 - <div class="flex items-center gap-3">
382 - <!-- <van-button
383 - @click="showMaterialsPopup = false"
384 - type="default"
385 - size="small"
386 - round
387 - class="w-8 h-8 p-0 bg-gray-100 border-0"
388 - >
389 - <van-icon name="cross" size="16" class="text-gray-600" />
390 - </van-button> -->
391 - <div class="px-1">
392 - <h2 class="text-lg font-medium text-gray-900">学习资料</h2>
393 - <p class="text-xs text-gray-500">
394 - 共 {{ courseFile?.list ? courseFile.list.length : 0 }} 个文件
395 - </p>
396 - </div>
397 - </div>
398 - <div class="px-2 py-1 bg-blue-50 rounded-full">
399 - <!-- <span class="text-blue-600 text-sm font-medium">
400 - {{ courseFile?.list ? courseFile.list.length : 0 }}
401 - </span> -->
402 - <van-button @click="showMaterialsPopup = false" type="default" size="small" round
403 - class="w-8 h-8 p-0 bg-gray-100 border-0">
404 - <van-icon name="cross" size="16" class="text-gray-600" />
405 - </van-button>
406 - </div>
407 - </div>
408 - </div>
409 -
410 - <!-- 文件列表 -->
411 - <div class="flex-1 overflow-y-auto p-4 pb-safe">
412 - <div v-if="courseFile?.list && courseFile.list.length > 0" class="space-y-4">
413 - <FrostedGlass v-for="(file, index) in courseFile.list" :key="index" :bgOpacity="70"
414 - blurLevel="md"
415 - className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform">
416 - <!-- 文件信息 -->
417 - <div class="flex items-start gap-4 mb-4 p-2">
418 - <div
419 - 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">
420 - <van-icon :name="getFileIcon(file.title || file.name)" class="text-blue-600"
421 - :size="22" />
422 - </div>
423 - <div class="flex-1 min-w-0">
424 - <h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title ||
425 - file.name }}</h3>
426 - <div class="flex items-center justify-between gap-4 text-sm text-gray-600">
427 - <div class="flex items-center gap-1">
428 - <van-icon name="label-o" size="12" style="margin-right: 0.25rem;" />
429 - <span>{{ getFileType(file.title || file.name) }}</span>
430 - <span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) +
431 - 'MB' : ''
432 - }}</span>
433 - </div>
434 - <!-- 复制地址按钮 -->
435 - <!-- <button
436 - @click="copyFileUrl(file.url)"
437 - class="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors"
438 - title="复制文件地址"
439 - >
440 - <van-icon name="link" size="12" />
441 - <span>复制地址</span>
442 - </button> -->
443 - </div>
444 - </div>
445 - </div>
446 -
447 - <!-- 操作按钮 -->
448 - <div class="flex gap-2" style="margin: 1rem;">
449 - <!-- 桌面端:显示在线查看、新窗口打开和下载文件按钮 -->
450 - <!-- <template v-if="isDesktop">
451 - <button
452 - v-if="canOpenInNewWindow(file.title)"
453 - @click="openFileInNewWindow(file)"
454 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
455 - >
456 - <van-icon name="eye-o" size="16" />
457 - 在线查看
458 - </button>
459 - <button
460 - @click="downloadFile(file)"
461 - class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
462 - >
463 - <van-icon name="down" size="16" />
464 - 下载文件
465 - </button>
466 - </template> -->
467 -
468 - <!-- 统一使用移动端操作逻辑:根据文件类型显示不同的预览按钮 -->
469 - <!-- Office 文档显示预览按钮 -->
470 - <!-- <button
471 - v-if="isOfficeFile(file.url)"
472 - @click="showOfficeDocument(file)"
473 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
474 - >
475 - <van-icon name="description" size="16" />
476 - 文档预览
477 - </button> -->
478 - <!-- PDF文件显示在线查看按钮 -->
479 - <button v-if="file.url && file.url.toLowerCase().includes('.pdf')"
480 - @click="showPdf(file)"
481 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
482 - <van-icon name="eye-o" size="16" />
483 - 在线查看
484 - </button>
485 - <!-- 音频文件显示音频播放按钮 -->
486 - <button v-else-if="isAudioFile(file.url)" @click="showAudio(file)"
487 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
488 - <van-icon name="music-o" size="16" />
489 - 音频播放
490 - </button>
491 - <!-- 视频文件显示视频播放按钮 -->
492 - <button v-else-if="isVideoFile(file.url)" @click="showVideo(file)"
493 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
494 - <van-icon name="video-o" size="16" />
495 - 视频播放
496 - </button>
497 - <!-- 图片文件显示图片预览按钮 -->
498 - <button v-else-if="isImageFile(file.url)" @click="showImage(file)"
499 - class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
500 - <van-icon name="photo-o" size="16" />
501 - 图片预览
502 - </button>
503 - <!-- 其他文件显示下载按钮 -->
504 - <!-- <button
505 - @click="downloadFile(file)"
506 - class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
507 - >
508 - <van-icon name="down" size="16" />
509 - 下载文件
510 - </button> -->
511 - </div>
512 - </FrostedGlass>
513 - </div>
514 -
515 - <!-- 空状态 -->
516 - <div v-else class="flex flex-col items-center justify-center py-16 px-4">
517 - <div
518 - class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
519 - <van-icon name="folder-o" :size="28" class="text-gray-400" />
520 - </div>
521 - <p class="text-gray-500 text-base sm:text-lg mb-2 text-center">暂无学习资料</p>
522 - <p class="text-gray-400 text-sm text-center">请联系老师上传相关资料</p>
523 - </div>
524 - </div>
525 - </div>
526 - </van-popup>
527 </div> 236 </div>
528 </template> 237 </template>
529 238
...@@ -533,40 +242,59 @@ import { useRoute, useRouter } from 'vue-router'; ...@@ -533,40 +242,59 @@ import { useRoute, useRouter } from 'vue-router';
533 import { useTitle } from '@vueuse/core'; 242 import { useTitle } from '@vueuse/core';
534 import VideoPlayer from '@/components/ui/VideoPlayer.vue'; 243 import VideoPlayer from '@/components/ui/VideoPlayer.vue';
535 import AudioPlayer from '@/components/ui/AudioPlayer.vue'; 244 import AudioPlayer from '@/components/ui/AudioPlayer.vue';
536 -import FrostedGlass from '@/components/ui/FrostedGlass.vue';
537 import CheckInDialog from '@/components/ui/CheckInDialog.vue'; 245 import CheckInDialog from '@/components/ui/CheckInDialog.vue';
538 // import OfficeViewer from '@/components/ui/OfficeViewer.vue'; 246 // import OfficeViewer from '@/components/ui/OfficeViewer.vue';
539 import dayjs from 'dayjs'; 247 import dayjs from 'dayjs';
540 -import { formatDate, wxInfo } from '@/utils/tools'
541 -import axios from 'axios';
542 -import { v4 as uuidv4 } from "uuid";
543 -import { useIntersectionObserver } from '@vueuse/core';
544 -import PdfViewer from '@/components/ui/PdfViewer.vue';
545 import { showToast } from 'vant'; 248 import { showToast } from 'vant';
249 +import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue';
250 +import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue';
251 +import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue';
252 +import { useStudyComments } from '@/composables/useStudyComments';
253 +import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker';
546 254
547 // 导入接口 255 // 导入接口
548 -import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course'; 256 +import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course';
549 -import { addStudyRecordAPI } from "@/api/record";
550 257
551 const route = useRoute(); 258 const route = useRoute();
552 const router = useRouter(); 259 const router = useRouter();
553 const course = ref(null); 260 const course = ref(null);
554 261
555 -// 设备检测 262 +const {
556 -const deviceInfo = wxInfo(); 263 + commentCount,
557 -const isDesktop = deviceInfo.isPC; 264 + commentList,
558 -const isMobile = deviceInfo.isMobile; 265 + newComment,
266 + showCommentPopup,
267 + popupComment,
268 + popupCommentList,
269 + popupLoading,
270 + popupFinished,
271 + refreshComments,
272 + toggleLike,
273 + submitComment,
274 + onPopupLoad,
275 + submitPopupComment
276 +} = useStudyComments(course);
277 +
278 +/**
279 + * @function handleCommentDeleted
280 + * @description 删除评论成功后,同步更新评论列表与数量(包含主列表与弹窗列表)
281 + * @param {number|string} comment_id - 被删除的评论ID
282 + * @returns {void}
283 + */
284 +const handleCommentDeleted = (comment_id) => {
285 + const id = String(comment_id || '')
286 + if (!id) return
287 +
288 + commentList.value = (commentList.value || []).filter(item => String(item?.id) !== id)
289 + popupCommentList.value = (popupCommentList.value || []).filter(item => String(item?.id) !== id)
290 + commentCount.value = Math.max(0, Number(commentCount.value || 0) - 1)
291 +}
559 292
560 const activeTab = ref('intro'); 293 const activeTab = ref('intro');
561 -const newComment = ref('');
562 const showCatalog = ref(false); 294 const showCatalog = ref(false);
563 const isPlaying = ref(false); 295 const isPlaying = ref(false);
564 const videoPlayerRef = ref(null); 296 const videoPlayerRef = ref(null);
565 const audioPlayerRef = ref(null); 297 const audioPlayerRef = ref(null);
566 -const showCommentPopup = ref(false);
567 -const popupComment = ref('');
568 -
569 -const scrollContainer = ref(null);
570 298
571 // 课程目录相关 299 // 课程目录相关
572 const course_lessons = ref([]); 300 const course_lessons = ref([]);
...@@ -652,13 +380,6 @@ const handleCheckInSuccess = () => { ...@@ -652,13 +380,6 @@ const handleCheckInSuccess = () => {
652 showToast('打卡成功'); 380 showToast('打卡成功');
653 }; 381 };
654 382
655 -// 评论列表分页参数
656 -const popupCommentList = ref([]);
657 -const popupLoading = ref(false);
658 -const popupFinished = ref(false);
659 -const popupLimit = ref(5);
660 -const popupPage = ref(0);
661 -
662 const audioList = ref([]); 383 const audioList = ref([]);
663 384
664 // 设置页面标题 385 // 设置页面标题
...@@ -689,8 +410,6 @@ const handleScroll = () => { ...@@ -689,8 +410,6 @@ const handleScroll = () => {
689 } 410 }
690 }; 411 };
691 412
692 -const commentCount = ref(0);
693 -const commentList = ref([]);
694 const courseFile = ref({}); 413 const courseFile = ref({});
695 414
696 const handleLessonClick = async (lesson) => { 415 const handleLessonClick = async (lesson) => {
...@@ -719,12 +438,7 @@ const handleLessonClick = async (lesson) => { ...@@ -719,12 +438,7 @@ const handleLessonClick = async (lesson) => {
719 audioList.value = []; 438 audioList.value = [];
720 } 439 }
721 440
722 - // 获取评论列表 441 + await refreshComments();
723 - const comment = await getGroupCommentListAPI({ group_id: data.group_id, schedule_id: data.id });
724 - if (comment.code) {
725 - commentList.value = comment.data.comment_list;
726 - commentCount.value = comment.data.comment_count;
727 - }
728 442
729 // 重新计算顶部和底部容器的高度 443 // 重新计算顶部和底部容器的高度
730 nextTick(() => { 444 nextTick(() => {
...@@ -750,10 +464,6 @@ const handleLessonClick = async (lesson) => { ...@@ -750,10 +464,6 @@ const handleLessonClick = async (lesson) => {
750 } 464 }
751 }; 465 };
752 466
753 -const pdfShow = ref(false);
754 -const pdfTitle = ref('');
755 -const pdfUrl = ref('');
756 -
757 // Office 文档预览相关 467 // Office 文档预览相关
758 const officeShow = ref(false); 468 const officeShow = ref(false);
759 const officeTitle = ref(''); 469 const officeTitle = ref('');
...@@ -773,8 +483,6 @@ const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态 ...@@ -773,8 +483,6 @@ const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态
773 const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用 483 const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用
774 484
775 const showPdf = ({ title, url, meta_id }) => { 485 const showPdf = ({ title, url, meta_id }) => {
776 - pdfTitle.value = title;
777 - pdfUrl.value = url;
778 // 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记 486 // 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记
779 const encodedUrl = encodeURIComponent(url); 487 const encodedUrl = encodeURIComponent(url);
780 const encodedTitle = encodeURIComponent(title); 488 const encodedTitle = encodeURIComponent(title);
...@@ -791,41 +499,41 @@ const showPdf = ({ title, url, meta_id }) => { ...@@ -791,41 +499,41 @@ const showPdf = ({ title, url, meta_id }) => {
791 * 显示 Office 文档预览 499 * 显示 Office 文档预览
792 * @param {Object} file - 文件对象,包含title、url、meta_id 500 * @param {Object} file - 文件对象,包含title、url、meta_id
793 */ 501 */
794 -const showOfficeDocument = ({ title, url, meta_id }) => { 502 +// const showOfficeDocument = ({ title, url, meta_id }) => {
795 - console.log('showOfficeDocument called with:', { title, url, meta_id }); 503 +// console.log('showOfficeDocument called with:', { title, url, meta_id });
796 - 504 +
797 - // 清理 URL 中的反引号和多余空格 505 +// // 清理 URL 中的反引号和多余空格
798 - const cleanUrl = url.replace(/`/g, '').trim(); 506 +// const cleanUrl = url.replace(/`/g, '').trim();
799 - 507 +
800 - officeTitle.value = title; 508 +// officeTitle.value = title;
801 - officeUrl.value = cleanUrl; 509 +// officeUrl.value = cleanUrl;
802 - officeFileType.value = getOfficeFileType(cleanUrl); 510 +// officeFileType.value = getOfficeFileType(cleanUrl);
803 - 511 +
804 - console.log('Office document props set:', { 512 +// console.log('Office document props set:', {
805 - title: officeTitle.value, 513 +// title: officeTitle.value,
806 - url: officeUrl.value, 514 +// url: officeUrl.value,
807 - fileType: officeFileType.value 515 +// fileType: officeFileType.value
808 - }); 516 +// });
809 - 517 +
810 - // 验证 URL 格式 518 +// // 验证 URL 格式
811 - try { 519 +// try {
812 - new URL(cleanUrl); 520 +// new URL(cleanUrl);
813 - console.log('URL validation passed:', cleanUrl); 521 +// console.log('URL validation passed:', cleanUrl);
814 - } catch (error) { 522 +// } catch (error) {
815 - console.error('Invalid URL format:', cleanUrl, error); 523 +// console.error('Invalid URL format:', cleanUrl, error);
816 - showToast('文档链接格式不正确'); 524 +// showToast('文档链接格式不正确');
817 - return; 525 +// return;
818 - } 526 +// }
819 - 527 +
820 - officeShow.value = true; 528 +// officeShow.value = true;
821 - 529 +
822 - // 新增记录 530 +// // 新增记录
823 - let paramsObj = { 531 +// let paramsObj = {
824 - schedule_id: courseId.value, 532 +// schedule_id: courseId.value,
825 - meta_id 533 +// meta_id
826 - } 534 +// }
827 - addRecord(paramsObj); 535 +// addRecord(paramsObj);
828 -}; 536 +// };
829 537
830 /** 538 /**
831 * 显示音频播放器 539 * 显示音频播放器
...@@ -931,6 +639,13 @@ const courseId = computed(() => { ...@@ -931,6 +639,13 @@ const courseId = computed(() => {
931 return route.params.id || ''; 639 return route.params.id || '';
932 }); 640 });
933 641
642 +const { startAction, endAction, addRecord } = useStudyRecordTracker({
643 + course,
644 + courseId,
645 + videoPlayerRef,
646 + audioPlayerRef
647 +});
648 +
934 onMounted(async () => { 649 onMounted(async () => {
935 // 延迟设置topWrapper和bottomWrapper的高度 650 // 延迟设置topWrapper和bottomWrapper的高度
936 setTimeout(() => { 651 setTimeout(() => {
...@@ -963,12 +678,8 @@ onMounted(async () => { ...@@ -963,12 +678,8 @@ onMounted(async () => {
963 }) 678 })
964 } 679 }
965 680
966 - // 获取评论列表 681 + // 刷新评论列表
967 - const comment = await getGroupCommentListAPI({ group_id: course.value.group_id, schedule_id: course.value.id }); 682 + await refreshComments();
968 - if (comment.code) {
969 - commentList.value = comment.data.comment_list;
970 - commentCount.value = comment.data.comment_count;
971 - }
972 683
973 // 获取课程目录 684 // 获取课程目录
974 const detail = await getCourseDetailAPI({ i: course.value.group_id }); 685 const detail = await getCourseDetailAPI({ i: course.value.group_id });
...@@ -1016,56 +727,6 @@ onMounted(async () => { ...@@ -1016,56 +727,6 @@ onMounted(async () => {
1016 } 727 }
1017 }); 728 });
1018 729
1019 -// 提交评论
1020 -// 切换点赞状态
1021 -const toggleLike = async (comment) => {
1022 - try {
1023 - if (!comment.is_like) {
1024 - const { code } = await addGroupCommentLikeAPI({ i: comment.id });
1025 - if (code) {
1026 - comment.is_like = true;
1027 - comment.like_count += 1;
1028 - }
1029 - } else {
1030 - const { code } = await delGroupCommentLikeAPI({ i: comment.id });
1031 - if (code) {
1032 - comment.is_like = false;
1033 - comment.like_count -= 1;
1034 - }
1035 - }
1036 - } catch (error) {
1037 - console.error('点赞操作失败:', error);
1038 - }
1039 -};
1040 -
1041 -// 发送按钮,提交评论
1042 -const submitComment = async () => {
1043 - if (!newComment.value.trim()) return;
1044 -
1045 - try {
1046 - const { code, data } = await addGroupCommentAPI({
1047 - group_id: course.value.group_id,
1048 - schedule_id: course.value.id,
1049 - note: newComment.value
1050 - });
1051 -
1052 - if (code) {
1053 - // 刷新评论列表
1054 - const comment = await getGroupCommentListAPI({
1055 - group_id: course.value.group_id,
1056 - schedule_id: course.value.id,
1057 - });
1058 - if (comment.code) {
1059 - commentList.value = comment.data.comment_list;
1060 - commentCount.value = comment.data.comment_count;
1061 - }
1062 - newComment.value = '';
1063 - }
1064 - } catch (error) {
1065 - console.error('提交评论失败:', error);
1066 - }
1067 -};
1068 -
1069 // 处理标签页切换 730 // 处理标签页切换
1070 const handleTabChange = (name) => { 731 const handleTabChange = (name) => {
1071 // 先更新activeTab值 732 // 先更新activeTab值
...@@ -1094,104 +755,12 @@ const handleTabChange = (name) => { ...@@ -1094,104 +755,12 @@ const handleTabChange = (name) => {
1094 }); 755 });
1095 }; 756 };
1096 757
1097 -
1098 -// 加载更多弹框评论
1099 -const onPopupLoad = async () => {
1100 - const nextPage = popupPage.value;
1101 - try {
1102 - const res = await getGroupCommentListAPI({
1103 - group_id: course.value.group_id,
1104 - schedule_id: course.value.id,
1105 - limit: popupLimit.value,
1106 - page: nextPage
1107 - });
1108 - if (res.code) {
1109 - // 使用Set进行去重处理
1110 - const newComments = res.data.comment_list;
1111 - const existingIds = new Set(popupCommentList.value.map(comment => comment.id));
1112 - const uniqueNewComments = newComments.filter(comment => !existingIds.has(comment.id));
1113 - popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments];
1114 - popupFinished.value = res.data.comment_list.length < popupLimit.value;
1115 - popupPage.value = nextPage + 1;
1116 - }
1117 - } catch (error) {
1118 - console.error('加载评论失败:', error);
1119 - }
1120 - popupLoading.value = false;
1121 -};
1122 -
1123 // 在组件卸载时移除滚动监听 758 // 在组件卸载时移除滚动监听
1124 onUnmounted(() => { 759 onUnmounted(() => {
1125 window.removeEventListener('scroll', handleScroll); 760 window.removeEventListener('scroll', handleScroll);
1126 endAction(); 761 endAction();
1127 }); 762 });
1128 763
1129 -// 提交弹窗中的评论
1130 -const submitPopupComment = async () => {
1131 - if (!popupComment.value.trim()) return;
1132 -
1133 - try {
1134 - const { code, data } = await addGroupCommentAPI({
1135 - group_id: course.value.group_id,
1136 - schedule_id: course.value.id,
1137 - note: popupComment.value
1138 - });
1139 -
1140 - if (code) {
1141 - // 重置弹框评论列表并重新加载
1142 - popupCommentList.value = [];
1143 - popupPage.value = 0;
1144 - popupFinished.value = false;
1145 - await onPopupLoad();
1146 -
1147 - // 更新评论数量和清空输入
1148 - commentCount.value = data.comment_count;
1149 - // 更新弹框标题中的评论总数
1150 - const comment = await getGroupCommentListAPI({
1151 - group_id: course.value.group_id,
1152 - schedule_id: course.value.id
1153 - });
1154 - if (comment.code) {
1155 - commentList.value = comment.data.comment_list;
1156 - commentCount.value = comment.data.comment_count;
1157 - }
1158 - popupComment.value = '';
1159 - }
1160 - } catch (error) {
1161 - console.error('提交评论失败:', error);
1162 - }
1163 -};
1164 -// 监听弹窗显示状态变化
1165 -watch(showCommentPopup, async (newVal) => {
1166 - if (newVal) {
1167 - // 打开弹窗时重置状态
1168 - popupCommentList.value = [];
1169 - popupPage.value = 0;
1170 - popupFinished.value = false;
1171 - popupLoading.value = true;
1172 -
1173 - // 获取最新的评论总数
1174 - const comment = await getGroupCommentListAPI({
1175 - group_id: course.value.group_id,
1176 - schedule_id: course.value.id
1177 - });
1178 - if (comment.code) {
1179 - commentList.value = comment.data.comment_list;
1180 - commentCount.value = comment.data.comment_count;
1181 - }
1182 -
1183 - // 加载第一页数据
1184 - onPopupLoad();
1185 - }
1186 -});
1187 -
1188 -// 下载文件失败提示弹窗状态
1189 -const showDownloadFailDialog = ref(false);
1190 -const downloadFailInfo = ref({
1191 - fileName: '',
1192 - fileUrl: ''
1193 -});
1194 -
1195 // 学习资料弹窗状态 764 // 学习资料弹窗状态
1196 const showMaterialsPopup = ref(false); 765 const showMaterialsPopup = ref(false);
1197 766
...@@ -1211,330 +780,76 @@ watch(showMaterialsPopup, (val, oldVal) => { ...@@ -1211,330 +780,76 @@ watch(showMaterialsPopup, (val, oldVal) => {
1211 } 780 }
1212 }); 781 });
1213 782
1214 -/**
1215 - * 复制文件地址到剪贴板
1216 - * @param {string} url - 要复制的文件地址
1217 - */
1218 -const copyFileUrl = async (url) => {
1219 - try {
1220 - if (navigator.clipboard && window.isSecureContext) {
1221 - // 现代浏览器支持的方式
1222 - await navigator.clipboard.writeText(url);
1223 - showToast('文件地址已复制到剪贴板');
1224 - } else {
1225 - // 兼容旧浏览器的方式
1226 - const textArea = document.createElement('textarea');
1227 - textArea.value = url;
1228 - textArea.style.position = 'fixed';
1229 - textArea.style.left = '-999999px';
1230 - textArea.style.top = '-999999px';
1231 - document.body.appendChild(textArea);
1232 - textArea.focus();
1233 - textArea.select();
1234 -
1235 - try {
1236 - document.execCommand('copy');
1237 - showToast('文件地址已复制到剪贴板');
1238 - } catch (err) {
1239 - console.error('复制失败:', err);
1240 - showToast('复制失败,请手动复制地址');
1241 - } finally {
1242 - document.body.removeChild(textArea);
1243 - }
1244 - }
1245 - } catch (err) {
1246 - console.error('复制到剪贴板失败:', err);
1247 - showToast('复制失败,请手动复制地址');
1248 - }
1249 -};
1250 -
1251 -/**
1252 - * 复制到剪贴板
1253 - * @param {string} url - 要复制的URL
1254 - */
1255 -const copyToClipboard = async (url) => {
1256 - try {
1257 - if (navigator.clipboard && window.isSecureContext) {
1258 - // 现代浏览器支持的方式
1259 - await navigator.clipboard.writeText(url);
1260 - showToast('文件链接已复制到剪贴板');
1261 - } else {
1262 - // 兼容旧浏览器的方式
1263 - const textArea = document.createElement('textarea');
1264 - textArea.value = url;
1265 - textArea.style.position = 'fixed';
1266 - textArea.style.left = '-999999px';
1267 - textArea.style.top = '-999999px';
1268 - document.body.appendChild(textArea);
1269 - textArea.focus();
1270 - textArea.select();
1271 -
1272 - try {
1273 - document.execCommand('copy');
1274 - showToast('文件链接已复制到剪贴板');
1275 - } catch (err) {
1276 - console.error('复制失败:', err);
1277 - showToast('复制失败,请手动复制链接');
1278 - } finally {
1279 - document.body.removeChild(textArea);
1280 - }
1281 - }
1282 - } catch (err) {
1283 - console.error('复制到剪贴板失败:', err);
1284 - showToast('复制失败,请手动复制链接');
1285 - }
1286 -};
1287 -
1288 -/**
1289 - * 尝试直接下载文件(适用于同源或支持CORS的文件)
1290 - * @param {string} fileUrl - 文件URL
1291 - * @param {string} fileName - 文件名
1292 - */
1293 -const tryDirectDownload = (fileUrl, fileName) => {
1294 - try {
1295 - const a = document.createElement('a');
1296 - a.href = fileUrl;
1297 - a.download = fileName;
1298 - a.style.display = 'none';
1299 - document.body.appendChild(a);
1300 - a.click();
1301 - document.body.removeChild(a);
1302 - return true;
1303 - } catch (error) {
1304 - console.error('直接下载失败:', error);
1305 - return false;
1306 - }
1307 -};
1308 -
1309 -// 下载文件
1310 -const downloadFile = ({ title, url, meta_id }) => {
1311 - // 获取文件URL和文件名
1312 - const fileUrl = url;
1313 - const fileName = title;
1314 -
1315 - // 根据文件URL后缀获取MIME类型
1316 - const getMimeType = (url) => {
1317 - const extension = url.split('.').pop().toLowerCase();
1318 - const mimeTypes = {
1319 - 'pdf': 'application/pdf',
1320 - 'doc': 'application/msword',
1321 - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1322 - 'xls': 'application/vnd.ms-excel',
1323 - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1324 - 'ppt': 'application/vnd.ms-powerpoint',
1325 - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1326 - 'txt': 'text/plain',
1327 - 'zip': 'application/zip',
1328 - 'rar': 'application/x-rar-compressed',
1329 - '7z': 'application/x-7z-compressed',
1330 - 'mp3': 'audio/mpeg',
1331 - 'acc': 'audio/aac', // 添加对 acc 格式的支持
1332 - 'aac': 'audio/aac', // 添加对 aac 格式的支持
1333 - 'wav': 'audio/wav', // 添加对 wav 格式的支持
1334 - 'ogg': 'audio/ogg', // 添加对 ogg 格式的支持
1335 - 'mp4': 'video/mp4',
1336 - 'jpg': 'image/jpeg',
1337 - 'jpeg': 'image/jpeg',
1338 - 'png': 'image/png',
1339 - 'gif': 'image/gif'
1340 - };
1341 - return mimeTypes[extension] || 'application/octet-stream';
1342 - };
1343 -
1344 - // 首先尝试直接下载(适用于同源文件或支持下载的链接)
1345 - const directDownloadSuccess = tryDirectDownload(fileUrl, fileName);
1346 -
1347 - // 如果直接下载可能成功,等待一段时间后检查是否真的成功
1348 - if (directDownloadSuccess) {
1349 - // 记录下载行为
1350 - let paramsObj = {
1351 - schedule_id: courseId.value,
1352 - meta_id
1353 - }
1354 - addRecord(paramsObj);
1355 - return;
1356 - }
1357 -
1358 - // 如果直接下载失败,尝试通过axios下载
1359 - axios({
1360 - method: 'get',
1361 - url: fileUrl,
1362 - responseType: 'blob', // 表示返回的数据类型是Blob
1363 - timeout: 30000 // 设置30秒超时
1364 - }).then((response) => {
1365 - try {
1366 - const blob = new Blob([response.data], { type: getMimeType(fileUrl) });
1367 - const blobUrl = window.URL.createObjectURL(blob);
1368 -
1369 - const a = document.createElement('a');
1370 - a.href = blobUrl;
1371 - a.download = fileName;
1372 - a.style.display = 'none';
1373 - document.body.appendChild(a);
1374 - a.click();
1375 - document.body.removeChild(a);
1376 -
1377 - // 延迟释放URL,确保下载完成
1378 - setTimeout(() => {
1379 - window.URL.revokeObjectURL(blobUrl);
1380 - }, 1000);
1381 -
1382 - // 新增记录
1383 - let paramsObj = {
1384 - schedule_id: courseId.value,
1385 - meta_id
1386 - }
1387 - addRecord(paramsObj);
1388 -
1389 - showToast('文件下载已开始');
1390 - } catch (blobError) {
1391 - console.error('创建下载链接失败:', blobError);
1392 - // 显示下载失败提示
1393 - downloadFailInfo.value = {
1394 - fileName: fileName,
1395 - fileUrl: fileUrl
1396 - };
1397 - showDownloadFailDialog.value = true;
1398 - }
1399 - }).catch((error) => {
1400 - console.error('下载文件出错:', error);
1401 -
1402 - // 根据错误类型提供不同的处理方式
1403 - let errorMessage = '下载失败';
1404 - if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
1405 - errorMessage = '下载超时';
1406 - } else if (error.response && error.response.status === 404) {
1407 - errorMessage = '文件不存在';
1408 - } else if (error.response && error.response.status === 403) {
1409 - errorMessage = '无权限访问文件';
1410 - } else if (error.message.includes('CORS') || error.message.includes('cross-origin')) {
1411 - errorMessage = '跨域访问限制';
1412 - }
1413 -
1414 - console.log(`${errorMessage},显示手动下载提示`);
1415 -
1416 - // 显示下载失败提示弹窗
1417 - downloadFailInfo.value = {
1418 - fileName: fileName,
1419 - fileUrl: fileUrl
1420 - };
1421 - showDownloadFailDialog.value = true;
1422 - });
1423 -}
1424 783
1425 /** 784 /**
1426 * 在新窗口中打开文件 785 * 在新窗口中打开文件
1427 * @param {Object} file - 文件对象,包含title、url、meta_id 786 * @param {Object} file - 文件对象,包含title、url、meta_id
1428 */ 787 */
1429 -const openFileInNewWindow = ({ title, url, meta_id }) => { 788 +// const openFileInNewWindow = ({ title, url, meta_id }) => {
1430 - // 在新窗口中打开文件URL 789 +// // 在新窗口中打开文件URL
1431 - window.open(url, '_blank'); 790 +// window.open(url, '_blank');
1432 - 791 +
1433 - // 记录访问行为 792 +// // 记录访问行为
1434 - let paramsObj = { 793 +// let paramsObj = {
1435 - schedule_id: courseId.value, 794 +// schedule_id: courseId.value,
1436 - meta_id 795 +// meta_id
1437 - } 796 +// }
1438 - addRecord(paramsObj); 797 +// addRecord(paramsObj);
1439 -} 798 +// }
1440 799
1441 /** 800 /**
1442 * 判断文件是否可以在新窗口中打开 801 * 判断文件是否可以在新窗口中打开
1443 * @param {string} fileName - 文件名 802 * @param {string} fileName - 文件名
1444 * @returns {boolean} 是否可以在新窗口中打开 803 * @returns {boolean} 是否可以在新窗口中打开
1445 */ 804 */
1446 -const canOpenInNewWindow = (fileName) => { 805 +// const canOpenInNewWindow = (fileName) => {
1447 - if (!fileName || typeof fileName !== 'string') { 806 +// if (!fileName || typeof fileName !== 'string') {
1448 - return false; 807 +// return false;
1449 - } 808 +// }
1450 -
1451 - const extension = fileName.split('.').pop().toLowerCase();
1452 - const supportedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'aac', 'wav', 'ogg', 'mp4', 'avi', 'mov'];
1453 - return supportedTypes.includes(extension);
1454 -}
1455 -
1456 -/**
1457 - * 判断文件是否为音频文件
1458 - * @param {string} fileName - 文件名
1459 - * @returns {boolean} 是否为音频文件
1460 - */
1461 -const isAudioFile = (fileName) => {
1462 - if (!fileName || typeof fileName !== 'string') {
1463 - return false;
1464 - }
1465 -
1466 - const extension = fileName.split('.').pop().toLowerCase();
1467 - const audioTypes = ['mp3', 'aac', 'wav', 'ogg'];
1468 - return audioTypes.includes(extension);
1469 -}
1470 -
1471 -/**
1472 - * 判断文件是否为视频文件
1473 - * @param {string} fileName - 文件名
1474 - * @returns {boolean} 是否为视频文件
1475 - */
1476 -const isVideoFile = (fileName) => {
1477 - if (!fileName || typeof fileName !== 'string') {
1478 - return false;
1479 - }
1480 -
1481 - const extension = fileName.split('.').pop().toLowerCase();
1482 - const videoTypes = ['mp4', 'avi', 'mov'];
1483 - return videoTypes.includes(extension);
1484 -}
1485 809
1486 -/** 810 +// const extension = fileName.split('.').pop().toLowerCase();
1487 - * 判断文件是否为图片文件 811 +// const supportedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'aac', 'wav', 'ogg', 'mp4', 'avi', 'mov'];
1488 - * @param {string} fileName - 文件名 812 +// return supportedTypes.includes(extension);
1489 - * @returns {boolean} 是否为图片文件 813 +// }
1490 - */
1491 -const isImageFile = (fileName) => {
1492 - if (!fileName || typeof fileName !== 'string') {
1493 - return false;
1494 - }
1495 814
1496 - const extension = fileName.split('.').pop().toLowerCase();
1497 - const imageTypes = ['jpg', 'jpeg', 'png', 'gif'];
1498 - return imageTypes.includes(extension);
1499 -}
1500 815
1501 /** 816 /**
1502 * 判断文件是否为 Office 文档 817 * 判断文件是否为 Office 文档
1503 * @param {string} fileName - 文件名 818 * @param {string} fileName - 文件名
1504 * @returns {boolean} 是否为 Office 文档 819 * @returns {boolean} 是否为 Office 文档
1505 */ 820 */
1506 -const isOfficeFile = (fileName) => { 821 +// const isOfficeFile = (fileName) => {
1507 - if (!fileName || typeof fileName !== 'string') { 822 +// if (!fileName || typeof fileName !== 'string') {
1508 - return false; 823 +// return false;
1509 - } 824 +// }
1510 825
1511 - const extension = fileName.split('.').pop().toLowerCase(); 826 +// const extension = fileName.split('.').pop().toLowerCase();
1512 - const officeTypes = ['docx', 'xlsx', 'xls', 'pptx']; 827 +// const officeTypes = ['docx', 'xlsx', 'xls', 'pptx'];
1513 - return officeTypes.includes(extension); 828 +// return officeTypes.includes(extension);
1514 -} 829 +// }
1515 830
1516 /** 831 /**
1517 * 获取 Office 文档类型 832 * 获取 Office 文档类型
1518 * @param {string} fileName - 文件名 833 * @param {string} fileName - 文件名
1519 * @returns {string} 文档类型 (docx, excel, pptx) 834 * @returns {string} 文档类型 (docx, excel, pptx)
1520 */ 835 */
1521 -const getOfficeFileType = (fileName) => { 836 +// const getOfficeFileType = (fileName) => {
1522 - if (!fileName || typeof fileName !== 'string') { 837 +// if (!fileName || typeof fileName !== 'string') {
1523 - return ''; 838 +// return '';
1524 - } 839 +// }
1525 840
1526 - const extension = fileName.split('.').pop().toLowerCase(); 841 +// const extension = fileName.split('.').pop().toLowerCase();
1527 842
1528 - if (extension === 'docx') { 843 +// if (extension === 'docx') {
1529 - return 'docx'; 844 +// return 'docx';
1530 - } else if (extension === 'xlsx' || extension === 'xls') { 845 +// } else if (extension === 'xlsx' || extension === 'xls') {
1531 - return 'excel'; 846 +// return 'excel';
1532 - } else if (extension === 'pptx') { 847 +// } else if (extension === 'pptx') {
1533 - return 'pptx'; 848 +// return 'pptx';
1534 - } 849 +// }
1535 850
1536 - return ''; 851 +// return '';
1537 -} 852 +// }
1538 853
1539 /** 854 /**
1540 * 音频播放事件 855 * 音频播放事件
...@@ -1556,144 +871,6 @@ const onAudioPause = (audio) => { ...@@ -1556,144 +871,6 @@ const onAudioPause = (audio) => {
1556 endAction(); 871 endAction();
1557 } 872 }
1558 873
1559 -// 记录视频时长和当前播放位置的变量
1560 -const videoDuration = ref(0);
1561 -const currentPosition = ref(0);
1562 -
1563 -// 记录音频时长和当前播放位置的变量
1564 -const audioDuration = ref(0);
1565 -const audioPosition = ref(0);
1566 -
1567 -/**
1568 - * 开始操作
1569 - * @param action
1570 - * @param item
1571 - */
1572 -const startAction = (item) => {
1573 - // 先清除可能存在的定时器
1574 - if (window.actionTimer) {
1575 - clearInterval(window.actionTimer);
1576 - }
1577 -
1578 - // 获取视频总时长(如果是视频播放)
1579 - if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) {
1580 - videoDuration.value = videoPlayerRef.value.getPlayer().duration();
1581 - }
1582 -
1583 - // 获取音频总时长(如果是音频播放)
1584 - if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
1585 - audioDuration.value = audioPlayerRef.value.getPlayer().duration;
1586 - }
1587 -
1588 - // 生成唯一标识符
1589 - let uuid = uuidv4();
1590 - console.warn('开始操作', uuid);
1591 -
1592 - // 设置定时器,持续执行操作
1593 - window.actionTimer = setInterval(() => {
1594 - console.warn('持续操作中', uuid);
1595 -
1596 - let paramsObj = {
1597 - schedule_id: courseId.value,
1598 - meta_id: item?.meta_id,
1599 - }
1600 -
1601 - // 更新当前播放位置(如果是视频播放)
1602 - if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) {
1603 - currentPosition.value = videoPlayerRef.value.getPlayer().currentTime();
1604 - console.log('视频总时长:', videoDuration.value, '当前位置:', currentPosition.value, 'id:', videoPlayerRef.value.getId());
1605 - paramsObj = {
1606 - schedule_id: courseId.value,
1607 - meta_id: videoPlayerRef.value.getId(),
1608 - media_duration: videoDuration.value,
1609 - playback_position: currentPosition.value,
1610 - playback_id: uuid,
1611 - }
1612 - }
1613 -
1614 - // 更新当前播放位置(如果是音频播放)
1615 - if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
1616 - audioPosition.value = audioPlayerRef.value.getPlayer().currentTime;
1617 - console.log('音频总时长:', audioDuration.value, '当前位置:', audioPosition.value, 'id:', item?.meta_id);
1618 - paramsObj = {
1619 - schedule_id: courseId.value,
1620 - meta_id: item?.meta_id,
1621 - media_duration: audioDuration.value,
1622 - playback_position: audioPosition.value,
1623 - playback_id: uuid,
1624 - }
1625 - }
1626 -
1627 - // 新增记录
1628 - addRecord(paramsObj);
1629 -
1630 - // 这里可以添加需要持续执行的具体操作
1631 - }, 3000); // 3秒执行一次,可以根据需求调整时间间隔
1632 -}
1633 -
1634 -/**
1635 - * 结束操作
1636 - * @param action
1637 - * @param item
1638 - */
1639 -const endAction = (item) => {
1640 - // 在结束前记录最后的播放位置
1641 - if (videoPlayerRef.value && videoPlayerRef.value.player) {
1642 - currentPosition.value = videoPlayerRef.value.player.currentTime();
1643 - console.log('结束时 - 视频总时长:', videoDuration.value, '最终位置:', currentPosition.value);
1644 - }
1645 -
1646 - // 在结束前记录最后的音频播放位置
1647 - if (course.value?.course_type === 'audio' && document.querySelector('audio')) {
1648 - const audioElement = document.querySelector('audio');
1649 - audioPosition.value = audioElement.currentTime || 0;
1650 - console.log('结束时 - 音频总时长:', audioDuration.value, '最终位置:', audioPosition.value);
1651 - }
1652 -
1653 - // 清除定时器,停止执行startAction
1654 - if (window.actionTimer) {
1655 - clearInterval(window.actionTimer);
1656 - window.actionTimer = null;
1657 - console.warn('操作已停止');
1658 - }
1659 -}
1660 -
1661 -/**
1662 - * 添加学习记录
1663 - * @param paramsObj
1664 - */
1665 -const addRecord = async (paramsObj) => {
1666 - await addStudyRecordAPI(paramsObj);
1667 -}
1668 -
1669 -// 监听目录弹框显示状态,当打开时滚动到当前课程位置
1670 -watch(showCatalog, (newVal) => {
1671 - if (newVal) {
1672 - // 等待DOM更新后执行滚动
1673 - nextTick(() => {
1674 - // 查找当前选中的课程元素
1675 - const selectedLesson = document.querySelector('.text-green-600')?.closest('.bg-white');
1676 - if (selectedLesson) {
1677 - // 获取滚动容器
1678 - const scrollContainer = document.querySelector('.van-popup .overflow-y-auto');
1679 - if (scrollContainer) {
1680 - // 计算滚动位置,使选中元素位于容器中间
1681 - const containerHeight = scrollContainer.clientHeight;
1682 - const lessonTop = selectedLesson.offsetTop;
1683 - const lessonHeight = selectedLesson.clientHeight;
1684 - const scrollTop = lessonTop - (containerHeight / 2) + (lessonHeight / 2);
1685 -
1686 - // 平滑滚动到指定位置
1687 - scrollContainer.scrollTo({
1688 - top: scrollTop,
1689 - behavior: 'smooth'
1690 - });
1691 - }
1692 - }
1693 - });
1694 - }
1695 -});
1696 -
1697 // 打卡相关状态 874 // 打卡相关状态
1698 const showCheckInDialog = ref(false); 875 const showCheckInDialog = ref(false);
1699 const task_list = ref([]); 876 const task_list = ref([]);
...@@ -1727,84 +904,6 @@ const formatFileSize = (size) => { ...@@ -1727,84 +904,6 @@ const formatFileSize = (size) => {
1727 904
1728 return `${fileSize.toFixed(1)} ${units[index]}`; 905 return `${fileSize.toFixed(1)} ${units[index]}`;
1729 } 906 }
1730 -
1731 -/**
1732 - * 根据文件名获取文件图标
1733 - * @param {string} fileName - 文件名
1734 - * @returns {string} 图标名称
1735 - */
1736 -const getFileIcon = (fileName) => {
1737 - // 添加空值检查
1738 - if (!fileName || typeof fileName !== 'string') {
1739 - return 'description'; // 默认图标
1740 - }
1741 -
1742 - const extension = fileName.split('.').pop().toLowerCase();
1743 - const iconMap = {
1744 - 'pdf': 'description',
1745 - 'doc': 'description',
1746 - 'docx': 'description',
1747 - 'xls': 'description',
1748 - 'xlsx': 'description',
1749 - 'ppt': 'description',
1750 - 'pptx': 'description',
1751 - 'txt': 'description',
1752 - 'zip': 'bag-o',
1753 - 'rar': 'bag-o',
1754 - '7z': 'bag-o',
1755 - 'mp3': 'music-o',
1756 - 'aac': 'music-o',
1757 - 'wav': 'music-o',
1758 - 'ogg': 'music-o',
1759 - 'mp4': 'video-o',
1760 - 'avi': 'video-o',
1761 - 'mov': 'video-o',
1762 - 'jpg': 'photo-o',
1763 - 'jpeg': 'photo-o',
1764 - 'png': 'photo-o',
1765 - 'gif': 'photo-o'
1766 - };
1767 - return iconMap[extension] || 'description';
1768 -}
1769 -
1770 -/**
1771 - * 根据文件名获取文件类型描述
1772 - * @param {string} fileName - 文件名
1773 - * @returns {string} 文件类型描述
1774 - */
1775 -const getFileType = (fileName) => {
1776 - // 添加空值检查
1777 - if (!fileName || typeof fileName !== 'string') {
1778 - return '未知文件'; // 默认类型
1779 - }
1780 -
1781 - const extension = fileName.split('.').pop().toLowerCase();
1782 - const typeMap = {
1783 - 'pdf': 'PDF文档',
1784 - 'doc': 'Word文档',
1785 - 'docx': 'Word文档',
1786 - 'xls': 'Excel表格',
1787 - 'xlsx': 'Excel表格',
1788 - 'ppt': 'PPT演示',
1789 - 'pptx': 'PPT演示',
1790 - 'txt': '文本文件',
1791 - 'zip': '压缩文件',
1792 - 'rar': '压缩文件',
1793 - '7z': '压缩文件',
1794 - 'mp3': '音频文件',
1795 - 'aac': '音频文件',
1796 - 'wav': '音频文件',
1797 - 'ogg': '音频文件',
1798 - 'mp4': '视频文件',
1799 - 'avi': '视频文件',
1800 - 'mov': '视频文件',
1801 - 'jpg': '图片文件',
1802 - 'jpeg': '图片文件',
1803 - 'png': '图片文件',
1804 - 'gif': '图片文件'
1805 - };
1806 - return typeMap[extension] || '未知文件';
1807 -}
1808 </script> 907 </script>
1809 908
1810 <style lang="less" scoped> 909 <style lang="less" scoped>
......