Files
rating/pages/rating/detail.vue
2026-06-22 15:48:20 +08:00

770 lines
21 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="page-container">
<!-- 顶部导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<view class="nav-left" @tap="goBack">
<text class="back-icon"></text>
<text class="nav-title">夯拉评分</text>
</view>
<view class="nav-right">
<text class="search-icon"></text>
<image class="nav-avatar" src="/static/images/default-avatar.png" mode="aspectFill" />
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<!-- 头部信息卡片 -->
<view class="hero-card card">
<image class="hero-avatar" :src="detailData.avatar" mode="aspectFill" />
<view class="hero-info">
<view class="hero-name-row">
<text class="hero-name">{{ detailData.name }}</text>
<text class="hero-score">{{ detailData.score }}</text>
</view>
<view class="hero-meta">
<text class="meta-rank">🥇 No.1</text>
<view class="meta-heat">
<image class="meta-icon" src="/static/images/icon/fire.png" mode="aspectFit"></image>
<text>{{ detailData.hotScore }}w 热度</text>
</view>
<text class="meta-count" style="margin-left: 20rpx; color: #999;">{{ detailData.ratingCount }} 人评分</text>
</view>
</view>
</view>
<!-- AI 观察员 -->
<view class="ai-card">
<view class="ai-header">
<image class="ai-icon ai-icon-img" src="/static/images/icon/robot.png" mode="aspectFit"></image>
<text class="ai-title">AI观察员</text>
</view>
<text class="ai-content">{{ detailData.aiSummary }}</text>
</view>
<!-- 你的态度 -->
<AttitudePanel
ref="attitudePanelRef"
:initial-action="currentAction"
:sending="isSendingComment"
@action-change="handleActionChange"
@send-comment="handleSendComment"
/>
<!-- 大家都在 PK -->
<!-- <view v-if="pkPreview" class="pk-panel card" @tap="goToPk()">
<view class="pk-header">
<text class="section-title">大家都在 PK</text>
<view class="go-compare">去对比<text class="arrow-right"></text></view>
</view>
<view class="pk-content">
<view class="pk-person">
<image class="pk-avatar blue-border" :src="pkPreview.left.avatar" mode="aspectFill" />
<text class="pk-name">{{ pkPreview.left.name }}</text>
<text class="pk-score blue-text">{{ pkPreview.left.scoreText }}</text>
</view>
<text class="vs-text">VS</text>
<view class="pk-person">
<image class="pk-avatar yellow-border" :src="pkPreview.right.avatar" mode="aspectFill" />
<text class="pk-name">{{ pkPreview.right.name }}</text>
<text class="pk-score yellow-text">{{ pkPreview.right.scoreText }}</text>
</view>
</view>
<view class="pk-bar-wrap">
<view class="pk-bar blue-bar" :style="{ width: `${pkPreview.leftPercent}%` }"></view>
<view class="pk-bar yellow-bar" :style="{ width: `${pkPreview.rightPercent}%` }"></view>
</view>
<view class="pk-data-text">
<text class="blue-text">{{ pkPreview.leftPercent }}% 看好</text>
<text class="yellow-text">{{ pkPreview.rightPercent }}% 看好</text>
</view>
</view> -->
<!-- 评论区 -->
<CommentSection
ref="commentSectionRef"
variant="topic"
section-title="评论区"
:request-key="commentRequestKey"
:visible="!!commentRequestKey"
:allow-compose="false"
page-name="rating_detail"
loading-desc="正在拉取大家的锐评"
empty-title="还没有评论"
empty-desc="来发第一条锐评带动一下讨论气氛"
empty-tip="你的观点可能就是这条榜单最有意思的开始"
:show-empty-visual="true"
load-more-mode="scroll"
footer-hint-text="上拉加载更多"
:show-success-toast="false"
:list-handler="fetchTopicComments"
:replies-handler="fetchTopicCommentReplies"
:publish-handler="publishTopicComment"
:normalize-comment="normalizeCommentItem"
@need-login="openLoginPopup"
@published="handleCommentPublished"
/>
</view>
<!-- 评分成功特效弹窗 -->
<SuccessPopup ref="successPopupRef" />
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { getStatusBarHeight } from "@/utils/system";
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
import AttitudePanel from "@/components/AttitudePanel/AttitudePanel.vue";
import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
import CommentSection from "@/components/CommentSection/CommentSection.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, topicItemCommentReplyList } from "@/api/topicItem";
import { formatDate } from "@/utils/date";
import { getShareToken } from "@/utils/common";
import { getShareReward } from "@/api/system";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const statusBarHeight = ref(getStatusBarHeight() || 44);
const currentAction = ref('');
const successPopupRef = ref(null);
const loginPopupRef = ref(null);
const attitudePanelRef = ref(null);
const commentSectionRef = ref(null);
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const isSendingComment = ref(false);
const pkPreview = ref(null);
const commentRequestKey = computed(() => {
if (!detailData.value.topicId || !itemId.value) return '';
return `${detailData.value.topicId}_${itemId.value}`;
});
const goBack = () => {
uni.navigateBack();
};
const openLoginPopup = (message = '请先登录后再操作') => {
loginPopupRef.value?.open();
if (message) {
uni.showToast({
title: message,
icon: 'none'
});
}
};
const detailData = ref({
id: '',
name: '',
score: '0.0',
avatar: '',
aiSummary: '加载中...',
userAction: "",
topicId: ''
});
const itemId = ref('');
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 normalizeCommentItem = (item = {}, rootComment = null) => {
const user = item.user || {};
const replyToUser = item.replyToUser || {};
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,
badge: '',
badgeIcon: '',
userAction: item.userAction || '',
replyCount: Number(item.replyCount || 0),
replyToName: replyToUser.nickname || '',
repliesLoading: false,
repliesLoaded: !!rootComment,
repliesHasMore: false,
repliesPage: 1,
replies: rootComment ? [] : []
};
};
const calcPkPercent = (leftScore, rightScore) => {
const left = Number(leftScore) || 0;
const right = Number(rightScore) || 0;
if (left <= 0 && right <= 0) {
return { leftPercent: 50, rightPercent: 50 };
}
const total = left + right || 1;
let leftPercent = Math.round((left / total) * 100);
leftPercent = Math.min(82, Math.max(18, leftPercent));
return {
leftPercent,
rightPercent: 100 - leftPercent
};
};
const buildPkPreview = (items) => {
const sortedItems = [...items].sort((a, b) => (Number(b.scoreAvg) || 0) - (Number(a.scoreAvg) || 0));
const currentIndex = sortedItems.findIndex((item) => String(item.id) === String(itemId.value));
if (currentIndex === -1) return null;
const currentItem = sortedItems[currentIndex];
const rival =
sortedItems[currentIndex === 0 ? 1 : currentIndex - 1] ||
sortedItems.find((item) => String(item.id) !== String(itemId.value));
if (!currentItem || !rival) return null;
const { leftPercent, rightPercent } = calcPkPercent(currentItem.scoreAvg, rival.scoreAvg);
return {
left: {
id: currentItem.id,
name: currentItem.name || '当前对象',
avatar: buildAvatarUrl(currentItem.avatarUrl),
scoreText: `${Number(currentItem.scoreAvg || 0).toFixed(1)}`
},
right: {
id: rival.id,
name: rival.name || '对手',
avatar: buildAvatarUrl(rival.avatarUrl),
scoreText: `${Number(rival.scoreAvg || 0).toFixed(1)}`
},
leftPercent,
rightPercent
};
};
const getPkPreviewData = async () => {
if (!detailData.value.topicId || !itemId.value) {
pkPreview.value = null;
return;
}
try {
const res = await fetchTopicItemList(detailData.value.topicId, 1);
const rawList = res?.data?.records || res?.records || res?.data?.list || res?.list || [];
const list = Array.isArray(rawList) ? rawList : [];
pkPreview.value = list.length >= 2 ? buildPkPreview(list) : null;
} catch (error) {
pkPreview.value = null;
}
};
const goToPk = () => {
if (!detailData.value.topicId || !itemId.value) return;
uni.navigateTo({
url: `/pages/rating/pk?topicId=${detailData.value.topicId}&itemId=${itemId.value}`
});
};
const handleActionChange = async (action) => {
if (!isLoggedIn.value) {
openLoginPopup();
return;
}
const originAction = detailData.value.userAction;
const originScore = detailData.value.score;
const isCancel = detailData.value.userAction === action.label;
try {
detailData.value.userAction = isCancel ? '' : action.label;
currentAction.value = detailData.value.userAction;
const res = await topicItemScore({
topicId: detailData.value.topicId,
itemId: itemId.value,
scoreType: action.scoreType
});
detailData.value.score = res?.scoreAvg || originScore;
// 如果是成功评分(非取消),展示特效弹窗
if (!isCancel && successPopupRef.value) {
successPopupRef.value.show(action);
}
uni.$trackRecord({
eventName: 'rating',
eventType: 'rating',
elementId: `item_${itemId.value}`,
elementContent: `评分项_${detailData.value.name}`,
customParams: {
scoreType: action.scoreType,
action: action.label,
topicId: detailData.value.topicId,
topicTitle: detailData.value.name,
score: detailData.value.score,
page: 'rating_detail'
}
});
} catch (error) {
detailData.value.userAction = originAction;
detailData.value.score = originScore;
currentAction.value = originAction;
}
};
const publishTopicComment = ({ content, replyTarget, mode }) => {
return topicItemComment({
topicId: detailData.value.topicId,
itemId: itemId.value,
content,
parentCommentId: mode === 'reply' ? replyTarget?.parentCommentId : undefined,
replyToCommentId: mode === 'reply' ? replyTarget?.replyToCommentId : undefined
});
};
const fetchTopicComments = ({ page, orderBy }) => {
return topicItemCommentList(detailData.value.topicId, itemId.value, page, orderBy);
};
const fetchTopicCommentReplies = ({ rootCommentId, page }) => {
return topicItemCommentReplyList({
rootCommentId,
page
});
};
const handleCommentPublished = ({ mode, content, replyTarget }) => {
if (successPopupRef.value) {
successPopupRef.value.show({
emoji: '💬',
label: mode === 'reply' ? '回复已发送' : '你的锐评已发布'
}, 'comment');
}
uni.$trackRecord({
eventName: 'comment',
eventType: 'comment',
elementId: `item_${itemId.value}`,
elementContent: `评论项_${detailData.value.name}`,
customParams: {
comment: content,
replyToCommentId: replyTarget?.replyToCommentId || '',
page: 'rating_detail'
}
});
};
const handleSendComment = async (payload) => {
if (!isLoggedIn.value) {
openLoginPopup();
return;
}
if (!detailData.value.topicId || !itemId.value || isSendingComment.value) return;
try {
isSendingComment.value = true;
uni.showLoading({ title: '发送中...' });
const res = await publishTopicComment({
content: payload.content,
mode: 'comment',
replyTarget: null
});
uni.hideLoading();
if(res?.success === false) {
uni.showToast({
title: res?.message || '发送失败',
icon: 'none'
});
return;
}
handleCommentPublished({
mode: 'comment',
content: payload.content,
replyTarget: null
});
attitudePanelRef.value?.resetComment?.();
await commentSectionRef.value?.refreshComments?.();
} catch (error) {
uni.hideLoading();
uni.showToast({
title: '发送失败',
icon: 'none'
});
} finally {
isSendingComment.value = false;
}
};
const getDetailData = async () => {
if (!itemId.value) return;
try {
uni.showLoading({ title: '加载中' });
const res = await fetchTopicRatingItems(itemId.value);
const data = res?.data || res || {};
detailData.value = {
id: itemId.value,
name: data.name || '未知',
score: data.scoreAvg ? Number(data.scoreAvg).toFixed(1) : '0.0',
avatar: data.avatarUrl ? `${FILE_BASE_URL}${data.avatarUrl}` : '',
aiSummary: data.description || '暂无总结',
hotScore: data.hotScore || '0.00',
topicId: data.topicId || detailData.value.topicId || '',
ratingCount: data.scoreCount || 0,
userAction: data.userAction || ""
};
currentAction.value = data.userAction || '';
await commentSectionRef.value?.refreshComments?.();
getPkPreviewData();
} catch (error) {
console.error('获取详情失败:', error);
uni.showToast({
title: '获取详情失败',
icon: 'none'
});
} finally {
uni.hideLoading();
uni.stopPullDownRefresh();
}
};
onLoad((options) => {
uni.$trackRecord({
eventName: "rating_item_detail_page_visit",
eventType: `visit`,
elementId: options.itemId
})
if (options.topicId) {
detailData.value.topicId = options.topicId;
}
if (options.itemId) {
itemId.value = options.itemId;
getDetailData();
}
});
const handleLoginSuccess = () => {
getDetailData();
};
onShareAppMessage(async () => {
const shareToken = await getShareToken("rating_item");
getShareReward({ scene: "rating_item" });
const itemName = detailData.value.name || "这个评分对象";
return {
title: `${itemName}」在夯拉评分里是什么段位?`,
path: `/pages/rating/detail?itemId=${itemId.value}&topicId=${detailData.value.topicId}&shareToken=${shareToken}`,
};
});
onShareTimeline(async () => {
const shareToken = await getShareToken("rating_item");
getShareReward({ scene: "rating_item_timeline" });
const itemName = detailData.value.name || "这个评分对象";
const score = detailData.value.score || "0.0";
return {
title: `${itemName}当前评分 ${score},来看看大家怎么评`,
query: `itemId=${itemId.value}&topicId=${detailData.value.topicId}&shareToken=${shareToken}`,
};
});
onPullDownRefresh(() => {
getDetailData();
});
onReachBottom(() => {
commentSectionRef.value?.loadMoreComments?.();
});
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f5f6fa;
padding-bottom: 40rpx;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: #f5f6fa;
}
.nav-content {
height: 44px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32rpx;
}
.nav-left {
display: flex;
align-items: center;
}
.back-icon {
width: 40rpx;
height: 40rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='19' y1='12' x2='5' y2='12'%3E%3C/line%3E%3Cpolyline points='12 19 5 12 12 5'%3E%3C/polyline%3E%3C/svg%3E");
background-size: cover;
margin-right: 16rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 700;
color: #111;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
}
.nav-right {
display: flex;
align-items: center;
}
.search-icon {
width: 40rpx;
height: 40rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232953ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");
background-size: cover;
margin-right: 24rpx;
}
.nav-avatar {
width: 52rpx;
height: 52rpx;
border-radius: 8rpx;
background-color: #eee;
}
.content-wrap {
padding: 24rpx 32rpx;
}
.card {
background-color: #fff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
/* 英雄卡片 */
.hero-card {
display: flex;
align-items: center;
}
.hero-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
margin-right: 32rpx;
border: 4rpx solid #fff;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.1);
}
.hero-info {
flex: 1;
}
.hero-name-row {
display: flex;
align-items: baseline;
margin-bottom: 12rpx;
}
.hero-name {
font-size: 44rpx;
font-weight: bold;
color: #111;
margin-right: 16rpx;
}
.hero-score {
font-size: 44rpx;
font-weight: 900;
color: #2953ff;
font-family: 'DIN Alternate', sans-serif;
}
.hero-meta {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
}
.meta-rank {
margin-right: 24rpx;
font-weight: bold;
color: #d9a000;
}
.meta-heat {
display: flex;
align-items: center;
color: #666;
}
.meta-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
/* AI 观察员 */
.ai-card {
background: #ffffff;
border: 2rpx solid #e0d6ff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.ai-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.ai-icon {
width: 36rpx;
height: 36rpx;
margin-right: 12rpx;
}
.ai-icon-img {
display: block;
}
.ai-title {
font-size: 28rpx;
font-weight: 800;
color: #7b46f1;
}
.ai-content {
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
/* 你的态度面板 */
/* 大家都在 PK */
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #111;
margin-bottom: 32rpx;
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
}
.pk-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.pk-header .section-title {
margin-bottom: 0;
}
.go-compare {
font-size: 24rpx;
color: #2953ff;
display: flex;
align-items: center;
}
.arrow-right {
width: 24rpx;
height: 24rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232953ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E");
background-size: cover;
}
.pk-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 40rpx;
}
.pk-person {
display: flex;
flex-direction: column;
align-items: center;
}
.pk-avatar {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
border: 4rpx solid transparent;
margin-bottom: 12rpx;
}
.blue-border { border-color: #2953ff; }
.yellow-border { border-color: #ffc107; }
.pk-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.pk-score {
margin-top: 8rpx;
font-size: 22rpx;
font-weight: 700;
}
.vs-text {
font-size: 40rpx;
font-weight: 900;
color: #a1a1a1;
}
.pk-bar-wrap {
height: 16rpx;
border-radius: 100rpx;
display: flex;
overflow: hidden;
margin-bottom: 16rpx;
}
.pk-bar { height: 100%; }
.blue-bar { background-color: #4d44f1; }
.yellow-bar { background-color: #ffe066; }
.pk-data-text {
display: flex;
justify-content: space-between;
font-size: 24rpx;
font-weight: bold;
}
.blue-text { color: #4d44f1; }
.yellow-text { color: #333; }
</style>