669 lines
18 KiB
Vue
669 lines
18 KiB
Vue
<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月的索引是1(0是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>
|