hookehuyr

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

新增学习目录弹窗组件、学习评论组件和学习资料弹窗组件
添加学习记录跟踪器和学习评论跟踪器组合式函数
优化音频播放器组件样式和交互逻辑
......@@ -43,6 +43,9 @@ declare module 'vue' {
SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
SharePoster: typeof import('./components/ui/SharePoster.vue')['default']
StarryBackground: typeof import('./components/effects/StarryBackground.vue')['default']
StudyCatalogPopup: typeof import('./components/studyDetail/StudyCatalogPopup.vue')['default']
StudyCommentsSection: typeof import('./components/studyDetail/StudyCommentsSection.vue')['default']
StudyMaterialsPopup: typeof import('./components/studyDetail/StudyMaterialsPopup.vue')['default']
SummerCampCard: typeof import('./components/ui/SummerCampCard.vue')['default']
TaskCalendar: typeof import('./components/ui/TaskCalendar.vue')['default']
TaskCascaderFilter: typeof import('./components/teacher/TaskCascaderFilter.vue')['default']
......
<template>
<van-popup v-model:show="show_catalog_model" position="bottom" round closeable safe-area-inset-bottom
style="height: 80%">
<div class="flex flex-col h-full">
<div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
<div class="text-lg font-medium">课程目录</div>
</div>
<div ref="scroll_container_ref" class="flex-1 overflow-y-auto px-4 py-2" style="overflow-y: scroll;">
<div v-if="lessons.length" class="space-y-4">
<div v-for="(lesson, index) in lessons" :key="index" @click="$emit('lessonClick', lesson)"
class="lesson-item bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative"
:class="{ 'is-active': String(courseId) === String(lesson.id) }"
:data-lesson-id="String(lesson.id)">
<div v-if="lesson.progress > 0 && lesson.progress < 100"
class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
上次看到</div>
<div class="text-black text-base mb-2"
:class="{ 'text-green-600 font-medium': String(courseId) === String(lesson.id) }">
{{ lesson.title }} •
<span class="text-sm text-gray-500">{{ courseTypeMaps[lesson.course_type] }}</span>
</div>
<div class="flex items-center text-sm text-gray-500">
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') : '暂无'
}}</span>
<span class="mx-2">|</span>
<span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
</div>
</div>
</div>
<van-empty v-else description="暂无目录" />
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue';
import dayjs from 'dayjs';
const props = defineProps({
showCatalog: {
type: Boolean,
default: false
},
lessons: {
type: Array,
default: () => []
},
courseId: {
type: [String, Number],
default: ''
},
courseTypeMaps: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits([
'update:showCatalog',
'lessonClick'
]);
const scroll_container_ref = ref(null);
const show_catalog_model = computed({
get: () => props.showCatalog,
set: (val) => emit('update:showCatalog', val)
});
watch(show_catalog_model, (newVal) => {
if (!newVal) return;
nextTick(() => {
const container = scroll_container_ref.value;
if (!container) return;
const active_item = container.querySelector('.lesson-item.is-active');
if (!active_item) return;
const container_height = container.clientHeight;
const item_top = active_item.offsetTop;
const item_height = active_item.clientHeight;
const scroll_top = item_top - (container_height / 2) + (item_height / 2);
container.scrollTo({
top: scroll_top,
behavior: 'smooth'
});
});
});
</script>
<template>
<div id="comment" class="py-4 px-4 space-y-4" :style="{ paddingBottom: bottomWrapperHeight }">
<div class="flex justify-between items-center mb-4">
<div class="text-gray-900 font-medium text-sm">评论 ({{ commentCount }})</div>
<div class="text-gray-500 cursor-pointer text-sm" @click="show_comment_popup_model = true">查看更多</div>
</div>
<div v-for="comment in commentList" :key="comment.id" class="border-b border-gray-100 last:border-b-0 py-4">
<div class="flex">
<img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
<div class="flex-1 ml-3">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
<div class="flex items-center space-x-1">
<span class="text-sm text-gray-500">{{ comment.like_count }}</span> &nbsp;
<van-icon :name="comment.is_like ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
@click="$emit('toggleLike', comment)" class="text-lg cursor-pointer" />
</div>
</div>
<p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
<div class="flex items-center justify-between">
<div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
<van-icon v-if="comment.is_my" name="ellipsis" class="text-gray-400"
@click="show_action_sheet(comment)" />
</div>
</div>
</div>
</div>
<van-popup v-model:show="show_comment_popup_model" position="bottom" round closeable safe-area-inset-bottom
style="height: 80%">
<div class="flex flex-col h-full">
<div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
<div class="text-lg font-medium">全部评论 ({{ commentCount }})</div>
</div>
<div class="flex-1 overflow-y-auto">
<van-list v-model:loading="popup_loading_model" :finished="popupFinished" finished-text="没有更多评论了"
@load="$emit('popupLoad')" class="px-4 py-2 pb-16">
<div v-for="comment in popupCommentList" :key="comment.id"
class="border-b border-gray-100 last:border-b-0 py-4">
<div class="flex">
<img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
<div class="flex-1 ml-3">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
<div class="flex items-center space-x-1">
<span class="text-sm text-gray-500">{{ comment.like_count }}</span>
&nbsp;
<van-icon :name="comment.is_like ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
@click="$emit('toggleLike', comment)" class="text-lg cursor-pointer" />
</div>
</div>
<p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
<div class="flex items-center justify-between">
<div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
<van-icon v-if="comment.is_my" name="ellipsis" class="text-gray-400"
@click="show_action_sheet(comment)" />
</div>
</div>
</div>
</div>
</van-list>
</div>
<div class="flex-none border-t px-4 py-2 bg-white fixed bottom-0 left-0 right-0 z-10">
<div class="flex items-center space-x-2">
<van-field v-model="popup_comment_model" rows="1" autosize type="textarea" placeholder="请输入评论"
class="flex-1 bg-gray-100 rounded-lg" />
<van-button type="primary" size="small" @click="$emit('submitPopupComment')">发送</van-button>
</div>
</div>
</div>
</van-popup>
<van-action-sheet v-model:show="show_actions" :actions="actions" cancel-text="取消" close-on-click-action
@select="on_select_action" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { showConfirmDialog, showToast } from 'vant'
import { formatDate } from '@/utils/tools'
import { delGroupCommentAPI } from '@/api/course'
const props = defineProps({
commentCount: {
type: Number,
default: 0
},
commentList: {
type: Array,
default: () => []
},
showCommentPopup: {
type: Boolean,
default: false
},
popupCommentList: {
type: Array,
default: () => []
},
popupLoading: {
type: Boolean,
default: false
},
popupFinished: {
type: Boolean,
default: false
},
popupComment: {
type: String,
default: ''
},
bottomWrapperHeight: {
type: String,
default: '0px'
}
});
const emit = defineEmits([
'update:showCommentPopup',
'update:popupLoading',
'update:popupComment',
'toggleLike',
'popupLoad',
'submitPopupComment',
'commentDeleted'
]);
const show_comment_popup_model = computed({
get: () => props.showCommentPopup,
set: (val) => emit('update:showCommentPopup', val)
});
const popup_loading_model = computed({
get: () => props.popupLoading,
set: (val) => emit('update:popupLoading', val)
});
const popup_comment_model = computed({
get: () => props.popupComment,
set: (val) => emit('update:popupComment', val)
});
const show_actions = ref(false)
const current_comment = ref(null)
const actions = [
{ name: '删除', color: '#ef4444' },
]
/**
* @function show_action_sheet
* @description 打开评论操作面板(目前仅支持删除)
* @param {Object} comment - 当前选中的评论对象
* @returns {void}
*/
const show_action_sheet = (comment) => {
current_comment.value = comment
show_actions.value = true
}
/**
* @function on_select_action
* @description 处理评论操作面板选项点击
* @param {Object} action - 选中的动作项
* @returns {void}
*/
const on_select_action = (action) => {
if (action?.name === '删除') {
confirm_delete_comment()
}
}
/**
* @function confirm_delete_comment
* @description 二次确认删除评论并调用接口
* @returns {Promise<void>}
*/
const confirm_delete_comment = async () => {
const comment_id = current_comment.value?.id
if (!comment_id) return
try {
await showConfirmDialog({
title: '温馨提示',
message: '确定要删除这条评论吗?',
})
} catch (e) {
return
}
const { code } = await delGroupCommentAPI({ i: comment_id })
if (code) {
showToast('评论删除成功')
emit('commentDeleted', comment_id)
}
}
</script>
<template>
<van-popup v-model:show="show_materials_popup_model" position="bottom" :style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="true" :lock-scroll="true">
<div class="flex flex-col h-full bg-gray-50">
<div class="bg-white shadow-sm border-b border-gray-100">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<div class="px-1">
<h2 class="text-lg font-medium text-gray-900">学习资料</h2>
<p class="text-xs text-gray-500">共 {{ files.length }} 个文件</p>
</div>
</div>
<div class="px-2 py-1 rounded-full">
<van-button @click="show_materials_popup_model = false" type="default" size="small" round
class="w-8 h-8 p-0 bg-gray-100 border-0">
<van-icon name="cross" size="16" class="text-gray-600" />
</van-button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 pb-safe">
<div v-if="files.length > 0" class="space-y-4">
<FrostedGlass v-for="(file, index) in files" :key="index" :bgOpacity="70" blurLevel="md"
className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform">
<div class="flex items-start gap-4 mb-4 p-2">
<div
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">
<van-icon :name="getFileIcon(file.title || file.name)" class="text-blue-600" :size="22" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title ||
file.name }}</h3>
<div class="flex items-center justify-between gap-4 text-sm text-gray-600">
<div class="flex items-center gap-1">
<van-icon name="label-o" size="12" style="margin-right: 0.25rem;" />
<span>{{ getFileType(file.title || file.name) }}</span>
<span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) + 'MB' :
'' }}</span>
</div>
</div>
</div>
</div>
<div class="flex gap-2" style="margin: 1rem;">
<button v-if="isPdfFile(file.url)" @click="$emit('openPdf', file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="eye-o" size="16" />
在线查看
</button>
<button v-else-if="isAudioFile(file.url)" @click="$emit('openAudio', file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="music-o" size="16" />
音频播放
</button>
<button v-else-if="isVideoFile(file.url)" @click="$emit('openVideo', file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="video-o" size="16" />
视频播放
</button>
<button v-else-if="isImageFile(file.url)" @click="$emit('openImage', file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="photo-o" size="16" />
图片预览
</button>
</div>
</FrostedGlass>
</div>
<div v-else class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<van-icon name="folder-o" :size="28" class="text-gray-400" />
</div>
<p class="text-gray-500 text-base sm:text-lg mb-2 text-center">暂无学习资料</p>
<p class="text-gray-400 text-sm text-center">请联系老师上传相关资料</p>
</div>
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed } from 'vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
const props = defineProps({
showMaterialsPopup: {
type: Boolean,
default: false
},
files: {
type: Array,
default: () => []
}
});
const emit = defineEmits([
'update:showMaterialsPopup',
'openPdf',
'openAudio',
'openVideo',
'openImage'
]);
const show_materials_popup_model = computed({
get: () => props.showMaterialsPopup,
set: (val) => emit('update:showMaterialsPopup', val)
});
const isPdfFile = (url) => {
if (!url || typeof url !== 'string') return false;
return url.toLowerCase().includes('.pdf');
}
const isAudioFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') return false;
const extension = fileName.split('.').pop().toLowerCase();
const audioTypes = ['mp3', 'aac', 'wav', 'ogg'];
return audioTypes.includes(extension);
}
const isVideoFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') return false;
const extension = fileName.split('.').pop().toLowerCase();
const videoTypes = ['mp4', 'avi', 'mov'];
return videoTypes.includes(extension);
}
const isImageFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') return false;
const extension = fileName.split('.').pop().toLowerCase();
const imageTypes = ['jpg', 'jpeg', 'png', 'gif'];
return imageTypes.includes(extension);
}
const getFileIcon = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return 'description';
}
const extension = fileName.split('.').pop().toLowerCase();
const iconMap = {
pdf: 'description',
doc: 'description',
docx: 'description',
xls: 'description',
xlsx: 'description',
ppt: 'description',
pptx: 'description',
txt: 'description',
zip: 'bag-o',
rar: 'bag-o',
'7z': 'bag-o',
mp3: 'music-o',
aac: 'music-o',
wav: 'music-o',
ogg: 'music-o',
mp4: 'video-o',
avi: 'video-o',
mov: 'video-o',
jpg: 'photo-o',
jpeg: 'photo-o',
png: 'photo-o',
gif: 'photo-o'
};
return iconMap[extension] || 'description';
}
const getFileType = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return '未知文件';
}
const extension = fileName.split('.').pop().toLowerCase();
const typeMap = {
pdf: 'PDF文档',
doc: 'Word文档',
docx: 'Word文档',
xls: 'Excel表格',
xlsx: 'Excel表格',
ppt: 'PPT演示',
pptx: 'PPT演示',
txt: '文本文件',
zip: '压缩文件',
rar: '压缩文件',
'7z': '压缩文件',
mp3: '音频文件',
aac: '音频文件',
wav: '音频文件',
ogg: '音频文件',
mp4: '视频文件',
avi: '视频文件',
mov: '视频文件',
jpg: '图片文件',
jpeg: '图片文件',
png: '图片文件',
gif: '图片文件'
};
return typeMap[extension] || '未知文件';
}
</script>
<!--
* @Date: 2025-04-07 12:35:35
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-11 20:28:32
* @LastEditTime: 2025-12-27 23:56:18
* @FilePath: /mlaj/src/components/ui/AudioPlayer.vue
* @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
-->
......@@ -58,7 +58,7 @@
@click="togglePlay"
:class="{'playing': isPlaying, 'opacity-50 cursor-not-allowed': isLoading}"
:disabled="isLoading"
class="w-12 h-12 flex items-center justify-center rounded-full bg-blue-500 hover:bg-blue-600 transition-colors shadow-lg"
class="w-12 h-12 flex items-center justify-center rounded-full transition-colors shadow-lg"
>
<font-awesome-icon
:icon="['fas' , isPlaying ? 'pause' : 'play']"
......@@ -79,8 +79,8 @@
<!-- 倍速播放和播放列表按钮 -->
<div class="flex justify-between items-center mt-4">
<!-- 倍速播放控件 -->
<button
@click="cycleSpeed"
<button
@click="cycleSpeed"
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"
>
<span class="text-sm font-medium">{{ speed }}x</span>
......@@ -506,7 +506,7 @@ const cycleSpeed = () => {
const currentIndex = speedOptions.indexOf(speed.value)
const nextIndex = (currentIndex + 1) % speedOptions.length
const newSpeed = speedOptions[nextIndex]
speed.value = newSpeed
if (audio.value) {
audio.value.playbackRate = newSpeed
......
import { ref, watch } from 'vue';
import { getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI } from '@/api/course';
/**
* 学习评论跟踪器
* @param {*} course 课程对象
* @returns 学习评论跟踪器对象,包含 commentCount、commentList、newComment、showCommentPopup、popupComment、popupCommentList、popupLoading、popupFinished、popupLimit、popupPage、refreshComments、toggleLike、submitComment 等方法
*/
export const useStudyComments = (course) => {
const commentCount = ref(0);
const commentList = ref([]);
const newComment = ref('');
const showCommentPopup = ref(false);
const popupComment = ref('');
const popupCommentList = ref([]);
const popupLoading = ref(false);
const popupFinished = ref(false);
const popupLimit = ref(5);
const popupPage = ref(0);
const refreshComments = async () => {
if (!course.value?.group_id || !course.value?.id) return;
const comment = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id
});
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
};
const toggleLike = async (comment) => {
try {
if (!comment.is_like) {
const { code } = await addGroupCommentLikeAPI({ i: comment.id });
if (code) {
comment.is_like = true;
comment.like_count += 1;
}
} else {
const { code } = await delGroupCommentLikeAPI({ i: comment.id });
if (code) {
comment.is_like = false;
comment.like_count -= 1;
}
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
const submitComment = async () => {
if (!newComment.value.trim()) return;
if (!course.value?.group_id || !course.value?.id) return;
try {
const { code } = await addGroupCommentAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
note: newComment.value
});
if (code) {
await refreshComments();
newComment.value = '';
}
} catch (error) {
console.error('提交评论失败:', error);
}
};
const onPopupLoad = async () => {
if (!course.value?.group_id || !course.value?.id) {
popupLoading.value = false;
return;
}
const nextPage = popupPage.value;
try {
const res = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
limit: popupLimit.value,
page: nextPage
});
if (res.code) {
const newComments = res.data.comment_list;
const existingIds = new Set(popupCommentList.value.map(item => item.id));
const uniqueNewComments = newComments.filter(item => !existingIds.has(item.id));
popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments];
popupFinished.value = res.data.comment_list.length < popupLimit.value;
popupPage.value = nextPage + 1;
}
} catch (error) {
console.error('加载评论失败:', error);
}
popupLoading.value = false;
};
const submitPopupComment = async () => {
if (!popupComment.value.trim()) return;
if (!course.value?.group_id || !course.value?.id) return;
try {
const { code, data } = await addGroupCommentAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
note: popupComment.value
});
if (code) {
popupCommentList.value = [];
popupPage.value = 0;
popupFinished.value = false;
await onPopupLoad();
commentCount.value = data.comment_count;
await refreshComments();
popupComment.value = '';
}
} catch (error) {
console.error('提交评论失败:', error);
}
};
watch(showCommentPopup, async (newVal) => {
if (!newVal) return;
if (!course.value?.group_id || !course.value?.id) return;
popupCommentList.value = [];
popupPage.value = 0;
popupFinished.value = false;
popupLoading.value = true;
await refreshComments();
onPopupLoad();
});
return {
commentCount,
commentList,
newComment,
showCommentPopup,
popupComment,
popupCommentList,
popupLoading,
popupFinished,
refreshComments,
toggleLike,
submitComment,
onPopupLoad,
submitPopupComment
};
};
import { ref, onUnmounted } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { addStudyRecordAPI } from '@/api/record';
/**
* 学习记录跟踪器
* @param {*} course 课程对象
* @param {*} courseId 课程ID
* @param {*} videoPlayerRef 视频播放器引用
* @param {*} audioPlayerRef 音频播放器引用
* @returns 学习记录跟踪器对象,包含 startAction、addRecord 等方法
*/
export const useStudyRecordTracker = ({ course, courseId, videoPlayerRef, audioPlayerRef }) => {
const action_timer = ref(null);
const playback_id = ref('');
const clear_timer = () => {
if (action_timer.value) {
clearInterval(action_timer.value);
action_timer.value = null;
}
};
const addRecord = async (paramsObj) => {
if (!paramsObj || !paramsObj.schedule_id) return;
return await addStudyRecordAPI(paramsObj);
};
const get_schedule_id = () => {
if (typeof courseId === 'object' && courseId && 'value' in courseId) return courseId.value;
return courseId || '';
};
const get_video_payload = () => {
const player = videoPlayerRef?.value?.getPlayer?.();
if (!player) return null;
const duration = typeof player.duration === 'function' ? player.duration() : (player.duration || 0);
const position = typeof player.currentTime === 'function' ? player.currentTime() : (player.currentTime || 0);
const meta_id = videoPlayerRef?.value?.getId?.();
if (!meta_id) return null;
return {
meta_id,
media_duration: duration,
playback_position: position,
playback_id: playback_id.value,
};
};
const get_audio_payload = (item) => {
const player = audioPlayerRef?.value?.getPlayer?.();
if (!player) return null;
const meta_id = item?.meta_id;
if (!meta_id) return null;
return {
meta_id,
media_duration: player.duration || 0,
playback_position: player.currentTime || 0,
playback_id: playback_id.value,
};
};
const startAction = (item) => {
clear_timer();
const schedule_id = get_schedule_id();
if (!schedule_id) return;
playback_id.value = uuidv4();
action_timer.value = setInterval(() => {
const is_video = course?.value?.course_type === 'video';
const is_audio = course?.value?.course_type === 'audio';
let payload = null;
if (is_video) payload = get_video_payload();
if (is_audio) payload = get_audio_payload(item);
if (!payload) payload = get_video_payload() || get_audio_payload(item);
if (!payload) return;
addRecord({
schedule_id,
...payload,
});
}, 3000);
};
const endAction = () => {
clear_timer();
};
onUnmounted(() => {
clear_timer();
});
return {
startAction,
endAction,
addRecord,
};
};
......@@ -27,8 +27,7 @@
:video-id="courseFile?.list?.length ? courseFile['list'][0]['meta_id'] : ''"
@onPlay="handleVideoPlay" @onPause="handleVideoPause" />
</div>
<div v-if="course.course_type === 'audio'" class="w-full relative"
style="border-bottom: 1px solid #F3F4F6;">
<div v-if="course.course_type === 'audio'" class="w-full relative border-b border-gray-200">
<!-- 音频播放器 -->
<AudioPlayer ref="audioPlayerRef" v-if="audioList.length" :songs="audioList" @play="onAudioPlay"
@pause="onAudioPause" />
......@@ -122,85 +121,13 @@
</div>
<div class="h-2 bg-gray-100"></div>
<div id="comment" class="py-4 px-4 space-y-4" :style="{ paddingBottom: bottomWrapperHeight }">
<div class="flex justify-between items-center mb-4">
<div class="text-gray-900 font-medium text-sm">评论 ({{ commentCount }})</div>
<div class="text-gray-500 cursor-pointer text-sm" @click="showCommentPopup = true">查看更多</div>
</div>
<!-- 显示几条评论 -->
<div v-for="comment in commentList" :key="comment.id"
class="border-b border-gray-100 last:border-b-0 py-4">
<div class="flex">
<img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
class="w-10 h-10 rounded-full flex-shrink-0" style="margin-right: 0.5rem;" />
<div class="flex-1 ml-3">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户' }}</span>
<div class="flex items-center space-x-1">
<span class="text-sm text-gray-500">{{ comment.like_count }}</span> &nbsp;
<van-icon :name="comment.is_like ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
@click="toggleLike(comment)" class="text-lg cursor-pointer" />
</div>
</div>
<p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
<div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time) }}</div>
</div>
</div>
</div>
<van-popup v-model:show="showCommentPopup" position="bottom" round closeable safe-area-inset-bottom
style="height: 80%">
<div class="flex flex-col h-full">
<!-- 固定头部 -->
<div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
<div class="text-lg font-medium">全部评论 ({{ commentCount }})</div>
</div>
<!-- 可滚动的评论列表 -->
<div class="flex-1 overflow-y-auto">
<van-list v-model:loading="popupLoading" :finished="popupFinished"
finished-text="没有更多评论了" @load="onPopupLoad" class="px-4 py-2 pb-16">
<div v-for="comment in popupCommentList" :key="comment.id"
class="border-b border-gray-100 last:border-b-0 py-4">
<div class="flex">
<img :src="comment.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
class="w-10 h-10 rounded-full flex-shrink-0"
style="margin-right: 0.5rem;" />
<div class="flex-1 ml-3">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-900">{{ comment.name || '匿名用户'
}}</span>
<div class="flex items-center space-x-1">
<span class="text-sm text-gray-500">{{ comment.like_count
}}</span>
&nbsp;
<van-icon :name="comment.is_like ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.is_like, 'text-gray-400': !comment.is_like }"
@click="toggleLike(comment)"
class="text-lg cursor-pointer" />
</div>
</div>
<p class="text-gray-700 text-sm mb-1">{{ comment.note }}</p>
<div class="text-gray-400 text-xs">{{ formatDate(comment.updated_time)
}}</div>
</div>
</div>
</div>
</van-list>
</div>
<!-- 固定底部输入框 -->
<div class="flex-none border-t px-4 py-2 bg-white fixed bottom-0 left-0 right-0 z-10">
<div class="flex items-center space-x-2">
<van-field v-model="popupComment" rows="1" autosize type="textarea"
placeholder="请输入评论" class="flex-1 bg-gray-100 rounded-lg" />
<van-button type="primary" size="small" @click="submitPopupComment">发送</van-button>
</div>
</div>
</div>
</van-popup>
</div>
<!-- 评论区 -->
<StudyCommentsSection :comment-count="commentCount" :comment-list="commentList"
:popup-comment-list="popupCommentList" :popup-finished="popupFinished"
:bottom-wrapper-height="bottomWrapperHeight" v-model:showCommentPopup="showCommentPopup"
v-model:popupLoading="popupLoading" v-model:popupComment="popupComment" @toggleLike="toggleLike"
@popupLoad="onPopupLoad" @submitPopupComment="submitPopupComment"
@commentDeleted="handleCommentDeleted" />
</div>
<!-- 底部操作栏 -->
......@@ -212,13 +139,15 @@
<span class="text-xs text-gray-600">课程目录</span>
</div>
<div class="flex-grow flex-1 min-w-0">
<van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言"
<!-- <van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入留言"
class="bg-gray-100 rounded-lg !p-0">
<template #input>
<textarea v-model="newComment" rows="1" placeholder="请输入留言"
class="w-full h-full bg-transparent outline-none resize-none" />
</template>
</van-field>
</van-field> -->
<van-field v-model="newComment" rows="1" autosize type="textarea" placeholder="请输入评论"
class="flex-1 bg-gray-100 rounded-lg" />
</div>
<van-button type="primary" size="small" @click="submitComment">发送</van-button>
</div>
......@@ -238,51 +167,20 @@
</template>
</van-image-preview>
<!-- 课程目录弹出层 -->
<van-popup v-model:show="showCatalog" position="bottom" round closeable safe-area-inset-bottom
style="height: 80%">
<div class="flex flex-col h-full">
<!-- 固定头部 -->
<div class="flex-none px-4 py-3 border-b bg-white sticky top-0 z-10">
<div class="text-lg font-medium">课程目录</div>
</div>
<!-- 可滚动的目录列表 -->
<div ref="scrollContainer" class="flex-1 overflow-y-auto px-4 py-2" style="overflow-y: scroll;">
<div v-if="course_lessons.length" class="space-y-4">
<div v-for="(lesson, index) in course_lessons" :key="index" @click="handleLessonClick(lesson)"
class="bg-white p-4 cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-200 relative">
<div v-if="lesson.progress > 0 && lesson.progress < 100"
class="absolute top-2 right-2 px-2 py-1 bg-green-100 text-green-600 text-xs rounded">
上次看到</div>
<div class="text-black text-base mb-2"
:class="{ 'text-green-600 font-medium': courseId == lesson.id }">{{ lesson.title }} •
<span class="text-sm text-gray-500">{{ course_type_maps[lesson.course_type] }}</span>
</div>
<div class="flex items-center text-sm text-gray-500">
<span>开课时间: {{ lesson.schedule_time ? dayjs(lesson.schedule_time).format('YYYY-MM-DD') :
'暂无'
}}</span>
<span class="mx-2">|</span>
<span v-if="lesson.duration">建议时长: {{ lesson.duration }} 分钟</span>
</div>
</div>
</div>
<van-empty v-else description="暂无目录" />
</div>
</div>
</van-popup>
<!-- 课程目录弹窗 -->
<StudyCatalogPopup v-model:showCatalog="showCatalog" :lessons="course_lessons" :course-id="courseId"
:course-type-maps="course_type_maps" @lessonClick="handleLessonClick" />
<!-- PDF预览改为独立页面,点击资源时跳转到 /pdfPreview -->
<!-- Office 文档预览弹窗 -->
<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }">
<!--<van-popup v-model:show="officeShow" position="center" round closeable :style="{ height: '80%', width: '90%' }">
<div class="h-full flex flex-col">
<div class="p-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-center truncate">{{ officeTitle }}</h3>
</div>
<div class="flex-1 overflow-auto">
<!-- <OfficeViewer
<!~~ <OfficeViewer
v-if="officeShow && officeUrl && officeFileType"
:src="officeUrl"
:file-type="officeFileType"
......@@ -290,10 +188,10 @@
@rendered="onOfficeRendered"
@error="onOfficeError"
@retry="onOfficeRetry"
/> -->
/> ~~>
</div>
</div>
</van-popup>
</van-popup>-->
<!-- 音频播放器弹窗 -->
<van-popup v-model:show="audioShow" position="bottom" round closeable :style="{ height: '60%', width: '100%' }">
......@@ -332,198 +230,9 @@
<CheckInDialog v-model:show="showCheckInDialog" :items_today="task_list" :items_history="timeout_task_list"
@check-in-success="handleCheckInSuccess" />
<!-- 下载失败提示弹窗 -->
<van-popup v-model:show="showDownloadFailDialog" position="center" round closeable
:style="{ width: '85%', maxWidth: '400px' }">
<div class="p-6">
<div class="text-center mb-4">
<van-icon name="warning-o" size="48" color="#ff6b6b" class="mb-2" />
<h3 class="text-lg font-medium text-gray-800">下载失败</h3>
</div>
<div class="text-center text-gray-600 mb-4">
<p class="mb-2">暂时无法自动下载文件</p>
<p class="text-sm">请复制下方链接手动下载</p>
</div>
<div class="mb-4">
<div class="text-sm text-gray-500 mb-2">文件名:</div>
<div class="bg-gray-50 p-3 rounded-lg text-sm break-all">
{{ downloadFailInfo.fileName }}
</div>
</div>
<div class="mb-6">
<div class="text-sm text-gray-500 mb-2">文件链接:</div>
<div class="bg-gray-50 p-3 rounded-lg text-sm break-all max-h-20 overflow-y-auto">
{{ downloadFailInfo.fileUrl }}
</div>
</div>
<div class="flex gap-3">
<van-button block type="default" @click="showDownloadFailDialog = false" class="flex-1">
关闭
</van-button>
<van-button block type="primary" @click="copyToClipboard(downloadFailInfo.fileUrl)" class="flex-1">
复制链接
</van-button>
</div>
</div>
</van-popup>
<!-- 学习资料全屏弹窗 -->
<van-popup v-model:show="showMaterialsPopup" position="bottom" :style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="true" :lock-scroll="true">
<div class="flex flex-col h-full bg-gray-50">
<!-- 头部导航栏 -->
<div class="bg-white shadow-sm border-b border-gray-100">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<!-- <van-button
@click="showMaterialsPopup = false"
type="default"
size="small"
round
class="w-8 h-8 p-0 bg-gray-100 border-0"
>
<van-icon name="cross" size="16" class="text-gray-600" />
</van-button> -->
<div class="px-1">
<h2 class="text-lg font-medium text-gray-900">学习资料</h2>
<p class="text-xs text-gray-500">
共 {{ courseFile?.list ? courseFile.list.length : 0 }} 个文件
</p>
</div>
</div>
<div class="px-2 py-1 bg-blue-50 rounded-full">
<!-- <span class="text-blue-600 text-sm font-medium">
{{ courseFile?.list ? courseFile.list.length : 0 }}
</span> -->
<van-button @click="showMaterialsPopup = false" type="default" size="small" round
class="w-8 h-8 p-0 bg-gray-100 border-0">
<van-icon name="cross" size="16" class="text-gray-600" />
</van-button>
</div>
</div>
</div>
<!-- 文件列表 -->
<div class="flex-1 overflow-y-auto p-4 pb-safe">
<div v-if="courseFile?.list && courseFile.list.length > 0" class="space-y-4">
<FrostedGlass v-for="(file, index) in courseFile.list" :key="index" :bgOpacity="70"
blurLevel="md"
className="p-5 hover:bg-white/80 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] transform">
<!-- 文件信息 -->
<div class="flex items-start gap-4 mb-4 p-2">
<div
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">
<van-icon :name="getFileIcon(file.title || file.name)" class="text-blue-600"
:size="22" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-gray-900 mb-2 line-clamp-2">{{ file.title ||
file.name }}</h3>
<div class="flex items-center justify-between gap-4 text-sm text-gray-600">
<div class="flex items-center gap-1">
<van-icon name="label-o" size="12" style="margin-right: 0.25rem;" />
<span>{{ getFileType(file.title || file.name) }}</span>
<span class="ml-2">{{ file.size ? (file.size / 1024 / 1024).toFixed(2) +
'MB' : ''
}}</span>
</div>
<!-- 复制地址按钮 -->
<!-- <button
@click="copyFileUrl(file.url)"
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"
title="复制文件地址"
>
<van-icon name="link" size="12" />
<span>复制地址</span>
</button> -->
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2" style="margin: 1rem;">
<!-- 桌面端:显示在线查看、新窗口打开和下载文件按钮 -->
<!-- <template v-if="isDesktop">
<button
v-if="canOpenInNewWindow(file.title)"
@click="openFileInNewWindow(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="eye-o" size="16" />
在线查看
</button>
<button
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="down" size="16" />
下载文件
</button>
</template> -->
<!-- 统一使用移动端操作逻辑:根据文件类型显示不同的预览按钮 -->
<!-- Office 文档显示预览按钮 -->
<!-- <button
v-if="isOfficeFile(file.url)"
@click="showOfficeDocument(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="description" size="16" />
文档预览
</button> -->
<!-- PDF文件显示在线查看按钮 -->
<button v-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 音频文件显示音频播放按钮 -->
<button v-else-if="isAudioFile(file.url)" @click="showAudio(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="music-o" size="16" />
音频播放
</button>
<!-- 视频文件显示视频播放按钮 -->
<button v-else-if="isVideoFile(file.url)" @click="showVideo(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="video-o" size="16" />
视频播放
</button>
<!-- 图片文件显示图片预览按钮 -->
<button v-else-if="isImageFile(file.url)" @click="showImage(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2">
<van-icon name="photo-o" size="16" />
图片预览
</button>
<!-- 其他文件显示下载按钮 -->
<!-- <button
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="down" size="16" />
下载文件
</button> -->
</div>
</FrostedGlass>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-16 px-4">
<div
class="w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<van-icon name="folder-o" :size="28" class="text-gray-400" />
</div>
<p class="text-gray-500 text-base sm:text-lg mb-2 text-center">暂无学习资料</p>
<p class="text-gray-400 text-sm text-center">请联系老师上传相关资料</p>
</div>
</div>
</div>
</van-popup>
<!-- 学习资源弹窗 -->
<StudyMaterialsPopup v-model:showMaterialsPopup="showMaterialsPopup" :files="courseFile?.list || []"
@openPdf="showPdf" @openAudio="showAudio" @openVideo="showVideo" @openImage="showImage" />
</div>
</template>
......@@ -533,40 +242,59 @@ import { useRoute, useRouter } from 'vue-router';
import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
import CheckInDialog from '@/components/ui/CheckInDialog.vue';
// import OfficeViewer from '@/components/ui/OfficeViewer.vue';
import dayjs from 'dayjs';
import { formatDate, wxInfo } from '@/utils/tools'
import axios from 'axios';
import { v4 as uuidv4 } from "uuid";
import { useIntersectionObserver } from '@vueuse/core';
import PdfViewer from '@/components/ui/PdfViewer.vue';
import { showToast } from 'vant';
import StudyCommentsSection from '@/components/studyDetail/StudyCommentsSection.vue';
import StudyCatalogPopup from '@/components/studyDetail/StudyCatalogPopup.vue';
import StudyMaterialsPopup from '@/components/studyDetail/StudyMaterialsPopup.vue';
import { useStudyComments } from '@/composables/useStudyComments';
import { useStudyRecordTracker } from '@/composables/useStudyRecordTracker';
// 导入接口
import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI, getCourseDetailAPI } from '@/api/course';
import { addStudyRecordAPI } from "@/api/record";
import { getScheduleCourseAPI, getCourseDetailAPI } from '@/api/course';
const route = useRoute();
const router = useRouter();
const course = ref(null);
// 设备检测
const deviceInfo = wxInfo();
const isDesktop = deviceInfo.isPC;
const isMobile = deviceInfo.isMobile;
const {
commentCount,
commentList,
newComment,
showCommentPopup,
popupComment,
popupCommentList,
popupLoading,
popupFinished,
refreshComments,
toggleLike,
submitComment,
onPopupLoad,
submitPopupComment
} = useStudyComments(course);
/**
* @function handleCommentDeleted
* @description 删除评论成功后,同步更新评论列表与数量(包含主列表与弹窗列表)
* @param {number|string} comment_id - 被删除的评论ID
* @returns {void}
*/
const handleCommentDeleted = (comment_id) => {
const id = String(comment_id || '')
if (!id) return
commentList.value = (commentList.value || []).filter(item => String(item?.id) !== id)
popupCommentList.value = (popupCommentList.value || []).filter(item => String(item?.id) !== id)
commentCount.value = Math.max(0, Number(commentCount.value || 0) - 1)
}
const activeTab = ref('intro');
const newComment = ref('');
const showCatalog = ref(false);
const isPlaying = ref(false);
const videoPlayerRef = ref(null);
const audioPlayerRef = ref(null);
const showCommentPopup = ref(false);
const popupComment = ref('');
const scrollContainer = ref(null);
// 课程目录相关
const course_lessons = ref([]);
......@@ -652,13 +380,6 @@ const handleCheckInSuccess = () => {
showToast('打卡成功');
};
// 评论列表分页参数
const popupCommentList = ref([]);
const popupLoading = ref(false);
const popupFinished = ref(false);
const popupLimit = ref(5);
const popupPage = ref(0);
const audioList = ref([]);
// 设置页面标题
......@@ -689,8 +410,6 @@ const handleScroll = () => {
}
};
const commentCount = ref(0);
const commentList = ref([]);
const courseFile = ref({});
const handleLessonClick = async (lesson) => {
......@@ -719,12 +438,7 @@ const handleLessonClick = async (lesson) => {
audioList.value = [];
}
// 获取评论列表
const comment = await getGroupCommentListAPI({ group_id: data.group_id, schedule_id: data.id });
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
await refreshComments();
// 重新计算顶部和底部容器的高度
nextTick(() => {
......@@ -750,10 +464,6 @@ const handleLessonClick = async (lesson) => {
}
};
const pdfShow = ref(false);
const pdfTitle = ref('');
const pdfUrl = ref('');
// Office 文档预览相关
const officeShow = ref(false);
const officeTitle = ref('');
......@@ -773,8 +483,6 @@ const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态
const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用
const showPdf = ({ title, url, meta_id }) => {
pdfTitle.value = title;
pdfUrl.value = url;
// 跳转到PDF预览页面,并带上返回的课程ID和打开资料弹框的标记
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
......@@ -791,41 +499,41 @@ const showPdf = ({ title, url, meta_id }) => {
* 显示 Office 文档预览
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showOfficeDocument = ({ title, url, meta_id }) => {
console.log('showOfficeDocument called with:', { title, url, meta_id });
// 清理 URL 中的反引号和多余空格
const cleanUrl = url.replace(/`/g, '').trim();
officeTitle.value = title;
officeUrl.value = cleanUrl;
officeFileType.value = getOfficeFileType(cleanUrl);
console.log('Office document props set:', {
title: officeTitle.value,
url: officeUrl.value,
fileType: officeFileType.value
});
// 验证 URL 格式
try {
new URL(cleanUrl);
console.log('URL validation passed:', cleanUrl);
} catch (error) {
console.error('Invalid URL format:', cleanUrl, error);
showToast('文档链接格式不正确');
return;
}
officeShow.value = true;
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
};
// const showOfficeDocument = ({ title, url, meta_id }) => {
// console.log('showOfficeDocument called with:', { title, url, meta_id });
// // 清理 URL 中的反引号和多余空格
// const cleanUrl = url.replace(/`/g, '').trim();
// officeTitle.value = title;
// officeUrl.value = cleanUrl;
// officeFileType.value = getOfficeFileType(cleanUrl);
// console.log('Office document props set:', {
// title: officeTitle.value,
// url: officeUrl.value,
// fileType: officeFileType.value
// });
// // 验证 URL 格式
// try {
// new URL(cleanUrl);
// console.log('URL validation passed:', cleanUrl);
// } catch (error) {
// console.error('Invalid URL format:', cleanUrl, error);
// showToast('文档链接格式不正确');
// return;
// }
// officeShow.value = true;
// // 新增记录
// let paramsObj = {
// schedule_id: courseId.value,
// meta_id
// }
// addRecord(paramsObj);
// };
/**
* 显示音频播放器
......@@ -931,6 +639,13 @@ const courseId = computed(() => {
return route.params.id || '';
});
const { startAction, endAction, addRecord } = useStudyRecordTracker({
course,
courseId,
videoPlayerRef,
audioPlayerRef
});
onMounted(async () => {
// 延迟设置topWrapper和bottomWrapper的高度
setTimeout(() => {
......@@ -963,12 +678,8 @@ onMounted(async () => {
})
}
// 获取评论列表
const comment = await getGroupCommentListAPI({ group_id: course.value.group_id, schedule_id: course.value.id });
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
// 刷新评论列表
await refreshComments();
// 获取课程目录
const detail = await getCourseDetailAPI({ i: course.value.group_id });
......@@ -1016,56 +727,6 @@ onMounted(async () => {
}
});
// 提交评论
// 切换点赞状态
const toggleLike = async (comment) => {
try {
if (!comment.is_like) {
const { code } = await addGroupCommentLikeAPI({ i: comment.id });
if (code) {
comment.is_like = true;
comment.like_count += 1;
}
} else {
const { code } = await delGroupCommentLikeAPI({ i: comment.id });
if (code) {
comment.is_like = false;
comment.like_count -= 1;
}
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
// 发送按钮,提交评论
const submitComment = async () => {
if (!newComment.value.trim()) return;
try {
const { code, data } = await addGroupCommentAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
note: newComment.value
});
if (code) {
// 刷新评论列表
const comment = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
});
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
newComment.value = '';
}
} catch (error) {
console.error('提交评论失败:', error);
}
};
// 处理标签页切换
const handleTabChange = (name) => {
// 先更新activeTab值
......@@ -1094,104 +755,12 @@ const handleTabChange = (name) => {
});
};
// 加载更多弹框评论
const onPopupLoad = async () => {
const nextPage = popupPage.value;
try {
const res = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
limit: popupLimit.value,
page: nextPage
});
if (res.code) {
// 使用Set进行去重处理
const newComments = res.data.comment_list;
const existingIds = new Set(popupCommentList.value.map(comment => comment.id));
const uniqueNewComments = newComments.filter(comment => !existingIds.has(comment.id));
popupCommentList.value = [...popupCommentList.value, ...uniqueNewComments];
popupFinished.value = res.data.comment_list.length < popupLimit.value;
popupPage.value = nextPage + 1;
}
} catch (error) {
console.error('加载评论失败:', error);
}
popupLoading.value = false;
};
// 在组件卸载时移除滚动监听
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
endAction();
});
// 提交弹窗中的评论
const submitPopupComment = async () => {
if (!popupComment.value.trim()) return;
try {
const { code, data } = await addGroupCommentAPI({
group_id: course.value.group_id,
schedule_id: course.value.id,
note: popupComment.value
});
if (code) {
// 重置弹框评论列表并重新加载
popupCommentList.value = [];
popupPage.value = 0;
popupFinished.value = false;
await onPopupLoad();
// 更新评论数量和清空输入
commentCount.value = data.comment_count;
// 更新弹框标题中的评论总数
const comment = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id
});
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
popupComment.value = '';
}
} catch (error) {
console.error('提交评论失败:', error);
}
};
// 监听弹窗显示状态变化
watch(showCommentPopup, async (newVal) => {
if (newVal) {
// 打开弹窗时重置状态
popupCommentList.value = [];
popupPage.value = 0;
popupFinished.value = false;
popupLoading.value = true;
// 获取最新的评论总数
const comment = await getGroupCommentListAPI({
group_id: course.value.group_id,
schedule_id: course.value.id
});
if (comment.code) {
commentList.value = comment.data.comment_list;
commentCount.value = comment.data.comment_count;
}
// 加载第一页数据
onPopupLoad();
}
});
// 下载文件失败提示弹窗状态
const showDownloadFailDialog = ref(false);
const downloadFailInfo = ref({
fileName: '',
fileUrl: ''
});
// 学习资料弹窗状态
const showMaterialsPopup = ref(false);
......@@ -1211,330 +780,76 @@ watch(showMaterialsPopup, (val, oldVal) => {
}
});
/**
* 复制文件地址到剪贴板
* @param {string} url - 要复制的文件地址
*/
const copyFileUrl = async (url) => {
try {
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器支持的方式
await navigator.clipboard.writeText(url);
showToast('文件地址已复制到剪贴板');
} else {
// 兼容旧浏览器的方式
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast('文件地址已复制到剪贴板');
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制地址');
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制到剪贴板失败:', err);
showToast('复制失败,请手动复制地址');
}
};
/**
* 复制到剪贴板
* @param {string} url - 要复制的URL
*/
const copyToClipboard = async (url) => {
try {
if (navigator.clipboard && window.isSecureContext) {
// 现代浏览器支持的方式
await navigator.clipboard.writeText(url);
showToast('文件链接已复制到剪贴板');
} else {
// 兼容旧浏览器的方式
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast('文件链接已复制到剪贴板');
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制链接');
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error('复制到剪贴板失败:', err);
showToast('复制失败,请手动复制链接');
}
};
/**
* 尝试直接下载文件(适用于同源或支持CORS的文件)
* @param {string} fileUrl - 文件URL
* @param {string} fileName - 文件名
*/
const tryDirectDownload = (fileUrl, fileName) => {
try {
const a = document.createElement('a');
a.href = fileUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return true;
} catch (error) {
console.error('直接下载失败:', error);
return false;
}
};
// 下载文件
const downloadFile = ({ title, url, meta_id }) => {
// 获取文件URL和文件名
const fileUrl = url;
const fileName = title;
// 根据文件URL后缀获取MIME类型
const getMimeType = (url) => {
const extension = url.split('.').pop().toLowerCase();
const mimeTypes = {
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt': 'text/plain',
'zip': 'application/zip',
'rar': 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
'mp3': 'audio/mpeg',
'acc': 'audio/aac', // 添加对 acc 格式的支持
'aac': 'audio/aac', // 添加对 aac 格式的支持
'wav': 'audio/wav', // 添加对 wav 格式的支持
'ogg': 'audio/ogg', // 添加对 ogg 格式的支持
'mp4': 'video/mp4',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif'
};
return mimeTypes[extension] || 'application/octet-stream';
};
// 首先尝试直接下载(适用于同源文件或支持下载的链接)
const directDownloadSuccess = tryDirectDownload(fileUrl, fileName);
// 如果直接下载可能成功,等待一段时间后检查是否真的成功
if (directDownloadSuccess) {
// 记录下载行为
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
return;
}
// 如果直接下载失败,尝试通过axios下载
axios({
method: 'get',
url: fileUrl,
responseType: 'blob', // 表示返回的数据类型是Blob
timeout: 30000 // 设置30秒超时
}).then((response) => {
try {
const blob = new Blob([response.data], { type: getMimeType(fileUrl) });
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 延迟释放URL,确保下载完成
setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, 1000);
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
showToast('文件下载已开始');
} catch (blobError) {
console.error('创建下载链接失败:', blobError);
// 显示下载失败提示
downloadFailInfo.value = {
fileName: fileName,
fileUrl: fileUrl
};
showDownloadFailDialog.value = true;
}
}).catch((error) => {
console.error('下载文件出错:', error);
// 根据错误类型提供不同的处理方式
let errorMessage = '下载失败';
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
errorMessage = '下载超时';
} else if (error.response && error.response.status === 404) {
errorMessage = '文件不存在';
} else if (error.response && error.response.status === 403) {
errorMessage = '无权限访问文件';
} else if (error.message.includes('CORS') || error.message.includes('cross-origin')) {
errorMessage = '跨域访问限制';
}
console.log(`${errorMessage},显示手动下载提示`);
// 显示下载失败提示弹窗
downloadFailInfo.value = {
fileName: fileName,
fileUrl: fileUrl
};
showDownloadFailDialog.value = true;
});
}
/**
* 在新窗口中打开文件
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const openFileInNewWindow = ({ title, url, meta_id }) => {
// 在新窗口中打开文件URL
window.open(url, '_blank');
// 记录访问行为
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
}
// const openFileInNewWindow = ({ title, url, meta_id }) => {
// // 在新窗口中打开文件URL
// window.open(url, '_blank');
// // 记录访问行为
// let paramsObj = {
// schedule_id: courseId.value,
// meta_id
// }
// addRecord(paramsObj);
// }
/**
* 判断文件是否可以在新窗口中打开
* @param {string} fileName - 文件名
* @returns {boolean} 是否可以在新窗口中打开
*/
const canOpenInNewWindow = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const supportedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'aac', 'wav', 'ogg', 'mp4', 'avi', 'mov'];
return supportedTypes.includes(extension);
}
/**
* 判断文件是否为音频文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为音频文件
*/
const isAudioFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const audioTypes = ['mp3', 'aac', 'wav', 'ogg'];
return audioTypes.includes(extension);
}
/**
* 判断文件是否为视频文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为视频文件
*/
const isVideoFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const videoTypes = ['mp4', 'avi', 'mov'];
return videoTypes.includes(extension);
}
// const canOpenInNewWindow = (fileName) => {
// if (!fileName || typeof fileName !== 'string') {
// return false;
// }
/**
* 判断文件是否为图片文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为图片文件
*/
const isImageFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
// const extension = fileName.split('.').pop().toLowerCase();
// const supportedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'aac', 'wav', 'ogg', 'mp4', 'avi', 'mov'];
// return supportedTypes.includes(extension);
// }
const extension = fileName.split('.').pop().toLowerCase();
const imageTypes = ['jpg', 'jpeg', 'png', 'gif'];
return imageTypes.includes(extension);
}
/**
* 判断文件是否为 Office 文档
* @param {string} fileName - 文件名
* @returns {boolean} 是否为 Office 文档
*/
const isOfficeFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
// const isOfficeFile = (fileName) => {
// if (!fileName || typeof fileName !== 'string') {
// return false;
// }
const extension = fileName.split('.').pop().toLowerCase();
const officeTypes = ['docx', 'xlsx', 'xls', 'pptx'];
return officeTypes.includes(extension);
}
// const extension = fileName.split('.').pop().toLowerCase();
// const officeTypes = ['docx', 'xlsx', 'xls', 'pptx'];
// return officeTypes.includes(extension);
// }
/**
* 获取 Office 文档类型
* @param {string} fileName - 文件名
* @returns {string} 文档类型 (docx, excel, pptx)
*/
const getOfficeFileType = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return '';
}
// const getOfficeFileType = (fileName) => {
// if (!fileName || typeof fileName !== 'string') {
// return '';
// }
const extension = fileName.split('.').pop().toLowerCase();
// const extension = fileName.split('.').pop().toLowerCase();
if (extension === 'docx') {
return 'docx';
} else if (extension === 'xlsx' || extension === 'xls') {
return 'excel';
} else if (extension === 'pptx') {
return 'pptx';
}
// if (extension === 'docx') {
// return 'docx';
// } else if (extension === 'xlsx' || extension === 'xls') {
// return 'excel';
// } else if (extension === 'pptx') {
// return 'pptx';
// }
return '';
}
// return '';
// }
/**
* 音频播放事件
......@@ -1556,144 +871,6 @@ const onAudioPause = (audio) => {
endAction();
}
// 记录视频时长和当前播放位置的变量
const videoDuration = ref(0);
const currentPosition = ref(0);
// 记录音频时长和当前播放位置的变量
const audioDuration = ref(0);
const audioPosition = ref(0);
/**
* 开始操作
* @param action
* @param item
*/
const startAction = (item) => {
// 先清除可能存在的定时器
if (window.actionTimer) {
clearInterval(window.actionTimer);
}
// 获取视频总时长(如果是视频播放)
if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) {
videoDuration.value = videoPlayerRef.value.getPlayer().duration();
}
// 获取音频总时长(如果是音频播放)
if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
audioDuration.value = audioPlayerRef.value.getPlayer().duration;
}
// 生成唯一标识符
let uuid = uuidv4();
console.warn('开始操作', uuid);
// 设置定时器,持续执行操作
window.actionTimer = setInterval(() => {
console.warn('持续操作中', uuid);
let paramsObj = {
schedule_id: courseId.value,
meta_id: item?.meta_id,
}
// 更新当前播放位置(如果是视频播放)
if (videoPlayerRef.value && videoPlayerRef.value.getPlayer()) {
currentPosition.value = videoPlayerRef.value.getPlayer().currentTime();
console.log('视频总时长:', videoDuration.value, '当前位置:', currentPosition.value, 'id:', videoPlayerRef.value.getId());
paramsObj = {
schedule_id: courseId.value,
meta_id: videoPlayerRef.value.getId(),
media_duration: videoDuration.value,
playback_position: currentPosition.value,
playback_id: uuid,
}
}
// 更新当前播放位置(如果是音频播放)
if (audioPlayerRef.value && audioPlayerRef.value.getPlayer()) {
audioPosition.value = audioPlayerRef.value.getPlayer().currentTime;
console.log('音频总时长:', audioDuration.value, '当前位置:', audioPosition.value, 'id:', item?.meta_id);
paramsObj = {
schedule_id: courseId.value,
meta_id: item?.meta_id,
media_duration: audioDuration.value,
playback_position: audioPosition.value,
playback_id: uuid,
}
}
// 新增记录
addRecord(paramsObj);
// 这里可以添加需要持续执行的具体操作
}, 3000); // 3秒执行一次,可以根据需求调整时间间隔
}
/**
* 结束操作
* @param action
* @param item
*/
const endAction = (item) => {
// 在结束前记录最后的播放位置
if (videoPlayerRef.value && videoPlayerRef.value.player) {
currentPosition.value = videoPlayerRef.value.player.currentTime();
console.log('结束时 - 视频总时长:', videoDuration.value, '最终位置:', currentPosition.value);
}
// 在结束前记录最后的音频播放位置
if (course.value?.course_type === 'audio' && document.querySelector('audio')) {
const audioElement = document.querySelector('audio');
audioPosition.value = audioElement.currentTime || 0;
console.log('结束时 - 音频总时长:', audioDuration.value, '最终位置:', audioPosition.value);
}
// 清除定时器,停止执行startAction
if (window.actionTimer) {
clearInterval(window.actionTimer);
window.actionTimer = null;
console.warn('操作已停止');
}
}
/**
* 添加学习记录
* @param paramsObj
*/
const addRecord = async (paramsObj) => {
await addStudyRecordAPI(paramsObj);
}
// 监听目录弹框显示状态,当打开时滚动到当前课程位置
watch(showCatalog, (newVal) => {
if (newVal) {
// 等待DOM更新后执行滚动
nextTick(() => {
// 查找当前选中的课程元素
const selectedLesson = document.querySelector('.text-green-600')?.closest('.bg-white');
if (selectedLesson) {
// 获取滚动容器
const scrollContainer = document.querySelector('.van-popup .overflow-y-auto');
if (scrollContainer) {
// 计算滚动位置,使选中元素位于容器中间
const containerHeight = scrollContainer.clientHeight;
const lessonTop = selectedLesson.offsetTop;
const lessonHeight = selectedLesson.clientHeight;
const scrollTop = lessonTop - (containerHeight / 2) + (lessonHeight / 2);
// 平滑滚动到指定位置
scrollContainer.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}
});
}
});
// 打卡相关状态
const showCheckInDialog = ref(false);
const task_list = ref([]);
......@@ -1727,84 +904,6 @@ const formatFileSize = (size) => {
return `${fileSize.toFixed(1)} ${units[index]}`;
}
/**
* 根据文件名获取文件图标
* @param {string} fileName - 文件名
* @returns {string} 图标名称
*/
const getFileIcon = (fileName) => {
// 添加空值检查
if (!fileName || typeof fileName !== 'string') {
return 'description'; // 默认图标
}
const extension = fileName.split('.').pop().toLowerCase();
const iconMap = {
'pdf': 'description',
'doc': 'description',
'docx': 'description',
'xls': 'description',
'xlsx': 'description',
'ppt': 'description',
'pptx': 'description',
'txt': 'description',
'zip': 'bag-o',
'rar': 'bag-o',
'7z': 'bag-o',
'mp3': 'music-o',
'aac': 'music-o',
'wav': 'music-o',
'ogg': 'music-o',
'mp4': 'video-o',
'avi': 'video-o',
'mov': 'video-o',
'jpg': 'photo-o',
'jpeg': 'photo-o',
'png': 'photo-o',
'gif': 'photo-o'
};
return iconMap[extension] || 'description';
}
/**
* 根据文件名获取文件类型描述
* @param {string} fileName - 文件名
* @returns {string} 文件类型描述
*/
const getFileType = (fileName) => {
// 添加空值检查
if (!fileName || typeof fileName !== 'string') {
return '未知文件'; // 默认类型
}
const extension = fileName.split('.').pop().toLowerCase();
const typeMap = {
'pdf': 'PDF文档',
'doc': 'Word文档',
'docx': 'Word文档',
'xls': 'Excel表格',
'xlsx': 'Excel表格',
'ppt': 'PPT演示',
'pptx': 'PPT演示',
'txt': '文本文件',
'zip': '压缩文件',
'rar': '压缩文件',
'7z': '压缩文件',
'mp3': '音频文件',
'aac': '音频文件',
'wav': '音频文件',
'ogg': '音频文件',
'mp4': '视频文件',
'avi': '视频文件',
'mov': '视频文件',
'jpg': '图片文件',
'jpeg': '图片文件',
'png': '图片文件',
'gif': '图片文件'
};
return typeMap[extension] || '未知文件';
}
</script>
<style lang="less" scoped>
......