Files
rating/pages/pk/index.vue

783 lines
21 KiB
Vue
Raw Normal View History

2026-06-11 15:51:45 +08:00
<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>
2026-06-14 23:20:07 +08:00
<text class="hero-title">随机PK</text>
2026-06-11 15:51:45 +08:00
<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>
2026-06-15 22:29:03 +08:00
<CommentSection
2026-06-15 19:59:07 +08:00
v-if="currentPair && hasVoted"
ref="commentSectionRef"
2026-06-15 22:29:03 +08:00
variant="pk"
:request-key="commentRequestKey"
2026-06-15 19:59:07 +08:00
:visible="hasVoted"
2026-06-15 22:29:03 +08:00
: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"
2026-06-15 19:59:07 +08:00
@need-login="openLoginPopup('请先登录后再参与评论')"
2026-06-15 22:29:03 +08:00
@published="handleCommentPublished"
2026-06-15 19:59:07 +08:00
/>
<view v-else-if="!currentPair" class="state-card">
2026-06-11 15:51:45 +08:00
<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";
2026-06-15 22:29:03 +08:00
import { fetchRandomTopicList, vote, fetchCommentList, fetchCommentReplyList, publishComment, likeComment } from "@/api/pk";
2026-06-11 15:51:45 +08:00
import { FILE_BASE_URL } from "@/utils/constants";
2026-06-15 22:29:03 +08:00
import { formatDate } from "@/utils/date";
import CommentSection from "@/components/CommentSection/CommentSection.vue";
2026-06-11 15:51:45 +08:00
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);
2026-06-15 19:59:07 +08:00
const commentSectionRef = ref(null);
2026-06-11 15:51:45 +08:00
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);
2026-06-15 19:59:07 +08:00
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;
});
2026-06-15 22:29:03 +08:00
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}`;
});
2026-06-11 15:51:45 +08:00
const buildAvatarUrl = (avatar) => {
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
2026-06-15 22:29:03 +08:00
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: "" };
2026-06-11 15:51:45 +08:00
};
2026-06-15 22:29:03 +08:00
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 = () => {};
2026-06-11 15:51:45 +08:00
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();
};
2026-06-15 19:59:07 +08:00
const handleLoginSuccess = async () => {
if (hasVoted.value) {
await commentSectionRef.value?.refreshComments?.();
}
};
2026-06-11 15:51:45 +08:00
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>