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']"
......
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,
};
};
This diff is collapsed. Click to expand it.