feat(新页面): 情侣排行榜页面

This commit is contained in:
hzj 2026-01-16 20:25:08 +08:00
parent 953db2ed3f
commit 09f513b4bf
13 changed files with 3904 additions and 0 deletions

245
src/api/couple.js Normal file
View File

@ -0,0 +1,245 @@
import { get, post } from '../utils/http.js'
// 获取我的-cp对象.
export const apiGetMyCpObject = async () => {
try {
const response = await get(`/user/cp-relationship/pair`)
return response
} catch (error) {
console.error('Failed to get my cp object:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 发送CP告白信封.
export const apiSendLoveLetter = async (data) => {
try {
const response = await post(`/user/cp-relationship/send-love-letter`, data)
return response
} catch (error) {
console.error('Failed to send love letter:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 查询告白墙.
export const apiGetLoveLetterWall = async () => {
try {
const response = await get(`/user/cp-relationship/love-letter-wall`)
return response
} catch (error) {
console.error('Failed to get love letter wall:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 查询我收到的最新告白信封.
export const apiGetLatestLoveLetter = async () => {
try {
const response = await get(`/user/cp-relationship/latest-love-letter`)
return response
} catch (error) {
console.error('Failed to get latest love letter:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// cp排行榜(周榜和赛季榜)
export const apiGetCpRanking = async (type) => {
try {
const response = await get(`/activity/week-cp-gift/ranking-data?type=${type}`)
return response
} catch (error) {
console.error('Failed to get cp ranking:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 赛季榜Top1列表
export const apiGetCpSeasonTop1List = async () => {
try {
const response = await get(`/activity/week-cp-gift/season-top1`)
return response
} catch (error) {
console.error('Failed to get cp season top 1:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 奖励池.
export const apiGetCpRewardPool = async (type) => {
try {
const response = await get(`/activity/week-cp-gift/list-reward-pool?type=${type}`)
return response
} catch (error) {
console.error('Failed to get cp reward pool:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 获取我的剩余抽奖券数量
export const apiGetMyLotteryTicketCount = async (activityId) => {
try {
const response = await get(`/activity/lottery/my-ticket-count?activityId=${activityId}`)
return response
} catch (error) {
console.error('Failed to get my lottery ticket count:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 根据用户id与碎片集合查询用户碎片背包并且数量大于0
export const apiGetUserFragmentBackpack = async (data) => {
try {
const response = await post(
`/user/backpack/client/list/by/user-id?userId=${data.userId}&fragmentsIds=${data.fragmentsIds}`,
)
return response
} catch (error) {
console.error('Failed to get user fragment backpack:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 获取活动详情
export const activityDetail = async (activityCode) => {
try {
const response = await get(`/activity/lottery/detail/${activityCode}`)
return response
} catch (error) {
console.error('Failed to fetch get activity detail:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 获取我的总抽奖次数
export const myTotalDrawCount = async () => {
try {
const response = await get(`/activity/lottery/my-total-draw-count`)
return response
} catch (error) {
console.error('Failed to fetch get my total draw count:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 执行单次抽奖
export const onceDraw = async (data) => {
try {
const response = await post('/activity/lottery/draw', data)
return response
} catch (error) {
console.error('Failed to fetch once draw:', error)
throw error
}
}
// 执行连抽
export const multiDraw = async (data) => {
try {
const response = await post('/activity/lottery/multi-draw', data)
return response
} catch (error) {
console.error('Failed to fetch multiply draw:', error)
throw error
}
}
// 获取我的中奖记录(默认最新100条)
export const drawRecords = async (activityCode) => {
try {
const response = await get(
`/activity/lottery/my-records?pageSize=100&activityCode=${activityCode}`,
)
return response
} catch (error) {
console.error('Failed to fetch get draw records:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 查询抽奖背包流水(水晶&碎片)
export const apiGetDrawBackpackLogs = async (data) => {
try {
const response = await post(`/activity/lottery/backpack/logs`, data)
return response
} catch (error) {
console.error('Failed to fetch get draw backpack logs:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 获取指定类型活动资源
export const apiGetActivityResource = async (activityType) => {
try {
const response = await get(
`/props-activity-cnf/client/listActivityResource?sysOrigin=LIKEI&activityType=${activityType}`,
)
return response
} catch (error) {
console.error('Failed to get activity resource:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 用碎片兑换资源
export const exchangeGood = async (data) => {
try {
const response = await post(`/activity/week-star/exchange`, data)
return response
} catch (error) {
console.error('Failed to exchange goods:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 查询签到状态
export const apiGetMySignInStatus = async () => {
try {
const response = await get(`/signin/status`)
return response
} catch (error) {
console.error('Failed to fetch get my sign in status:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 执行签到
export const apiPostMySignIn = async (data) => {
try {
const response = await post(`/signin/check-in`, data)
return response
} catch (error) {
console.error('Failed to sign in:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}
// 补签
export const apiPostMySignInSupplement = async (data) => {
try {
const response = await post(`/signin/supplement`, data)
return response
} catch (error) {
console.error('Failed to sign in:', error)
console.error('error:' + error.response.errorMsg)
throw error
}
}

View File

@ -331,6 +331,12 @@ const router = createRouter({
},
// 固定排行榜活动
{
path: '/couple',
name: 'couple',
component: () => import('../views/Ranking/Couple/index.vue'),
meta: { requiresAuth: true },
}, //沙特下周一当天早上6点北京时间3点刷新
{
path: '/top-list',
name: 'top-list',

View File

@ -50,6 +50,7 @@ const RANKING_PAGES = [
'/weekly-star', //每周明星
'/ranking', //总排行榜
'/games-king', //游戏排行榜
'/couple', //情侣排行榜
]
// 活动页面

View File

@ -401,6 +401,7 @@ class RouteGuard {
'/weekly-star', //每周明星
'/ranking', //总排行榜
'/games-king', //游戏排行榜
'/couple', //情侣排行榜
]
// 活动页面

View File

@ -0,0 +1,223 @@
<template>
<div style="padding: 4vw 0">
<!-- 栏目按钮 -->
<div
style="
width: 100vw;
padding: 0 4vw;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2vw;
"
>
<img
v-smart-img
:src="imageUrl(getImgName('gameLotteryBt'))"
alt=""
style="flex: 1; min-width: 0; display: block; object-fit: cover"
@click="changeTag('lottery')"
/>
<img
v-smart-img
:src="imageUrl(getImgName('gameSignInBt'))"
alt=""
style="flex: 1; min-width: 0; display: block; object-fit: cover"
@click="changeTag('signIn')"
/>
<img
v-smart-img
:src="imageUrl(getImgName('gameExchangeBt'))"
alt=""
style="flex: 1; min-width: 0; display: block; object-fit: cover"
@click="changeTag('exchange')"
/>
</div>
<div style="position: relative">
<!-- 中奖历史按钮 -->
<img
v-smart-img
:src="imageUrl('historyBt')"
alt=""
style="width: 8vw; position: absolute; z-index: 2; top: 0vw; right: 4vw"
@click="showDrawRecord"
/>
<itemCenter
style="position: relative; z-index: 1"
:imgUrl="imageUrl(getImgName('myPropsBg'))"
:contentStyle="`inset: 28vw 17vw 25vw;justify-content:space-between;`"
>
<!-- 水晶数量 -->
<div style="display: flex; align-items: center">
<img
v-smart-img
:src="imageUrl('crystal')"
alt=""
style="display: block; width: 15vw; object-fit: cover"
/>
<div style="font-size: 2em; font-weight: 600; color: #ff79a1">:{{ crystal }}</div>
</div>
<!-- 碎片数量 -->
<div style="display: flex; align-items: center">
<img
v-smart-img
:src="imageUrl('debris')"
alt=""
style="display: block; width: 15vw; object-fit: cover"
/>
<div style="font-size: 2em; font-weight: 600; color: #ff79a1">:{{ debris }}</div>
</div>
</itemCenter>
</div>
<!-- 游戏模块 -->
<lottery v-show="lotteryShow" style="margin-top: -35vw" />
<signIn v-show="signInShow" />
<exchange v-show="exchangeShow" />
</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 { getUserId, 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 { storeToRefs } from 'pinia'
import {
apiGetMyLotteryTicketCount, //
apiGetUserFragmentBackpack, //
apiGetDrawBackpackLogs, // (&)
} from '@/api/couple.js'
import itemCenter from '@/components/itemCenter.vue'
import maskLayer from '@/components/MaskLayer.vue'
import lottery from './components/lottery.vue'
import signIn from './components/signIn.vue'
import exchange from './components/exchange.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) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
const {
crystal, //
debris, //
receiveRecordShow, //
receiveRecord, //
} = storeToRefs(coupleStore)
const lotteryShow = ref(true) //
const signInShow = ref(false) //
const exchangeShow = ref(false) //
//
const changeTag = (module) => {
switch (module) {
case 'lottery':
lotteryShow.value = true
signInShow.value = false
exchangeShow.value = false
break
case 'signIn':
lotteryShow.value = false
signInShow.value = true
exchangeShow.value = false
break
case 'exchange':
lotteryShow.value = false
signInShow.value = false
exchangeShow.value = true
break
}
}
//
const getMyLotteryTicketCount = async () => {
try {
const response = await apiGetMyLotteryTicketCount('2004471533988167125')
if (response.status) {
crystal.value = response.body || 0
}
} catch (error) {
showError(error.message)
}
}
//
const getUserFragmentBackpack = async () => {
try {
const response = await apiGetUserFragmentBackpack({
userId: getUserId(),
// userId: '1957345312961527809',
fragmentsIds: ['2011710323414081538'],
})
if (response.status) {
debris.value = response.body?.[0]?.quantity || 0
}
} catch (error) {
showError(error.message)
}
}
//
const getReceiveRecords = async () => {
const resReceiveRecords = await apiGetDrawBackpackLogs({
pageSize: 100,
pageNum: 1,
})
if (resReceiveRecords.status && resReceiveRecords.body) {
receiveRecord.value = resReceiveRecords.body?.records || []
receiveRecordShow.value = true
}
}
//
const showDrawRecord = () => {
getReceiveRecords() //
}
//
const initData = () => {
getMyLotteryTicketCount() //
getUserFragmentBackpack() //
}
onMounted(() => {
initData() //
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,123 @@
<template>
<div style="padding: 0 4vw; display: grid; grid-template-columns: repeat(3, 1fr); gap: 2vw">
<itemCenter
v-for="item in goodsList"
:key="item.id"
:imgUrl="imageUrl('goodsItem')"
:contentStyle="`flex-direction: column;justify-content: space-between;align-items: center;`"
>
<div
style="
width: 100%;
min-width: 0;
aspect-ratio: 1/0.95;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
:src="item.propsGroup?.activityRewardProps?.[0].cover"
alt=""
style="width: 70%; display: block; object-fit: cover"
/>
</div>
<div style="display: flex; justify-content: center; align-items: center">
<img
v-smart-img
:src="imageUrl(getImgName('debris'))"
alt=""
style="display: block; width: 2em; object-fit: cover"
/>
<div style="font-weight: 600; color: #ff79a1" @click="exchangeGoods(item)">
*{{ JSON.parse(item.rule.jsonData).need_fragments }}
</div>
</div>
</itemCenter>
</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 { getUserId, 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 {
apiGetActivityResource, //
apiGetMyLotteryTicketCount, //
apiGetUserFragmentBackpack, //
exchangeGood, //
} from '@/api/couple.js'
import itemCenter from '@/components/itemCenter.vue'
import maskLayer from '@/components/MaskLayer.vue'
import { storeToRefs } from 'pinia'
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) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
const {
exchangeConfirmShow, //
exchangeItem, //
} = storeToRefs(coupleStore)
const goodsList = ref([]) //
//
const getActivityResource = async () => {
try {
const response = await apiGetActivityResource('APRIL_ACTIVITY_FRAGMENT')
if (response.status) {
goodsList.value = response.body || []
}
} catch (error) {
showError(error.message)
}
}
//
const exchangeGoods = async (goods) => {
exchangeItem.value = {
userId: getUserId(),
propsGroupId: goods.rule.id,
fragmentsId: '2011710323414081538',
}
//
exchangeConfirmShow.value = true
}
onMounted(() => {
getActivityResource() //
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,456 @@
<template>
<div>
<itemCenter
:imgUrl="imageUrl('lotteryBg')"
:contentStyle="`flex-direction: column;justify-content: flex-start;gap: 5vw`"
>
<!-- 中奖历史按钮 -->
<img
v-smart-img
:src="imageUrl('historyBt')"
alt=""
style="width: 8vw; position: absolute; top: 40vw; right: 4vw"
@click="getDrawRecords"
/>
<!-- 抽奖转盘 -->
<div
style="
width: 100%;
height: 71vw;
align-self: stretch;
margin: 68.5vw 0 0;
padding: 0 15vw;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 1vw;
direction: ltr;
"
>
<div
v-for="(prize, index) in activity.prizeList"
style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
"
:style="[getGridPosition(index)]"
>
<img
v-smart-img
:src="prize.prizeImage || ''"
:alt="prize.prizeName || ''"
width="50%"
style="display: block"
/>
<div
style="
font-size: 0.8em;
font-weight: 700;
color: transparent;
text-shadow:
-0.25px -0.25px 1px #ff37a1,
0.25px 0.25px 1px #ff37a1,
0 0 3px rgba(255, 55, 161, 0.8),
0 0 5px rgba(255, 55, 161, 0.6),
0 0 4px rgba(191, 0, 101, 0.3);
-webkit-text-stroke-width: 0.8px;
-webkit-text-stroke-color: #fff;
text-transform: capitalize;
"
>
{{ prize.prizeName }}
</div>
<!-- 转到的奖品项 -->
<img
v-show="activeIndex === index"
v-smart-img
:src="imageUrl('prizeActiveFrame')"
alt=""
style="
display: block;
width: 100%;
position: absolute;
inset: 0;
"
/>
</div>
<!-- 水晶数量 -->
<div
style="
grid-area: 2 / 2;
display: flex;
justify-content: center;
align-items: center;
gap: 1vw;
"
>
<img
v-smart-img
:src="imageUrl(getImgName('crystal'))"
alt=""
style="display: block; width: 8vw; object-fit: cover"
/>
<div style="font-size: 1.5em; font-weight: 600; color: #fff">*{{ crystal }}</div>
</div>
</div>
<!-- 抽奖按钮 -->
<div
style="height: 12vw; display: flex; justify-content: space-between; gap: 14vw"
class="drawBts"
>
<!-- 单抽 -->
<div style="width: 25vw; height: 100%" @click="sweepstakes(1)">
<div
v-if="crystal >= 1"
style="
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
:src="imageUrl(getImgName('crystal'))"
alt=""
style="display: block; width: 3em; object-fit: cover"
/>
<div
style="color: #fff; font-size: 1.2em; font-weight: 600; text-shadow: 0 1px 0 #8b00d1"
>
*1
</div>
</div>
<div
v-else
style="
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
src="/src/assets/icon/coin.png"
alt=""
style="display: block; width: 3em; object-fit: cover"
/>
<div
style="color: #fff; font-size: 1.2em; font-weight: 600; text-shadow: 0 1px 0 #8b00d1"
>
*1000
</div>
</div>
</div>
<!-- 10连抽 -->
<div style="width: 25vw; height: 100%" @click="sweepstakes(10)">
<div
v-if="crystal >= 10"
style="
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
:src="imageUrl(getImgName('crystal'))"
alt=""
style="display: block; width: 3em; object-fit: cover"
/>
<div
style="color: #fff; font-size: 1.2em; font-weight: 600; text-shadow: 0 1px 0 #8b00d1"
>
*10
</div>
</div>
<div
v-else
style="
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
src="/src/assets/icon/coin.png"
alt=""
style="display: block; width: 3em; object-fit: cover"
/>
<div
style="color: #fff; font-size: 1.2em; font-weight: 600; text-shadow: 0 1px 0 #8b00d1"
>
*5000
</div>
</div>
</div>
</div>
</itemCenter>
</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 { getUserId, 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 { storeToRefs } from 'pinia'
import {
activityDetail, //
onceDraw, //
multiDraw, //
drawRecords, //
apiGetMyLotteryTicketCount, //
apiGetUserFragmentBackpack, //
} from '@/api/couple.js'
import itemCenter from '@/components/itemCenter.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) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
const {
crystal, //
debris, //
resultShow, //
result, //
drawRecordShow, //
myRecords, //
} = storeToRefs(coupleStore)
const activity = ref({}) //
const isRolling = ref(false) //
const activeIndex = ref(0)
//
const openPopup = (type) => {
switch (type) {
case 'result':
resultShow.value = true
break
case 'history':
drawRecordShow.value = true
break
}
}
//
const getGridPosition = (index) => {
//
const positions = [
{ gridArea: '1/1' }, //
{ gridArea: '1/2' }, //
{ gridArea: '1/3' }, //
{ gridArea: '2/3' }, //
{ gridArea: '3/3' }, //
{ gridArea: '3/2' }, //
{ gridArea: '3/1' }, //
{ gridArea: '2/1' }, //
]
return positions[index]
}
//
const startLottery = (resultIndex) => {
console.log('开始抽奖动画')
return new Promise((resolve) => {
activeIndex.value = -1
let speed = 200 //
let currentCycle = 0 //
let constantSpeedCycle = 0 //
let totalCycles = 1 // 2
const roll = () => {
activeIndex.value = (activeIndex.value + 1) % activity.value.prizeList.length
console.log('当前索引:', activeIndex.value)
if (activeIndex.value === resultIndex && speed > 200) {
setTimeout(() => {
resolve() //
}, 500)
} else {
if (activeIndex.value === activity.value.prizeList.length - 1) {
currentCycle++
}
//
if (currentCycle < totalCycles) {
speed -= 20
} else {
//
if (activeIndex.value === activity.value.prizeList.length - 1) {
constantSpeedCycle++
}
if (constantSpeedCycle > 2) {
//
speed += 20
}
}
setTimeout(roll, speed)
}
}
roll()
})
}
//
const sweepstakes = async (consecutive = 1) => {
if (isRolling.value) return //
isRolling.value = true
let data = {
activityId: '2004471533988167125',
drawCount: consecutive,
needCoins: consecutive > crystal.value,
}
try {
//
if (consecutive == 1) {
const resOnceDraw = await onceDraw(data)
if (resOnceDraw.status && resOnceDraw.body) {
result.value = [resOnceDraw.body] //
const resItem = resOnceDraw.body //
//
const index = activity.value.prizeList.findIndex(
(prize) => prize.prizeName == resItem?.prize.prizeName,
) //
console.log('index:', index)
updateNumbers() // &
await startLottery(index)
}
}
//
else {
const resMultiDraw = await multiDraw(data)
if (resMultiDraw.status && resMultiDraw.body) {
result.value = resMultiDraw.body.results //
//
let index = activity.value.prizeList.findIndex(
(prize) => prize.prizeName == result.value[0]?.prize.prizeName,
)
console.log('index:', index)
updateNumbers() // &
await startLottery(index)
}
}
openPopup('result') //
isRolling.value = false
} catch (error) {
isRolling.value = false
}
}
//
const getDrawRecords = async () => {
const resDrawRecords = await drawRecords('67125')
if (resDrawRecords.status && resDrawRecords.body) {
myRecords.value = resDrawRecords.body?.records || []
openPopup('history') //
}
}
//
const getActivityDetail = async () => {
const resDetail = await activityDetail('67125')
if (resDetail.status && resDetail.body) {
activity.value = resDetail.body
} else {
activity.value = {}
}
}
//
const getMyLotteryTicketCount = async () => {
try {
const response = await apiGetMyLotteryTicketCount('2004471533988167125')
if (response.status) {
crystal.value = response.body || 0
}
} catch (error) {
showError(error.message)
}
}
//
const getUserFragmentBackpack = async () => {
try {
const response = await apiGetUserFragmentBackpack({
userId: getUserId(),
// userId: '1957345312961527809',
fragmentsIds: ['2011710323414081538'],
})
if (response.status) {
debris.value = response.body?.[0]?.quantity || 0
}
} catch (error) {
showError(error.message)
}
}
// &
const updateNumbers = async () => {
await getMyLotteryTicketCount() //
await getUserFragmentBackpack() //
}
onMounted(() => {
getActivityDetail() //
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,315 @@
<template>
<div>
<!-- 规则&倒计时 -->
<itemCenter :imgUrl="imageUrl('checkInRule')" :contentStyle="`inset: 8vw 28vw 105vw;`">
<div class="timeBox">
<div class="timeText" style="">{{ Days }}D</div>
<div class="timeText timeGap"></div>
<div class="timeText" style="">{{ Hours }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ Minutes }}</div>
<div class="timeText timeGap">:</div>
<div class="timeText" style="">{{ Seconds }}</div>
</div>
</itemCenter>
<!-- 签到奖池列表 -->
<div
style="
position: relative;
padding: 0 2vw 4vw;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4vw;
"
>
<signInItem
v-for="(rewards, index) in signInInfo.dailyConfigs"
:key="index"
:rewards="rewards"
:currentDayIndex="signInInfo.currentDayIndex"
:style="getGridPosition(index)"
/>
<!-- 箭头覆盖层 -->
<div
style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
"
>
<!-- 根据签到项之间的关系绘制箭头 -->
<img
v-smart-img
v-for="(arrow, arrowIndex) in arrows"
:key="'arrow-' + arrowIndex"
:src="imageUrl('arrowRight')"
:style="arrow.style"
alt="arrow"
style="position: absolute; width: 8vw; object-fit: contain"
/>
</div>
</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 { storeToRefs } from 'pinia'
import {
apiGetMySignInStatus, //
apiPostMySignIn, //
apiPostMySignInSupplement, //
} from '@/api/couple.js'
import itemCenter from '@/components/itemCenter.vue'
import signInItem from './signInItem.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const coupleStore = useCoupleStore()
const {
signInInfo, //
} = storeToRefs(coupleStore)
const langStore = useLangStore()
//
const currentLangType = computed(() => {
return langStore.selectedLang?.type || 'en'
})
const isLoading = ref(true) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
//
const Days = ref(0)
const Hours = ref(0)
const Minutes = ref(0)
const Seconds = ref(0)
let countdownTimer = 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)
Days.value = Math.floor(totalSeconds / (24 * 3600))
Hours.value = formatTime(Math.floor((totalSeconds % (24 * 3600)) / 3600))
Minutes.value = formatTime(Math.floor((totalSeconds % 3600) / 60))
Seconds.value = formatTime(Math.floor(totalSeconds % 60))
}
//
const startCountdown = () => {
getCountdown() //
countdownTimer = setInterval(getCountdown, 1000)
}
//
const getGridPosition = (index) => {
//
const positions = [
{ gridArea: '1/1' }, //1
{ gridArea: '1/2' }, //1
{ gridArea: '2/2' }, //2
{ gridArea: '2/1' }, //2
{ gridArea: '3/1' }, //3
{ gridArea: '3/2' }, //3
{ gridArea: '4/2' }, //4
]
return positions[index]
}
//
const arrows = computed(() => {
const items = signInInfo.value?.dailyConfigs || []
const arrowList = []
//
for (let i = 0; i < items.length - 1; i++) {
//
switch (i) {
case 0: // 1 -> 2
arrowList.push({
style: 'top: 10.6%; left: calc(50%); transform: translateX(-50%);',
})
break
case 1: // 2 -> 3
arrowList.push({
style:
'top: calc(10.6% * 2 + 4.5vw); left: 75%; transform: translateX(-50%) rotate(90deg);',
})
break
case 2: // 3 -> 4
arrowList.push({
style:
'top: calc(10.6% * 3 + 4.5vw + 5.5vw); left: 50%; transform: translateX(-50%) rotate(180deg);',
})
break
case 3: // 4 -> 5
arrowList.push({
style:
'top: calc(10.6% * 4 + 4.5vw * 2 + 4vw); left: 25%; transform: translateX(-50%) rotate(90deg);',
})
break
case 4: // 5 -> 6
arrowList.push({
style:
'top: calc(10.6% * 5 + 4.5vw * 2 + 4vw * 2); left: 50%; transform: translateX(-50%);',
})
break
case 5: // 6 -> 7
arrowList.push({
style:
'top: calc(10.6% * 6 + 4.5vw * 3 + 4vw * 2); left: 75%; transform: translateX(-50%) rotate(90deg);',
})
break
}
}
return arrowList
})
//
const getSignInStatus = async () => {
try {
const response = await apiGetMySignInStatus()
if (response.status) {
signInInfo.value = response.body || {}
}
} catch (error) {
console.error('Failed to get sign in status:', error)
throw error
}
}
//
onMounted(() => {
getSignInStatus() //
startCountdown() //
})
onUnmounted(() => {
//
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = 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;
}
</style>

View File

@ -0,0 +1,396 @@
<template>
<div style="width: 100%; position: relative">
<itemCenter
:imgUrl="imageUrl('rewardBg')"
:contentStyle="`inset: 4vw 6vw 10vw;`"
:class="{ 'grayscale-container': rewards.dayIndex > currentDayIndex || !rewards.isSigned }"
>
<!-- 奖品展示 -->
<div style="width: 100%; height: 100%" :style="layerStyle">
<!-- 奖品项 -->
<itemCenter
v-for="(goods, index) in dailyConfigs"
:imgUrl="imageUrl('rewardGoodsBg')"
:contentStyle="`flex-direction: column;justify-content: space-between;align-items: center;`"
:style="getGridPosition(index)"
>
<div
style="
width: 100%;
min-width: 0;
aspect-ratio: 1/0.95;
display: flex;
justify-content: center;
align-items: center;
"
>
<img
v-smart-img
:src="goods.cover"
alt=""
style="width: 50%; display: block; object-fit: cover"
/>
</div>
<div
style="margin-top: -0.5vw; display: flex; justify-content: center; align-items: center"
>
<div style="font-size: 0.5em; font-weight: 600; color: #ff3b76">
{{ formatRewardDisplay(goods.type, goods) }}
</div>
</div>
</itemCenter>
</div>
<!-- 领取条件 -->
<div
style="position: absolute; bottom: -10vw; width: 60%; height: 10vw"
@click="postSignIn(rewards)"
>
<div
v-if="rewards.isSigned"
style="
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ff79a1;
font-weight: 600;
"
>
Received
</div>
<div
v-else
style="
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
"
>
<img
v-smart-img
v-if="rewards.isPaid"
src="/src/assets/icon/coin.png"
alt=""
style="width: 1.3em; min-width: 0; display: block; object-fit: cover"
/>
<div style="color: #ff79a1; font-weight: 600">
{{ rewards.isPaid ? rewards.paidCost : 'Free' }}
</div>
</div>
</div>
</itemCenter>
<!-- 补签 -->
<div
v-if="rewards.dayIndex < currentDayIndex && !rewards.isSigned"
style="
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
"
@click="postSignInSupplement(rewards.dayIndex)"
>
<itemCenter
style="width: 80%"
:imgUrl="imageUrl('makeUpBtBg')"
:contentStyle="`inset: 0;flex-direction: column;`"
>
<div style="color: #ff79a1; font-weight: 600">Make-up</div>
<div style="display: flex; justify-content: center; align-items: center">
<img
v-smart-img
src="/src/assets/icon/coin.png"
alt=""
style="width: 1.3em; min-width: 0; display: block; object-fit: cover"
/>
<div style="color: #ff79a1; font-weight: 600">*{{ rewards.paidCost + 500 }}</div>
</div>
</itemCenter>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { handleAvatarImageError } from '@/utils/imageHandler.js'
import { useRoute, useRouter } from 'vue-router'
import { useLangStore } from '@/stores/lang'
import { useI18n } from 'vue-i18n'
import { getPngUrl } from '@/config/imagePaths.js'
import { useCoupleStore } from '@/stores/couple'
import { storeToRefs } from 'pinia'
import { formatRewardDisplay } from '@/utils/rewardFormatter.js'
import {
apiGetMyLotteryTicketCount, //
apiGetUserFragmentBackpack, //
apiGetMySignInStatus, //
apiPostMySignIn, //
apiPostMySignInSupplement, //
} from '@/api/couple.js'
import itemCenter from '@/components/itemCenter.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const coupleStore = useCoupleStore()
const {
crystal, //
debris, //
TipsShow, //
needCheckInTipsShow, //
signInSuccessShow, //
signInInfo, //
} = storeToRefs(coupleStore)
const langStore = useLangStore()
//
const currentLangType = computed(() => {
return langStore.selectedLang?.type || 'en'
})
const isLoading = ref(true) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
//
const props = defineProps({
//
rewards: {
type: Object,
default: () => ({}),
},
//
currentDayIndex: {
type: Number,
default: 0,
},
})
//
const dailyConfigs = computed(() => {
let configs = props.rewards?.addActivityPropsGroup?.activityRewardProps
if (props.rewards?.lotteryTicketCount > 0) {
return [
...configs,
{
type: 'TICKET',
cover: imageUrl('crystal'),
content: props.rewards.lotteryTicketCount,
},
]
} else {
return configs
}
})
//
const layerStyle = computed(() => {
let length = dailyConfigs.value.length
switch (length) {
case 1:
return `display:flex;justify-content:center;align-items:center;width: 18vw;`
case 2:
return `display: grid;grid-template-columns: repeat(2, 1fr);grid-template-rows: repeat(2, 1fr);`
case 3:
return `display: grid;grid-template-columns: repeat(2, 1fr);grid-template-rows: repeat(2, 1fr);`
case 4:
return `display: grid;grid-template-columns: repeat(2, 1fr);grid-template-rows: repeat(2, 1fr);`
default:
return `display: grid;grid-template-columns: repeat(2, 1fr);grid-template-rows: repeat(2, 1fr);`
}
})
//
const getGridPosition = (index) => {
let length = dailyConfigs.value.length
let gridPosition = []
switch (length) {
case 1:
gridPosition = []
break
case 2:
gridPosition = [
{ gridArea: '1/1', position: 'relative', top: '2vw' }, //1
{ gridArea: '2/2' }, //2
]
break
case 3:
gridPosition = [
{ gridArea: '1/1', position: 'relative', left: '2vw' }, //1
{ gridArea: '2/1', position: 'relative' }, //2
{ gridArea: '2/2', position: 'relative', left: '2vw', bottom: '2vw' }, //2
]
break
case 4:
gridPosition = [
{ gridArea: '1/1' }, //1
{ gridArea: '1/2' }, //1
{ gridArea: '2/1' }, //2
{ gridArea: '2/2' }, //2
]
break
default:
gridPosition = []
break
}
return gridPosition[index]
}
//
const getSignInStatus = async () => {
try {
const response = await apiGetMySignInStatus()
if (response.status) {
signInInfo.value = response.body || {}
}
} catch (error) {
console.error('Failed to get sign in status:', error)
throw error
}
}
//
const getMyLotteryTicketCount = async () => {
try {
const response = await apiGetMyLotteryTicketCount('2004471533988167125')
if (response.status) {
crystal.value = response.body || 0
}
} catch (error) {
showError(error.message)
}
}
//
const getUserFragmentBackpack = async () => {
try {
const response = await apiGetUserFragmentBackpack({
userId: getUserId(),
// userId: '1957345312961527809',
fragmentsIds: ['2011710323414081538'],
})
if (response.status) {
debris.value = response.body?.[0]?.quantity || 0
}
} catch (error) {
showError(error.message)
}
}
//
const updateNumbers = () => {
getSignInStatus() //
getMyLotteryTicketCount() //
getUserFragmentBackpack() //
}
//
const postSignIn = async (rewards) => {
if (rewards.isSigned) return //
try {
const response = await apiPostMySignIn({ dayIndex: rewards.dayIndex })
if (response.status) {
signInSuccessShow.value = true //
updateNumbers() //
}
} catch (error) {
if (error.response.errorCode == 5000) {
TipsShow.value = true //
} else if (error.response.errorCode == 3283) {
needCheckInTipsShow.value = true //
}
console.error('签到失败:', error)
}
}
//
const postSignInSupplement = async (dayIndex) => {
try {
const response = await apiPostMySignInSupplement({ dayIndex })
if (response.status) {
signInSuccessShow.value = true //
updateNumbers() //
}
} catch (error) {
if (error.response.errorCode == 5000) {
TipsShow.value = true //
} else if (error.response.errorCode == 3283) {
needCheckInTipsShow.value = true //
}
console.error('补签失败:', error)
}
}
</script>
<style lang="scss" scoped>
* {
color: black;
}
.grayscale-container {
filter: grayscale(100%) brightness(90%);
pointer-events: none;
}
.showText {
color: #fff;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.top1Text {
font-size: 0.75em;
}
.text {
font-size: 0.6em;
}
@media screen and (max-width: 360px) {
* {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
* {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
* {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
* {
font-size: 32px;
}
}
</style>

View File

@ -0,0 +1,174 @@
<template>
<div style="padding: 4vw 0">
<!-- 栏目按钮 -->
<div
style="
width: 100vw;
padding: 0 4vw;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10vw;
"
>
<img
v-smart-img
:src="imageUrl(getImgName('rankingWeeklyBt'))"
alt=""
style="flex: 1; min-width: 0; display: block; object-fit: cover"
@click="changeTag('weekly')"
/>
<img
v-smart-img
:src="imageUrl(getImgName('rankingSeasonBt'))"
alt=""
style="flex: 1; min-width: 0; display: block; object-fit: cover"
@click="changeTag('season')"
/>
</div>
<!-- 排行榜 -->
<div>
<!-- 前三名 -->
<!-- 从第4名开始 -->
</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 { useThrottle } from '@/utils/useDebounce.js'
import { storeToRefs } from 'pinia'
import {
apiGetCpRanking, // 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 langStore = useLangStore()
//
const currentLangType = computed(() => {
return langStore.selectedLang?.type || 'en'
})
const isLoading = ref(true) //
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
//
const getImgName = (filename) => {
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
}
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() //
break
case 'season':
if (seasonShow.value) return
weeklyShow.value = false
seasonShow.value = true
apiGetRanking() //
break
}
}
const RankingLoadmore = ref(null) //
const rankingPageNo = ref(1) //
const Ranking = ref([]) //
const rankingAdd = ref([]) //
//
const rankingTotal = computed(() => {
return [...Ranking.value, ...rankingAdd.value]
})
const myRanking = ref({}) //
// 3
const RankingHasTop3 = computed(() => {
let RankingShow = [...Ranking.value]
if (Ranking.value.length < 3) {
let addNullUser = Array.from({ length: 3 - Ranking.value.length }, () => ({
avatar: '',
nickname: '',
quantity: '',
}))
RankingShow.push(...addNullUser)
}
return RankingShow
})
//
const showRanking = computed(() => {
return Ranking.value.filter((_, index) => index >= 10)
// 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.message)
}
}
//
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()
}
})
})
//
onMounted(() => {
apiGetRanking() //
//
if (RankingLoadmore.value) {
console.log('监控加载模块')
observer.observe(RankingLoadmore.value)
}
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,293 @@
<!-- 告白墙弹幕 -->
<template>
<div
style="position: relative; width: 100%; height: 32vw; overflow: hidden; pointer-events: none"
>
<!-- 双行弹幕轨道 -->
<div
v-for="(track, index) in visibleTracks"
:key="index"
style="position: absolute; width: 100%; height: 50px"
:style="{ top: `calc(${index} * 16vw)` }"
>
<div
v-for="item in track"
:key="item.id"
class="barrage-item"
style="
position: absolute;
border-radius: 26px;
padding: 8px;
display: flex;
align-items: center;
gap: 4px;
backdrop-filter: blur(1px);
"
:style="getBarrageStyle(item)"
@animationend="handleAnimationEnd(item.id)"
>
<!-- 发送人 -->
<img
v-smart-img
:src="item.senderAvatar || ''"
alt=""
style="
width: 8vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
"
@error="handleAvatarImageError"
/>
<!-- 接收人 -->
<img
v-smart-img
:src="item.receiverAvatar || ''"
alt=""
style="
width: 8vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
"
@error="handleAvatarImageError"
/>
<div
style="
white-space: nowrap;
font-weight: 700;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(
180deg,
#fff9a9 26.92%,
#ed7d0a 52.08%,
#fffab5 73.08%
);
"
>
{{ item.content }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { handleAvatarImageError } from '@/utils/imageHandler.js'
const { locale, t } = useI18n()
const props = defineProps({
barrageList: {
type: Array,
required: true,
default: () => [],
},
speed: {
type: Number,
default: 80, //
},
maxItems: {
type: Number,
default: 4, //
},
loopInterval: {
type: Number,
default: 2000, // (ms)
},
})
const tracks = ref([[], []]) //
const idCounter = ref(0) // ID
const currentIndex = ref(0) //
//
const visibleTracks = computed(() => tracks.value.filter((track) => track.length > 0))
//
const getBarrageStyle = (item) => {
const isRTL = locale.value === 'ar'
return {
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
'--start-pos': `${item.startPos}px`,
borderRadius: '200px',
background: 'linear-gradient(180deg, #FFACBB 0%, rgba(255, 172, 187, 0.00) 58.38%), #FF4768',
boxShadow:
'0 1px 4.5px 0 rgba(0, 0, 0, 0.10), -1px 0 5px 0 rgba(255, 255, 255, 0.70) inset, -3.5px 0 7.5px 0 rgba(255, 255, 255, 0.20) inset, 1px 0 5px 0 rgba(255, 255, 255, 0.70) inset, 3.5px 0 7.5px 0 rgba(255, 255, 255, 0.20) inset',
// RTL
...(isRTL
? {
right: '0',
left: 'auto',
transform: 'translateX(-100vw)',
}
: {
left: '0',
right: 'auto',
transform: 'translateX(100vw)',
}),
}
}
//
const generateBarrageProps = (item) => {
const textLength = item.content.length
return {
duration: Math.max(8 - textLength * 0.2, 5) * (100 / props.speed), // 0.25
startPos: 0, //
}
}
//
const addToTrack = (item) => {
//
const trackIndex = tracks.value.reduce(
(minIndex, track, index) => (track.length < tracks.value[minIndex].length ? index : minIndex),
0,
)
if (tracks.value[trackIndex].length < props.maxItems) {
tracks.value[trackIndex].push({
...item,
id: idCounter.value++, //
delay: 0, // 0-4
...generateBarrageProps(item),
})
}
}
//
const cleanCompleted = () => {
tracks.value.forEach((track) => {
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].completed) {
track.splice(i, 1)
}
}
})
}
//
const handleAnimationEnd = (id) => {
tracks.value.forEach((track) => {
const item = track.find((item) => item.id === id)
if (item) item.completed = true
})
}
//
const getNextBarrage = () => {
if (props.barrageList.length === 0) return null
const item = props.barrageList[currentIndex.value] //
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length //0
return { ...item } //
}
//
let barrageInterval = null
//
const startBarrage = () => {
barrageInterval = setInterval(() => {
cleanCompleted() //
const nextBarrage = getNextBarrage() //
if (nextBarrage) {
addToTrack(nextBarrage) //
}
}, props.loopInterval)
}
onMounted(() => {
startBarrage() //
})
onUnmounted(() => {
clearInterval(barrageInterval) //
})
//
watch(
() => props.barrageList,
(newList) => {
currentIndex.value = 0 //
clearInterval(barrageInterval) //
//
if (newList.length !== 0) {
startBarrage() //
}
},
{ deep: true },
)
</script>
<style scoped>
/* 样式保持不变(同之前版本) */
.barrage-item {
will-change: transform;
animation: moveLeft linear forwards;
}
@keyframes moveLeft {
0% {
transform: translateX(100vw);
}
100% {
transform: translateX(-100%);
}
}
/* RTL支持 - 镜像弹幕动画 */
[dir='rtl'] .barrage-item {
animation: moveRight linear forwards;
}
@keyframes moveRight {
0% {
transform: translateX(-100vw);
}
100% {
transform: translateX(100%);
}
}
@media screen and (max-width: 360px) {
* {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
* {
font-size: 14px;
}
}
@media screen and (min-width: 768px) {
* {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
* {
font-size: 32px;
}
}
</style>

View File

@ -0,0 +1,285 @@
<template>
<div style="width: 100%">
<div style="position: relative">
<img
v-smart-img
:src="BorderImgUrl"
alt=""
width="100%"
style="display: block; position: relative; z-index: 2"
/>
<!-- 头像 -->
<div
style="
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
"
:style="{ height: avatarContainerHeight }"
>
<div :style="{ width: avatarWidth }">
<img
v-smart-img
:src="avatarUrl || ''"
alt=""
width="100%"
style="display: block; border-radius: 50%; aspect-ratio: 1/1; object-fit: cover"
@error="handleAvatarImageError"
/>
</div>
</div>
<!-- 用户名和贡献值 -->
<div
style="
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
"
:style="{ height: bottomSectionHeight }"
>
<div
style="display: flex; justify-content: center; align-items: center"
:style="{
width: nameValueWidth,
height: nameValueHeight,
}"
>
<div style="font-weight: 700" class="showText" :class="isTopOne ? 'top1Text' : 'text'">
{{ name }}
</div>
</div>
<div
style="
padding: 1px 2px;
display: flex;
justify-content: center;
align-items: center;
"
:style="{
width: distributionValueWidth,
height: distributionValueHeight,
}"
>
<div
style="font-weight: 590"
class="showText"
:class="isTopOne ? 'top1Text' : 'text'"
:style="{
color: ranking == '1' ? `#FFE601` : '#FFF',
}"
>
${{ distributionValue }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { handleAvatarImageError } from '@/utils/imageHandler.js'
import { computed } from 'vue'
const props = defineProps({
ranking: {
type: String,
default: '',
},
avatarUrl: {
type: String,
default: '',
},
BorderImgUrl: {
type: String,
required: true,
},
name: {
type: String,
default: '',
},
distributionValue: {
type: String,
default: '',
},
})
//
const isTopOne = computed(() => {
return props.ranking === '1'
})
//
const avatarContainerHeight = computed(() => {
switch (props.ranking) {
case '1':
return '70%'
case '2':
return '60%'
case '3':
return '60%'
default:
return '60%'
}
})
//
const avatarWidth = computed(() => {
switch (props.ranking) {
case '1':
return '43%'
case '2':
return '77%'
case '3':
return '77%'
default:
return '77%'
}
})
//
const bottomSectionHeight = computed(() => {
switch (props.ranking) {
case '1':
return '35%'
case '2':
return '39%'
case '3':
return '40%'
default:
return '39%'
}
})
//
const nameValueWidth = computed(() => {
switch (props.ranking) {
case '1':
return '30%'
case '2':
return '60%'
case '3':
return '60%'
default:
return '60%'
}
})
//
const nameValueHeight = computed(() => {
switch (props.ranking) {
case '1':
return '25%'
case '2':
return '25%'
case '3':
return '25%'
default:
return '25%'
}
})
//
const distributionValueWidth = computed(() => {
switch (props.ranking) {
case '1':
return '25%'
case '2':
return '40%'
case '3':
return '40%'
default:
return '40%'
}
})
//
const distributionValueHeight = computed(() => {
switch (props.ranking) {
case '1':
return '35%'
case '2':
return '20%'
case '3':
return '20%'
default:
return '20%'
}
})
//
const coinWidth = computed(() => {
switch (props.ranking) {
case '1':
return '1em'
case '2':
return '0.9em'
case '3':
return '0.9em'
default:
return '0.9em'
}
})
</script>
<style lang="scss" scoped>
* {
color: black;
}
.showText {
color: #fff;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.top1Text {
font-size: 0.75em;
}
.text {
font-size: 0.6em;
}
@media screen and (max-width: 360px) {
* {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
* {
font-size: 16px;
}
}
@media screen and (min-width: 768px) {
* {
font-size: 24px;
}
}
@media screen and (min-width: 1024px) {
* {
font-size: 32px;
}
}
</style>

File diff suppressed because it is too large Load Diff