Files
rating/pages/pk/index.vue
2026-06-15 22:29:03 +08:00

783 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">
<text class="nav-title">PK站</text>
<view class="nav-action" @tap="refreshPool">
<text class="nav-action-text">刷新</text>
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<view class="hero-card">
<view class="hero-badge">Battle Arena</view>
<text class="hero-title">随机PK</text>
<text class="hero-desc">系统会从当前可用话题中随机生成对局只给你还没参与过的 PK </text>
</view>
<view v-if="loading" class="state-card">
<text class="state-title">正在生成 PK 对局...</text>
<text class="state-desc">马上就给你安排一场新的神仙打架</text>
</view>
<view v-else-if="currentPair" class="pk-card">
<view class="topic-pill">
<text class="topic-pill-label">当前话题</text>
<text class="topic-pill-title">{{ currentPair.topicTitle }}</text>
</view>
<view class="battle-stage">
<view class="fighter-card left" :class="{ chosen: selectedSide === 'left' }">
<image class="fighter-avatar" :src="currentPair.left.avatar" mode="aspectFill" />
<text class="fighter-name">{{ currentPair.left.name }}</text>
<!-- <text class="fighter-meta">{{ currentPair.left.scoreText }}</text> -->
</view>
<view class="battle-middle">
<view class="vs-badge">VS</view>
<text class="battle-middle-text">你更支持谁</text>
</view>
<view class="fighter-card right" :class="{ chosen: selectedSide === 'right' }">
<image class="fighter-avatar" :src="currentPair.right.avatar" mode="aspectFill" />
<text class="fighter-name">{{ currentPair.right.name }}</text>
<!-- <text class="fighter-meta">{{ currentPair.right.scoreText }}</text> -->
</view>
</view>
<view v-if="hasVoted" class="result-card">
<view class="result-header">
<text class="result-title">PK 结果</text>
<text class="result-choice">你的选择{{ selectedSide === 'left' ? currentPair.left.name : currentPair.right.name }}</text>
</view>
<view class="support-bar">
<view class="support-fill left-fill" :style="{ width: `${supportStats.leftPercent}%` }"></view>
<view class="support-fill right-fill" :style="{ width: `${supportStats.rightPercent}%` }"></view>
</view>
<view class="support-text">
<text class="left-text">{{ supportStats.leftPercent }}% 支持 {{ currentPair.left.name }}</text>
<text class="right-text">{{ supportStats.rightPercent }}% 支持 {{ currentPair.right.name }}</text>
</view>
</view>
<view v-else class="action-row">
<button class="vote-btn left-btn" :disabled="voting" @tap="handleVote('left')">支持 {{ currentPair.left.name }}</button>
<button class="vote-btn right-btn" :disabled="voting" @tap="handleVote('right')">支持 {{ currentPair.right.name }}</button>
</view>
<view class="footer-row">
<!-- <view class="battle-tip-card">
<image class="battle-tip-icon" src="/static/images/icon/robot.png" mode="aspectFit"></image>
<text class="battle-tip-text">{{ battleInsight }}</text>
</view> -->
<button class="next-btn" :disabled="loading" @tap="loadNextPair">下一条 PK</button>
</view>
</view>
<CommentSection
v-if="currentPair && hasVoted"
ref="commentSectionRef"
variant="pk"
:request-key="commentRequestKey"
:visible="hasVoted"
:allow-compose="true"
compose-title="说说你的理由"
:compose-desc="`支持 ${selectedItemLabel} 的观点会更容易引发讨论`"
compose-placeholder="写下你的 PK 观点..."
page-name="pk_station"
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('请先登录后再参与评论')"
@published="handleCommentPublished"
/>
<view v-else-if="!currentPair" class="state-card">
<text class="state-title">暂时没有新的 PK 对局</text>
<text class="state-desc">当前没有可参与的随机对局稍后再来看看有没有新的组合</text>
<button class="state-btn" @tap="loadNextPair">重新获取</button>
</view>
</view>
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
import { getStatusBarHeight } from "@/utils/system";
import { fetchRandomTopicList, vote, 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";
import { getShareReward } from "@/api/system";
import { getShareToken } from "@/utils/common";
const userStore = useUserStore();
const statusBarHeight = ref(getStatusBarHeight() || 44);
const loginPopupRef = ref(null);
const commentSectionRef = ref(null);
const loading = ref(false);
const voting = ref(false);
const currentPair = ref(null);
const selectedSide = ref("");
const voteStats = ref({ leftPercent: 50, rightPercent: 50 });
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
const hasVoted = computed(() => !!selectedSide.value);
const selectedItemId = computed(() => {
if (!currentPair.value || !selectedSide.value) return "";
return selectedSide.value === "left" ? currentPair.value.left.id : currentPair.value.right.id;
});
const selectedItemLabel = computed(() => {
if (!currentPair.value || !selectedSide.value) return "";
return selectedSide.value === "left" ? currentPair.value.left.name : currentPair.value.right.name;
});
const selectedTone = computed(() => {
if (!selectedSide.value) return "";
return selectedSide.value === "left" ? "left" : "right";
});
const commentRequestKey = computed(() => {
if (!currentPair.value?.id || !selectedItemId.value) return "";
return `${currentPair.value.id}_${selectedItemId.value}`;
});
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 || !currentPair.value) {
return { choiceLabel: "", choiceTone: "" };
}
if (chooseItemId === currentPair.value.left?.id) {
return { choiceLabel: `支持 ${currentPair.value.left.name}`, choiceTone: "left" };
}
if (chooseItemId === currentPair.value.right?.id) {
return { choiceLabel: `支持 ${currentPair.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 fetchPkCommentList = ({ page, orderBy }) => {
return fetchCommentList({
pkId: currentPair.value?.id,
page,
orderBy,
});
};
const fetchPkCommentReplies = ({ rootCommentId, page }) => {
return fetchCommentReplyList({
pkId: currentPair.value?.id,
rootCommentId,
page,
});
};
const publishPkComment = ({ content, replyTarget, mode }) => {
return publishComment({
pkId: currentPair.value?.id,
chooseItemId: selectedItemId.value,
content,
parentCommentId: mode === "reply" ? replyTarget?.parentCommentId : undefined,
replyToCommentId: mode === "reply" ? replyTarget?.replyToCommentId : undefined,
});
};
const handleCommentPublished = () => {};
const normalizePercent = (value) => {
const number = Number(value);
if (!Number.isFinite(number)) return null;
return number <= 1 ? Math.round(number * 100) : Math.round(number);
};
const normalizeStats = (data = {}) => {
const source = data?.data || data || {};
const leftRaw =
source.leftPercent ??
source.leftSupportRate ??
source.leftItemSupportRate ??
source.leftRate ??
source.leftItemRate ??
source.leftRatio ??
source.leftItemRatio ??
source.leftSupportPercent ??
source.leftItemSupportPercent ??
source.leftItemPercent;
const rightRaw =
source.rightPercent ??
source.rightSupportRate ??
source.rightItemSupportRate ??
source.rightRate ??
source.rightItemRate ??
source.rightRatio ??
source.rightItemRatio ??
source.rightSupportPercent ??
source.rightItemSupportPercent ??
source.rightItemPercent;
let leftPercent = normalizePercent(leftRaw);
let rightPercent = normalizePercent(rightRaw);
if (leftPercent === null && rightPercent === null) {
const leftCount = Number(source.leftCount ?? source.leftVotes ?? source.leftSupportCount ?? source.leftItemSupportCount ?? 0);
const rightCount = Number(source.rightCount ?? source.rightVotes ?? source.rightSupportCount ?? source.rightItemSupportCount ?? 0);
const total = leftCount + rightCount;
if (total > 0) {
leftPercent = Math.round((leftCount / total) * 100);
rightPercent = 100 - leftPercent;
}
} else if (leftPercent === null) {
leftPercent = 100 - rightPercent;
} else if (rightPercent === null) {
rightPercent = 100 - leftPercent;
}
if (leftPercent === null || rightPercent === null) {
return { leftPercent: 50, rightPercent: 50 };
}
leftPercent = Math.max(0, Math.min(100, leftPercent));
rightPercent = Math.max(0, Math.min(100, rightPercent));
const totalPercent = leftPercent + rightPercent;
if (totalPercent && totalPercent !== 100) {
leftPercent = Math.round((leftPercent / totalPercent) * 100);
rightPercent = 100 - leftPercent;
}
return { leftPercent, rightPercent };
};
const supportStats = computed(() => voteStats.value);
const battleInsight = computed(() => {
if (!currentPair.value) return "随机生成新对局中...";
const left = currentPair.value.left;
const right = currentPair.value.right;
if (!hasVoted.value) {
return `${left.name}${right.name} 正在同台 PK先站队再看大家的支持率。`;
}
const leader = supportStats.value.leftPercent >= supportStats.value.rightPercent ? left : right;
const percent = leader.id === left.id ? supportStats.value.leftPercent : supportStats.value.rightPercent;
return `${leader.name} 当前获得 ${percent}% 支持,下一条 PK 可能又是完全不同的风向。`;
});
const openLoginPopup = (message = "请先登录后再操作") => {
loginPopupRef.value?.open();
if (message) {
uni.showToast({ title: message, icon: "none" });
}
};
const normalizePair = (item) => {
if (!item?.id) return null;
return {
id: item.id,
topicId: item.topicId,
topicTitle: item.topicTitle || "未知话题",
left: {
id: item.leftItemId,
name: item.leftItemName || "未知对象",
avatar: buildAvatarUrl(item.leftItemAvatar),
scoreText: "等待站队",
},
right: {
id: item.rightItemId,
name: item.rightItemName || "未知对象",
avatar: buildAvatarUrl(item.rightItemAvatar),
scoreText: "等待站队",
},
};
};
const loadRandomPair = async () => {
const pair = await fetchRandomTopicList();
currentPair.value = normalizePair(pair);
selectedSide.value = "";
voteStats.value = normalizeStats(pair);
};
const loadNextPair = async () => {
if (loading.value) return;
try {
loading.value = true;
await loadRandomPair();
} catch (error) {
currentPair.value = null;
} finally {
loading.value = false;
uni.stopPullDownRefresh();
}
};
const handleVote = async (side) => {
if (!isLoggedIn.value) {
openLoginPopup("请先登录后再参与 PK");
return;
}
if (!currentPair.value || hasVoted.value || voting.value) return;
const itemId = side === "left" ? currentPair.value.left.id : currentPair.value.right.id;
if (!itemId) return;
try {
voting.value = true;
const result = await vote({
pkId: currentPair.value.id,
itemId,
});
selectedSide.value = side;
voteStats.value = normalizeStats(result);
} finally {
voting.value = false;
}
};
const refreshPool = () => {
loadNextPair();
};
const handleLoginSuccess = async () => {
if (hasVoted.value) {
await commentSectionRef.value?.refreshComments?.();
}
};
onLoad(() => {
loadNextPair();
});
onPullDownRefresh(() => {
loadNextPair();
});
onShareAppMessage(async () => {
const shareToken = await getShareToken("pk_station");
getShareReward({ scene: "pk_station" });
return {
title: currentPair.value
? `${currentPair.value.left.name} VS ${currentPair.value.right.name},你站谁?`
: "夯拉评分 PK站来看看下一场神仙打架",
path: `/pages/pk/index?shareToken=${shareToken}`,
};
});
onShareTimeline(async () => {
const shareToken = await getShareToken("pk_station");
getShareReward({ scene: "pk_station_timeline" });
return {
title: currentPair.value
? `${currentPair.value.left.name}${currentPair.value.right.name} 正在 PK快来站队`
: "夯拉评分 PK站已开启来刷下一场对决",
query: `shareToken=${shareToken}`,
};
});
</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 32rpx;
}
.nav-title {
font-size: 34rpx;
font-weight: 800;
color: #202433;
}
.nav-action {
min-width: 72rpx;
display: flex;
justify-content: flex-end;
}
.nav-action-text {
font-size: 26rpx;
color: #5c43f5;
font-weight: 700;
}
.content-wrap {
padding: 24rpx 32rpx 40rpx;
}
.hero-card,
.pk-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: 42rpx;
font-weight: 800;
color: #1f2432;
margin-bottom: 12rpx;
}
.hero-desc {
font-size: 25rpx;
color: #7b8191;
line-height: 1.7;
}
.pk-card {
padding: 30rpx;
}
.topic-pill {
display: flex;
flex-direction: column;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(92, 67, 245, 0.08), rgba(92, 67, 245, 0.03));
margin-bottom: 28rpx;
}
.topic-pill-label {
font-size: 22rpx;
color: #8a90a0;
margin-bottom: 8rpx;
}
.topic-pill-title {
font-size: 30rpx;
color: #222736;
font-weight: 800;
line-height: 1.45;
}
.battle-stage {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
}
.fighter-card {
width: 230rpx;
min-height: 304rpx;
border-radius: 28rpx;
padding: 28rpx 18rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
box-sizing: border-box;
border: 2rpx solid transparent;
}
.fighter-card.left {
border-color: rgba(92, 67, 245, 0.18);
}
.fighter-card.right {
border-color: rgba(255, 153, 102, 0.18);
}
.fighter-card.chosen {
transform: translateY(-4rpx);
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.14);
}
.fighter-avatar {
width: 128rpx;
height: 128rpx;
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-meta {
font-size: 24rpx;
color: #858b9b;
}
.battle-middle {
display: flex;
flex-direction: column;
align-items: center;
}
.vs-badge {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6a5bff, #4d44f1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
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;
}
.action-row {
display: flex;
gap: 18rpx;
margin-bottom: 22rpx;
}
.vote-btn,
.next-btn,
.state-btn {
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
}
.vote-btn::after,
.next-btn::after,
.state-btn::after {
border: none;
}
.vote-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
}
.left-btn {
background: linear-gradient(135deg, #6a5bff, #4d44f1);
color: #fff;
}
.right-btn {
background: linear-gradient(135deg, #ffb25c, #ff8e63);
color: #fff;
}
.result-card {
margin-bottom: 22rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #f8f9ff;
}
.result-header {
display: flex;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 16rpx;
}
.result-title {
font-size: 28rpx;
color: #222736;
font-weight: 800;
}
.result-choice {
font-size: 24rpx;
color: #5c43f5;
font-weight: 700;
text-align: right;
}
.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-text {
display: flex;
justify-content: space-between;
gap: 20rpx;
font-size: 24rpx;
font-weight: 700;
}
.left-text {
color: #5c43f5;
}
.right-text {
color: #ff8d5f;
text-align: right;
}
.footer-row {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.battle-tip-card {
display: flex;
align-items: flex-start;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(92, 67, 245, 0.06), rgba(92, 67, 245, 0.02));
}
.battle-tip-icon {
width: 30rpx;
height: 30rpx;
margin-right: 12rpx;
margin-top: 4rpx;
flex-shrink: 0;
}
.battle-tip-text {
font-size: 24rpx;
color: #596071;
line-height: 1.65;
}
.next-btn,
.state-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #111827;
color: #fff;
}
.state-card {
padding: 56rpx 40rpx;
text-align: center;
}
.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;
}
</style>