feat: repley

This commit is contained in:
zzc
2026-06-15 08:56:18 +08:00
parent cd1595373b
commit 082cd011d2
4 changed files with 369 additions and 89 deletions

View File

@@ -50,10 +50,8 @@
ref="attitudePanelRef"
:initial-action="currentAction"
:sending="isSendingComment"
:reply-target="replyTarget"
@action-change="handleActionChange"
@send-comment="handleSendComment"
@cancel-reply="clearReplyTarget"
/>
<!-- 大家都在 PK -->
@@ -124,6 +122,8 @@
@need-login="openLoginPopup"
@like-change="handleCommentLikeChange"
@reply="handleReplyComment"
@toggle-replies="handleToggleReplies"
@more-replies="handleLoadMoreReplies"
/>
</view>
@@ -135,22 +135,62 @@
</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="isSendingReply"
:adjust-position="false"
confirm-type="send"
:placeholder="replyPlaceholder"
placeholder-class="reply-popup-placeholder"
@confirm="handleSendReply"
/>
<button
class="reply-popup-send"
:class="{ disabled: isSendingReply || !replyContent.trim() }"
:disabled="isSendingReply || !replyContent.trim()"
@tap="handleSendReply"
>
{{ isSendingReply ? '发送中' : '发送' }}
</button>
</view>
</view>
<SuccessPopup ref="successPopupRef" />
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { getStatusBarHeight } from "@/utils/system";
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline, onUnload } from "@dcloudio/uni-app";
import AttitudePanel from "@/components/AttitudePanel/AttitudePanel.vue";
import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
import CommentItem from "@/components/CommentItem/CommentItem.vue";
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
import { FILE_BASE_URL } from "@/utils/constants";
import { fetchTopicRatingItems as fetchTopicItemList } from "@/api/topic";
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList } from "@/api/topicItem";
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList, topicItemCommentReplyList } from "@/api/topicItem";
import { formatDate } from "@/utils/date";
import { getShareToken } from "@/utils/common";
import { getShareReward } from "@/api/system";
@@ -202,10 +242,17 @@ const detailData = ref({
const comments = ref([]);
const itemId = ref('');
const replyTarget = ref(null);
const replyPopupVisible = ref(false);
const replyInputFocus = ref(false);
const replyContent = ref('');
const replyKeyboardHeight = ref(0);
const isSendingReply = ref(false);
let keyboardHeightListener = null;
const buildAvatarUrl = (avatar) => {
if (!avatar) return 'https://api.dicebear.com/7.x/avataaars/svg?seed=fallback';
return String(avatar).startsWith('http') ? avatar : `${FILE_BASE_URL}${avatar}`;
const normalizedAvatar = String(avatar).trim();
return normalizedAvatar.startsWith('http') ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
};
const normalizeCommentItem = (item = {}, rootComment = null) => {
@@ -227,9 +274,11 @@ const normalizeCommentItem = (item = {}, rootComment = null) => {
userAction: item.userAction || '',
replyCount: Number(item.replyCount || 0),
replyToName: replyToUser.nickname || '',
replies: rootComment
? []
: (Array.isArray(item.replies) ? item.replies.map((reply) => normalizeCommentItem(reply, item)) : [])
repliesLoading: false,
repliesLoaded: !!rootComment,
repliesHasMore: false,
repliesPage: 1,
replies: rootComment ? [] : []
};
};
@@ -237,6 +286,51 @@ const clearReplyTarget = () => {
replyTarget.value = null;
};
const replyPlaceholder = computed(() => {
return replyTarget.value?.replyToName
? `回复 @${replyTarget.value.replyToName}...`
: '回复一下这条评论';
});
const patchCommentItem = (commentId, updater) => {
comments.value = comments.value.map((comment) => {
if (comment.id !== commentId) return comment;
return updater(comment);
});
};
const resolveReplyHasMore = (res, list, currentPage) => {
const data = res?.data || res || {};
const hasNext = data?.hasNext || res?.hasNext;
if (typeof hasNext === 'boolean') return hasNext;
const pages = Number(data?.pages || res?.pages || 0);
if (pages > 0) return currentPage < pages;
const total = Number(data?.totalCount || data?.total || res?.totalCount || res?.total || 0);
if (total > 0) {
return currentPage * list.length < total || list.length < total;
}
return list.length > 0;
};
const openReplyPopup = async () => {
replyPopupVisible.value = true;
await nextTick();
replyInputFocus.value = false;
await nextTick();
replyInputFocus.value = true;
};
const closeReplyPopup = () => {
replyPopupVisible.value = false;
replyInputFocus.value = false;
replyKeyboardHeight.value = 0;
replyContent.value = '';
clearReplyTarget();
};
const handleReplyComment = (payload) => {
if (!isLoggedIn.value) {
openLoginPopup();
@@ -250,6 +344,7 @@ const handleReplyComment = (payload) => {
replyToName: payload.replyToName || '',
content: payload.content || ''
};
openReplyPopup();
};
const calcPkPercent = (leftScore, rightScore) => {
@@ -367,23 +462,27 @@ const handleActionChange = async (action) => {
}
};
const handleSendComment = async (payload) => {
const submitComment = async ({ content, parentCommentId, replyToCommentId, successMode = 'comment' }) => {
if (!isLoggedIn.value) {
openLoginPopup();
return;
}
if (!detailData.value.topicId || !itemId.value || isSendingComment.value) return;
if (!detailData.value.topicId || !itemId.value) return false;
try {
isSendingComment.value = true;
if (successMode === 'reply') {
isSendingReply.value = true;
} else {
isSendingComment.value = true;
}
uni.showLoading({ title: '发送中...' });
await topicItemComment({
topicId: detailData.value.topicId,
itemId: itemId.value,
content: payload.content,
parentCommentId: replyTarget.value?.parentCommentId || undefined,
replyToCommentId: replyTarget.value?.replyToCommentId || undefined
content,
parentCommentId: parentCommentId || undefined,
replyToCommentId: replyToCommentId || undefined
});
uni.hideLoading();
@@ -391,7 +490,7 @@ const handleSendComment = async (payload) => {
if (successPopupRef.value) {
successPopupRef.value.show({
emoji: '💬',
label: '你的锐评已发布'
label: successMode === 'reply' ? '回复已发送' : '你的锐评已发布'
}, 'comment');
}
@@ -404,32 +503,64 @@ const handleSendComment = async (payload) => {
elementId: `item_${itemId.value}`,
elementContent: `评论项_${detailData.value.name}`,
customParams: {
comment: payload.content,
replyToCommentId: replyTarget.value?.replyToCommentId || '',
comment: content,
replyToCommentId: replyToCommentId || '',
page: 'rating_detail'
}
});
attitudePanelRef.value?.resetComment?.();
clearReplyTarget();
await getCommentsData({ replace: true });
return true;
} catch (error) {
uni.hideLoading();
uni.showToast({
title: '发送失败',
icon: 'none'
});
return false;
} finally {
isSendingComment.value = false;
isSendingReply.value = false;
}
};
const handleSendComment = async (payload) => {
if (isSendingComment.value) return;
const success = await submitComment({
content: payload.content,
successMode: 'comment'
});
if (!success) return;
currentPage.value = 1;
hasMore.value = true;
attitudePanelRef.value?.resetComment?.();
await getCommentsData({ replace: true });
};
const handleSendReply = async () => {
const content = replyContent.value.trim();
if (!content) {
uni.showToast({ title: '请输入回复内容', icon: 'none' });
return;
}
if (!replyTarget.value || isSendingReply.value) return;
const success = await submitComment({
content,
parentCommentId: replyTarget.value.parentCommentId,
replyToCommentId: replyTarget.value.replyToCommentId,
successMode: 'reply'
});
if (!success) return;
closeReplyPopup();
await getCommentsData({ replace: true });
};
const switchTab = (tab) => {
if (currentTab.value === tab) return;
currentTab.value = tab;
currentPage.value = 1;
hasMore.value = true;
commentError.value = '';
clearReplyTarget();
closeReplyPopup();
getCommentsData({ replace: true });
};
@@ -456,7 +587,7 @@ const getCommentsData = async ({ replace = false } = {}) => {
try {
commentLoading.value = true;
commentError.value = '';
const orderBy = currentTab.value === 'hot' ? 1 : 2;
const orderBy = currentTab.value;
const res = await topicItemCommentList(detailData.value.topicId, itemId.value, currentPage.value, orderBy);
const rawList =
res?.data?.records ||
@@ -490,10 +621,59 @@ const retryLoadComments = () => {
if (!comments.value.length) {
currentPage.value = 1;
}
clearReplyTarget();
closeReplyPopup();
getCommentsData({ replace: currentPage.value === 1 });
};
const loadReplies = async (comment, page = 1) => {
if (!comment?.id) return;
patchCommentItem(comment.id, (item) => ({
...item,
repliesLoading: true
}));
try {
const res = await topicItemCommentReplyList({
rootCommentId: comment.id,
page
});
const data = res?.data || res || {};
const rawList = data?.list || res?.list || [];
const list = Array.isArray(rawList) ? rawList : [];
const normalizedReplies = list.map((reply) => normalizeCommentItem(reply, comment));
const hasMoreReplies = resolveReplyHasMore(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;
@@ -569,6 +749,12 @@ const getDetailData = async () => {
};
onLoad((options) => {
if (uni.onKeyboardHeightChange) {
keyboardHeightListener = (res) => {
replyKeyboardHeight.value = res?.height || 0;
};
uni.onKeyboardHeightChange(keyboardHeightListener);
}
uni.$trackRecord({
eventName: "rating_item_detail_page_visit",
eventType: `visit`,
@@ -588,7 +774,7 @@ const handleLoginSuccess = () => {
hasMore.value = true;
comments.value = [];
commentError.value = '';
clearReplyTarget();
closeReplyPopup();
getDetailData();
};
@@ -618,10 +804,16 @@ onPullDownRefresh(() => {
hasMore.value = true;
comments.value = [];
commentError.value = '';
clearReplyTarget();
closeReplyPopup();
getDetailData();
});
onUnload(() => {
if (uni.offKeyboardHeightChange && keyboardHeightListener) {
uni.offKeyboardHeightChange(keyboardHeightListener);
}
});
onReachBottom(() => {
if (!hasMore.value || commentLoading.value) return;
currentPage.value += 1;
@@ -1071,4 +1263,96 @@ onReachBottom(() => {
color: #999;
font-size: 24rpx;
}
.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;
}
.reply-popup-send::after {
border: none;
}
</style>