Files
rating/pages/pk/index.vue

661 lines
16 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>
<view v-else 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 } from "@/api/pk";
import { FILE_BASE_URL } from "@/utils/constants";
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 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 buildAvatarUrl = (avatar) => {
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
return String(avatar).startsWith("http") ? avatar : `${FILE_BASE_URL}${avatar}`;
};
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 = () => {};
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>