2026-01-20 11:06:28 +08:00

669 lines
18 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="
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 :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" />
<!-- 前三名 -->
<TopUser
v-for="(value, index) in 3"
:ranking="index + 1 + ''"
:rankingItem="RankingHasTop3[index] || {}"
:rewards="rewardsTop3[index] || {}"
/>
<!-- 从第4名开始 -->
<itemCenter
v-for="item in showRanking"
:imgUrl="imageUrl(`rankingItemBg`)"
:flip="true"
:contentStyle="`padding: 0 5%;justify-content: flex-start;`"
>
<!-- 排名 -->
<div style="width: 10vw; display: flex; justify-content: center; align-items: center">
<img
v-smart-img
v-for="(digit, index) in getRankDigits(item.rank)"
:src="getDigitImageUrl(digit)"
style="display: block; width: 3vw; object-fit: cover"
/>
</div>
<!-- 头像 -->
<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;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
:src="imageUrl(`rankingCoupleSign`)"
alt=""
style="width: 15vw; display: block; object-fit: cover"
/>
</div>
<!-- 情侣1头像 -->
<img
v-smart-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"
@click="viewUserInfo(item.userId)"
/>
<!-- 情侣2头像 -->
<img
v-smart-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"
@click="viewUserInfo(item.cpUserId)"
/>
</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 getNextMondayInBeijing = () => {
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(getNextMondayInBeijing()) // 闭包保存目标时间 (核心修正)
// 格式化时间
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)
}
// 获取2月19号早上5点的北京时间
const getNextMonthFirstInBeijing = () => {
const now = new Date()
// 获取当前的北京时间
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const year = beijingTime.getUTCFullYear()
const month = beijingTime.getUTCMonth()
const date = beijingTime.getUTCDate()
const hour = beijingTime.getUTCHours()
// 确定目标年份 - 如果当前时间在本年度2月19日之前则目标是今年2月19日否则是明年2月19日
let targetYear = year
// 检查是否已经超过今年的2月19日
if (month > 1 || (month === 1 && date > 19) || (month === 1 && date === 19 && hour >= 5)) {
// 如果当前时间已经超过今年的2月19日5点则目标是明年的2月19日
targetYear = year + 1
}
// 目标月份固定为2月月份索引为1
const targetMonth = 1 // 2月的索引是10是1月
// 创建代表北京时间的UTC时间戳 - 2月19号而不是1号
// 使用Date.UTC创建代表"北京时间"的时间戳
const targetDateAsBeijing = Date.UTC(targetYear, targetMonth, 19, 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 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 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.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
if (seasonShow.value) {
rewards[0].activityRewardProps.push(
{
cover: '',
content: 'Banner*30Days',
quantity: 0,
type: 'GIFT',
},
{
cover: '',
content: 'ID: 7 digits * 1',
quantity: 0,
type: 'GIFT',
},
)
} else {
rewards[0].activityRewardProps.push({
cover: '',
content: 'Homepage Ad* 7 days',
quantity: 0,
type: 'GIFT',
})
}
rewardsTop3.value = response.body || [] // 前三名奖励
}
} catch (error) {
showError(error.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: 0;
right: 3vw;
}
[dir='rtl'] .helpBt {
right: unset;
left: 3vw;
}
</style>