28 KiB
StarGalaxy 组件实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 square 页面「星河」tab 渲染一个 3D 倾斜椭圆轨道 + 9 item 顺时针旋转 + TOP 1-3 颁奖台的排行榜组件
Architecture: 三层拆分 — index.vue(容器/数据/装饰)+ PodiumCard.vue(TOP 1-3 大卡)+ ScatteredRanks.vue(TOP 4-12 9 个散落 item)+ config.js(9 slot 位置公式)。单组 @keyframes orbit 配合 9 个不同 animation-delay 实现旋转。复用现有 getHotRankingApi 和 getAssetCoverRealUrl。
Tech Stack: Vue 3 Composition API + uni-app + CSS keyframes(uni-app 跨端支持 transform)。无单元测试框架(项目 package.json 只有 vuex),通过 H5 端 npm run dev:h5 可视化验证。
Spec: docs/superpowers/specs/2026-06-10-square-stargalaxy-component-design.md
文件结构
| 路径 | 类型 | 职责 |
|---|---|---|
frontend/pages/square/components/StarGalaxy/index.vue |
新建 | 容器组件:数据加载、装饰层、3D 椭圆轨道 SVG、TOP 1-3 颁奖台 + ScatteredRanks 编排 |
frontend/pages/square/components/StarGalaxy/PodiumCard.vue |
新建 | TOP 1-3 大卡(钻石渐变外框 + cover + 下方 TOP N 标签 + 可选皇冠) |
frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue |
新建 | TOP 4-12 9 个散落 item(cover + 上方 TOP N 标签) |
frontend/pages/square/components/StarGalaxy/config.js |
新建 | 9 slot 位置/translate/scale 公式 + KEYFRAMES 常量 |
frontend/pages/square/square.vue |
修改 | 在「星河」tab 分支渲染 <StarGalaxy />,并设置 onShow 重置 activeContentTab 为 "xinghe" |
每个新文件职责单一,文件之间通过 props/emit 通信。
Task 1: 创建 config.js
Files:
-
Create:
frontend/pages/square/components/StarGalaxy/config.js -
Step 1.1: 创建目录
mkdir -p frontend/pages/square/components/StarGalaxy
- Step 1.2: 写入 config.js
// StarGalaxy 组件配置常量
// 9 个散落 item 沿 65° 倾斜椭圆轨道排列
// slot 0 = 最前(底部,TOP 4 起始位置),slot 4-5 = 最后(顶部)
export const RING = {
cx: 187, // 椭圆圆心 x
cy: 510, // 椭圆圆心 y
rx: 130, // 水平半径
ry: 55, // 垂直半径(cos(65°) ≈ 0.423,模拟向后倾 65°)
startAngle: 180, // 起始角:slot 0 在正下方
step: 40, // 间隔角(顺时针 = 负方向 → step = -40 在 CSS 中)
}
// item 固定尺寸(label 在 cover 上方)
export const ITEM = {
width: 46, // cover + label 宽度
labelHeight: 14, // 顶部 label 高度
coverHeight: 56, // 底部 cover 高度
gap: 2, // label 与 cover 之间的间距
}
// total: 14 + 2 + 56 = 72
// TOP 6 / TOP 11 推到边缘,避免与 TOP 5/7、TOP 10/12 重叠
// TOP 6 推到屏幕右侧 (321, 488),TOP 11 推到屏幕左侧 (8, 488)
export const OVERRIDES = {
6: { x: 321, y: 488 },
11: { x: 8, y: 488 },
}
// 计算 y 在椭圆前/后位置的比例(0 = 最后, 1 = 最前)
function yFactor(y) {
// y=458 (back) → 0; y=565 (front) → 1
return Math.max(0, Math.min(1, (y - 458) / 107))
}
// 生成 9 个 item 的位置配置
export function generateRingPositions() {
return Array.from({ length: 9 }, (_, i) => {
const rank = i + 4
const alpha = (RING.startAngle + i * RING.step) * Math.PI / 180
const baseX = RING.cx + RING.rx * Math.sin(alpha) - ITEM.width / 2
const baseY = RING.cy - RING.ry * Math.cos(alpha) - (ITEM.labelHeight + ITEM.gap + ITEM.coverHeight) / 2
const ovr = OVERRIDES[rank]
const y = ovr?.y ?? baseY
const x = ovr?.x ?? baseX
const f = yFactor(y)
return {
rank,
x,
y,
scale: 0.75 + 0.40 * f, // 0.75 → 1.15
zIndex: Math.round(f * 10), // 0 → 10
}
})
}
// 单组 @keyframes(CSS 模板字符串)
// translate 值是相对 slot 0 中心 (164, 530) 的偏移
export const ORBIT_KEYFRAMES = `
@keyframes orbit {
0% { transform: translate(0,0) scale(1.15); }
11.11% { transform: translate(84px,-13px) scale(1.05); }
22.22% { transform: translate(157px,-43px) scale(0.95); }
33.33% { transform: translate(113px,-83px) scale(0.85); }
44.44% { transform: translate(45px,-107px) scale(0.75); }
55.55% { transform: translate(-45px,-107px) scale(0.75); }
66.66% { transform: translate(-113px,-83px) scale(0.85); }
77.77% { transform: translate(-156px,-43px) scale(0.95); }
88.88% { transform: translate(-84px,-13px) scale(1.05); }
100% { transform: translate(0,0) scale(1.15); }
}
@keyframes crownPulse {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.15); }
}
`
// 各 slot 对应 ring-item 类的 r0..r8 的 delay(负值让 item 起始位置 = slot)
export const RING_DELAYS = [0, -4, -8, -12, -16, -20, -24, -28, -32]
- Step 1.3: 验证文件创建
ls -la frontend/pages/square/components/StarGalaxy/config.js
Expected: 文件存在,~80 行
- Step 1.4: 提交
cd frontend
git add pages/square/components/StarGalaxy/config.js
git commit -m "feat(stargalaxy): add ring position config and orbit keyframes"
Task 2: 创建 PodiumCard.vue
Files:
-
Create:
frontend/pages/square/components/StarGalaxy/PodiumCard.vue -
Step 2.1: 写入 PodiumCard.vue
<template>
<view class="podium-card" :class="['podium-' + rank]" :style="cardStyle" @click="handleClick">
<!-- 钻石渐变外框 -->
<view class="diamond-frame" :style="frameStyle"></view>
<!-- 藏品主图(不规则圆角) -->
<view class="cover-wrap">
<image
class="cover-image"
:src="item.cover_url || item.cover_image || ''"
mode="aspectFill"
/>
<!-- 青绿色高光 overlay -->
<view class="cover-highlight"></view>
</view>
<!-- 钻石渐变边框层 -->
<view class="diamond-border"></view>
<!-- 皇冠(仅 TOP 1) -->
<view v-if="rank === 4" class="crown">👑</view>
<!-- TOP N 标签(cover 下方居中) -->
<view class="top-label" :style="labelStyle">TOP {{ displayRank }}</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
item: { type: Object, required: true },
rank: { type: Number, required: true, validator: v => v >= 4 && v <= 6 },
size: { type: Object, default: () => ({ width: 120, height: 130 }) },
})
const emit = defineEmits(['cardClick'])
// 内部 rank 4/5/6 对外显示 1/2/3(颁奖台:TOP 1/2/3)
const displayRank = computed(() => props.rank - 3)
// 不同 rank 的标签渐变
const labelGradients = {
4: 'radial-gradient(ellipse, #FFD700, #FFF6A8 30%, #DAA520 100%)', // 金
5: 'radial-gradient(ellipse, #C0C0C0, #E8E8E8 50%, #7A7A7A)', // 银
6: 'radial-gradient(ellipse, #CD7F32, #E8A45C 50%, #A0522D)', // 铜
}
const labelSizes = {
4: { w: 96, h: 22, font: 13 },
5: { w: 78, h: 18, font: 11 },
6: { w: 78, h: 18, font: 11 },
}
// 外框颜色(金/银/铜)
const frameGradients = {
4: 'radial-gradient(ellipse at -10% 5%, #FFD700 0%, #FF3939 32%, #FFEDA5 59%, #FF6B6B 100%)',
5: 'radial-gradient(ellipse at -10% 5%, #C0C0C0 0%, #FF6B6B 32%, #E8E8E8 59%, #9A9A9A 100%)',
6: 'radial-gradient(ellipse at -10% 5%, #CD7F32 0%, #FF3939 32%, #E8A45C 59%, #A0522D 100%)',
}
const cardStyle = {
width: props.size.width + 'rpx',
height: props.size.height + 'rpx',
}
const frameStyle = {
background: frameGradients[props.rank],
}
const labelStyle = {
width: labelSizes[props.rank].w + 'rpx',
height: labelSizes[props.rank].h + 'rpx',
fontSize: labelSizes[props.rank].font + 'rpx',
background: labelGradients[props.rank],
}
function handleClick() {
emit('cardClick', props.item)
}
</script>
<style scoped>
.podium-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.diamond-frame {
position: absolute;
inset: 0;
border-radius: 8rpx 44rpx 8rpx 38rpx;
filter: blur(6rpx);
opacity: 0.7;
}
.cover-wrap {
position: absolute;
inset: 10rpx;
border-radius: 6rpx 40rpx 6rpx 34rpx;
overflow: hidden;
background: #fff;
box-shadow: 4rpx 4rpx 28rpx rgba(127, 7, 7, 0.5);
}
.cover-image {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.cover-highlight {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 42%;
background: linear-gradient(180deg, rgba(83, 244, 211, 0.4) 1%, transparent 70%);
pointer-events: none;
}
.diamond-border {
position: absolute;
inset: 8rpx;
border: 4rpx solid transparent;
border-radius: 8rpx 42rpx 8rpx 36rpx;
background: linear-gradient(135deg, #86BEFF, #FF3939, #88FFCE, #4D9AF8) border-box;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.crown {
position: absolute;
top: -44rpx;
left: 50%;
transform: translateX(-50%);
font-size: 44rpx;
animation: crownPulse 2s ease-in-out infinite;
z-index: 5;
}
.top-label {
position: absolute;
bottom: -4rpx;
left: 50%;
transform: translateX(-50%);
border-radius: 11rpx;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
color: #fff;
text-shadow: 0 1rpx 3rpx rgba(0, 0, 0, 0.5);
box-shadow: 0 6rpx 16rpx rgba(255, 140, 0, 0.5);
z-index: 6;
letter-spacing: 1rpx;
}
</style>
- Step 2.2: 验证文件创建
ls -la frontend/pages/square/components/StarGalaxy/PodiumCard.vue
wc -l frontend/pages/square/components/StarGalaxy/PodiumCard.vue
Expected: ~150 行
- Step 2.3: 提交
cd frontend
git add pages/square/components/StarGalaxy/PodiumCard.vue
git commit -m "feat(stargalaxy): add PodiumCard for TOP 1-3 with gold/silver/bronze labels"
Task 3: 创建 ScatteredRanks.vue
Files:
-
Create:
frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue -
Step 3.1: 写入 ScatteredRanks.vue
<template>
<view class="scattered-ranks">
<!-- 椭圆轨道装饰(虚线) -->
<svg class="orbit-svg" viewBox="0 0 375 170" preserveAspectRatio="none">
<defs>
<linearGradient id="sg-orbit-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
<stop offset="50%" stop-color="rgba(255,255,255,0.4)" />
<stop offset="100%" stop-color="rgba(255,250,189,0.85)" />
</linearGradient>
<radialGradient id="sg-center-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(255,250,189,0.5)" />
<stop offset="100%" stop-color="transparent" />
</radialGradient>
</defs>
<ellipse cx="187" cy="55" rx="80" ry="35" fill="url(#sg-center-glow)" />
<ellipse cx="187" cy="55" rx="130" ry="55" fill="none" stroke="url(#sg-orbit-grad)" stroke-width="1.5" stroke-dasharray="3,3" />
<path d="M 57,55 A 130,55 0 0,0 317,55" stroke="rgba(255,250,189,0.85)" stroke-width="2.5" fill="none" />
</svg>
<!-- 9 个散落 item -->
<view
v-for="(p, i) in positions"
:key="p.rank"
class="ring-item"
:class="'r' + (p.rank - 4)"
:style="ringItemStyle(p)"
@click="handleClick(items[i])"
>
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image
class="cover-image"
:src="(items[i]?.cover_url) || (items[i]?.cover_image) || ''"
mode="aspectFill"
/>
</view>
</view>
</template>
<script setup>
import { ORBIT_KEYFRAMES, RING_DELAYS } from './config.js'
const props = defineProps({
items: { type: Array, required: true }, // length 9
positions: { type: Array, required: true }, // from generateRingPositions()
})
const emit = defineEmits(['cardClick'])
// 静态 base 位置:slot 0 中心 (187, 565),item 46×72,top-left = (164, 530)
const BASE_X = 164
const BASE_Y = 530
function ringItemStyle(p) {
return {
left: BASE_X + 'rpx',
top: BASE_Y + 'rpx',
zIndex: p.zIndex,
transform: `scale(${p.scale})`,
animationDelay: RING_DELAYS[p.rank - 4] + 's',
}
}
function formatLabel(rank) {
return 'TOP ' + rank
}
function handleClick(item) {
if (item) emit('cardClick', item)
}
</script>
<style scoped>
.scattered-ranks {
position: relative;
width: 750rpx;
height: 720rpx;
pointer-events: none;
}
.orbit-svg {
position: absolute;
top: 390rpx;
left: 0;
width: 750rpx;
height: 340rpx;
pointer-events: none;
z-index: 0;
}
.ring-item {
position: absolute;
width: 46rpx;
height: 72rpx; /* 14 label + 2 gap + 56 cover */
transform-origin: center;
pointer-events: auto;
cursor: pointer;
display: flex;
flex-direction: column;
animation: orbit 36s linear infinite;
}
.top-label {
width: 46rpx;
height: 14rpx;
background: radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8);
border-radius: 7rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 7rpx;
font-weight: 900;
color: #FFFABD;
text-shadow: -1rpx 1rpx 2rpx rgba(206, 9, 9, 0.84);
}
/* TOP 4 label 是金渐变(最显眼) */
.r0 .top-label {
background: radial-gradient(ellipse, #FFFFFF, #FFFABD 30%, #4D9AF8 100%);
box-shadow: 0 4rpx 12rpx rgba(255, 250, 189, 0.55);
font-size: 8rpx;
}
.cover-image {
margin-top: 2rpx;
width: 46rpx;
height: 56rpx;
border-radius: 5rpx;
box-shadow: 3rpx 3rpx 6rpx rgba(198, 13, 13, 0.45);
}
.r0 .cover-image {
box-shadow: 0 12rpx 28rpx rgba(255, 32, 36, 0.5), 0 0 24rpx rgba(255, 250, 189, 0.55);
}
</style>
<style>
/* 关键帧:放在非 scoped 块中,让所有 ring-item 共享 */
@keyframes orbit {
0% { transform: translate(0,0) scale(1.15); }
11.11% { transform: translate(84px,-13px) scale(1.05); }
22.22% { transform: translate(157px,-43px) scale(0.95); }
33.33% { transform: translate(113px,-83px) scale(0.85); }
44.44% { transform: translate(45px,-107px) scale(0.75); }
55.55% { transform: translate(-45px,-107px) scale(0.75); }
66.66% { transform: translate(-113px,-83px) scale(0.85); }
77.77% { transform: translate(-156px,-43px) scale(0.95); }
88.88% { transform: translate(-84px,-13px) scale(1.05); }
100% { transform: translate(0,0) scale(1.15); }
}
@keyframes crownPulse {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.15); }
}
/* 可访问性:减少动画 */
@media (prefers-reduced-motion: reduce) {
.ring-item,
.crown {
animation: none !important;
}
}
</style>
- Step 3.2: 验证
ls -la frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue
- Step 3.3: 提交
cd frontend
git add pages/square/components/StarGalaxy/ScatteredRanks.vue
git commit -m "feat(stargalaxy): add ScatteredRanks with 9 ring items + 36s orbit animation"
Task 4: 创建 StarGalaxy/index.vue 容器
Files:
-
Create:
frontend/pages/square/components/StarGalaxy/index.vue -
Step 4.1: 写入 index.vue
<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>
- Step 4.2: 验证文件结构
ls -la frontend/pages/square/components/StarGalaxy/
Expected: 4 files (config.js, PodiumCard.vue, ScatteredRanks.vue, index.vue)
- Step 4.3: 提交
cd frontend
git add pages/square/components/StarGalaxy/index.vue
git commit -m "feat(stargalaxy): add container with data loading, decoration, podium + scattered orchestration"
Task 5: 在 square.vue 中集成 StarGalaxy
Files:
-
Modify:
frontend/pages/square/square.vue:42-49(添加星河分支) -
Step 5.1: 导入 StarGalaxy 组件
在 square.vue 顶部 import 区域(约第 71 行附近)添加:
import StarGalaxy from "./components/StarGalaxy/index.vue";
- Step 5.2: 添加星河分支
在 <view v-if="activeContentTab === 'xingbang'" class="hot-category-wrapper"> 之前,添加新的星河分支(替换原 <!-- 在线榜单区块 - 仅在 星榜 时显示 --> 注释块):
<!-- 星河区块 - 仅在 星河 时显示 -->
<view
v-if="activeContentTab === 'xinghe'"
ref="starGalaxyRef"
class="star-galaxy-wrapper"
>
<StarGalaxy @cardClick="handleCardClick" />
</view>
<!-- 在线榜单区块 - 仅在 星榜 时显示 -->
<view
v-if="activeContentTab === 'xingbang'"
ref="hotCategoryRef"
class="hot-category-wrapper"
>
<HotCategoryBlock @cardClick="handleCardClick" />
</view>
- Step 5.3: 验证 square.vue 语法
cd frontend
npx --no-install eslint pages/square/square.vue 2>/dev/null || echo "无 eslint,跳过"
- Step 5.4: 提交
cd frontend
git add pages/square/square.vue
git commit -m "feat(square): wire StarGalaxy component into 星河 tab"
Task 6: H5 端可视化验证
Files: 无(仅验证)
- Step 6.1: 启动 H5 调试服务
cd frontend
npm run dev:h5
Expected: 浏览器打开 http://localhost:8080(或类似端口),square 页面正常加载。
- Step 6.2: 进入「星河」tab 视觉验证
手动检查清单:
-
顶部「★ 星河 ★」标题可见
-
3 张 TOP 1-3 颁奖台卡片显示,TOP 1 在中央最大,TOP 2 在左上,TOP 3 在右上
-
TOP 1 顶部有皇冠,cover 下方有金渐变「TOP 1」标签
-
TOP 2 下方有银渐变「TOP 2」标签
-
TOP 3 下方有铜渐变「TOP 3」标签
-
下方椭圆轨道上有 9 个 item(TOP 4-12)排成圆环
-
TOP 4 在最前(最大),TOP 8/9 在最后(最小)
-
9 个 item 顺时针缓慢旋转(36s 一圈)
-
点击任一 item 跳到 asset-detail 页
-
Step 6.3: 检查动画
-
打开 DevTools 观察 .ring-item 的 transform 在变化
-
旋转应该是匀速、连续、无卡顿
-
前后 item 的大小差异明显(TOP 4 比 TOP 8 大约 1.5 倍)
-
Step 6.4: 检查错误状态
在 DevTools 中断网(Network → Offline),刷新页面,应看到「加载失败,点击重试」+ 重试按钮。恢复网络点击重试,数据应正常加载。
- Step 6.5: 提交验证记录(如有问题修复)
cd frontend
git status
# 如有修改:
git add -A
git commit -m "fix(stargalaxy): visual verification fixes" || echo "无修改需提交"
Task 7: 跨端兼容性烟测
Files: 无(仅验证)
- Step 7.1: 验证关键 CSS 兼容性
确认以下 CSS 属性能在 uni-app 跨端运行(小程序、H5、APP):
transform: scale()+transform-origin: center— ✅ uni-app 全支持filter: blur()— ✅ H5 + APP 支持,小程序需enable-backdrop-filterradial-gradient— ✅ H5 + APP 支持,小程序有限制@keyframes+animation— ✅ uni-app 全支持
如果小程序有问题,对应的 @media 块需要适配或使用条件编译。
- Step 7.2: 文档记录
在 frontend/pages/square/components/StarGalaxy/README.md 写组件使用说明:
# StarGalaxy 组件
square 页「星河」tab 的排行榜组件。
## 视觉特征
- 65° 倾斜椭圆轨道
- TOP 1-3 颁奖台(cover + 下方标签 + 钻石外框)
- TOP 4-12 9 个散落 item(cover + 上方标签)
- 9 item 顺时针 36s 旋转一圈
- 近大远小(scale 0.75→1.15)
## 数据来源
`getHotRankingApi("displaying", null, 1, 12)` — 与「星榜」共用 API,取前 12 条。
## 文件
- `index.vue` — 容器
- `PodiumCard.vue` — TOP 1-3 大卡
- `ScatteredRanks.vue` — TOP 4-12 9 散落 item
- `config.js` — 位置公式和 keyframes
## 可调参数
- 椭圆倾斜角(config.js: RING.ry)— 改 ry 调整倾斜度
- 旋转周期(ScatteredRanks.vue: animation duration)— 改 36s 调整速度
- 近大远小范围(keyframes scale 0.75→1.15)— 改 0.75/1.15 调整
## 可访问性
- `prefers-reduced-motion: reduce` 时关闭旋转动画
- label 文字使用高对比度的 #FFFABD + 红色 text-shadow
cd frontend
git add pages/square/components/StarGalaxy/README.md
git commit -m "docs(stargalaxy): add component README"
验收清单(最终)
- Task 1-5 全部完成并提交
- Task 6.2 视觉验证清单全部 ✅
- Task 6.3 动画流畅
- Task 6.4 错误状态可重试
- Task 7 README 文档完成
- 跨端验证(至少 H5 通过;小程序/APP 在后续 PR 验证)
- 代码无 ESLint 错误(如有 ESLint 配置)
- 所有提交信息遵循
feat/fix/docs/...Conventional Commits 规范
风险与回退
| 风险 | 回退方案 |
|---|---|
| 旋转动画在低端机卡顿 | 在 keyframes 中加 transform: translateZ(0) 强制 GPU 加速 |
小程序不支持 radial-gradient |
改用 background-image: linear-gradient 模拟或接受色彩降级 |
| 椭圆轨道 SVG 在小程序不显示 | 用纯 CSS border 模拟椭圆装饰 |
| TOP 6/11 推到边缘与不同屏幕宽度冲突 | 在 config.js 用百分比 + 媒体查询自适应 |
后续 PR(v2 增强,不在本计划范围)
- 缩略图懒加载优化(
<image lazy-load>) - 9 item 悬停暂停动画
- 减少运动偏好支持进一步增强
- 双指捏合缩放查看详情