feat: pk
This commit is contained in:
21
api/pk.js
Normal file
21
api/pk.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { request } from "@/utils/request.js";
|
||||
|
||||
// 获取随机一组PK主题
|
||||
export const fetchRandomTopicList = async () => {
|
||||
return request({
|
||||
url: "/api/rating/pk/random",
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户投票
|
||||
* @param { pkId, itemId }
|
||||
*/
|
||||
export const vote = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/vote",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
};
|
||||
@@ -50,7 +50,7 @@
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx8d0a4bfb94c9a7f0",
|
||||
"appid" : "wx146a2e83b50f9f75",
|
||||
"__usePrivacyCheck__" : true,
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
|
||||
22
pages.json
22
pages.json
@@ -49,6 +49,14 @@
|
||||
"backgroundColor": "#fbfbfb"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pk/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "PK站",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/webview/index",
|
||||
"style": {
|
||||
@@ -80,6 +88,14 @@
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/rating/pk",
|
||||
"style": {
|
||||
"navigationBarTitleText": "PK 对决",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/topics",
|
||||
"style": {
|
||||
@@ -112,6 +128,12 @@
|
||||
"iconPath": "static/images/tabBar/home.png",
|
||||
"selectedIconPath": "static/images/tabBar/home_s.png"
|
||||
},
|
||||
{
|
||||
"text": "PK站",
|
||||
"pagePath": "pages/pk/index",
|
||||
"iconPath": "static/images/tabBar/creation.png",
|
||||
"selectedIconPath": "static/images/tabBar/creation_s.png"
|
||||
},
|
||||
{
|
||||
"text": "发布",
|
||||
"pagePath": "pages/release/index",
|
||||
|
||||
660
pages/pk/index.vue
Normal file
660
pages/pk/index.vue
Normal file
@@ -0,0 +1,660 @@
|
||||
<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">随机站队,一秒开打</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>
|
||||
|
||||
<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>
|
||||
@@ -55,31 +55,33 @@
|
||||
/>
|
||||
|
||||
<!-- 大家都在 PK -->
|
||||
<!-- <view class="pk-panel card">
|
||||
<view v-if="pkPreview" class="pk-panel card" @tap="goToPk()">
|
||||
<view class="pk-header">
|
||||
<text class="section-title">大家都在 PK</text>
|
||||
<view class="go-compare">去对比<text class="arrow-right"></text></view>
|
||||
</view>
|
||||
<view class="pk-content">
|
||||
<view class="pk-person">
|
||||
<image class="pk-avatar blue-border" :src="detailData.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ detailData.name }}</text>
|
||||
<image class="pk-avatar blue-border" :src="pkPreview.left.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ pkPreview.left.name }}</text>
|
||||
<text class="pk-score blue-text">{{ pkPreview.left.scoreText }}</text>
|
||||
</view>
|
||||
<text class="vs-text">VS</text>
|
||||
<view class="pk-person">
|
||||
<image class="pk-avatar yellow-border" src="https://api.dicebear.com/7.x/avataaars/svg?seed=13" mode="aspectFill" />
|
||||
<text class="pk-name">康熙</text>
|
||||
<image class="pk-avatar yellow-border" :src="pkPreview.right.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ pkPreview.right.name }}</text>
|
||||
<text class="pk-score yellow-text">{{ pkPreview.right.scoreText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pk-bar-wrap">
|
||||
<view class="pk-bar blue-bar" style="width: 63%;"></view>
|
||||
<view class="pk-bar yellow-bar" style="width: 37%;"></view>
|
||||
<view class="pk-bar blue-bar" :style="{ width: `${pkPreview.leftPercent}%` }"></view>
|
||||
<view class="pk-bar yellow-bar" :style="{ width: `${pkPreview.rightPercent}%` }"></view>
|
||||
</view>
|
||||
<view class="pk-data-text">
|
||||
<text class="blue-text">63% 支持</text>
|
||||
<text class="yellow-text">37% 支持</text>
|
||||
<text class="blue-text">{{ pkPreview.leftPercent }}% 看好</text>
|
||||
<text class="yellow-text">{{ pkPreview.rightPercent }}% 看好</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 评论区 -->
|
||||
<view class="comments-section card">
|
||||
@@ -144,6 +146,7 @@ import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
|
||||
import CommentItem from "@/components/CommentItem/CommentItem.vue";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { fetchTopicRatingItems as fetchTopicItemList } from "@/api/topic";
|
||||
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList } from "@/api/topicItem";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { getShareToken } from "@/utils/common";
|
||||
@@ -162,6 +165,7 @@ const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nic
|
||||
const commentLoading = ref(false);
|
||||
const isSendingComment = ref(false);
|
||||
const commentError = ref('');
|
||||
const pkPreview = ref(null);
|
||||
const commentLoadText = computed(() => {
|
||||
if (commentLoading.value && comments.value.length) return '加载中...';
|
||||
if (!hasMore.value) return '没有更多了';
|
||||
@@ -195,6 +199,80 @@ const detailData = ref({
|
||||
const comments = ref([]);
|
||||
const itemId = ref('');
|
||||
|
||||
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 calcPkPercent = (leftScore, rightScore) => {
|
||||
const left = Number(leftScore) || 0;
|
||||
const right = Number(rightScore) || 0;
|
||||
if (left <= 0 && right <= 0) {
|
||||
return { leftPercent: 50, rightPercent: 50 };
|
||||
}
|
||||
const total = left + right || 1;
|
||||
let leftPercent = Math.round((left / total) * 100);
|
||||
leftPercent = Math.min(82, Math.max(18, leftPercent));
|
||||
return {
|
||||
leftPercent,
|
||||
rightPercent: 100 - leftPercent
|
||||
};
|
||||
};
|
||||
|
||||
const buildPkPreview = (items) => {
|
||||
const sortedItems = [...items].sort((a, b) => (Number(b.scoreAvg) || 0) - (Number(a.scoreAvg) || 0));
|
||||
const currentIndex = sortedItems.findIndex((item) => String(item.id) === String(itemId.value));
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const currentItem = sortedItems[currentIndex];
|
||||
const rival =
|
||||
sortedItems[currentIndex === 0 ? 1 : currentIndex - 1] ||
|
||||
sortedItems.find((item) => String(item.id) !== String(itemId.value));
|
||||
|
||||
if (!currentItem || !rival) return null;
|
||||
|
||||
const { leftPercent, rightPercent } = calcPkPercent(currentItem.scoreAvg, rival.scoreAvg);
|
||||
return {
|
||||
left: {
|
||||
id: currentItem.id,
|
||||
name: currentItem.name || '当前对象',
|
||||
avatar: buildAvatarUrl(currentItem.avatarUrl),
|
||||
scoreText: `${Number(currentItem.scoreAvg || 0).toFixed(1)} 分`
|
||||
},
|
||||
right: {
|
||||
id: rival.id,
|
||||
name: rival.name || '对手',
|
||||
avatar: buildAvatarUrl(rival.avatarUrl),
|
||||
scoreText: `${Number(rival.scoreAvg || 0).toFixed(1)} 分`
|
||||
},
|
||||
leftPercent,
|
||||
rightPercent
|
||||
};
|
||||
};
|
||||
|
||||
const getPkPreviewData = async () => {
|
||||
if (!detailData.value.topicId || !itemId.value) {
|
||||
pkPreview.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchTopicItemList(detailData.value.topicId, 1);
|
||||
const rawList = res?.data?.records || res?.records || res?.data?.list || res?.list || [];
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
pkPreview.value = list.length >= 2 ? buildPkPreview(list) : null;
|
||||
} catch (error) {
|
||||
pkPreview.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPk = () => {
|
||||
if (!detailData.value.topicId || !itemId.value) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/pk?topicId=${detailData.value.topicId}&itemId=${itemId.value}`
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionChange = async (action) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
@@ -420,6 +498,7 @@ const getDetailData = async () => {
|
||||
if (currentPage.value === 1) {
|
||||
getCommentsData({ replace: true });
|
||||
}
|
||||
getPkPreviewData();
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error);
|
||||
uni.showToast({
|
||||
@@ -737,6 +816,12 @@ onReachBottom(() => {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pk-score {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vs-text {
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
|
||||
@@ -37,6 +37,30 @@
|
||||
<text class="ai-content">{{ topicData.aiSummary }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="pkPair.left && pkPair.right" class="pk-entry-card" @tap="goToPk()">
|
||||
<view class="pk-entry-header">
|
||||
<view class="pk-entry-title-wrap">
|
||||
<text class="pk-entry-title">PK 对决模式</text>
|
||||
<text class="pk-entry-subtitle">快速站队,看谁更能打</text>
|
||||
</view>
|
||||
<view class="pk-entry-action">
|
||||
<text>进入 PK</text>
|
||||
<text class="pk-entry-arrow"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pk-entry-battle">
|
||||
<view class="pk-entry-user left">
|
||||
<image class="pk-entry-avatar" :src="FILE_BASE_URL + pkPair.left.avatarUrl" mode="aspectFill" />
|
||||
<text class="pk-entry-name">{{ pkPair.left.name }}</text>
|
||||
</view>
|
||||
<view class="pk-entry-vs">VS</view>
|
||||
<view class="pk-entry-user right">
|
||||
<image class="pk-entry-avatar" :src="FILE_BASE_URL + pkPair.right.avatarUrl" mode="aspectFill" />
|
||||
<text class="pk-entry-name">{{ pkPair.right.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 领奖台 (Top 3) -->
|
||||
<view class="podium-section" v-if="ratingItems && ratingItems.length >= 3">
|
||||
<!-- 第二名 -->
|
||||
@@ -180,6 +204,10 @@ const currentTopicId = ref(null);
|
||||
const successPopupRef = ref(null);
|
||||
const loginPopupRef = ref(null);
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const pkPair = computed(() => ({
|
||||
left: ratingItems.value?.[0] || null,
|
||||
right: ratingItems.value?.[1] || null
|
||||
}));
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({
|
||||
@@ -373,6 +401,13 @@ const goToDetail = (itemId) => {
|
||||
});
|
||||
};
|
||||
|
||||
const goToPk = (itemId = '') => {
|
||||
if (!currentTopicId.value) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/pk?topicId=${currentTopicId.value}${itemId ? `&itemId=${itemId}` : ''}`
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
@@ -582,6 +617,103 @@ onReachBottom(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pk-entry-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f4ff 52%, #eef1ff 100%);
|
||||
border: 2rpx solid rgba(99, 88, 255, 0.14);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 42rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(91, 84, 223, 0.08);
|
||||
}
|
||||
|
||||
.pk-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.pk-entry-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #1d2233;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.pk-entry-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #7d8495;
|
||||
}
|
||||
|
||||
.pk-entry-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5c43f5;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pk-entry-arrow {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-left: 6rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235c43f5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.pk-entry-battle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pk-entry-user {
|
||||
width: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pk-entry-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.pk-entry-user.left .pk-entry-avatar {
|
||||
border-color: rgba(92, 67, 245, 0.22);
|
||||
}
|
||||
|
||||
.pk-entry-user.right .pk-entry-avatar {
|
||||
border-color: rgba(255, 154, 76, 0.22);
|
||||
}
|
||||
|
||||
.pk-entry-name {
|
||||
font-size: 26rpx;
|
||||
color: #2a2f3c;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pk-entry-vs {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6f5aff, #4d44f1);
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1rpx;
|
||||
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.22);
|
||||
}
|
||||
|
||||
/* 领奖台 (Top 3) */
|
||||
.podium-section {
|
||||
display: flex;
|
||||
|
||||
643
pages/rating/pk.vue
Normal file
643
pages/rating/pk.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user