793 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="comments-content">
<h4>评论</h4>
<!-- 评论列表 -->
<div class="comment-list" v-if="displayComments.length > 0">
<div class="comment-item" v-for="comment in displayComments" :key="comment.id">
<div class="comment-avatar">
<img :src="comment.avatar" :alt="comment.username" />
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-username">{{ comment.username }}</span>
<span v-if="comment.userType === 'instructor'" class="instructor-badge">{{ comment.userBadge }}</span>
<span v-else-if="comment.userType === 'student'" class="student-badge">{{ comment.userBadge }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-actions">
<button v-if="comment.isPinned" class="action-btn">
<span class="top">置顶评论</span>
</button>
<button class="action-btn">
<span>{{ comment.time }}</span>
</button>
<button class="action-btn" @click="likeComment(comment)">
<span>{{ comment.isLiked ? '已点赞' : '点赞' }} ({{ comment.likeCount }})</span>
</button>
<button v-if="!comment.replies || comment.replies.length === 0" class="action-btn"
@click="startReply(comment.id, comment.username)">
回复
</button>
</div>
<!-- 回复区域 -->
<div class="comment-replies" v-if="comment.replies && comment.replies.length > 0">
<div class="reply-item instructor-reply" v-for="reply in comment.replies" :key="reply.id">
<div class="reply-avatar">
<img :src="reply.avatar" :alt="reply.username" />
</div>
<div class="reply-content">
<div class="reply-main">
<div class="reply-header">
<span class="reply-username">{{ reply.username }}</span>
<span class="reply-badge instructor">{{ reply.badge }}</span>
</div>
<div class="reply-text">{{ reply.content }}</div>
</div>
<div class="reply-footer">
<span class="reply-time">{{ reply.time }}</span>
<div class="reply-actions">
<button class="reply-action-btn">回复</button>
</div>
</div>
</div>
</div>
</div>
<!-- 回复输入框 -->
<div v-if="replyingTo === comment.id" class="reply-input-section">
<div class="reply-input-header">
<span>回复 @{{ replyToUsername }}</span>
</div>
<div class="reply-input-content">
<textarea v-model="replyContent" class="reply-textarea" placeholder="请输入回复内容..." :maxlength="500"
rows="3"></textarea>
<div class="reply-input-actions">
<button class="reply-cancel-btn" @click="cancelReply">取消</button>
<button class="reply-submit-btn" @click="submitReply">发送</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!loading && !error" class="empty-state">
<div class="empty-content">
<h3 class="empty-title">暂无评论</h3>
<p class="empty-description">还没有人发表评论快来抢沙发吧</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-content">
<p>正在加载评论...</p>
</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-container">
<div class="error-content">
<p>{{ error }}</p>
<button @click="loadComments" class="retry-btn">重试</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { CommentApi } from '@/api/modules/comment'
// 路由和消息
const route = useRoute()
const message = useMessage()
// 课程ID
const courseId = computed(() => route.params.id as string)
// 评论数据
const displayComments = ref<any[]>([])
const loading = ref(false)
const error = ref('')
// 回复相关
const replyingTo = ref<number | null>(null)
const replyToUsername = ref('')
const replyContent = ref('')
// 时间格式化函数
const formatTime = (timeStr: string) => {
try {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
// 小于1分钟
if (diff < 60000) {
return '刚刚'
}
// 小于1小时
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
// 小于1天
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`
}
// 小于7天
if (diff < 604800000) {
return `${Math.floor(diff / 86400000)}天前`
}
// 超过7天显示具体日期
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).replace(/\//g, '.')
} catch (error) {
return timeStr
}
}
// 加载评论数据
const loadComments = async () => {
if (!courseId.value) {
console.error('❌ 课程ID不存在')
return
}
try {
loading.value = true
error.value = ''
console.log('🚀 开始加载课程评论:', courseId.value)
// 调用评论API - 使用正确的接口路径
const response = await CommentApi.getCourseComments(Number(courseId.value), {
page: 1,
pageSize: 20,
sortBy: 'newest'
})
console.log('📊 评论API响应:', response)
if (response.data && response.data.code === 200) {
const comments = response.data.result || []
// 转换API数据格式
const apiComments = comments.map((comment: any) => {
// 调试:打印原始评论数据
console.log('🔍 原始评论数据:', comment)
console.log('🔍 用户身份相关字段:', {
userType: comment.userType,
user_type: comment.user_type,
role: comment.role,
isTeacher: comment.isTeacher,
is_teacher: comment.is_teacher,
userRole: comment.userRole,
user_role: comment.user_role,
type: comment.type,
userCategory: comment.userCategory,
user_category: comment.user_category
})
// 根据接口数据判断用户身份
let userType = 'user'
let userBadge = '用户'
// 检查是否是讲师/教师 - 扩展更多可能的字段
if (comment.userType === 'teacher' || comment.user_type === 'teacher' ||
comment.role === 'teacher' || comment.role === 'instructor' ||
comment.isTeacher === true || comment.is_teacher === true ||
comment.userRole === 'teacher' || comment.user_role === 'teacher' ||
comment.type === 'teacher' || comment.type === 'instructor' ||
comment.userCategory === 'teacher' || comment.user_category === 'teacher') {
userType = 'instructor'
userBadge = '讲师'
console.log('✅ 识别为讲师')
}
// 检查是否是学生
else if (comment.userType === 'student' || comment.user_type === 'student' ||
comment.role === 'student' || comment.isStudent === true ||
comment.is_student === true ||
comment.userRole === 'student' || comment.user_role === 'student' ||
comment.type === 'student' ||
comment.userCategory === 'student' || comment.user_category === 'student') {
userType = 'student'
userBadge = '学生'
console.log('✅ 识别为学生')
} else {
console.log('❌ 未识别用户身份,默认为用户')
}
return {
id: comment.id,
username: comment.userName || comment.username || '匿名用户',
avatar: comment.userAvatar || comment.avatar || '/images/activity/1.png',
time: formatTime(comment.createTime || comment.create_time),
content: comment.content,
isPinned: comment.isPinned || comment.izTop === 1,
type: comment.isPinned ? 'pinned' : 'comment',
likeCount: comment.likeCount || 0,
isLiked: comment.isLiked || false,
userType: userType,
userBadge: userBadge,
replies: (comment.replies || []).map((reply: any) => {
// 调试:打印原始回复数据
console.log('🔍 原始回复数据:', reply)
console.log('🔍 回复用户身份相关字段:', {
userType: reply.userType,
user_type: reply.user_type,
role: reply.role,
isTeacher: reply.isTeacher,
is_teacher: reply.is_teacher,
userRole: reply.userRole,
user_role: reply.user_role,
type: reply.type,
userCategory: reply.userCategory,
user_category: reply.user_category
})
// 根据接口数据判断用户身份
let userType = 'user'
let userBadge = '用户'
// 检查是否是讲师/教师 - 扩展更多可能的字段
if (reply.userType === 'teacher' || reply.user_type === 'teacher' ||
reply.role === 'teacher' || reply.role === 'instructor' ||
reply.isTeacher === true || reply.is_teacher === true ||
reply.userRole === 'teacher' || reply.user_role === 'teacher' ||
reply.type === 'teacher' || reply.type === 'instructor' ||
reply.userCategory === 'teacher' || reply.user_category === 'teacher') {
userType = 'instructor'
userBadge = '讲师'
console.log('✅ 回复识别为讲师')
}
// 检查是否是学生
else if (reply.userType === 'student' || reply.user_type === 'student' ||
reply.role === 'student' || reply.isStudent === true ||
reply.is_student === true ||
reply.userRole === 'student' || reply.user_role === 'student' ||
reply.type === 'student' ||
reply.userCategory === 'student' || reply.user_category === 'student') {
userType = 'student'
userBadge = '学生'
console.log('✅ 回复识别为学生')
} else {
console.log('❌ 回复未识别用户身份,默认为用户')
}
return {
id: reply.id || `reply_${Date.now()}`,
username: reply.userName || reply.username || reply.user_name || '匿名用户',
avatar: reply.userAvatar || reply.avatar || reply.user_avatar || '/images/activity/1.png',
time: formatTime(reply.createTime || reply.create_time || reply.time),
content: reply.content || reply.text || '',
type: userType,
badge: userBadge
}
})
}
})
// 只使用API返回的真实数据
displayComments.value = apiComments
console.log('✅ API评论数据:', apiComments.length, '条')
console.log('✅ 转换后的评论数据:', displayComments.value)
// 调试查看API返回的原始回复数据
if (comments.length > 0 && comments[0].replies) {
console.log('🔍 API原始回复数据:', comments[0].replies)
}
} else {
console.warn('⚠️ 评论API返回数据为空或失败')
// 显示空状态
displayComments.value = []
}
} catch (err: any) {
console.error('❌ 加载评论失败:', err)
console.error('❌ 错误详情:', {
message: err.message,
status: err.response?.status,
statusText: err.response?.statusText,
data: err.response?.data,
url: err.config?.url
})
error.value = `加载评论失败: ${err.response?.status || err.message}`
// 显示空状态
displayComments.value = []
} finally {
loading.value = false
}
}
// 开始回复
const startReply = (commentId: number, username: string) => {
replyingTo.value = commentId
replyToUsername.value = username
}
// 提交回复
const submitReply = async () => {
if (!replyContent.value.trim()) {
message.warning('请输入回复内容')
return
}
try {
console.log('🚀 发送回复请求:', {
content: replyContent.value,
targetType: 'comment',
targetId: replyingTo.value
})
// 使用专门的回复接口
const response = await CommentApi.replyComment({
content: replyContent.value,
targetType: 'comment',
targetId: String(replyingTo.value),
parentId: replyingTo.value || undefined
})
console.log('📊 回复API响应:', response)
if (response.data && response.data.code === 200) {
message.success('回复成功')
replyContent.value = ''
replyingTo.value = null
replyToUsername.value = ''
// 重新加载评论
await loadComments()
} else {
message.error(response.data?.message || '回复失败')
}
} catch (err) {
console.error('❌ 回复失败:', err)
message.error('回复失败,请重试')
}
}
// 点赞评论
const likeComment = async (comment: any) => {
try {
console.log('🚀 发送点赞请求:', comment.id)
const response = await CommentApi.likeComment(comment.id)
console.log('📊 点赞API响应:', response)
if (response.data && response.data.code === 200) {
comment.isLiked = !comment.isLiked
comment.likeCount += comment.isLiked ? 1 : -1
message.success(comment.isLiked ? '点赞成功' : '取消点赞')
} else {
message.error(response.data?.message || '操作失败')
}
} catch (err) {
console.error('❌ 点赞失败:', err)
message.error('点赞失败,请重试')
}
}
// 取消回复
const cancelReply = () => {
replyContent.value = ''
replyingTo.value = null
replyToUsername.value = ''
}
// 组件挂载时加载数据
onMounted(() => {
loadComments()
})
</script>
<style scoped>
.comments-content h4 {
font-size: 18px;
font-weight: 500;
color: #333;
margin: 0 0 12px 0;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.comment-item {
display: flex;
gap: 12px;
}
.comment-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.comment-username {
font-size: 14px;
font-weight: 600;
color: #333;
}
.instructor-badge {
display: inline-block;
padding: 2px 6px;
background: #EEF9FF;
color: #008BD7;
font-size: 10px;
border-radius: 2px;
margin-left: 8px;
font-weight: 500;
}
.student-badge {
display: inline-block;
padding: 2px 6px;
background: #fff7e6;
color: #fa8c16;
font-size: 10px;
border-radius: 2px;
margin-left: 8px;
font-weight: 500;
}
.comment-text {
font-size: 14px;
line-height: 1.6;
color: #333;
margin-bottom: 12px;
}
.comment-actions {
display: flex;
align-items: center;
gap: 16px;
}
.action-btn {
background: none;
border: none;
font-size: 12px;
color: #999;
cursor: pointer;
display: flex;
align-items: center;
gap: 14px;
transition: color 0.3s;
}
.action-btn:hover {
color: #1890ff;
}
.action-btn .top {
background: #FFF4F4;
color: #FF304B;
padding: 4px 6px;
border-radius: 20px;
font-size: 10px;
font-weight: 500;
}
/* 回复和二级评论样式 */
.comment-replies {
margin-top: 12px;
}
.reply-item {
display: flex;
gap: 12px;
margin-bottom: 16px;
position: relative;
}
.reply-item:last-child {
margin-bottom: 0;
}
.reply-avatar {
flex-shrink: 0;
}
.reply-avatar img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.reply-content {
flex: 1;
}
.reply-main {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.reply-header {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.reply-username {
font-size: 14px;
color: #666;
}
.reply-badge {
width: 32px;
height: 20px;
text-align: center;
line-height: 20px;
font-size: 10px;
font-weight: 500;
}
.reply-badge.instructor {
background: #EEF9FF;
color: #008BD7;
}
.reply-badge.user {
background: #f6ffed;
color: #52c41a;
}
.reply-badge.student {
background: #fff7e6;
color: #fa8c16;
}
.reply-badge.teacher {
background: #f6ffed;
color: #52c41a;
}
.reply-time {
font-size: 12px;
color: #999;
}
.reply-text {
font-size: 14px;
line-height: 1.5;
color: #333;
flex: 1;
}
.reply-footer {
display: flex;
justify-content: left;
align-items: center;
margin-top: 8px;
gap: 15px;
}
.reply-actions {
display: flex;
align-items: center;
gap: 12px;
}
.reply-action-btn {
background: none;
border: none;
font-size: 12px;
color: #999;
cursor: pointer;
transition: color 0.3s;
}
.reply-action-btn:hover {
color: #1890ff;
}
/* 空状态样式 */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 60px 20px;
min-height: 200px;
}
.empty-content {
text-align: center;
max-width: 300px;
}
.empty-title {
font-size: 18px;
font-weight: 500;
color: #374151;
margin: 0 0 8px 0;
}
.empty-description {
font-size: 14px;
color: #6B7280;
margin: 0 0 24px 0;
line-height: 1.5;
}
/* 加载和错误状态样式 */
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.loading-content,
.error-content {
text-align: center;
}
.loading-content p,
.error-content p {
color: #666;
font-size: 14px;
margin-bottom: 16px;
}
.retry-btn {
background: #1890ff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.retry-btn:hover {
background: #40a9ff;
}
/* 回复输入框样式 */
.reply-input-section {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.reply-input-header {
margin-bottom: 12px;
font-size: 14px;
color: #666;
font-weight: 500;
}
.reply-input-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.reply-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
resize: vertical;
outline: none;
transition: border-color 0.3s;
}
.reply-textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.reply-textarea::placeholder {
color: #bfbfbf;
}
.reply-input-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.reply-cancel-btn {
padding: 6px 16px;
border: 1px solid #d9d9d9;
background: white;
color: #666;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.reply-cancel-btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.reply-submit-btn {
padding: 6px 16px;
border: none;
background: #1890ff;
color: white;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.3s;
}
.reply-submit-btn:hover {
background: #40a9ff;
}
.reply-submit-btn:active {
background: #096dd9;
}
</style>