feat(stargalaxy): add container with data loading, decoration, podium + scattered orchestration
This commit is contained in:
parent
bfb15c57e5
commit
4ad16dd91f
275
frontend/pages/square/components/StarGalaxy/index.vue
Normal file
275
frontend/pages/square/components/StarGalaxy/index.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<view class="stargalaxy-container">
|
||||
<!-- 装饰层:粉红渐变 overlay + 樱花粉光晕 + 暖黄光晕 -->
|
||||
<view class="decoration-layer">
|
||||
<view class="halo-pink"></view>
|
||||
<view class="halo-yellow"></view>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="title">★ 星河 ★</view>
|
||||
|
||||
<!-- Loading 骨架 -->
|
||||
<view v-if="loading" class="skeleton-grid">
|
||||
<view v-for="i in 3" :key="'p' + i" class="skeleton-podium"></view>
|
||||
<view v-for="i in 9" :key="'s' + i" class="skeleton-ring"></view>
|
||||
</view>
|
||||
|
||||
<!-- Error -->
|
||||
<view v-else-if="error" class="error-state">
|
||||
<text class="error-text">加载失败,点击重试</text>
|
||||
<view class="retry-btn" @click="loadData">重试</view>
|
||||
</view>
|
||||
|
||||
<!-- Empty:数据为空 -->
|
||||
<view v-else-if="items.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无星河数据</text>
|
||||
</view>
|
||||
|
||||
<!-- Success -->
|
||||
<template v-else>
|
||||
<!-- 颁奖台:TOP 1-3 -->
|
||||
<view class="podium-row">
|
||||
<PodiumCard
|
||||
v-for="(item, i) in podiumItems"
|
||||
:key="item.id || i"
|
||||
:item="item"
|
||||
:rank="i + 4"
|
||||
:size="PODIUM_SIZES[i + 4]"
|
||||
:style="PODIUM_POSITIONS[i + 4]"
|
||||
@cardClick="handleCardClick"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 散落 9 item:TOP 4-12 -->
|
||||
<ScatteredRanks
|
||||
:items="scatteredItems"
|
||||
:positions="ringPositions"
|
||||
@cardClick="handleCardClick"
|
||||
/>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="footer-hint">每日 0:00 更新榜单</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import PodiumCard from './PodiumCard.vue'
|
||||
import ScatteredRanks from './ScatteredRanks.vue'
|
||||
import { generateRingPositions } from './config.js'
|
||||
import { getHotRankingApi } from '@/utils/api.js'
|
||||
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'
|
||||
|
||||
const emit = defineEmits(['cardClick'])
|
||||
|
||||
// 颁奖台 3 个位置的尺寸和位置(与 ScatteredRanks 的 rpx 单位对齐)
|
||||
const PODIUM_SIZES = {
|
||||
4: { width: 240, height: 260 }, // TOP 1
|
||||
5: { width: 200, height: 200 }, // TOP 2
|
||||
6: { width: 192, height: 192 }, // TOP 3
|
||||
}
|
||||
const PODIUM_POSITIONS = {
|
||||
4: { position: 'absolute', top: '400rpx', left: '50%', transform: 'translateX(-50%)' },
|
||||
5: { position: 'absolute', top: '120rpx', left: '60rpx' },
|
||||
6: { position: 'absolute', top: '150rpx', right: '60rpx' },
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const ringPositions = generateRingPositions()
|
||||
|
||||
const podiumItems = computed(() => items.value.slice(0, 3))
|
||||
const scatteredItems = computed(() => items.value.slice(3, 12))
|
||||
|
||||
async function resolveUrl(item) {
|
||||
const cover = item.cover_url || item.cover_image || ''
|
||||
if (cover) {
|
||||
item.cover_url = await getAssetCoverRealUrl(cover)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getHotRankingApi('displaying', null, 1, 12)
|
||||
if (res && res.code === 200 && res.data?.items) {
|
||||
items.value = await Promise.all(
|
||||
res.data.items.map(async (item) => {
|
||||
return await resolveUrl({ ...item, id: item.id || item.asset_id })
|
||||
})
|
||||
)
|
||||
} else {
|
||||
items.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[StarGalaxy] 加载失败', e?.message ?? e)
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(item) {
|
||||
emit('cardClick', item)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理(如有 timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stargalaxy-container {
|
||||
position: relative;
|
||||
width: 750rpx;
|
||||
min-height: 1440rpx;
|
||||
padding-bottom: 200rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.decoration-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background: linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 0%, #FF9C9C 86%, #FF2024 100%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.halo-pink {
|
||||
position: absolute;
|
||||
top: 580rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 680rpx;
|
||||
height: 340rpx;
|
||||
background: #F3D3E3;
|
||||
border-radius: 50%;
|
||||
filter: blur(60rpx);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.halo-yellow {
|
||||
position: absolute;
|
||||
top: 100rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
background: #FFFABD;
|
||||
border-radius: 50%;
|
||||
filter: blur(50rpx);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
color: #FFFABD;
|
||||
text-shadow: -1rpx 1rpx 4rpx rgba(206, 9, 9, 0.84);
|
||||
letter-spacing: 8rpx;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.podium-row {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 200rpx 60rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-podium {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12rpx;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
background-image: linear-gradient(90deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.25) 50%, rgba(255,255,255,0.05) 100%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.skeleton-ring {
|
||||
width: 60rpx;
|
||||
height: 80rpx;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8rpx;
|
||||
background-image: linear-gradient(90deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.25) 50%, rgba(255,255,255,0.05) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.error-state {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 600rpx 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 600rpx 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-block;
|
||||
padding: 16rpx 60rpx;
|
||||
background: linear-gradient(135deg, #FFD700, #FF6B6B);
|
||||
color: #fff;
|
||||
border-radius: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.3);
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user