hookehuyr

feat(课程评论): 实现课程评论的点赞、取消点赞及分页加载功能

添加了课程评论的点赞和取消点赞功能,并优化了评论列表的分页加载逻辑。同时,修复了评论提交后的列表刷新问题,确保数据一致性。
/*
* @Date: 2025-04-15 09:32:07
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-04-18 14:43:46
* @LastEditTime: 2025-04-18 17:15:52
* @FilePath: /mlaj/src/api/course.js
* @Description: 课程模块相关接口
*/
......@@ -15,6 +15,8 @@ const Api = {
GROUP_COMMENT_ADD: '/srv/?a=group_comment_add',
GROUP_COMMENT_EDIT: '/srv/?a=group_comment_edit',
GROUP_COMMENT_DEL: '/srv/?a=group_comment_del',
GROUP_COMMENT_LIKE: '/srv/?a=group_comment_like',
GROUP_COMMENT_DISLIKE: '/srv/?a=group_comment_dislike',
}
/**
......@@ -44,6 +46,7 @@ export const getScheduleCourseAPI = (params) => fn(fetch.get(Api.GET_SCHEDULE_CO
/**
* @description: 获取课程评论列表
* @param: i 课程 ID
* @param: schedule_id 章节ID,非必须,在课程章节内查询时需要
* @param: limit 每页数量 默认10
* @param: page 页码
* @return: data: { comment_score 课程评论分数, comment_count 评论数量, comment_list [{ id 评论id, created_by 评论人ID, name 评论人姓名, note 评论内容, score 分数, create_time 评论时间}] 评论列表}
......@@ -53,6 +56,7 @@ export const getGroupCommentListAPI = (params) => fn(fetch.get(Api.GET_GROUP_COM
/**
* @description: 添加课程评论
* @param: i 课程 ID
* @param: schedule_id 章节ID,非必须,在课程章节添加时需要
* @param: note 评论内容
* @param: score 分数
* @return: data: ''
......@@ -70,7 +74,21 @@ export const editGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_E
/**
* @description: 删除课程评论
* @param: i 课程 ID
* @param: i 课程ID
* @return: data: ''
*/
export const delGroupCommentAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_DEL, params))
/**
* @description: 点赞章节评论
* @param: i 评论ID
* @return: data: ''
*/
export const addGroupCommentLikeAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_LIKE, params))
/**
* @description: 取消点赞章节评论
* @param: i 评论ID
* @return: data: ''
*/
export const delGroupCommentLikeAPI = (params) => fn(fetch.post(Api.GROUP_COMMENT_DISLIKE, params))
......
......@@ -20,7 +20,7 @@ declare module 'vue' {
GradientHeader: typeof import('./components/ui/GradientHeader.vue')['default']
LiveStreamCard: typeof import('./components/ui/LiveStreamCard.vue')['default']
MenuItem: typeof import('./components/ui/MenuItem.vue')['default']
ReviewPopup: typeof import('./components/ui/ReviewPopup.vue')['default']
ReviewPopup: typeof import('./components/courses/ReviewPopup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./components/ui/SearchBar.vue')['default']
......
......@@ -11,7 +11,7 @@
<div v-if="course.course_type === 'video'" class="w-full bg-black relative">
<!-- 视频封面和播放按钮 -->
<div v-if="!isPlaying" class="relative w-full" style="aspect-ratio: 16/9;">
<img :src="course.cover" :alt="course.title" class="w-full h-full object-cover" />
<img :src="courseFile.cover" :alt="course.title" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="startPlay">
<div
......@@ -22,7 +22,7 @@
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="course.file" :autoplay="false"
<VideoPlayer v-show="isPlaying" ref="videoPlayerRef" :video-url="courseFile.url" :autoplay="false"
@onPlay="handleVideoPlay" @onPause="handleVideoPause" />
</div>
<div v-if="course.course_type === 'audio'" class="w-full relative" style="border-bottom: 1px solid #F3F4F6;">
......@@ -35,7 +35,7 @@
<van-tab title="介绍" name="intro">
</van-tab>
<van-tab :title-style="{ 'min-width': '50%' }" name="comments">
<template #title>评论(999)</template>
<template #title>评论({{ commentCount }})</template>
</van-tab>
</van-tabs>
</div>
......@@ -57,27 +57,27 @@
<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">评论 ({{ comments.length }})</div>
<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 comments.slice(0, 3)" :key="comment.id"
<!-- 显示条评论 -->
<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" class="w-10 h-10 rounded-full flex-shrink-0"
style="margin-right: 0.5rem;" />
<!-- <img :src="comment.avatar" 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.username }}</span>
<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.likes }}</span> &nbsp;
<van-icon :name="comment.isLiked ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.isLiked, 'text-gray-400': !comment.isLiked }"
<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.content }}</p>
<div class="text-gray-400 text-xs">{{ comment.time }}</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>
......@@ -86,36 +86,39 @@
<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">全部评论 ({{ comments.length }})</div>
<div class="text-lg font-medium">全部评论 ({{ popupCommentList.length }})</div>
</div>
<!-- 可滚动的评论列表 -->
<div class="flex-1 overflow-y-auto">
<div class="px-4 py-2 pb-16">
<div v-for="comment in comments" :key="comment.id"
<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" 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.username
}}</span>
<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.likes }}</span>
<span class="text-sm text-gray-500">{{ comment.like_count }}</span>
&nbsp;
<van-icon :name="comment.isLiked ? 'like' : 'like-o'"
:class="{ 'text-red-500': comment.isLiked, 'text-gray-400': !comment.isLiked }"
<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.content }}</p>
<div class="text-gray-400 text-xs">{{ comment.time }}</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>
</div>
</van-list>
</div>
<!-- 固定底部输入框 -->
......@@ -161,9 +164,10 @@ import { useTitle } from '@vueuse/core';
import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import dayjs from 'dayjs';
import { formatDate } from '@/utils/tools'
// 导入接口
import { getScheduleCourseAPI } from '@/api/course';
import { getScheduleCourseAPI, getGroupCommentListAPI, addGroupCommentAPI, addGroupCommentLikeAPI, delGroupCommentLikeAPI } from '@/api/course';
const route = useRoute();
const course = ref(null);
......@@ -193,123 +197,73 @@ const handleVideoPause = () => {
// 保持视频播放器可见,只在初始状态显示封面
};
// 评论列表
const comments = ref([
{
id: 1,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '教育的顶级传承,是你用什么样的心,传承智慧',
time: '2024-12-04 18:51',
likes: 12,
isLiked: false
},
{
id: 2,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '不要用战术上的勤奋,掩盖战略上的懒惰',
time: '2024-12-04 08:01',
likes: 8,
isLiked: true
},
{
id: 3,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '和老师积极互动,整个课堂像为你而讲',
time: '2024-12-04 07:54',
likes: 5,
isLiked: false
},
{
id: 1,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '教育的顶级传承,是你用什么样的心,传承智慧',
time: '2024-12-04 18:51',
likes: 12,
isLiked: false
},
{
id: 2,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '不要用战术上的勤奋,掩盖战略上的懒惰',
time: '2024-12-04 08:01',
likes: 8,
isLiked: true
},
{
id: 3,
username: '欢乐马',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: '和老师积极互动,整个课堂像为你而讲',
time: '2024-12-04 07:54',
likes: 5,
isLiked: false
}
]);
// 评论列表分页参数
const popupCommentList = ref([]);
const popupLoading = ref(false);
const popupFinished = ref(false);
const popupLimit = ref(5);
const popupPage = ref(0);
// 测试音频数据
const audioList = ref([
{
id: 1,
title: '示例音频 1',
artist: '演唱者 1',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 2,
title: '示例音频 2',
artist: '演唱者 2',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/08/99/00/5c88d4a8d1f5745026.mp3'
},
{
id: 3,
title: '示例音频 3',
artist: '演唱者 3',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 4,
title: '示例音频 4',
artist: '演唱者 4',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
{
id: 5,
title: '示例音频 5',
artist: '演唱者 5',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 6,
title: '示例音频 6',
artist: '演唱者 6',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
{
id: 7,
title: '示例音频 7',
artist: '演唱者 7',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
},
{
id: 8,
title: '示例音频 8',
artist: '演唱者 8',
cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
},
]);
// const audioList = ref([
// {
// id: 1,
// title: '示例音频 1',
// artist: '演唱者 1',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
// },
// {
// id: 2,
// title: '示例音频 2',
// artist: '演唱者 2',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/08/99/00/5c88d4a8d1f5745026.mp3'
// },
// {
// id: 3,
// title: '示例音频 3',
// artist: '演唱者 3',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
// },
// {
// id: 4,
// title: '示例音频 4',
// artist: '演唱者 4',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
// },
// {
// id: 5,
// title: '示例音频 5',
// artist: '演唱者 5',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
// },
// {
// id: 6,
// title: '示例音频 6',
// artist: '演唱者 6',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
// },
// {
// id: 7,
// title: '示例音频 7',
// artist: '演唱者 7',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/95/5c8af46b01eb138909.mp3'
// },
// {
// id: 8,
// title: '示例音频 8',
// artist: '演唱者 8',
// cover: 'https://cdn.ipadbiz.cn/mlaj/images/zMRLZh40kms.jpg',
// url: 'https://img.tukuppt.com/newpreview_music/09/03/72/5c8ad96fbf47328809.mp3'
// },
// ]);
const audioList = ref([]);
// 设置页面标题
useTitle('学习详情');
......@@ -333,6 +287,10 @@ const handleScroll = () => {
}
};
const commentCount = ref(0);
const commentList = ref([]);
const courseFile = ref({});
onMounted(async () => {
// 延迟设置topWrapper和bottomWrapper的高度
setTimeout(() => {
......@@ -356,37 +314,70 @@ onMounted(async () => {
const { code, data } = await getScheduleCourseAPI({ i: courseId });
if (code) {
course.value = data;
// TODO: 测试数据
course.value.cover = 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg';
courseFile.value = data.file;
// 音频列表处理
// if (data.course_type === 'audio') {
// audioList.value = [course.value.file];
// }
if (data.course_type === 'audio') {
audioList.value = [data.file];
}
// 获取评论列表
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 = (comment) => {
comment.isLiked = !comment.isLiked;
comment.likes += comment.isLiked ? 1 : -1;
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 = () => {
// 发送按钮,提交评论
const submitComment = async () => {
if (!newComment.value.trim()) return;
comments.value.unshift({
id: Date.now(),
username: '当前用户',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: newComment.value,
time: new Date().toLocaleString(),
likes: 0,
isLiked: false
});
newComment.value = '';
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);
}
};
// 处理标签页切换
......@@ -404,28 +395,74 @@ 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);
});
// 提交弹窗中的评论
const submitPopupComment = () => {
const submitPopupComment = async () => {
if (!popupComment.value.trim()) return;
comments.value.unshift({
id: Date.now(),
username: '当前用户',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
content: popupComment.value,
time: new Date().toLocaleString(),
likes: 0,
isLiked: false
});
popupComment.value = '';
showCommentPopup.value = false;
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;
popupComment.value = '';
}
} catch (error) {
console.error('提交评论失败:', error);
}
};
// 监听弹窗显示状态变化
watch(showCommentPopup, (newVal) => {
if (newVal) {
// 打开弹窗时重置状态
popupCommentList.value = [];
popupPage.value = 0;
popupFinished.value = false;
popupLoading.value = true;
// 加载第一页数据
onPopupLoad();
}
});
</script>
<style lang="less" scoped>
......