Compare commits

5 Commits

Author SHA1 Message Date
zzc
4fb8a05f8d feat/info-comment-jump 2026-06-25 07:35:36 +08:00
zzc
8d2337e5b7 feat: joinedDay 2026-06-22 18:13:10 +08:00
zzc
d14084c2a1 fix: main data 2026-06-22 18:07:44 +08:00
zzc
d1d4481ff5 fix: comment rick 2026-06-22 15:48:20 +08:00
zzc
a47b0e2a7a feat: rating 2026-06-22 15:33:26 +08:00
8 changed files with 229 additions and 33 deletions

View File

@@ -1,5 +1,9 @@
<template> <template>
<view class="comment-item"> <view
class="comment-item"
:id="`comment-${localComment.id}`"
:class="{ 'comment-item--anchored': localComment.id === props.activeAnchorId }"
>
<image class="c-avatar" :src="localComment.avatar" mode="aspectFill" /> <image class="c-avatar" :src="localComment.avatar" mode="aspectFill" />
<view class="c-content-wrap"> <view class="c-content-wrap">
<view class="c-header"> <view class="c-header">
@@ -45,6 +49,8 @@
v-for="reply in localComment.replies" v-for="reply in localComment.replies"
:key="reply.id" :key="reply.id"
class="reply-item" class="reply-item"
:id="`reply-${reply.id}`"
:class="{ 'reply-item--anchored': reply.id === props.activeAnchorId }"
> >
<image class="reply-avatar" :src="reply.avatar" mode="aspectFill" /> <image class="reply-avatar" :src="reply.avatar" mode="aspectFill" />
<view class="reply-main"> <view class="reply-main">
@@ -104,6 +110,10 @@ const props = defineProps({
pageName: { pageName: {
type: String, type: String,
default: 'rating_detail' default: 'rating_detail'
},
activeAnchorId: {
type: String,
default: ''
} }
}); });
@@ -256,13 +266,21 @@ const handleLike = async (target = null) => {
display: flex; display: flex;
padding: 32rpx 0; padding: 32rpx 0;
position: relative; position: relative;
transition: background-color 0.2s; transition: background-color 0.2s, transform 0.2s;
} }
.comment-item:active { .comment-item:active {
background-color: #fafbfe; background-color: #fafbfe;
} }
.comment-item--anchored {
margin: 0 -16rpx;
padding: 32rpx 16rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(92, 67, 245, 0.1), rgba(92, 67, 245, 0.04));
transform: translateY(-2rpx);
}
.comment-item::after { .comment-item::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -427,6 +445,13 @@ const handleLike = async (target = null) => {
display: flex; display: flex;
gap: 16rpx; gap: 16rpx;
padding-bottom: 18rpx; padding-bottom: 18rpx;
border-radius: 20rpx;
transition: background-color 0.2s, padding 0.2s;
}
.reply-item--anchored {
padding: 16rpx 14rpx 18rpx;
background: rgba(92, 67, 245, 0.08);
} }
.reply-avatar { .reply-avatar {

View File

@@ -79,6 +79,7 @@
:comment="comment" :comment="comment"
:like-handler="likeHandler" :like-handler="likeHandler"
:page-name="pageName" :page-name="pageName"
:active-anchor-id="activeAnchorId"
@need-login="emitNeedLogin" @need-login="emitNeedLogin"
@like-change="handleCommentLikeChange" @like-change="handleCommentLikeChange"
@reply="openReplyPopup" @reply="openReplyPopup"
@@ -331,8 +332,10 @@ const replyPopupVisible = ref(false);
const replyInputFocus = ref(false); const replyInputFocus = ref(false);
const replyContent = ref(""); const replyContent = ref("");
const replyKeyboardHeight = ref(0); const replyKeyboardHeight = ref(0);
const activeAnchorId = ref("");
let keyboardHeightListener = null; let keyboardHeightListener = null;
let anchorHighlightTimer = null;
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName); const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const replyPlaceholder = computed(() => { const replyPlaceholder = computed(() => {
@@ -433,6 +436,98 @@ const loadMoreComments = async () => {
await loadComments(); await loadComments();
}; };
const getCommentById = (commentId) => {
return comments.value.find((item) => item.id === commentId) || null;
};
const getLoadedAnchorTarget = (targetCommentId) => {
for (const comment of comments.value) {
if (String(comment.id) === String(targetCommentId)) {
return { type: "comment", id: comment.id };
}
const reply = (comment.replies || []).find((item) => String(item.id) === String(targetCommentId));
if (reply) {
return { type: "reply", id: reply.id, parentId: comment.id };
}
}
return null;
};
const markAnchorActive = (targetCommentId) => {
if (!targetCommentId) return;
activeAnchorId.value = String(targetCommentId);
if (anchorHighlightTimer) {
clearTimeout(anchorHighlightTimer);
}
anchorHighlightTimer = setTimeout(() => {
activeAnchorId.value = "";
anchorHighlightTimer = null;
}, 2200);
};
const scrollToAnchorNode = async (target) => {
if (!target?.id) return false;
markAnchorActive(target.id);
await nextTick();
return await new Promise((resolve) => {
uni.pageScrollTo({
selector: `#${target.type === "reply" ? "reply" : "comment"}-${target.id}`,
offsetTop: 108,
duration: 300,
success: () => resolve(true),
fail: () => resolve(false),
});
});
};
const ensureRepliesContainTarget = async (commentId, targetCommentId) => {
let currentComment = getCommentById(commentId);
if (!currentComment?.replyCount) return null;
if (!currentComment.repliesLoaded) {
await loadReplies(currentComment, 1);
currentComment = getCommentById(commentId);
const loadedTarget = getLoadedAnchorTarget(targetCommentId);
if (loadedTarget) return loadedTarget;
}
while (currentComment?.repliesHasMore) {
await loadReplies(currentComment, Number(currentComment.repliesPage || 1) + 1);
currentComment = getCommentById(commentId);
const loadedTarget = getLoadedAnchorTarget(targetCommentId);
if (loadedTarget) return loadedTarget;
}
return getLoadedAnchorTarget(targetCommentId);
};
const scrollToComment = async (targetCommentId) => {
if (!targetCommentId || !props.requestKey || !props.visible) return false;
let matchedTarget = getLoadedAnchorTarget(targetCommentId);
if (matchedTarget) {
return await scrollToAnchorNode(matchedTarget);
}
while (hasMore.value) {
await loadMoreComments();
matchedTarget = getLoadedAnchorTarget(targetCommentId);
if (matchedTarget) {
return await scrollToAnchorNode(matchedTarget);
}
}
for (const comment of comments.value) {
matchedTarget = await ensureRepliesContainTarget(comment.id, targetCommentId);
if (matchedTarget) {
return await scrollToAnchorNode(matchedTarget);
}
}
return false;
};
const switchTab = (tab) => { const switchTab = (tab) => {
if (currentTab.value === tab) return; if (currentTab.value === tab) return;
currentTab.value = tab; currentTab.value = tab;
@@ -446,11 +541,18 @@ const submitMainComment = async () => {
try { try {
sending.value = true; sending.value = true;
await props.publishHandler({ const res = await props.publishHandler({
content, content,
mode: "comment", mode: "comment",
replyTarget: null, replyTarget: null,
}); });
if(res?.success === false) {
uni.showToast({
title: res?.message || '发布失败',
icon: 'none'
});
return;
}
if (props.showSuccessToast) { if (props.showSuccessToast) {
uni.showToast({ title: props.mainSuccessText, icon: "none" }); uni.showToast({ title: props.mainSuccessText, icon: "none" });
} }
@@ -493,11 +595,18 @@ const submitReplyComment = async () => {
try { try {
sendingReply.value = true; sendingReply.value = true;
await props.publishHandler({ const res = await props.publishHandler({
content, content,
mode: "reply", mode: "reply",
replyTarget: replyTarget.value, replyTarget: replyTarget.value,
}); });
if(res?.success === false) {
uni.showToast({
title: res?.message || '回复失败',
icon: 'none'
});
return;
}
if (props.showSuccessToast) { if (props.showSuccessToast) {
uni.showToast({ title: props.replySuccessText, icon: "none" }); uni.showToast({ title: props.replySuccessText, icon: "none" });
} }
@@ -613,11 +722,15 @@ onUnmounted(() => {
if (uni.offKeyboardHeightChange && keyboardHeightListener) { if (uni.offKeyboardHeightChange && keyboardHeightListener) {
uni.offKeyboardHeightChange(keyboardHeightListener); uni.offKeyboardHeightChange(keyboardHeightListener);
} }
if (anchorHighlightTimer) {
clearTimeout(anchorHighlightTimer);
}
}); });
defineExpose({ defineExpose({
refreshComments, refreshComments,
loadMoreComments, loadMoreComments,
scrollToComment,
}); });
</script> </script>

View File

@@ -120,12 +120,8 @@ const loadTopicList = async (categoryId, page = 1) => {
try { try {
loading.value = true; loading.value = true;
const res = await fetchTopicList(categoryId, page); const res = await fetchTopicList(categoryId, page);
const rawList = const rawList =res?.list || [];
res?.data?.records ||
res?.data?.list ||
res?.records ||
res?.list ||
(Array.isArray(res?.data) ? res.data : []);
const list = Array.isArray(rawList) ? rawList : []; const list = Array.isArray(rawList) ? rawList : [];
// 合并数据 // 合并数据
topicList.value = [...topicList.value, ...list]; topicList.value = [...topicList.value, ...list];

View File

@@ -294,9 +294,8 @@ const resolveActionText = (item) => {
}; };
const resolveJumpText = (item) => { const resolveJumpText = (item) => {
if(item.title.includes("已提交审核") || item.title.includes("未通过")) return ""; if(item.title.includes("已提交审核") || item.title.includes("未通过") || item.title.includes("评分项")) return "";
if (item.itemId) return "查看评分详情";
if (item.topicId) return "查看话题"; if (item.topicId) return "查看话题";
return "查看消息"; return "查看消息";
}; };
@@ -405,9 +404,16 @@ const setItemReadState = (id, isRead = true) => {
}; };
const handleNavigate = (item) => { const handleNavigate = (item) => {
const isCommentInteraction = item?.type === 'comment' || item?.type === 'comment_like';
const targetCommentId =
item?.commentId ||
(item?.targetType === 'comment' ? item?.targetId : '') ||
item?.metadata?.commentId ||
'';
if (item.itemId) { if (item.itemId) {
uni.navigateTo({ uni.navigateTo({
url: `/pages/rating/detail?topicId=${item.topicId || ""}&itemId=${item.itemId}`, url: `/pages/rating/detail?topicId=${item.topicId || ""}&itemId=${item.itemId}${isCommentInteraction && targetCommentId ? `&commentId=${targetCommentId}` : ""}`,
}); });
return; return;
} }
@@ -438,7 +444,7 @@ const markSingleAsRead = async (item) => {
const handleItemTap = async (item) => { const handleItemTap = async (item) => {
await markSingleAsRead(item); await markSingleAsRead(item);
if(item.title.includes("未通过") || item.title.includes("已提交审核")) return if(item.title.includes("未通过") || item.title.includes("已提交审核") || item.title.includes("评分项")) return
handleNavigate(item); handleNavigate(item);
}; };

View File

@@ -32,6 +32,12 @@
<view class="tags-container" v-else-if="!isLoggedIn"> <view class="tags-container" v-else-if="!isLoggedIn">
<text class="tags-text">点击登录解锁更多功能</text> <text class="tags-text">点击登录解锁更多功能</text>
</view> </view>
<view v-if="isLoggedIn && userStats.joinedDay > 0" class="joined-day-pill">
<text class="joined-day-label">已加入</text>
<text class="joined-day-value">{{ userStats.joinedDay }}</text>
<text class="joined-day-label"></text>
</view>
<view class="stats-container"> <view class="stats-container">
<view class="stat-item"> <view class="stat-item">
@@ -40,8 +46,8 @@
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-item"> <view class="stat-item">
<text class="stat-num">{{ isLoggedIn ? userStats.comment : '-' }}</text> <text class="stat-num">{{ isLoggedIn ? userStats.pkCount : '-' }}</text>
<text class="stat-label">评论数</text> <text class="stat-label">参与PK</text>
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-item"> <view class="stat-item">
@@ -71,13 +77,13 @@
<view class="menu-item" @tap="navTo('/pages/mine/pk')"> <view class="menu-item" @tap="navTo('/pages/mine/pk')">
<view class="icon-left"><text class="icon-bookmark"></text></view> <view class="icon-left"><text class="icon-bookmark"></text></view>
<text class="menu-text">我参与的PK</text> <text class="menu-text">我参与的PK</text>
<text class="menu-value" v-if="isLoggedIn">{{ userStats.participant }}</text> <text class="menu-value" v-if="isLoggedIn">{{ userStats.pkCount }}</text>
</view> </view>
<view class="menu-item" @tap="navTo('comments')"> <!-- <view class="menu-item" @tap="navTo('comments')">
<view class="icon-left"><text class="icon-document"></text></view> <view class="icon-left"><text class="icon-document"></text></view>
<text class="menu-text">我的评论</text> <text class="menu-text">我的评论</text>
<text class="menu-value" v-if="isLoggedIn">{{ userStats.comment }}</text> <text class="menu-value" v-if="isLoggedIn">{{ userStats.comment }}</text>
</view> </view> -->
</view> </view>
<!-- Menu Group 2 --> <!-- Menu Group 2 -->
@@ -130,6 +136,8 @@ const userStats = ref({
participant: 0, participant: 0,
comment: 0, comment: 0,
liked: 0, liked: 0,
pkCount: 0,
joinedDay: 0,
label: [] label: []
}); });
@@ -139,6 +147,8 @@ const loadUserStats = async () => {
participant: 0, participant: 0,
comment: 0, comment: 0,
liked: 0, liked: 0,
pkCount: 0,
joinedDay: 0,
label: [] label: []
}; };
return; return;
@@ -150,6 +160,8 @@ const loadUserStats = async () => {
participant: data.participant || 0, participant: data.participant || 0,
comment: data.comment || 0, comment: data.comment || 0,
liked: data.liked || 0, liked: data.liked || 0,
pkCount: data.pkCount || 0,
joinedDay: data.joinedDay || 0,
label: data.label || [] label: data.label || []
}; };
} catch (error) { } catch (error) {
@@ -312,7 +324,7 @@ const navTo = (page) => {
.tags-container { .tags-container {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 48rpx; margin-bottom: 24rpx;
} }
.sparkle-icon { .sparkle-icon {
@@ -326,6 +338,31 @@ const navTo = (page) => {
color: #666; color: #666;
} }
.joined-day-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
height: 52rpx;
padding: 0 20rpx;
margin-bottom: 36rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(91, 84, 223, 0.12), rgba(69, 59, 199, 0.05));
box-shadow: inset 0 0 0 2rpx rgba(91, 84, 223, 0.08);
}
.joined-day-label {
font-size: 22rpx;
color: #7b8191;
font-weight: 500;
}
.joined-day-value {
font-size: 28rpx;
color: #453bc7;
font-weight: 800;
}
.stats-container { .stats-container {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -20,7 +20,7 @@
v-for="(item, index) in topics" v-for="(item, index) in topics"
:key="item.id || index" :key="item.id || index"
:topic="item" :topic="item"
@card-click="goToTopicDetail(item.id)" @item-click="goToTopicDetail(item.id)"
/> />
</view> </view>
@@ -83,15 +83,15 @@ const loadData = async (page = 1) => {
} }
// 构造适合 TopicCard 渲染的数据格式 // 构造适合 TopicCard 渲染的数据格式
const formattedList = list.map(item => ({ // const formattedList = list.map(item => ({
id: item.topicId || item.id, // id: item.topicId || item.id,
title: item.topicName || item.title || '未知话题', // title: item.topicName || item.title || '未知话题',
image: item.image || item.coverUrl || 'https://api.dicebear.com/7.x/shapes/svg?seed=' + (item.topicId || item.id), // 给个默认图防止报错 // image: item.image || item.coverUrl || 'https://api.dicebear.com/7.x/shapes/svg?seed=' + (item.topicId || item.id), // 给个默认图防止报错
views: item.participantCount || item.views || 0, // views: item.participantCount || item.views || 0,
items: item.items || item.ratingItems || [] // items: item.items || item.ratingItems || []
})); // }));
topics.value = [...topics.value, ...formattedList]; topics.value = [...topics.value, ...list];
// 判断是否有下一页 // 判断是否有下一页
hasMore.value = list.length >= pageSize; hasMore.value = list.length >= pageSize;

View File

@@ -140,6 +140,7 @@ const commentSectionRef = ref(null);
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName); const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const isSendingComment = ref(false); const isSendingComment = ref(false);
const pkPreview = ref(null); const pkPreview = ref(null);
const pendingAnchorCommentId = ref('');
const commentRequestKey = computed(() => { const commentRequestKey = computed(() => {
if (!detailData.value.topicId || !itemId.value) return ''; if (!detailData.value.topicId || !itemId.value) return '';
return `${detailData.value.topicId}_${itemId.value}`; return `${detailData.value.topicId}_${itemId.value}`;
@@ -360,6 +361,14 @@ const handleCommentPublished = ({ mode, content, replyTarget }) => {
}); });
}; };
const scrollToPendingComment = async () => {
if (!pendingAnchorCommentId.value) return;
const found = await commentSectionRef.value?.scrollToComment?.(pendingAnchorCommentId.value);
if (found) {
pendingAnchorCommentId.value = '';
}
};
const handleSendComment = async (payload) => { const handleSendComment = async (payload) => {
if (!isLoggedIn.value) { if (!isLoggedIn.value) {
openLoginPopup(); openLoginPopup();
@@ -371,13 +380,19 @@ const handleSendComment = async (payload) => {
try { try {
isSendingComment.value = true; isSendingComment.value = true;
uni.showLoading({ title: '发送中...' }); uni.showLoading({ title: '发送中...' });
await publishTopicComment({ const res = await publishTopicComment({
content: payload.content, content: payload.content,
mode: 'comment', mode: 'comment',
replyTarget: null replyTarget: null
}); });
uni.hideLoading(); uni.hideLoading();
if(res?.success === false) {
uni.showToast({
title: res?.message || '发送失败',
icon: 'none'
});
return;
}
handleCommentPublished({ handleCommentPublished({
mode: 'comment', mode: 'comment',
content: payload.content, content: payload.content,
@@ -418,6 +433,7 @@ const getDetailData = async () => {
currentAction.value = data.userAction || ''; currentAction.value = data.userAction || '';
await commentSectionRef.value?.refreshComments?.(); await commentSectionRef.value?.refreshComments?.();
await scrollToPendingComment();
getPkPreviewData(); getPkPreviewData();
} catch (error) { } catch (error) {
console.error('获取详情失败:', error); console.error('获取详情失败:', error);
@@ -440,6 +456,9 @@ onLoad((options) => {
if (options.topicId) { if (options.topicId) {
detailData.value.topicId = options.topicId; detailData.value.topicId = options.topicId;
} }
if (options.commentId) {
pendingAnchorCommentId.value = options.commentId;
}
if (options.itemId) { if (options.itemId) {
itemId.value = options.itemId; itemId.value = options.itemId;
getDetailData(); getDetailData();

View File

@@ -37,7 +37,7 @@
<text class="ai-content">{{ topicData.aiSummary }}</text> <text class="ai-content">{{ topicData.aiSummary }}</text>
</view> </view>
<view v-if="pkPair.left && pkPair.right" class="pk-entry-card" @tap="goToPk()"> <!-- <view v-if="pkPair.left && pkPair.right" class="pk-entry-card" @tap="goToPk()">
<view class="pk-entry-header"> <view class="pk-entry-header">
<view class="pk-entry-title-wrap"> <view class="pk-entry-title-wrap">
<text class="pk-entry-title">PK 对决模式</text> <text class="pk-entry-title">PK 对决模式</text>
@@ -59,7 +59,7 @@
<text class="pk-entry-name">{{ pkPair.right.name }}</text> <text class="pk-entry-name">{{ pkPair.right.name }}</text>
</view> </view>
</view> </view>
</view> </view> -->
<!-- 领奖台 (Top 3) --> <!-- 领奖台 (Top 3) -->
<view class="podium-section" v-if="ratingItems && ratingItems.length >= 3"> <view class="podium-section" v-if="ratingItems && ratingItems.length >= 3">