Files
rating/pages/pk/detail.vue
2026-06-15 23:37:33 +08:00

713 lines
19 KiB
Vue
Raw Permalink 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>
</view>
<text class="nav-title">PK详情</text>
<view class="nav-right" @tap="goToPkStation">
<text class="nav-right-text">PK站</text>
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<view v-if="detailData" class="hero-card">
<view class="hero-badge">PK Detail</view>
<text class="hero-title">{{ detailData.topicTitle }}</text>
<text class="hero-desc">{{ insightText }}</text>
<view class="summary-row">
<view class="summary-pill" :class="selectedTone ? `summary-pill--${selectedTone}` : ''">
<text class="summary-pill-label">我的选择</text>
<text class="summary-pill-value">{{ selectedItemLabel }}</text>
</view>
<view class="summary-meta">
<text class="summary-meta-item">{{ detailData.voteCount }} 人参与</text>
<text class="summary-dot"></text>
<text class="summary-meta-item">{{ detailData.votedAtText }}</text>
</view>
</view>
</view>
<view v-if="detailData" class="battle-card">
<view class="battle-stage">
<view class="fighter-card left" :class="{ selected: detailData.selfChooseSide === 'left' }">
<view class="fighter-badge" v-if="detailData.selfChooseSide === 'left'">我支持</view>
<image class="fighter-avatar" :src="detailData.left.avatar" mode="aspectFill" />
<text class="fighter-name">{{ detailData.left.name }}</text>
<text class="fighter-votes">{{ detailData.leftVoteCount }} </text>
</view>
<view class="battle-middle">
<view class="vs-badge">VS</view>
<text class="battle-middle-text">{{ leadText }}</text>
</view>
<view class="fighter-card right" :class="{ selected: detailData.selfChooseSide === 'right' }">
<view class="fighter-badge fighter-badge--right" v-if="detailData.selfChooseSide === 'right'">我支持</view>
<image class="fighter-avatar" :src="detailData.right.avatar" mode="aspectFill" />
<text class="fighter-name">{{ detailData.right.name }}</text>
<text class="fighter-votes">{{ detailData.rightVoteCount }} </text>
</view>
</view>
<view class="support-bar">
<view class="support-fill left-fill" :style="{ width: `${detailData.leftPercent}%` }"></view>
<view class="support-fill right-fill" :style="{ width: `${detailData.rightPercent}%` }"></view>
</view>
<view class="support-row">
<text class="support-text left-text">{{ detailData.leftPercent }}% 支持 {{ detailData.left.name }}</text>
<text class="support-text right-text">{{ detailData.rightPercent }}% 支持 {{ detailData.right.name }}</text>
</view>
</view>
<view v-else class="state-card">
<text class="state-title">暂时无法查看这场 PK</text>
<text class="state-desc">这场对决的信息不存在或已失效请返回列表重新进入</text>
<button class="state-btn" @tap="goBack">返回上一页</button>
</view>
<CommentSection
v-if="detailData"
ref="commentSectionRef"
variant="pk"
:request-key="commentRequestKey"
:visible="!!commentRequestKey"
:allow-compose="true"
compose-title="说说你的站队理由"
:compose-desc="`支持 ${selectedItemLabel} 的观点会更容易引发讨论`"
compose-placeholder="写下你的 PK 观点..."
page-name="pk_detail"
pill-label="我站"
:pill-value="selectedItemLabel"
:pill-tone="selectedTone"
loading-desc="看看大家如何评价这场对决"
empty-title="还没有评论"
empty-desc="来写下你的判断成为这场 PK 的第一位评论官"
footer-more-text="查看更多评论"
:list-handler="fetchPkCommentList"
:replies-handler="fetchPkCommentReplies"
:publish-handler="publishPkComment"
:normalize-comment="normalizePkComment"
:like-handler="likeComment"
@need-login="openLoginPopup('请先登录后再参与评论')"
/>
</view>
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getStatusBarHeight } from "@/utils/system";
import { fetchCommentList, fetchCommentReplyList, publishComment, likeComment } from "@/api/pk";
import { FILE_BASE_URL } from "@/utils/constants";
import { formatDate } from "@/utils/date";
import CommentSection from "@/components/CommentSection/CommentSection.vue";
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
import { useUserStore } from "@/stores/user";
const DETAIL_CACHE_PREFIX = "pk_detail_cache_";
const userStore = useUserStore();
const statusBarHeight = ref(getStatusBarHeight() || 44);
const loginPopupRef = ref(null);
const commentSectionRef = ref(null);
const detailData = ref(null);
const pkId = ref("");
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const selectedTone = computed(() => {
if (!detailData.value?.selfChooseSide) return "";
return detailData.value.selfChooseSide === "left" ? "left" : "right";
});
const selectedItemLabel = computed(() => {
if (!detailData.value) return "";
return detailData.value.selfChooseName || "未记录";
});
const commentRequestKey = computed(() => {
if (!detailData.value?.id || !detailData.value?.selfChooseItemId) return "";
return `${detailData.value.id}_${detailData.value.selfChooseItemId}`;
});
const leadText = computed(() => {
if (!detailData.value) return "";
const leftPercent = Number(detailData.value.leftPercent || 0);
const rightPercent = Number(detailData.value.rightPercent || 0);
const leader = leftPercent >= rightPercent ? detailData.value.left.name : detailData.value.right.name;
const leaderPercent = leftPercent >= rightPercent ? leftPercent : rightPercent;
return `${leader} 当前 ${leaderPercent}% 领先`;
});
const insightText = computed(() => {
if (!detailData.value) return "这是一场你参与过的 PK 对决";
const myChoice = detailData.value.selfChooseName || "你的选择";
const myLeading =
(detailData.value.selfChooseSide === "left" && detailData.value.leftPercent >= detailData.value.rightPercent) ||
(detailData.value.selfChooseSide === "right" && detailData.value.rightPercent >= detailData.value.leftPercent);
return myLeading
? `你支持的 ${myChoice} 当前占优,来看看评论区大家的判断是否和你一致。`
: `你支持的 ${myChoice} 暂时落后,看看评论区里有没有和你同样坚定的声音。`;
});
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 clampPercent = (value) => {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(0, Math.min(100, Math.round(number)));
};
const normalizePercentPair = (item = {}) => {
let leftPercent = clampPercent(item.leftPercent);
let rightPercent = clampPercent(item.rightPercent);
if (!leftPercent && !rightPercent) {
const leftVotes = Number(item.leftVoteCount || 0);
const rightVotes = Number(item.rightVoteCount || 0);
const totalVotes = leftVotes + rightVotes;
if (totalVotes > 0) {
leftPercent = Math.round((leftVotes / totalVotes) * 100);
rightPercent = 100 - leftPercent;
} else {
leftPercent = 50;
rightPercent = 50;
}
} else if (!leftPercent) {
leftPercent = 100 - rightPercent;
} else if (!rightPercent) {
rightPercent = 100 - leftPercent;
}
const total = leftPercent + rightPercent;
if (total && total !== 100) {
leftPercent = Math.round((leftPercent / total) * 100);
rightPercent = 100 - leftPercent;
}
return { leftPercent, rightPercent };
};
const normalizeDetail = (item = {}) => {
const { leftPercent, rightPercent } = normalizePercentPair(item);
const selfChooseSide =
item.selfChooseSide ||
(item.selfChooseItemId && item.selfChooseItemId === item.leftItemId
? "left"
: item.selfChooseItemId && item.selfChooseItemId === item.rightItemId
? "right"
: "");
const selfChooseName =
selfChooseSide === "right"
? (item.rightItemName || "右侧对象")
: selfChooseSide === "left"
? (item.leftItemName || "左侧对象")
: "未记录";
return {
id: item.id || "",
topicId: item.topicId || "",
topicTitle: item.topicTitle || "未知话题",
voteCount: Number(item.voteCount || 0),
leftVoteCount: Number(item.leftVoteCount || 0),
rightVoteCount: Number(item.rightVoteCount || 0),
selfChooseItemId: item.selfChooseItemId || "",
selfChooseSide,
selfChooseName,
leftPercent,
rightPercent,
votedAt: item.votedAt || "",
votedAtText: formatDate(item.votedAt, "YYYY-MM-DD HH:mm") || "刚刚参与",
left: {
id: item.leftItemId || "",
name: item.leftItemName || "左侧对象",
avatar: buildAvatarUrl(item.leftItemAvatar),
},
right: {
id: item.rightItemId || "",
name: item.rightItemName || "右侧对象",
avatar: buildAvatarUrl(item.rightItemAvatar),
},
};
};
const resolveChoiceMeta = (chooseItemId) => {
if (!chooseItemId || !detailData.value) {
return { choiceLabel: "", choiceTone: "" };
}
if (chooseItemId === detailData.value.left.id) {
return { choiceLabel: `支持 ${detailData.value.left.name}`, choiceTone: "left" };
}
if (chooseItemId === detailData.value.right.id) {
return { choiceLabel: `支持 ${detailData.value.right.name}`, choiceTone: "right" };
}
return { choiceLabel: "已站队", choiceTone: "" };
};
const normalizePkComment = (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 openLoginPopup = (message = "请先登录后再操作") => {
loginPopupRef.value?.open();
if (message) {
uni.showToast({ title: message, icon: "none" });
}
};
const goBack = () => {
uni.navigateBack();
};
const goToPkStation = () => {
uni.switchTab({
url: "/pages/pk/index",
});
};
const fetchPkCommentList = ({ page, orderBy }) => {
return fetchCommentList({
pkId: pkId.value,
page,
orderBy,
});
};
const fetchPkCommentReplies = ({ rootCommentId, page }) => {
return fetchCommentReplyList({
pkId: pkId.value,
rootCommentId,
page,
});
};
const publishPkComment = ({ content, replyTarget, mode }) => {
return publishComment({
pkId: pkId.value,
chooseItemId: detailData.value?.selfChooseItemId,
content,
parentCommentId: mode === "reply" ? replyTarget?.parentCommentId : undefined,
replyToCommentId: mode === "reply" ? replyTarget?.replyToCommentId : undefined,
});
};
const hydrateDetail = (options = {}) => {
const cacheKey = options.cacheKey || `${DETAIL_CACHE_PREFIX}${options.pkId || ""}`;
const cached = cacheKey ? uni.getStorageSync(cacheKey) : null;
if (cached && typeof cached === "object") {
detailData.value = normalizeDetail(cached);
pkId.value = detailData.value.id;
return;
}
if (options.payload) {
try {
const parsed = JSON.parse(decodeURIComponent(options.payload));
detailData.value = normalizeDetail(parsed);
pkId.value = detailData.value.id;
} catch (error) {
detailData.value = null;
}
}
};
const handleLoginSuccess = async () => {
await commentSectionRef.value?.refreshComments?.();
};
onLoad((options) => {
pkId.value = options.pkId || "";
hydrateDetail(options);
});
onPullDownRefresh(async () => {
await commentSectionRef.value?.refreshComments?.();
uni.stopPullDownRefresh();
});
onReachBottom(() => {
commentSectionRef.value?.loadMoreComments?.();
});
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(111, 90, 255, 0.14), transparent 32%),
linear-gradient(180deg, #f7f8ff 0%, #f7f8fc 44%, #ffffff 100%);
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(247, 248, 255, 0.88);
backdrop-filter: blur(24rpx);
}
.nav-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28rpx 0 24rpx;
}
.nav-left,
.nav-right {
min-width: 84rpx;
display: flex;
align-items: center;
}
.nav-right {
justify-content: flex-end;
}
.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='%23202433' 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;
}
.nav-title {
font-size: 34rpx;
font-weight: 800;
color: #202433;
}
.nav-right-text {
font-size: 24rpx;
color: #5c43f5;
font-weight: 700;
}
.content-wrap {
padding: 24rpx 32rpx 40rpx;
}
.hero-card,
.battle-card,
.state-card {
background: rgba(255, 255, 255, 0.94);
border: 2rpx solid rgba(229, 232, 246, 0.92);
border-radius: 32rpx;
box-shadow: 0 18rpx 42rpx rgba(87, 92, 145, 0.08);
}
.hero-card {
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-badge {
display: inline-flex;
align-items: center;
height: 44rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: rgba(92, 67, 245, 0.1);
color: #5c43f5;
font-size: 22rpx;
font-weight: 700;
margin-bottom: 18rpx;
}
.hero-title {
display: block;
font-size: 40rpx;
font-weight: 800;
color: #1f2432;
margin-bottom: 12rpx;
line-height: 1.4;
}
.hero-desc {
display: block;
font-size: 25rpx;
color: #7b8191;
line-height: 1.7;
margin-bottom: 24rpx;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.summary-pill {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 18rpx;
border-radius: 999rpx;
background: rgba(92, 67, 245, 0.1);
}
.summary-pill--right {
background: rgba(255, 141, 95, 0.12);
}
.summary-pill-label {
font-size: 22rpx;
color: #8a90a0;
}
.summary-pill-value {
font-size: 24rpx;
font-weight: 700;
color: #4d44f1;
}
.summary-pill--right .summary-pill-value {
color: #ff7a45;
}
.summary-meta {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
justify-content: flex-end;
}
.summary-meta-item {
font-size: 22rpx;
color: #8a90a0;
}
.summary-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #d1d5db;
}
.battle-card {
padding: 28rpx;
margin-bottom: 24rpx;
}
.battle-stage {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 22rpx;
}
.fighter-card {
position: relative;
width: 224rpx;
min-height: 276rpx;
border-radius: 28rpx;
padding: 30rpx 18rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
border: 2rpx solid transparent;
box-sizing: border-box;
}
.fighter-card.left {
border-color: rgba(92, 67, 245, 0.18);
}
.fighter-card.right {
border-color: rgba(255, 153, 102, 0.2);
}
.fighter-card.selected {
transform: translateY(-4rpx);
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.12);
}
.fighter-badge {
position: absolute;
top: 16rpx;
left: 16rpx;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(92, 67, 245, 0.12);
color: #5c43f5;
font-size: 20rpx;
font-weight: 700;
}
.fighter-badge--right {
background: rgba(255, 141, 95, 0.14);
color: #ff7a45;
}
.fighter-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 6rpx solid #fff;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
margin-bottom: 18rpx;
}
.fighter-name {
font-size: 30rpx;
color: #1f2432;
font-weight: 800;
text-align: center;
margin-bottom: 10rpx;
}
.fighter-votes {
font-size: 22rpx;
color: #858b9b;
}
.battle-middle {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.vs-badge {
width: 90rpx;
height: 90rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6a5bff, #4d44f1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 900;
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.24);
margin-bottom: 16rpx;
}
.battle-middle-text {
font-size: 22rpx;
color: #8a90a0;
text-align: center;
}
.support-bar {
height: 18rpx;
border-radius: 999rpx;
overflow: hidden;
display: flex;
background: #eceff8;
margin-bottom: 16rpx;
}
.support-fill {
height: 100%;
}
.left-fill {
background: linear-gradient(90deg, #6557ff, #4d44f1);
}
.right-fill {
background: linear-gradient(90deg, #ffb15f, #ff8d5f);
}
.support-row {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.support-text {
font-size: 24rpx;
font-weight: 700;
}
.left-text {
color: #5c43f5;
}
.right-text {
color: #ff8d5f;
text-align: right;
}
.state-card {
padding: 64rpx 40rpx;
text-align: center;
margin-bottom: 24rpx;
}
.state-title {
display: block;
font-size: 32rpx;
color: #232734;
font-weight: 800;
margin-bottom: 14rpx;
}
.state-desc {
display: block;
font-size: 25rpx;
color: #8a90a0;
line-height: 1.7;
margin-bottom: 28rpx;
}
.state-btn {
width: 240rpx;
height: 84rpx;
line-height: 84rpx;
border-radius: 999rpx;
background: #111827;
color: #fff;
font-size: 28rpx;
font-weight: 700;
}
.state-btn::after {
border: none;
}
</style>