Files
CosScene/clients/components/comment-list/comment-list.vue
T
2026-05-09 16:40:29 +08:00

585 lines
12 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.
<script setup>
import { ref, computed } from "vue";
import { getComments, createComment, reportComment } from "@/api/comment";
import { resolveImageUrl } from "@/utils/image";
const props = defineProps({
spotId: {
type: Number,
required: true,
},
isFavorited: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["toggle-favorite"]);
const comments = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const inputText = ref("");
const replyTarget = ref(null);
const sending = ref(false);
const showReport = ref(false);
const reportTargetId = ref(null);
const reportReason = ref("");
const inputPlaceholder = computed(() =>
replyTarget.value ? `回复 @${replyTarget.value.nickname}` : "写下你的评论..."
);
const fetchComments = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getComments(props.spotId, {
page: page.value,
page_size: pageSize,
});
const list = res.items || res.data || res || [];
if (reset) {
comments.value = list;
} else {
comments.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const handleSend = async () => {
const text = inputText.value.trim();
if (!text || sending.value) return;
sending.value = true;
try {
const data = { content: text };
if (replyTarget.value) {
data.parent_id = replyTarget.value.id;
}
await createComment(props.spotId, data);
inputText.value = "";
replyTarget.value = null;
await fetchComments(true);
uni.showToast({ title: "评论成功", icon: "none" });
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
};
const setReply = (comment) => {
replyTarget.value = {
id: comment.id,
nickname: comment.user?.nickname || "匿名用户",
};
};
const cancelReply = () => {
replyTarget.value = null;
};
const openReport = (commentId) => {
reportTargetId.value = commentId;
reportReason.value = "";
showReport.value = true;
};
const goUser = (userId) => {
if (userId) uni.navigateTo({ url: `/pages/user/index?id=${userId}` });
};
const closeReport = () => {
showReport.value = false;
reportTargetId.value = null;
reportReason.value = "";
};
const submitReport = async () => {
const reason = reportReason.value.trim();
if (!reason) {
uni.showToast({ title: "请输入举报原因", icon: "none" });
return;
}
try {
await reportComment(reportTargetId.value, { reason });
uni.showToast({ title: "举报已提交", icon: "none" });
closeReport();
} catch (e) {
console.error(e);
}
};
const formatTime = (t) => {
if (!t) return "";
const d = new Date(t);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${mm}-${dd} ${hh}:${mi}`;
};
fetchComments(true);
</script>
<template>
<view class="comment-section">
<view class="section-header">
<text class="section-title">评论</text>
</view>
<view v-if="comments.length === 0 && !loading" class="empty-tip">
<text>暂无评论快来发表第一条吧</text>
</view>
<view v-for="item in comments" :key="item.id" class="comment-card">
<view class="comment-main">
<view class="avatar-box" @tap="goUser(item.user?.id)">
<image
v-if="item.user?.avatar_url"
class="avatar-img"
:src="resolveImageUrl(item.user.avatar_url)"
mode="aspectFill"
/>
<text v-else class="avatar-text">{{ (item.user?.nickname || "匿")[0] }}</text>
</view>
<view class="comment-body">
<text class="nickname" @tap="goUser(item.user?.id)">{{ item.user?.nickname || "匿名用户" }}</text>
<text class="comment-content">{{ item.content }}</text>
<view class="comment-footer">
<text class="comment-time">{{ formatTime(item.created_at) }}</text>
<text class="action-btn" @tap="setReply(item)">回复</text>
<text class="action-btn report-btn" @tap="openReport(item.id)">举报</text>
</view>
</view>
</view>
<view v-if="item.replies && item.replies.length" class="replies-list">
<view v-for="reply in item.replies" :key="reply.id" class="reply-item">
<view class="reply-avatar-box" @tap="goUser(reply.user?.id)">
<image
v-if="reply.user?.avatar_url"
class="reply-avatar-img"
:src="resolveImageUrl(reply.user.avatar_url)"
mode="aspectFill"
/>
<text v-else class="reply-avatar-text">{{ (reply.user?.nickname || "匿")[0] }}</text>
</view>
<view class="reply-body">
<text class="reply-nickname" @tap="goUser(reply.user?.id)">{{ reply.user?.nickname || "匿名用户" }}</text>
<text class="reply-content">{{ reply.content }}</text>
<view class="reply-footer">
<text class="comment-time">{{ formatTime(reply.created_at) }}</text>
<text class="action-btn" @tap="setReply(reply)">回复</text>
<text class="action-btn report-btn" @tap="openReport(reply.id)">举报</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="hasMore && comments.length > 0" class="load-more" @tap="fetchComments()">
<text>{{ loading ? "加载中..." : "加载更多" }}</text>
</view>
<view class="input-bar">
<view v-if="replyTarget" class="reply-hint" @tap="cancelReply">
<text class="reply-hint-text">回复 @{{ replyTarget.nickname }}</text>
<text class="reply-cancel"></text>
</view>
<view class="input-row">
<input
class="comment-input"
v-model="inputText"
:placeholder="inputPlaceholder"
confirm-type="send"
@confirm="handleSend"
/>
<view class="send-btn" :class="{ disabled: !inputText.trim() }" @tap="handleSend">
<text class="send-text">发送</text>
</view>
<view
class="fav-quick-btn"
:class="{ active: props.isFavorited }"
@tap="emit('toggle-favorite')"
>
<uni-icons
:type="props.isFavorited ? 'heart-filled' : 'heart'"
size="18"
:color="props.isFavorited ? '#6366f1' : '#94a3b8'"
/>
</view>
</view>
</view>
<view v-if="showReport" class="report-mask" @tap.self="closeReport">
<view class="report-popup">
<text class="report-title">举报评论</text>
<textarea
class="report-textarea"
v-model="reportReason"
placeholder="请输入举报原因"
:maxlength="200"
/>
<view class="report-actions">
<view class="report-cancel-btn" @tap="closeReport">
<text>取消</text>
</view>
<view class="report-submit-btn" @tap="submitReport">
<text class="report-submit-text">提交</text>
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.comment-section {
padding-bottom: 120rpx;
}
.section-header {
padding: 24rpx 32rpx 16rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
}
.empty-tip {
text-align: center;
padding: 60rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.comment-card {
background: #ffffff;
margin: 0 32rpx 16rpx;
border-radius: 16rpx;
padding: 24rpx;
}
.comment-main {
display: flex;
}
.avatar-box {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: #e0e7ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 20rpx;
}
.avatar-img {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
}
.avatar-text {
font-size: 28rpx;
color: #6366f1;
font-weight: 600;
}
.comment-body {
flex: 1;
min-width: 0;
}
.nickname {
font-size: 26rpx;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 8rpx;
}
.comment-content {
font-size: 28rpx;
color: #334155;
line-height: 1.6;
display: block;
margin-bottom: 12rpx;
word-break: break-all;
}
.comment-footer {
display: flex;
align-items: center;
}
.comment-time {
font-size: 22rpx;
color: #94a3b8;
margin-right: 24rpx;
}
.action-btn {
font-size: 22rpx;
color: #6366f1;
margin-right: 20rpx;
}
.report-btn {
color: #94a3b8;
}
.replies-list {
margin-left: 92rpx;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f5f9;
}
.reply-item {
display: flex;
margin-bottom: 16rpx;
}
.reply-avatar-box {
width: 52rpx;
height: 52rpx;
border-radius: 26rpx;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 16rpx;
}
.reply-avatar-img {
width: 52rpx;
height: 52rpx;
border-radius: 26rpx;
}
.reply-avatar-text {
font-size: 22rpx;
color: #6366f1;
font-weight: 600;
}
.reply-body {
flex: 1;
min-width: 0;
}
.reply-nickname {
font-size: 24rpx;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 4rpx;
}
.reply-content {
font-size: 26rpx;
color: #475569;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
word-break: break-all;
}
.reply-footer {
display: flex;
align-items: center;
}
.load-more {
text-align: center;
padding: 24rpx 0;
color: #6366f1;
font-size: 26rpx;
}
.input-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
z-index: 100;
}
.reply-hint {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8rpx 16rpx;
margin-bottom: 8rpx;
background: #f1f5f9;
border-radius: 8rpx;
}
.reply-hint-text {
font-size: 24rpx;
color: #6366f1;
}
.reply-cancel {
font-size: 24rpx;
color: #94a3b8;
padding: 0 8rpx;
}
.input-row {
display: flex;
align-items: center;
}
.comment-input {
flex: 1;
height: 72rpx;
background: #f5f6fa;
border-radius: 36rpx;
padding: 0 28rpx;
font-size: 28rpx;
color: #1e293b;
}
.send-btn {
margin-left: 16rpx;
background: #6366f1;
border-radius: 36rpx;
padding: 0 32rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.send-btn.disabled {
opacity: 0.5;
}
.send-text {
color: #ffffff;
font-size: 28rpx;
font-weight: 500;
}
.fav-quick-btn {
margin-left: 12rpx;
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background: #f5f6fa;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fav-quick-btn.active {
background: rgba(99, 102, 241, 0.12);
}
.report-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.report-popup {
width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
padding: 40rpx;
}
.report-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.report-textarea {
width: 100%;
height: 200rpx;
background: #f5f6fa;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
color: #334155;
box-sizing: border-box;
}
.report-actions {
display: flex;
margin-top: 32rpx;
gap: 20rpx;
}
.report-cancel-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f6fa;
border-radius: 12rpx;
font-size: 28rpx;
color: #64748b;
}
.report-submit-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #6366f1;
border-radius: 12rpx;
}
.report-submit-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
</style>