2026-03-03 19:38:04 +08:00

833 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div style="padding: 4vw 0">
<!-- 栏目按钮 -->
<div
style="
min-height: 18.54vw;
width: 100vw;
padding: 0 4vw;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10vw;
"
>
<!-- 周榜按钮 -->
<div style="flex: 1; min-width: 0" @click="changeTag('weekly')">
<img
v-show="weeklyShow"
v-smart-img
:src="imageUrl(getImgName('rankingWeeklyBtActive'))"
alt=""
style="width: 100%; display: block; object-fit: cover"
/>
<img
v-show="!weeklyShow"
v-smart-img
:src="imageUrl(getImgName('rankingWeeklyBt'))"
alt=""
style="width: 100%; display: block; object-fit: cover"
/>
</div>
<!-- 季榜按钮 -->
<div style="flex: 1; min-width: 0" @click="changeTag('season')">
<img
v-show="seasonShow"
v-smart-img
:src="imageUrl(getImgName('rankingSeasonBtActive'))"
alt=""
style="width: 100%; display: block; object-fit: cover"
/>
<img
v-show="!seasonShow"
v-smart-img
:src="imageUrl(getImgName('rankingSeasonBt'))"
alt=""
style="width: 100%; display: block; object-fit: cover"
/>
</div>
</div>
<!-- 倒计时 -->
<itemCenter
style="min-height: 31.33vw"
:imgUrl="imageUrl(`timeBg`)"
:contentStyle="`inset:0 0 4vw;`"
>
<div v-show="weeklyShow" style="display: flex; justify-content: center; align-items: center">
<div class="timeBox">
<div class="timeText" style="">{{ WDays }}D</div>
<div class="timeText timeGap"></div>
<div class="timeText" style="">{{ WHours }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ WMinutes }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ WSeconds }}</div>
</div>
</div>
<div v-show="seasonShow" style="display: flex; justify-content: center; align-items: center">
<div class="timeBox">
<div class="timeText" style="">{{ MDays }}D</div>
<div class="timeText timeGap"></div>
<div class="timeText" style="">{{ MHours }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ MMinutes }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ MSeconds }}</div>
</div>
</div>
</itemCenter>
<!-- 排行榜 -->
<div style="position: relative; display: flex; flex-direction: column; gap: 2vw">
<!-- 帮助按钮 -->
<img v-smart-img :src="imageUrl('helpBt')" alt="" class="helpBt" @click="helpShow = true" />
<!-- 第一名排名图片 -->
<itemCenter
style="width: 9vw"
class="rankingBg"
:imgUrl="imageUrl(`top1RankingBg`)"
:flip="true"
:contentStyle="``"
>
<img v-smart-img :src="imageUrl('num1')" alt="" style="width: 3vw; display: block" />
</itemCenter>
<!-- 前三名 -->
<div style="margin-top: 2vw; display: flex; flex-direction: column; gap: 2vw">
<TopUser
:style="{ minHeight: index == 0 ? '58.51vw' : '39.07vw' }"
v-for="(value, index) in 3"
:ranking="index + 1 + ''"
:rankingItem="RankingHasTop3[index] || {}"
:rewards="showRewardsTop3[index] || {}"
/>
</div>
<!-- 从第4名开始 -->
<itemCenter
style="min-height: 25.92vw"
v-for="item in showRanking"
:imgUrl="imageUrl(`rankingItemBg`)"
:flip="true"
:contentStyle="`padding: 0 5%;justify-content: flex-start;`"
>
<!-- 排名 -->
<itemCenter
style="width: 9vw"
:imgUrl="imageUrl(`top1RankingBg`)"
:flip="true"
:contentStyle="``"
>
<div class="rankingText">
<img
v-smart-img
v-for="(digit, index) in getRankDigits(item.rank)"
:src="getDigitImageUrl(digit)"
style="display: block; width: 3vw; object-fit: cover"
/>
</div>
</itemCenter>
<!-- 头像 -->
<div style="width: 36vw">
<div
style="
width: 100%;
padding: 3vw 5vw;
height: 80%;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
"
>
<!-- 点击事件 -->
<div
style="
position: absolute;
z-index: 9;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
"
>
<!-- 情侣1 -->
<div
style="flex: 1; min-width: 0; align-self: stretch"
@click="viewUserInfo(item.userId)"
></div>
<!-- 情侣2 -->
<div
style="flex: 1; min-width: 0; align-self: stretch"
@click="viewUserInfo(item.cpUserId)"
></div>
</div>
<!-- 情侣标志 -->
<div
style="
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
:src="imageUrl(`rankingCoupleSign`)"
alt=""
style="width: 15vw; display: block; object-fit: cover"
/>
</div>
<!-- 情侣1头像 -->
<img
:src="item.userAvatar || ''"
alt=""
style="
display: block;
min-width: 0;
width: 12vw;
object-fit: cover;
aspect-ratio: 1/1;
border-radius: 50%;
border: 1px solid #fefa9f;
"
@error="handleAvatarImageError"
/>
<!-- 情侣2头像 -->
<img
:src="item.cpUserAvatar || ''"
alt=""
style="
display: block;
min-width: 0;
width: 12vw;
object-fit: cover;
aspect-ratio: 1/1;
border-radius: 50%;
border: 1px solid #fefa9f;
"
@error="handleAvatarImageError"
/>
</div>
</div>
<!-- 名称 -->
<div
style="
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1vw;
"
>
<div style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ item.userNickname || '' }}
</div>
<div style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ item.cpUserNickname || '' }}
</div>
</div>
<!-- 贡献 -->
<div style="width: auto; min-width: 0; display: flex; align-items: center">
<img
v-smart-img
:src="imageUrl('distribution')"
alt=""
style="width: 10vw; display: block; object-fit: cover"
/>
<div style="font-weight: 700">{{ item.total || '0' }}</div>
</div>
</itemCenter>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLangStore } from '@/stores/lang'
import { useI18n } from 'vue-i18n'
import { getPngUrl } from '@/config/imagePaths.js'
import { setUserInfo } from '@/utils/userStore.js'
import { viewUserInfo } from '@/utils/appBridge.js'
import { connectToApp } from '@/utils/appConnector.js'
import { preloadImages } from '@/utils/imagePreloader.js'
import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/imageHandler.js'
import { useCoupleStore } from '@/stores/couple'
import { useThrottle } from '@/utils/useDebounce.js'
import { storeToRefs } from 'pinia'
import {
apiGetCpRanking, // 获取cp排行榜
apiGetCpRewardPool, // 获取cp奖励池(前三名)
} from '@/api/couple.js'
import TopUser from './components/topUser.vue'
import itemCenter from '@/components/itemCenter.vue'
import maskLayer from '@/components/MaskLayer.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const coupleStore = useCoupleStore()
const langStore = useLangStore()
// 当前语言类型
const currentLangType = computed(() => {
return langStore.selectedLang?.type || 'en'
})
const isLoading = ref(true) // 预加载状态
// 获取OSS图片URL的函数
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
// 根据语言获取图片名
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
const {
helpShow, // 帮助按钮
myRanking, // 我的排名
} = storeToRefs(coupleStore)
// 倒计时相关数据
const WDays = ref(0)
const WHours = ref(0)
const WMinutes = ref(0)
const WSeconds = ref(0)
let countdownTimer = null
const MDays = ref(0)
const MHours = ref(0)
const MMinutes = ref(0)
const MSeconds = ref(0)
let monthlyCountdownTimer = null // 月度倒计时定时器
// 获取对应的时间戳
const getNextWeekInBeijing = () => {
const now = new Date()
const targetDate = new Date(now.getTime()) // 创建一个基于当前时间的新日期对象
// 转换为北京时间来判断星期几
const utcTime = now.getTime() + now.getTimezoneOffset() * 60000
const beijingTime = new Date(utcTime + 8 * 3600000) // UTC+8
const beijingDay = beijingTime.getDay() // 0=周日, 1=周一, ..., 6=周六
const beijingHour = beijingTime.getHours()
// 转换为 0=周一, 1=周二, ..., 6=周日
const normalizedDay = beijingDay === 0 ? 6 : beijingDay - 1
let daysToAdd
if (normalizedDay === 0) {
// 今天是周一
if (beijingHour >= 5) {
daysToAdd = 7 // 超过5点计算到下周一的所需时间
} else {
daysToAdd = 0 // 今天
}
} else {
daysToAdd = 7 - normalizedDay // 到下周一的天数
}
// 正确地添加天数
targetDate.setDate(targetDate.getDate() + daysToAdd) // 添加天数到下周一的当前时间
targetDate.setHours(5, 0, 0, 0) // 将时间重新设置为早上5点作为目标时间戳
return targetDate.getTime()
}
const targetTime = ref(getNextWeekInBeijing()) // 闭包保存目标时间 (核心修正)
// 格式化时间
const formatTime = (value) => {
return value < 10 ? `0${value}` : value
}
// 倒计时
const getCountdown = () => {
const now = Date.now()
let diff = targetTime.value - now
if (diff <= 0) {
// 初始化数据
initData()
targetTime.value += 7 * 86400000 // +7天 (更新闭包变量)
diff = targetTime.value - now
}
const totalSeconds = Math.floor(diff / 1000)
WDays.value = Math.floor(totalSeconds / (24 * 3600))
WHours.value = formatTime(Math.floor((totalSeconds % (24 * 3600)) / 3600))
WMinutes.value = formatTime(Math.floor((totalSeconds % 3600) / 60))
WSeconds.value = formatTime(Math.floor(totalSeconds % 60))
}
// 启动倒计时
const startCountdown = () => {
getCountdown() // 立即执行一次周倒计时
getMonthlyCountdown() // 立即执行一次月倒计时
countdownTimer = setInterval(getCountdown, 1000)
monthlyCountdownTimer = setInterval(getMonthlyCountdown, 1000)
}
// 获取下一个星座周期的截止时间
const getNextMonthFirstInBeijing = () => {
const now = new Date()
// 北京时间星座周期截止日期配置 - [月, 日]
const zodiacCutoffDates = [
[1, 20], // 摩羯座到水瓶座: 1月20日
[2, 19], // 水瓶座到双鱼座: 2月19日
[3, 21], // 双鱼座到白羊座: 3月21日
[4, 20], // 白羊座到金牛座: 4月20日
[5, 21], // 金牛座到双子座: 5月21日
[6, 22], // 双子座到巨蟹座: 6月22日
[7, 23], // 巨蟹座到狮子座: 7月23日
[8, 23], // 狮子座到处女座: 8月23日
[9, 23], // 处女座到天秤座: 9月23日
[10, 23], // 天秤座到天蝎座: 10月23日
[11, 22], // 天蝎座到射手座: 11月22日
[12, 22], // 射手座到摩羯座: 12月22日
]
// 获取当前的北京时间
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const currentYear = beijingTime.getUTCFullYear()
const currentMonth = beijingTime.getUTCMonth() + 1 // 月份从1开始
const currentDate = beijingTime.getUTCDate()
const currentHour = beijingTime.getUTCHours()
// 查找当前属于哪个星座周期
let targetDate = null
let targetYear = currentYear
// 获取本年度的截止时间
for (let i = 0; i < zodiacCutoffDates.length; i++) {
const [cutoffMonth, cutoffDate] = zodiacCutoffDates[i]
// 如果当前日期在某个星座周期内,则返回下一个周期的截止日期
if (
currentMonth < cutoffMonth ||
(currentMonth === cutoffMonth && currentDate < cutoffDate) ||
(currentMonth === cutoffMonth && currentDate === cutoffDate && currentHour < 5)
) {
// 当前时间处于当前年份的该星座周期内
targetDate = [cutoffMonth, cutoffDate]
break
}
}
// 如果仍未找到说明当前是年末的摩羯座周期需要找下一年的1月20日
if (!targetDate) {
targetDate = [1, 20]
targetYear = currentYear + 1
}
// 创建目标日期(北京时间)
// 使用Date.UTC创建UTC时间然后减去时差转换为UTC时间
const targetDateAsBeijing =
Date.UTC(targetYear, targetDate[0] - 1, targetDate[1], 5, 0, 0) - 8 * 60 * 60 * 1000
return targetDateAsBeijing
}
// 月度倒计时
const getMonthlyCountdown = () => {
const now = Date.now()
let diff = targetMonthTime.value - now
if (diff <= 0) {
// 如果时间已过,重新计算下一个月
targetMonthTime.value = getNextMonthFirstInBeijing()
diff = targetMonthTime.value - now
}
const totalSeconds = Math.floor(diff / 1000)
// 计算天数(这里需要考虑月份跨越的情况,使用总天数)
const totalDays = Math.floor(totalSeconds / (24 * 3600))
MDays.value = totalDays
const remainingHours = Math.floor((totalSeconds % (24 * 3600)) / 3600)
MHours.value = formatTime(remainingHours)
const remainingMinutes = Math.floor((totalSeconds % 3600) / 60)
MMinutes.value = formatTime(remainingMinutes)
const remainingSeconds = totalSeconds % 60
MSeconds.value = formatTime(remainingSeconds)
}
// 添加月度倒计时的目标时间
const targetMonthTime = ref(getNextMonthFirstInBeijing())
const weeklyShow = ref(true) //本周数据
const seasonShow = ref(false) //季度数据
const changeTag = (module) => {
switch (module) {
case 'weekly':
if (weeklyShow.value) return
weeklyShow.value = true
seasonShow.value = false
apiGetRanking() // 获取排行榜
apiGetTop3Rewards() // 获取前三名奖励
break
case 'season':
if (seasonShow.value) return
weeklyShow.value = false
seasonShow.value = true
apiGetRanking() // 获取排行榜
apiGetTop3Rewards() // 获取前三名奖励
break
}
}
const rewardsTop3 = ref([]) // 原前三名奖励列表
// 处理后的前三名奖励列表
const showRewardsTop3 = computed(() => {
// 对 rewardsTop3 进行深拷贝,避免引用问题
let newRewardsTop3 = JSON.parse(JSON.stringify(rewardsTop3.value))
// 季度榜添加奖励
if (seasonShow.value) {
newRewardsTop3[0].activityRewardProps.push(
{
cover: '',
content: 'Banner*30Days',
quantity: 0,
type: 'GIFT',
},
{
cover: '',
content: 'ID: 7 digits * 1',
quantity: 0,
type: 'GIFT',
},
)
}
// 周榜添加奖励
else {
// 第一名
newRewardsTop3[0]?.activityRewardProps.push(
{
cover: new URL('/src/assets/icon/coin.png', import.meta.url).href,
content: t('heartbeat_value_points_4_percent', {
amount: formatLargeNumber(Number(RankingHasTop3.value[0]?.totalNumber) * 0.04),
}),
quantity: 0,
type: 'GIFT',
},
{
cover: '',
content: 'Homepage Ad* 7 days',
quantity: 0,
type: 'GIFT',
},
)
// 第二名
newRewardsTop3[1]?.activityRewardProps.push({
cover: new URL('/src/assets/icon/coin.png', import.meta.url).href,
content: t('heartbeat_value_points_3_percent', {
amount: formatLargeNumber(Number(RankingHasTop3.value[1]?.totalNumber) * 0.03),
}),
quantity: 0,
type: 'GIFT',
})
// 第三名
newRewardsTop3[2]?.activityRewardProps.push({
cover: new URL('/src/assets/icon/coin.png', import.meta.url).href,
content: t('heartbeat_value_points_2_percent', {
amount: formatLargeNumber(Number(RankingHasTop3.value[2]?.totalNumber) * 0.02),
}),
quantity: 0,
type: 'GIFT',
})
}
return newRewardsTop3
})
const RankingLoadmore = ref(null) //触底模块
const rankingPageNo = ref(1) //分页页码
const Ranking = ref([]) //原始排行榜
const rankingAdd = ref([]) //加载排行榜项
// 完整排行榜
const rankingTotal = computed(() => {
return [...Ranking.value, ...rankingAdd.value]
})
// 处理排行榜小于3人时的情况
const RankingHasTop3 = computed(() => {
let RankingShow = [...Ranking.value]
if (Ranking.value.length < 3) {
let addNullUser = Array.from({ length: 3 - Ranking.value.length }, () => ({
rank: '',
userAvatar: '',
userId: '',
userNickname: '',
userSex: '',
cpUserAvatar: '',
cpUserId: '',
cpUserNickname: '',
cpUserSex: '',
total: '0',
totalNumber: '0',
}))
RankingShow.push(...addNullUser)
}
return RankingShow
})
// 展示用的榜单
const showRanking = computed(() => {
return Ranking.value.filter((_, index) => index >= 3)
// return []
})
// 获取排名数字的各个位数
const getRankDigits = (rank) => {
return String(rank).split('')
}
// 获取数字图片URL
const getDigitImageUrl = (digit) => {
if (/^[0-9]$/.test(digit)) {
return imageUrl(`num${digit}`) // 假设数字图片在相同目录下
}
return ''
}
// 计算前三名的奖励金币数量
const formatLargeNumber = (num) => {
if (typeof num !== 'number' || isNaN(num)) {
return '0'
}
const absNum = Math.abs(num)
if (absNum >= 1000000) {
const millionValue = num / 1000000
const roundedValue = Math.round(millionValue * 100) / 100
return roundedValue.toFixed(2) + 'M'
} else if (absNum >= 1000) {
const thousandValue = num / 1000
const roundedValue = Math.round(thousandValue * 100) / 100
return roundedValue.toFixed(2) + 'K'
} else {
return Math.floor(num).toString()
}
}
// 获取榜单
const apiGetRanking = async () => {
let type = weeklyShow.value ? 'WEEK' : 'SEASON'
try {
const response = await apiGetCpRanking(type)
if (response.status && response.body) {
Ranking.value = response.body.thisWeekUserTop || [] // 原始排行榜
myRanking.value = response.body.thisUser || {} // 我的排名
}
} catch (error) {
showError(error.response.errorMsg)
}
}
// 获取前三名奖励
const apiGetTop3Rewards = async () => {
let type = weeklyShow.value ? 'WEEK_CP_GIFT' : 'SEASON_CP_GIFT'
try {
const response = await apiGetCpRewardPool(type)
if (response.status && response.body) {
let rewards = response.body
rewardsTop3.value = response.body || [] // 前三名奖励
}
} catch (error) {
showError(error.response.errorMsg)
}
}
// 触发节流获取排行榜
const debouceGetRanking = useThrottle(() => {
// getRanking() //加载新的排行榜项
}, 1000)
// IntersectionObserver配置
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0 && rankingTotal.value.length != 0) {
console.log('触发RankingLoadmore加载')
debouceGetRanking()
}
})
})
// 页面初始化数据
const initData = async () => {
await Promise.all([
apiGetRanking(), // 获取排行榜
apiGetTop3Rewards(), // 获取前三名奖励
])
}
// 预加载关键图片
const preloadCriticalImages = async () => {
const criticalImages = []
const criticalImages_ar = []
if (currentLangType.value == 'en') {
await preloadImages(criticalImages)
} else if (currentLangType.value == 'ar') {
await preloadImages(criticalImages_ar)
}
}
// 完成预加载
const completePreloading = async () => {
try {
// 执行所有初始化操作
await Promise.all([initData(), preloadCriticalImages()])
console.log('预加载完成,初始化数据成功')
} catch (error) {
console.error('预加载过程中发生错误:', error)
} finally {
// 无论成功或失败都结束加载状态
isLoading.value = false
}
}
// 使用工具函数连接APP
const connectToAppHandler = async () => {
await connectToApp(() => {
// 连接成功回调
completePreloading() // 完成预加载
})
}
// 组件挂载时检测环境
onMounted(() => {
startCountdown() //开启倒计时
connectToAppHandler()
// 监听加载模块
if (RankingLoadmore.value) {
console.log('监控加载模块')
observer.observe(RankingLoadmore.value)
}
})
onUnmounted(() => {
// 销毁计时器
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
if (monthlyCountdownTimer) {
clearInterval(monthlyCountdownTimer)
monthlyCountdownTimer = null
}
})
</script>
<style lang="scss" scoped>
.timeBox {
width: 50vw;
display: flex;
justify-content: center;
}
.timeText {
color: #fff;
font-weight: 590;
font-size: 2em;
margin-top: 1vw;
background: linear-gradient(
180deg,
#ffdf6b 31.61%,
#ffe562 36.73%,
#fff 52.37%,
#ffe562 58.67%,
#ffa615 72.2%
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.timeGap {
width: 6px;
align-self: stretch;
display: flex;
justify-content: center;
align-items: center;
}
[dir='rtl'] .timeBox {
flex-direction: row-reverse;
}
.helpBt {
display: block;
width: 8vw;
object-fit: cover;
position: absolute;
z-index: 3;
top: 2vw;
right: 3vw;
}
[dir='rtl'] .helpBt {
right: unset;
left: 3vw;
}
.rankingBg {
position: absolute;
z-index: 3;
top: 6vw;
left: 3vw;
}
.rankingText {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
[dir='rtl'] .rankingText {
flex-direction: row-reverse;
}
[dir='rtl'] .rankingBg {
left: unset;
right: 3vw;
}
</style>