feat(study-detail): 新增学习详情页相关组件和功能
新增学习目录弹窗组件、学习评论组件和学习资料弹窗组件 添加学习记录跟踪器和学习评论跟踪器组合式函数 优化音频播放器组件样式和交互逻辑
Showing
8 changed files
with
908 additions
and
1050 deletions
| ... | @@ -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> | ||
| 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 | + | ||
| 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 | ... | ... |
src/composables/useStudyComments.js
0 → 100644
| 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 | +}; |
src/composables/useStudyRecordTracker.js
0 → 100644
| 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> | ||
| 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 | - | ||
| 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> | ... | ... |
-
Please register or login to post a comment