Files
akmon/pages/info/comments.uvue
2026-01-20 08:04:15 +08:00

1001 lines
20 KiB
Plaintext
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>
<view class="comments-container">
<!-- 评论头部 -->
<view class="comments-header">
<view class="header-left">
<text class="comments-title">{{ $t('mt.comment.title') }} ({{ totalComments }})</text>
</view>
<view class="header-right">
<view class="sort-selector" @click="showSortOptions">
<text class="sort-text">{{ $t(currentSortText) }}</text>
<text class="sort-arrow">▼</text>
</view>
</view>
</view>
<!-- 评论输入框 -->
<view class="comment-input-section" v-if="showCommentInput">
<view class="input-header">
<view class="user-avatar">
<text class="avatar-text">{{ userAvatarText }}</text>
</view>
<view class="input-actions">
<view class="cancel-btn" @click="cancelComment">
<text class="cancel-text">{{ $t('mt.comment.cancel') }}</text>
</view>
</view>
</view>
<textarea
class="comment-textarea"
v-model="commentContent"
:placeholder="$t('mt.comment.placeholder')"
:maxlength="500"
:auto-height="true"
@blur="handleInputBlur"
@focus="handleInputFocus">
</textarea>
<view class="input-footer">
<text class="char-count">{{ commentContent.length }}/500</text>
<view class="submit-btn" @click="submitComment" :class="{ disabled: !canSubmit }">
<text class="submit-text">{{ $t('mt.comment.submit') }}</text>
</view>
</view>
</view>
<!-- 快速评论按钮 -->
<view class="quick-comment-btn" @click="showCommentInput = true" v-if="!showCommentInput">
<text class="quick-comment-text">{{ $t('mt.comment.quick') }}</text>
</view>
<!-- 加载状态 -->
<view class="loading-section" v-if="loading">
<text class="loading-text">{{ $t('mt.status.loading') }}</text>
</view>
<!-- 错误状态 -->
<view class="error-section" v-if="error !== null">
<text class="error-text">{{ error }}</text>
<view class="retry-btn" @click="retryLoad">
<text class="retry-text">{{ $t('mt.action.retry') }}</text>
</view>
</view>
<!-- 评论列表 -->
<view class="comments-list" v-if="commentsList.length > 0">
<view
v-for="(comment, index) in commentsList"
:key="comment.id"
class="comment-item"
:class="{ 'is-reply': (comment.level ?? 0) > 0 }"
:style="{ marginLeft: ((comment.level ?? 0) * 20) + 'px' }">
<!-- 评论主体 -->
<view class="comment-main">
<view class="comment-avatar">
<text class="avatar-text">{{ (comment.user_name ?? '').substring(0, 1) }}</text>
</view>
<view class="comment-body">
<view class="comment-header">
<text class="author-name">{{ comment.user_name }}</text>
<text class="comment-time">{{ $t(formatRelativeTimeKey(comment.created_at)) }}</text>
</view>
<text class="comment-content">{{ comment.content }}</text>
<view class="comment-actions">
<view class="action-item" @click="toggleLike(comment)">
<text class="action-icon" :class="{ liked: comment.is_liked }">👍</text>
<text class="action-text">{{ comment.like_count }}</text>
</view>
<view class="action-item" @click="showReplyInput(comment)">
<text class="action-icon">💬</text>
<text class="action-text">{{ $t('mt.comment.reply') }}</text>
</view>
<view class="action-item" @click="showReportModal(comment)" v-if="comment?.is_author==false">
<text class="action-icon">⚠️</text>
<text class="action-text">{{ $t('mt.comment.report') }}</text>
</view>
<view class="action-item" @click="deleteComment(comment)" v-if="comment.is_author">
<text class="action-icon">🗑️</text>
<text class="action-text">{{ $t('mt.comment.delete') }}</text>
</view>
</view>
</view>
</view>
<!-- 回复输入框 -->
<view class="reply-input-section" v-if="activeReplyId === comment.id">
<view class="reply-input-header">
<text class="reply-target">{{ $t('mt.comment.replyTo') }} @{{ comment.user_name }}:</text>
<view class="reply-cancel" @click="cancelReply">
<text class="cancel-text">×</text>
</view>
</view>
<textarea
class="reply-textarea"
v-model="replyContent"
:placeholder="$t('mt.comment.replyPlaceholder')"
:maxlength="300"
:auto-height="true">
</textarea>
<view class="reply-footer">
<text class="char-count">{{ replyContent.length }}/300</text>
<view class="reply-submit-btn" @click="submitReply(comment)" :class="{ disabled: !canSubmitReply }">
<text class="submit-text">{{ $t('mt.comment.reply') }}</text>
</view>
</view>
</view>
<!-- 查看更多回复 -->
<view class="load-replies-btn" v-if="comment.reply_count > 0 && !isRepliesLoaded(comment)" @click="loadReplies(comment)">
<text class="load-replies-text">{{ $t('mt.comment.loadReplies', { count: comment.reply_count }) }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-section" v-if="commentsList.length === 0 && !loading && error === null">
<text class="empty-text">{{ $t('mt.comment.empty') }}</text>
</view>
<!-- 加载更多 -->
<view class="load-more-section" v-if="commentsList.length > 0 && hasMore">
<view class="load-more-btn" @click="loadMore" v-if="!loadingMore">
<text class="load-more-text">{{ $t('mt.comment.loadMore') }}</text>
</view>
<view class="loading-more" v-if="loadingMore">
<text class="loading-more-text">{{ $t('mt.loadingMore') }}</text>
</view>
</view>
<!-- 排序选择弹窗 -->
<view class="modal-overlay" v-if="showSortModal" @click="hideSortOptions">
<view class="sort-modal" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ $t('mt.comment.sortTitle') }}</text>
<view class="modal-close" @click="hideSortOptions">
<text class="close-text">×</text>
</view>
</view>
<view class="sort-list">
<view
v-for="(option, index) in sortOptionsList"
:key="option.value"
class="sort-item"
:class="{ active: currentSort === option.value }"
@click="selectSortOption(option)">
<text class="sort-name">{{ $t(option.text )}}</text>
</view>
</view>
</view>
</view>
<!-- 举报弹窗 -->
<view class="modal-overlay" v-if="showReportModalFlag" @click="hideReportModal">
<view class="report-modal" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ $t('mt.comment.reportTitle') }}</text>
<view class="modal-close" @click="hideReportModal">
<text class="close-text">×</text>
</view>
</view>
<view class="report-list">
<view
v-for="(type, index) in reportTypesList"
:key="type.value"
class="report-item"
@click="reportComment(type)">
<text class="report-text">{{ type.text }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import {
CommentData,
COMMENT_SORT_OPTIONS,
COMMENT_REPORT_TYPES,
formatRelativeTimeKey
} from './types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import { tt } from '@/utils/i18nfun.uts'
// 属性设计对齐 supadb.uvue支持灵活配置
const props = defineProps({
// 业务主键
targetType: {
type: String,
default: ''
},
targetId: {
type: String,
default: ''
},
userId: {
type: String,
default: ''
},
userName: {
type: String,
default: '游客'
},
// 通用 supadb.uvue 风格属性
collection: {
type: String,
default: 'ak_comments'
},
filter: {
type: UTSJSONObject,
default: () => ({}),
},
field: {
type: String,
default: '*'
},
orderby: {
type: String,
default: ''
},
pageCurrent: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 20
},
getcount: {
type: String,
default: ''
},
getone: {
type: Boolean,
default: false
},
loadtime: {
type: String,
default: 'auto'
},
// RPC 函数名
rpc: {
type: String,
default: ''
},
// RPC 参数
params: {
type: UTSJSONObject,
default: () => ({})
}
})
// 状态变量
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
const loadingMore = ref<boolean>(false)
const hasMore = ref<boolean>(true)
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
const totalComments = ref<number>(0)
// UI状态
const showCommentInput = ref<boolean>(false)
const showSortModal = ref<boolean>(false)
const showReportModalFlag = ref<boolean>(false)
const activeReplyId = ref<string>('')
const reportingCommentId = ref<string>('')
// 输入内容
const commentContent = ref<string>('')
const replyContent = ref<string>('')
// 排序和选项
const currentSort = ref<string>('created_at_desc')
const currentSortText = ref<string>('mt.comment.sort.latest')
const sortOptionsList = ref(COMMENT_SORT_OPTIONS)
const reportTypesList = ref(COMMENT_REPORT_TYPES)
// 数据列表
const commentsList = ref<Array<CommentData>>([])
const loadedReplies = ref<Array<string>>([]) // 已加载回复的评论ID
// 计算属性
const userAvatarText = computed((): string => {
return props.userName.substring(0, 1)
})
const canSubmit = computed((): boolean => {
return commentContent.value.trim().length > 0
})
const canSubmitReply = computed((): boolean => {
return replyContent.value.trim().length > 0
})
// 生命周期
// onMounted(() => {
// loadComments()
// })
// 加载评论
const loadComments = async () => {
console.log(props.targetId)
if (loading.value) return
loading.value = true
error.value = null
try {
// 构建查询
let query = supa.from('ak_comments')
.select('*',{count:'exact'})
.eq('target_type', props.targetType)
.eq('target_id', props.targetId)
.eq('status', 'active')
// 排序
const sortParts = currentSort.value.split('_')
const column = sortParts.slice(0, -1).join('_')
const direction = sortParts[sortParts.length - 1] === 'desc'
if (direction) {
query = query.order(column, { ascending: false })
} else {
query = query.order(column, { ascending: true })
}
// 分页
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value - 1
query = query.range(start, end)
const result = await query.executeAs<CommentData>()
if (result.error != null) {
throw new Error(result.error?.message??' error')
}
if (result.data !== null) {
const data = result.data as CommentData[]
if (currentPage.value === 1) {
commentsList.value = data
} else {
for (let i: Int = 0; i < data.length; i++) {
commentsList.value.push(data[i])
}
}
hasMore.value = data.length === pageSize.value
if (currentPage.value === 1) {
totalComments.value = commentsList.value.length
}
}
} catch (e: any) {
error.value = '加载评论失败,请稍后重试'
console.error('Comments loading error:', e)
} finally {
loading.value = false
}
}
// 提交评论
const submitComment = () => {
if (!canSubmit.value) return
// TODO: 调用API提交评论
uni.showToast({
title: '评论发表成功',
icon: 'success'
})
commentContent.value = ''
showCommentInput.value = false
// 刷新评论列表
currentPage.value = 1
loadComments()
}
// 显示回复输入框
const showReplyInput = (comment: CommentData) => {
activeReplyId.value = comment.id
replyContent.value = ''
}
// 取消回复
const cancelReply = () => {
activeReplyId.value = ''
replyContent.value = ''
}
// 提交回复
const submitReply = (comment: CommentData) => {
if (!canSubmitReply.value) return
// TODO: 调用API提交回复
uni.showToast({
title: '回复发表成功',
icon: 'success'
})
cancelReply()
// 刷新评论列表
currentPage.value = 1
loadComments()
}
// 点赞/取消点赞
const toggleLike = (comment: CommentData) => {
// TODO: 调用API切换点赞状态
const message = comment.is_liked === true ? '取消点赞' : '点赞成功'
uni.showToast({
title: message,
icon: 'success'
})
}
// 加载回复
const loadReplies = (comment: CommentData) => {
loadedReplies.value.push(comment.id)
// TODO: 加载该评论的回复
uni.showToast({
title: '加载回复功能开发中',
icon: 'none'
})
}
// 检查回复是否已加载
const isRepliesLoaded = (comment: CommentData): boolean => {
return loadedReplies.value.includes(comment.id)
}
// 删除评论
const deleteComment = (comment: CommentData) => {
uni.showModal({
title: '确认删除',
content: '删除后不可恢复,确定要删除这条评论吗?',
success: (res) => {
if (res.confirm) {
// TODO: 调用API删除评论
uni.showToast({
title: '删除成功',
icon: 'success'
})
// 刷新评论列表
currentPage.value = 1
loadComments()
}
}
})
}
// 举报相关
const showReportModal = (comment: CommentData) => {
reportingCommentId.value = comment.id
showReportModalFlag.value = true
}
const hideReportModal = () => {
showReportModalFlag.value = false
reportingCommentId.value = ''
}
const reportComment = (type: any) => {
// TODO: 调用API举报评论
uni.showToast({
title: '举报已提交',
icon: 'success'
})
hideReportModal()
}
// 排序相关
const showSortOptions = () => {
showSortModal.value = true
}
const hideSortOptions = () => {
showSortModal.value = false
}
import type { OptionItem } from './types.uts'
const selectSortOption = (option: OptionItem) => {
currentSort.value = option.value
currentSortText.value = option.text
hideSortOptions()
currentPage.value = 1
loadComments()
}
// 加载更多
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
currentPage.value += 1
setTimeout(() => {
loadComments()
loadingMore.value = false
}, 500)
}
// 重试加载
const retryLoad = () => {
error.value = null
loadComments()
}
// 取消评论
const cancelComment = () => {
commentContent.value = ''
showCommentInput.value = false
}
// 输入框事件
const handleInputFocus = () => {
// 输入框获得焦点时的处理
}
const handleInputBlur = () => {
// 输入框失去焦点时的处理
}
watch(props.targetId, (val:string): void => {
console.log(val)
if (typeof val === 'string' && val.length > 0) {
loadComments()
}
return;
}, { immediate: true })
</script>
<style>
.comments-container {
background-color: #ffffff;
padding: 16px;
}
.comments-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.header-left {
flex: 1;
}
.comments-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.header-right {
flex-direction: row;
align-items: center;
}
.sort-selector {
flex-direction: row;
align-items: center;
padding: 6px 12px;
background-color: #f1f5f9;
border-radius: 16px;
}
.sort-text {
font-size: 14px;
color: #475569;
margin-right: 4px;
}
.sort-arrow {
font-size: 12px;
color: #64748b;
}
.comment-input-section {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
border-width: 1px;
border-color: #e2e8f0;
}
.input-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 16px;
background-color: #3b82f6;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 14px;
color: #ffffff;
font-weight: bold;
}
.input-actions {
flex-direction: row;
}
.cancel-btn {
padding: 6px 12px;
background-color: #f1f5f9;
border-radius: 16px;
}
.cancel-text {
font-size: 14px;
color: #64748b;
}
.comment-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
background-color: #ffffff;
border-radius: 8px;
border-width: 1px;
border-color: #d1d5db;
font-size: 14px;
color: #1f2937;
line-height: 20px;
}
.input-footer {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.char-count {
font-size: 12px;
color: #94a3b8;
}
.submit-btn {
padding: 8px 16px;
background-color: #3b82f6;
border-radius: 20px;
}
.submit-btn.disabled {
background-color: #d1d5db;
}
.submit-text {
font-size: 14px;
color: #ffffff;
}
.quick-comment-btn {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
border-width: 1px;
border-color: #e2e8f0;
align-items: center;
justify-content: center;
}
.quick-comment-text {
font-size: 14px;
color: #94a3b8;
}
.loading-section, .error-section {
padding: 40px 16px;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 14px;
color: #94a3b8;
}
.error-text {
font-size: 14px;
color: #ef4444;
margin-bottom: 16px;
text-align: center;
}
.retry-btn {
background-color: #3b82f6;
border-radius: 20px;
padding: 8px 24px;
}
.retry-text {
font-size: 14px;
color: #ffffff;
}
.comments-list {
margin-bottom: 16px;
}
.comment-item {
margin-bottom: 16px;
}
.comment-item.is-reply {
border-left-width: 2px;
border-left-color: #e2e8f0;
padding-left: 12px;
}
.comment-main {
flex-direction: row;
align-items: flex-start;
}
.comment-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: #64748b;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.comment-body {
flex: 1;
}
.comment-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.author-name {
font-size: 14px;
font-weight: bold;
color: #1f2937;
}
.comment-time {
font-size: 12px;
color: #94a3b8;
}
.comment-content {
font-size: 14px;
color: #374151;
line-height: 20px;
margin-bottom: 8px;
}
.comment-actions {
flex-direction: row;
align-items: center;
}
.action-item {
flex-direction: row;
align-items: center;
padding: 4px 8px;
margin-right: 16px;
}
.action-icon {
font-size: 14px;
margin-right: 4px;
}
.action-icon.liked {
color: #3b82f6;
}
.action-text {
font-size: 12px;
color: #64748b;
}
.reply-input-section {
margin-top: 12px;
margin-left: 52px;
background-color: #f8fafc;
border-radius: 8px;
padding: 12px;
}
.reply-input-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.reply-target {
font-size: 12px;
color: #3b82f6;
}
.reply-cancel {
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
background-color: #f1f5f9;
border-radius: 12px;
}
.reply-textarea {
width: 100%;
min-height: 60px;
padding: 8px;
background-color: #ffffff;
border-radius: 6px;
border-width: 1px;
border-color: #d1d5db;
font-size: 14px;
color: #1f2937;
}
.reply-footer {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.reply-submit-btn {
padding: 6px 12px;
background-color: #3b82f6;
border-radius: 16px;
}
.reply-submit-btn.disabled {
background-color: #d1d5db;
}
.load-replies-btn {
margin-top: 8px;
margin-left: 52px;
padding: 6px 12px;
background-color: #f1f5f9;
border-radius: 16px;
align-items: center;
}
.load-replies-text {
font-size: 12px;
color: #3b82f6;
}
.empty-section {
padding: 60px 16px;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 16px;
color: #94a3b8;
text-align: center;
}
.load-more-section {
padding: 20px 16px;
align-items: center;
justify-content: center;
}
.load-more-btn {
background-color: #f1f5f9;
border-radius: 20px;
padding: 10px 24px;
border-width: 1px;
border-color: #e2e8f0;
}
.load-more-text {
font-size: 14px;
color: #475569;
}
.loading-more {
padding: 10px 24px;
}
.loading-more-text {
font-size: 14px;
color: #94a3b8;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.sort-modal, .report-modal {
background-color: #ffffff;
border-radius: 16px;
margin: 20px;
max-height: 500px;
min-width: 280px;
}
.modal-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 16px;
background-color: #f1f5f9;
}
.close-text {
font-size: 20px;
color: #64748b;
}
.sort-list, .report-list {
max-height: 400px;
}
.sort-item, .report-item {
padding: 16px 20px;
border-bottom-width: 1px;
border-bottom-color: #f1f5f9;
}
.sort-item.active {
background-color: #eff6ff;
}
.sort-name, .report-text {
font-size: 16px;
color: #1f2937;
}
.sort-item.active .sort-name {
color: #3b82f6;
}
</style>