644 lines
16 KiB
Vue
644 lines
16 KiB
Vue
<template>
|
||
<view class="page-container">
|
||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||
<view class="nav-content">
|
||
<view class="nav-back" @tap="goBack">
|
||
<text class="back-icon"></text>
|
||
</view>
|
||
<text class="nav-title">PK 对决</text>
|
||
<view class="nav-placeholder"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||
|
||
<view class="content-wrap" v-if="leftItem && rightItem">
|
||
<view class="hero-card">
|
||
<text class="hero-badge">Battle Mode</text>
|
||
<text class="hero-title">{{ topicTitle || '夯拉评分 PK 对决' }}</text>
|
||
<text class="hero-subtitle">你更看好谁?快速站队,感受榜单碰撞的爽感</text>
|
||
</view>
|
||
|
||
<view class="battle-card">
|
||
<view class="battle-arena">
|
||
<view class="fighter-card left" :class="{ selected: selectedSide === 'left' }">
|
||
<image class="fighter-avatar" :src="leftItem.avatar" mode="aspectFill" />
|
||
<text class="fighter-name">{{ leftItem.name }}</text>
|
||
<text class="fighter-score">{{ leftItem.scoreText }}</text>
|
||
</view>
|
||
|
||
<view class="battle-center">
|
||
<view class="vs-badge">VS</view>
|
||
<text class="battle-tip">{{ scoreGapText }}</text>
|
||
</view>
|
||
|
||
<view class="fighter-card right" :class="{ selected: selectedSide === 'right' }">
|
||
<image class="fighter-avatar" :src="rightItem.avatar" mode="aspectFill" />
|
||
<text class="fighter-name">{{ rightItem.name }}</text>
|
||
<text class="fighter-score">{{ rightItem.scoreText }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="support-bar">
|
||
<view class="support-fill left-fill" :style="{ width: `${supportData.leftPercent}%` }"></view>
|
||
<view class="support-fill right-fill" :style="{ width: `${supportData.rightPercent}%` }"></view>
|
||
</view>
|
||
|
||
<view class="support-text">
|
||
<text class="left-text">{{ supportData.leftPercent }}% 看好 {{ leftItem.name }}</text>
|
||
<text class="right-text">{{ supportData.rightPercent }}% 看好 {{ rightItem.name }}</text>
|
||
</view>
|
||
|
||
<view class="action-row">
|
||
<button class="support-btn left-btn" @tap="handleSupport('left')">我站 {{ leftItem.name }}</button>
|
||
<button class="support-btn right-btn" @tap="handleSupport('right')">我站 {{ rightItem.name }}</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="insight-card">
|
||
<view class="insight-header">
|
||
<image class="insight-icon" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||
<text class="insight-title">AI PK 分析</text>
|
||
</view>
|
||
<text class="insight-content">{{ battleInsight }}</text>
|
||
</view>
|
||
|
||
<view class="rivals-card" v-if="rivalOptions.length">
|
||
<view class="rivals-header">
|
||
<text class="rivals-title">换个对手继续 PK</text>
|
||
<text class="random-action" @tap="randomPickRival">随机换人</text>
|
||
</view>
|
||
<scroll-view scroll-x class="rivals-scroll" :show-scrollbar="false">
|
||
<view class="rivals-list">
|
||
<view
|
||
v-for="item in rivalOptions"
|
||
:key="item.id"
|
||
class="rival-chip"
|
||
:class="{ active: String(item.id) === String(rightItem.id) }"
|
||
@tap="changeRival(item.id)"
|
||
>
|
||
<image class="rival-avatar" :src="item.avatar" mode="aspectFill" />
|
||
<text class="rival-name">{{ item.name }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="empty-wrap">
|
||
<text class="empty-title">暂时还无法开启 PK</text>
|
||
<text class="empty-desc">至少需要两个评分对象,快去为榜单补充更多角色吧</text>
|
||
</view>
|
||
|
||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from "vue";
|
||
import { onLoad, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||
import { getStatusBarHeight } from "@/utils/system";
|
||
import { fetchTopicDetail, fetchTopicRatingItems } from "@/api/topic";
|
||
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 topicId = ref("");
|
||
const focusItemId = ref("");
|
||
const topicTitle = ref("");
|
||
const items = ref([]);
|
||
const leftItemId = ref("");
|
||
const rightItemId = ref("");
|
||
const selectedSide = ref("");
|
||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||
|
||
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 normalizeItem = (item) => ({
|
||
...item,
|
||
avatar: buildAvatarUrl(item.avatarUrl),
|
||
score: Number(item.scoreAvg || 0),
|
||
scoreText: `${Number(item.scoreAvg || 0).toFixed(1)} 分`,
|
||
});
|
||
|
||
const sortedItems = computed(() => [...items.value].sort((a, b) => b.score - a.score));
|
||
const leftItem = computed(() => sortedItems.value.find((item) => String(item.id) === String(leftItemId.value)) || null);
|
||
const rightItem = computed(() => sortedItems.value.find((item) => String(item.id) === String(rightItemId.value)) || null);
|
||
|
||
const calcPercent = (leftScore, rightScore) => {
|
||
const total = leftScore + rightScore;
|
||
if (!total) return { leftPercent: 50, rightPercent: 50 };
|
||
let leftPercent = Math.round((leftScore / total) * 100);
|
||
leftPercent = Math.max(18, Math.min(82, leftPercent));
|
||
if (selectedSide.value === "left") {
|
||
leftPercent = Math.min(88, leftPercent + 4);
|
||
} else if (selectedSide.value === "right") {
|
||
leftPercent = Math.max(12, leftPercent - 4);
|
||
}
|
||
return {
|
||
leftPercent,
|
||
rightPercent: 100 - leftPercent,
|
||
};
|
||
};
|
||
|
||
const supportData = computed(() => {
|
||
if (!leftItem.value || !rightItem.value) {
|
||
return { leftPercent: 50, rightPercent: 50 };
|
||
}
|
||
return calcPercent(leftItem.value.score, rightItem.value.score);
|
||
});
|
||
|
||
const scoreGapText = computed(() => {
|
||
if (!leftItem.value || !rightItem.value) return "火热对决";
|
||
const gap = Math.abs(leftItem.value.score - rightItem.value.score).toFixed(1);
|
||
if (gap === "0.0") return "势均力敌";
|
||
return `分差 ${gap}`;
|
||
});
|
||
|
||
const battleInsight = computed(() => {
|
||
if (!leftItem.value || !rightItem.value) return "当前榜单对象不足,暂时无法生成 PK 分析。";
|
||
if (leftItem.value.score === rightItem.value.score) {
|
||
return `${leftItem.value.name} 和 ${rightItem.value.name} 当前分数持平,这会是一场纯主观审美的大混战。`;
|
||
}
|
||
const leader = leftItem.value.score > rightItem.value.score ? leftItem.value : rightItem.value;
|
||
const follower = leader.id === leftItem.value.id ? rightItem.value : leftItem.value;
|
||
const gap = Math.abs(leftItem.value.score - rightItem.value.score).toFixed(1);
|
||
return `${leader.name} 目前在榜单分数上领先 ${gap} 分,但 ${follower.name} 更容易在 PK 模式里制造反转,适合拉好友一起站队。`;
|
||
});
|
||
|
||
const rivalOptions = computed(() => {
|
||
if (!leftItem.value) return [];
|
||
return sortedItems.value.filter((item) => String(item.id) !== String(leftItem.value.id));
|
||
});
|
||
|
||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||
loginPopupRef.value?.open();
|
||
if (message) {
|
||
uni.showToast({ title: message, icon: "none" });
|
||
}
|
||
};
|
||
|
||
const initBattle = () => {
|
||
if (sortedItems.value.length < 2) {
|
||
leftItemId.value = "";
|
||
rightItemId.value = "";
|
||
return;
|
||
}
|
||
|
||
const currentIndex = sortedItems.value.findIndex((item) => String(item.id) === String(focusItemId.value));
|
||
const primary = currentIndex >= 0 ? sortedItems.value[currentIndex] : sortedItems.value[0];
|
||
const rival =
|
||
sortedItems.value[currentIndex > 0 ? currentIndex - 1 : 1] ||
|
||
sortedItems.value.find((item) => String(item.id) !== String(primary.id));
|
||
|
||
leftItemId.value = primary?.id || "";
|
||
rightItemId.value = rival?.id || "";
|
||
};
|
||
|
||
const loadPageData = async () => {
|
||
if (!topicId.value) return;
|
||
try {
|
||
const [detailRes, itemsRes] = await Promise.all([
|
||
fetchTopicDetail(topicId.value),
|
||
fetchTopicRatingItems(topicId.value, 1),
|
||
]);
|
||
const detail = detailRes?.data || detailRes || {};
|
||
topicTitle.value = detail.title || "";
|
||
const rawList = itemsRes?.data?.records || itemsRes?.records || itemsRes?.data?.list || itemsRes?.list || [];
|
||
items.value = (Array.isArray(rawList) ? rawList : []).map(normalizeItem);
|
||
initBattle();
|
||
} catch (error) {
|
||
items.value = [];
|
||
}
|
||
};
|
||
|
||
const goBack = () => {
|
||
uni.navigateBack({ delta: 1 });
|
||
};
|
||
|
||
const changeRival = (id) => {
|
||
if (String(id) === String(leftItemId.value)) return;
|
||
rightItemId.value = id;
|
||
selectedSide.value = "";
|
||
};
|
||
|
||
const randomPickRival = () => {
|
||
if (!rivalOptions.value.length) return;
|
||
const pool = rivalOptions.value.filter((item) => String(item.id) !== String(rightItemId.value));
|
||
const targetPool = pool.length ? pool : rivalOptions.value;
|
||
const index = Math.floor(Math.random() * targetPool.length);
|
||
changeRival(targetPool[index].id);
|
||
};
|
||
|
||
const handleSupport = (side) => {
|
||
if (!isLoggedIn.value) {
|
||
openLoginPopup("请先登录后再站队");
|
||
return;
|
||
}
|
||
selectedSide.value = side;
|
||
const targetName = side === "left" ? leftItem.value?.name : rightItem.value?.name;
|
||
uni.showToast({
|
||
title: `已站队 ${targetName}`,
|
||
icon: "success",
|
||
});
|
||
};
|
||
|
||
const handleLoginSuccess = () => {};
|
||
|
||
onLoad((options) => {
|
||
topicId.value = options.topicId || "";
|
||
focusItemId.value = options.itemId || "";
|
||
loadPageData();
|
||
});
|
||
|
||
onShareAppMessage(async () => {
|
||
const shareToken = await getShareToken("rating_pk");
|
||
getShareReward({ scene: "rating_pk" });
|
||
return {
|
||
title: leftItem.value && rightItem.value
|
||
? `你更站 ${leftItem.value.name} 还是 ${rightItem.value.name}?`
|
||
: `来夯拉评分看看这场 PK 谁会赢`,
|
||
path: `/pages/rating/pk?topicId=${topicId.value}&itemId=${focusItemId.value}&shareToken=${shareToken}`,
|
||
};
|
||
});
|
||
|
||
onShareTimeline(async () => {
|
||
const shareToken = await getShareToken("rating_pk");
|
||
getShareReward({ scene: "rating_pk_timeline" });
|
||
return {
|
||
title: leftItem.value && rightItem.value
|
||
? `${leftItem.value.name} VS ${rightItem.value.name},你站谁?`
|
||
: `夯拉评分 PK 模式已开启,快来站队`,
|
||
query: `topicId=${topicId.value}&itemId=${focusItemId.value}&shareToken=${shareToken}`,
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page-container {
|
||
min-height: 100vh;
|
||
background:
|
||
radial-gradient(circle at top, rgba(108, 92, 231, 0.16), transparent 32%),
|
||
linear-gradient(180deg, #f7f8ff 0%, #f8f9fe 38%, #ffffff 100%);
|
||
padding-bottom: 40rpx;
|
||
}
|
||
|
||
.nav-bar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
background: rgba(247, 248, 255, 0.84);
|
||
backdrop-filter: blur(22rpx);
|
||
}
|
||
|
||
.nav-content {
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 32rpx;
|
||
}
|
||
|
||
.nav-back,
|
||
.nav-placeholder {
|
||
width: 88rpx;
|
||
}
|
||
|
||
.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%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||
background-size: cover;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 32rpx;
|
||
font-weight: 800;
|
||
color: #262b37;
|
||
}
|
||
|
||
.content-wrap {
|
||
padding: 24rpx 32rpx;
|
||
}
|
||
|
||
.hero-card,
|
||
.battle-card,
|
||
.insight-card,
|
||
.rivals-card {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 2rpx solid rgba(229, 232, 246, 0.9);
|
||
border-radius: 32rpx;
|
||
box-shadow: 0 18rpx 40rpx rgba(88, 93, 146, 0.08);
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.hero-card {
|
||
padding: 32rpx;
|
||
}
|
||
|
||
.hero-badge {
|
||
display: inline-flex;
|
||
height: 44rpx;
|
||
align-items: center;
|
||
padding: 0 16rpx;
|
||
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;
|
||
color: #1e2230;
|
||
font-weight: 800;
|
||
line-height: 1.35;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.hero-subtitle {
|
||
font-size: 26rpx;
|
||
color: #747b8c;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.battle-card {
|
||
padding: 32rpx 28rpx;
|
||
}
|
||
|
||
.battle-arena {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.fighter-card {
|
||
width: 240rpx;
|
||
min-height: 300rpx;
|
||
padding: 28rpx 20rpx;
|
||
border-radius: 28rpx;
|
||
background: linear-gradient(180deg, #f8f9ff 0%, #ffffff 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
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, 168, 92, 0.2);
|
||
}
|
||
|
||
.fighter-card.selected {
|
||
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.14);
|
||
transform: translateY(-4rpx);
|
||
}
|
||
|
||
.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: #202433;
|
||
font-weight: 800;
|
||
text-align: center;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.fighter-score {
|
||
font-size: 24rpx;
|
||
color: #81889b;
|
||
}
|
||
|
||
.battle-center {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.vs-badge {
|
||
width: 96rpx;
|
||
height: 96rpx;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #715eff, #4d44f1);
|
||
color: #fff;
|
||
font-size: 32rpx;
|
||
font-weight: 900;
|
||
letter-spacing: 1rpx;
|
||
box-shadow: 0 18rpx 32rpx rgba(92, 67, 245, 0.25);
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.battle-tip {
|
||
font-size: 22rpx;
|
||
color: #81889b;
|
||
}
|
||
|
||
.support-bar {
|
||
height: 18rpx;
|
||
border-radius: 999rpx;
|
||
overflow: hidden;
|
||
display: flex;
|
||
margin-bottom: 16rpx;
|
||
background: #eef1fb;
|
||
}
|
||
|
||
.support-fill {
|
||
height: 100%;
|
||
}
|
||
|
||
.left-fill {
|
||
background: linear-gradient(90deg, #5f56f4, #7a71ff);
|
||
}
|
||
|
||
.right-fill {
|
||
background: linear-gradient(90deg, #ffb056, #ff8d5f);
|
||
}
|
||
|
||
.support-text {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 20rpx;
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
margin-bottom: 28rpx;
|
||
}
|
||
|
||
.left-text {
|
||
color: #5c43f5;
|
||
}
|
||
|
||
.right-text {
|
||
color: #ff8d5f;
|
||
text-align: right;
|
||
}
|
||
|
||
.action-row {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
}
|
||
|
||
.support-btn {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.support-btn::after {
|
||
border: none;
|
||
}
|
||
|
||
.left-btn {
|
||
background: linear-gradient(135deg, #6556ff, #4d44f1);
|
||
color: #fff;
|
||
}
|
||
|
||
.right-btn {
|
||
background: linear-gradient(135deg, #ffb15f, #ff8d5f);
|
||
color: #fff;
|
||
}
|
||
|
||
.insight-card {
|
||
padding: 28rpx;
|
||
}
|
||
|
||
.insight-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.insight-icon {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
margin-right: 12rpx;
|
||
}
|
||
|
||
.insight-title {
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: #4d44f1;
|
||
}
|
||
|
||
.insight-content {
|
||
font-size: 26rpx;
|
||
color: #4b5161;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.rivals-card {
|
||
padding: 28rpx;
|
||
}
|
||
|
||
.rivals-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.rivals-title {
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: #212533;
|
||
}
|
||
|
||
.random-action {
|
||
font-size: 24rpx;
|
||
color: #5c43f5;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.rivals-scroll {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rivals-list {
|
||
display: inline-flex;
|
||
gap: 18rpx;
|
||
}
|
||
|
||
.rival-chip {
|
||
width: 176rpx;
|
||
padding: 18rpx 16rpx;
|
||
border-radius: 24rpx;
|
||
background: #f7f8fd;
|
||
border: 2rpx solid transparent;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.rival-chip.active {
|
||
background: #f1efff;
|
||
border-color: rgba(92, 67, 245, 0.18);
|
||
}
|
||
|
||
.rival-avatar {
|
||
width: 76rpx;
|
||
height: 76rpx;
|
||
border-radius: 50%;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.rival-name {
|
||
font-size: 24rpx;
|
||
color: #3a3f4d;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-wrap {
|
||
min-height: 60vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
padding: 0 56rpx;
|
||
}
|
||
|
||
.empty-title {
|
||
font-size: 34rpx;
|
||
color: #232733;
|
||
font-weight: 800;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.empty-desc {
|
||
font-size: 26rpx;
|
||
color: #81889b;
|
||
line-height: 1.7;
|
||
}
|
||
</style>
|