Files
rating/components/PkCommentSection/PkCommentSection.vue
2026-06-15 19:59:07 +08:00

826 lines
20 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>
<view class="pk-comment-section">
<view class="comment-header">
<view class="comment-tabs">
<text
v-for="tab in tabs"
:key="tab.value"
class="comment-tab"
:class="{ active: currentTab === tab.value }"
@tap="switchTab(tab.value)"
>
{{ tab.label }}
</text>
</view>
<view class="my-choice-pill" :class="selectedTone ? `my-choice-pill--${selectedTone}` : ''">
<text class="my-choice-pill-label">我站</text>
<text class="my-choice-pill-value">{{ selectedItemLabel }}</text>
</view>
</view>
<view class="compose-card">
<view class="compose-tip">
<text class="compose-title">说说你的理由</text>
<text class="compose-desc">支持 {{ selectedItemLabel }} 的观点会更容易引发讨论</text>
</view>
<view class="compose-box">
<input
class="compose-input"
v-model="commentText"
:disabled="sending"
placeholder="写下你的 PK 观点..."
placeholder-class="compose-placeholder"
confirm-type="send"
@confirm="submitMainComment"
/>
<button
class="compose-send"
:class="{ disabled: sending || !commentText.trim() }"
:disabled="sending || !commentText.trim()"
@tap="submitMainComment"
>
{{ sending ? "发送中" : "发布" }}
</button>
</view>
</view>
<view class="comment-list">
<view v-if="loading && !comments.length" class="comment-state">
<text class="comment-state-title">评论加载中...</text>
<text class="comment-state-desc">看看大家都站哪边</text>
</view>
<view v-else-if="loadError && !comments.length" class="comment-state">
<text class="comment-state-title">评论加载失败</text>
<text class="comment-state-desc">{{ loadError }}</text>
<button class="comment-state-btn" @tap="refreshComments">重新加载</button>
</view>
<view v-else-if="!comments.length" class="comment-state">
<text class="comment-state-title">还没有人发言</text>
<text class="comment-state-desc">你可以成为这场 PK 的第一位评论官</text>
</view>
<CommentItem
v-for="comment in comments"
:key="comment.id"
:comment="comment"
:like-handler="likeComment"
page-name="pk_station"
@need-login="emit('need-login')"
@like-change="handleCommentLikeChange"
@reply="openReplyPopup"
@toggle-replies="handleToggleReplies"
@more-replies="handleLoadMoreReplies"
/>
</view>
<view v-if="comments.length" class="comment-footer">
<text v-if="loading" class="comment-footer-text">加载中...</text>
<text
v-else-if="hasMore"
class="comment-footer-link"
@tap="loadComments()"
>
查看更多评论
</text>
<text v-else class="comment-footer-text">没有更多评论了</text>
</view>
<view
v-if="replyPopupVisible"
class="reply-popup-mask"
@tap="closeReplyPopup"
></view>
<view
v-if="replyPopupVisible"
class="reply-popup"
:style="{ bottom: `${replyKeyboardHeight}px` }"
>
<view class="reply-popup-header">
<view class="reply-popup-user">
<text class="reply-popup-label">回复</text>
<text class="reply-popup-name">@{{ replyTarget?.replyToName || "Ta" }}</text>
</view>
<text class="reply-popup-close" @tap="closeReplyPopup">取消</text>
</view>
<view class="reply-popup-input-wrap">
<input
class="reply-popup-input"
v-model="replyContent"
:focus="replyInputFocus"
:disabled="sendingReply"
:adjust-position="false"
confirm-type="send"
:placeholder="replyPlaceholder"
placeholder-class="reply-popup-placeholder"
@confirm="submitReplyComment"
/>
<button
class="reply-popup-send"
:class="{ disabled: sendingReply || !replyContent.trim() }"
:disabled="sendingReply || !replyContent.trim()"
@tap="submitReplyComment"
>
{{ sendingReply ? "发送中" : "发送" }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { onUnmounted } from "vue";
import CommentItem from "@/components/CommentItem/CommentItem.vue";
import { useUserStore } from "@/stores/user";
import {
fetchCommentList,
fetchCommentReplyList,
publishComment,
likeComment,
} from "@/api/pk";
import { FILE_BASE_URL } from "@/utils/constants";
import { formatDate } from "@/utils/date";
const props = defineProps({
pkId: {
type: String,
default: "",
},
selectedItemId: {
type: String,
default: "",
},
selectedItemLabel: {
type: String,
default: "",
},
currentPair: {
type: Object,
default: null,
},
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["need-login"]);
const userStore = useUserStore();
const tabs = [
{ label: "最热", value: "hot" },
{ label: "最新", value: "new" },
];
const comments = ref([]);
const currentTab = ref("hot");
const currentPage = ref(1);
const hasMore = ref(true);
const loading = ref(false);
const sending = ref(false);
const loadError = ref("");
const commentText = ref("");
const replyTarget = ref(null);
const replyPopupVisible = ref(false);
const replyInputFocus = ref(false);
const replyContent = ref("");
const replyKeyboardHeight = ref(0);
const sendingReply = ref(false);
let keyboardHeightListener = null;
const selectedTone = computed(() => {
if (!props.currentPair || !props.selectedItemId) return "";
if (props.selectedItemId === props.currentPair.left?.id) return "left";
if (props.selectedItemId === props.currentPair.right?.id) return "right";
return "";
});
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const replyPlaceholder = computed(() => {
return replyTarget.value?.replyToName
? `回复 @${replyTarget.value.replyToName}...`
: "写下你的回复";
});
const buildAvatarUrl = (avatar) => {
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
const normalizedAvatar = String(avatar).trim();
return normalizedAvatar.startsWith("http") ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
};
const resolveChoiceMeta = (chooseItemId) => {
if (!chooseItemId || !props.currentPair) {
return { choiceLabel: "", choiceTone: "" };
}
if (chooseItemId === props.currentPair.left?.id) {
return {
choiceLabel: `支持 ${props.currentPair.left.name}`,
choiceTone: "left",
};
}
if (chooseItemId === props.currentPair.right?.id) {
return {
choiceLabel: `支持 ${props.currentPair.right.name}`,
choiceTone: "right",
};
}
return { choiceLabel: "已站队", choiceTone: "" };
};
const normalizeComment = (item = {}, rootComment = null) => {
const user = item.user || {};
const replyToUser = item.replyToUser || {};
const choiceMeta = resolveChoiceMeta(item.chooseItemId);
return {
id: item.id,
rootCommentId: item.rootCommentId || rootComment?.id || null,
parentCommentId: item.parentCommentId || rootComment?.id || null,
replyToCommentId: item.replyToCommentId || null,
name: user.nickname || "匿名用户",
avatar: buildAvatarUrl(user.avatar),
time: formatDate(item.createdAt) || "刚刚",
content: item.content || "",
likes: Number(item.likeCount || 0),
isLiked: !!item.isLiked,
userAction: "",
replyCount: Number(item.replyCount || 0),
replyToName: replyToUser.nickname || "",
choiceLabel: choiceMeta.choiceLabel,
choiceTone: choiceMeta.choiceTone,
chooseItemId: item.chooseItemId || "",
repliesLoading: false,
repliesLoaded: false,
repliesHasMore: false,
repliesPage: 1,
replies: [],
};
};
const resetState = () => {
comments.value = [];
currentTab.value = "hot";
currentPage.value = 1;
hasMore.value = true;
loading.value = false;
sending.value = false;
loadError.value = "";
commentText.value = "";
closeReplyPopup();
};
const patchCommentItem = (commentId, updater) => {
comments.value = comments.value.map((comment) => {
if (comment.id !== commentId) return comment;
return updater(comment);
});
};
const resolveHasMore = (res, list, page) => {
const data = res?.data || res || {};
const hasNext = data?.hasNext ?? res?.hasNext;
if (typeof hasNext === "boolean") return hasNext;
const total = Number(data?.totalCount ?? data?.total ?? res?.totalCount ?? res?.total ?? 0);
if (total > 0) {
return page * Math.max(1, list.length) < total;
}
const pages = Number(data?.pages || res?.pages || 0);
if (pages > 0) return page < pages;
return list.length > 0;
};
const loadComments = async ({ reset = false } = {}) => {
if (!props.visible || !props.pkId || loading.value) return;
if (!reset && !hasMore.value) return;
const page = reset ? 1 : currentPage.value;
try {
loading.value = true;
loadError.value = "";
const res = await fetchCommentList({
pkId: props.pkId,
page,
orderBy: currentTab.value,
});
const source = res?.data || res || {};
const rawList =
source.list ||
source.records ||
res?.list ||
res?.records ||
(Array.isArray(source) ? source : []);
const list = Array.isArray(rawList) ? rawList : [];
const normalized = list.map((item) => normalizeComment(item));
comments.value = reset ? normalized : [...comments.value, ...normalized];
hasMore.value = resolveHasMore(res, list, page);
currentPage.value = page + 1;
} catch (error) {
loadError.value = "网络开了点小差,稍后重试";
} finally {
loading.value = false;
}
};
const refreshComments = async () => {
currentPage.value = 1;
hasMore.value = true;
await loadComments({ reset: true });
};
const switchTab = (tab) => {
if (currentTab.value === tab) return;
currentTab.value = tab;
refreshComments();
};
const submitMainComment = async () => {
if (!isLoggedIn.value) {
emit("need-login");
return;
}
const content = commentText.value.trim();
if (!content || sending.value || !props.pkId || !props.selectedItemId) return;
try {
sending.value = true;
await publishComment({
pkId: props.pkId,
chooseItemId: props.selectedItemId,
content,
});
uni.showToast({ title: "评论已发布", icon: "none" });
commentText.value = "";
await refreshComments();
} catch (error) {
uni.showToast({ title: "发布失败", icon: "none" });
} finally {
sending.value = false;
}
};
const openReplyPopup = async (payload) => {
if (!isLoggedIn.value) {
emit("need-login");
return;
}
replyTarget.value = payload;
replyPopupVisible.value = true;
await nextTick();
replyInputFocus.value = false;
await nextTick();
replyInputFocus.value = true;
};
const closeReplyPopup = () => {
replyPopupVisible.value = false;
replyInputFocus.value = false;
replyContent.value = "";
replyTarget.value = null;
replyKeyboardHeight.value = 0;
};
const submitReplyComment = async () => {
if (!isLoggedIn.value) {
emit("need-login");
return;
}
const content = replyContent.value.trim();
if (!content || !replyTarget.value || sendingReply.value || !props.pkId || !props.selectedItemId) return;
try {
sendingReply.value = true;
await publishComment({
pkId: props.pkId,
chooseItemId: props.selectedItemId,
content,
parentCommentId: replyTarget.value.parentCommentId,
replyToCommentId: replyTarget.value.replyToCommentId,
});
uni.showToast({ title: "回复已发送", icon: "none" });
closeReplyPopup();
await refreshComments();
} catch (error) {
uni.showToast({ title: "回复失败", icon: "none" });
} finally {
sendingReply.value = false;
}
};
const loadReplies = async (comment, page = 1) => {
if (!comment?.id || !props.pkId) return;
patchCommentItem(comment.id, (item) => ({
...item,
repliesLoading: true,
}));
try {
const res = await fetchCommentReplyList({
pkId: props.pkId,
rootCommentId: comment.id,
page,
});
const source = res?.data || res || {};
const rawList = source.list || res?.list || [];
const list = Array.isArray(rawList) ? rawList : [];
const normalizedReplies = list.map((reply) => normalizeComment(reply, comment));
const hasMoreReplies = resolveHasMore(res, list, page);
patchCommentItem(comment.id, (item) => ({
...item,
repliesLoading: false,
repliesLoaded: true,
repliesPage: page,
repliesHasMore: hasMoreReplies,
replies: page === 1 ? normalizedReplies : [...(item.replies || []), ...normalizedReplies],
}));
} catch (error) {
patchCommentItem(comment.id, (item) => ({
...item,
repliesLoading: false,
}));
uni.showToast({ title: "加载回复失败", icon: "none" });
}
};
const handleToggleReplies = (comment) => {
if (comment.repliesLoading || comment.repliesLoaded) return;
loadReplies(comment, 1);
};
const handleLoadMoreReplies = (comment) => {
if (comment.repliesLoading || !comment.repliesHasMore) return;
loadReplies(comment, Number(comment.repliesPage || 1) + 1);
};
const handleCommentLikeChange = ({ id, parentId, isReply, isLiked, likes }) => {
comments.value = comments.value.map((item) => {
if (!isReply && item.id !== id) return item;
if (isReply && item.id !== parentId) return item;
if (isReply) {
return {
...item,
replies: (item.replies || []).map((reply) => {
if (reply.id !== id) return reply;
return {
...reply,
isLiked,
likes,
};
}),
};
}
return {
...item,
isLiked,
likes,
};
});
};
watch(
() => [props.pkId, props.visible, props.selectedItemId],
([pkId, visible, selectedItemId]) => {
if (!pkId || !visible || !selectedItemId) {
resetState();
return;
}
currentPage.value = 1;
hasMore.value = true;
comments.value = [];
loadComments({ reset: true });
},
{ immediate: true }
);
watch(
() => props.currentPair,
() => {
comments.value = comments.value.map((comment) => ({
...comment,
...resolveChoiceMeta(comment.chooseItemId),
}));
}
);
if (uni.onKeyboardHeightChange) {
keyboardHeightListener = (res) => {
replyKeyboardHeight.value = res?.height || 0;
};
uni.onKeyboardHeightChange(keyboardHeightListener);
}
onUnmounted(() => {
if (uni.offKeyboardHeightChange && keyboardHeightListener) {
uni.offKeyboardHeightChange(keyboardHeightListener);
}
});
defineExpose({
refreshComments,
});
</script>
<style lang="scss" scoped>
.pk-comment-section {
margin-top: 24rpx;
padding: 32rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.94);
border: 2rpx solid rgba(229, 232, 246, 0.92);
box-shadow: 0 18rpx 42rpx rgba(87, 92, 145, 0.08);
}
.comment-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 28rpx;
}
.comment-tabs {
display: flex;
gap: 34rpx;
}
.comment-tab {
font-size: 30rpx;
color: #8a90a0;
font-weight: 600;
padding-bottom: 10rpx;
position: relative;
}
.comment-tab.active {
color: #1f2432;
font-weight: 800;
}
.comment-tab.active::after {
content: "";
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 28rpx;
height: 6rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #6557ff, #4d44f1);
}
.my-choice-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(92, 67, 245, 0.1);
}
.my-choice-pill--right {
background: rgba(255, 141, 95, 0.12);
}
.my-choice-pill-label {
font-size: 22rpx;
color: #8a90a0;
}
.my-choice-pill-value {
font-size: 22rpx;
font-weight: 700;
color: #4d44f1;
}
.my-choice-pill--right .my-choice-pill-value {
color: #ff7a45;
}
.compose-card {
margin-bottom: 28rpx;
padding: 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(92, 67, 245, 0.08), rgba(92, 67, 245, 0.03));
}
.compose-tip {
margin-bottom: 18rpx;
}
.compose-title {
display: block;
font-size: 28rpx;
font-weight: 800;
color: #202433;
}
.compose-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #7b8191;
}
.compose-box {
display: flex;
align-items: center;
gap: 16rpx;
}
.compose-input {
flex: 1;
height: 84rpx;
padding: 0 26rpx;
border-radius: 24rpx;
background: #ffffff;
font-size: 28rpx;
color: #1f2432;
box-sizing: border-box;
}
.compose-placeholder {
color: #a0a6b5;
}
.compose-send {
min-width: 132rpx;
height: 84rpx;
line-height: 84rpx;
margin: 0;
padding: 0 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #6557ff, #4d44f1);
color: #fff;
font-size: 26rpx;
font-weight: 700;
}
.compose-send.disabled {
opacity: 0.45;
}
.compose-send::after,
.comment-state-btn::after,
.reply-popup-send::after {
border: none;
}
.comment-list {
display: flex;
flex-direction: column;
min-height: 200rpx;
}
.comment-state {
min-height: 260rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #999;
padding: 36rpx 24rpx;
border-radius: 24rpx;
background: #f8f9ff;
}
.comment-state-title {
display: block;
font-size: 30rpx;
color: #232734;
font-weight: 800;
margin-bottom: 12rpx;
}
.comment-state-desc {
display: block;
font-size: 24rpx;
color: #8a90a0;
line-height: 1.7;
}
.comment-state-btn {
margin-top: 22rpx;
height: 72rpx;
line-height: 72rpx;
padding: 0 30rpx;
border-radius: 999rpx;
background: #111827;
color: #fff;
font-size: 24rpx;
}
.comment-footer {
padding-top: 20rpx;
text-align: center;
}
.comment-footer-text {
font-size: 22rpx;
color: #9ca3af;
}
.comment-footer-link {
font-size: 22rpx;
font-weight: 700;
color: #4f46e5;
}
.reply-popup-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.24);
z-index: 998;
}
.reply-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
padding: 24rpx 24rpx calc(env(safe-area-inset-bottom) + 24rpx);
background: #ffffff;
border-radius: 28rpx 28rpx 0 0;
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
}
.reply-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 20rpx;
}
.reply-popup-user {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.reply-popup-label {
font-size: 24rpx;
color: #6b7280;
}
.reply-popup-name {
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.reply-popup-close {
font-size: 24rpx;
color: #8b8f98;
}
.reply-popup-input-wrap {
display: flex;
align-items: center;
gap: 16rpx;
}
.reply-popup-input {
flex: 1;
height: 84rpx;
padding: 0 28rpx;
border-radius: 24rpx;
background: #f4f6fb;
font-size: 28rpx;
color: #1f2937;
box-sizing: border-box;
}
.reply-popup-placeholder {
color: #9ca3af;
}
.reply-popup-send {
min-width: 132rpx;
height: 84rpx;
line-height: 84rpx;
margin: 0;
padding: 0 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #111827 0%, #374151 100%);
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
}
.reply-popup-send.disabled {
opacity: 0.45;
}
</style>