topfans/docs/superpowers/plans/2026-06-10-square-stargalaxy-component.md

1022 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 keyframesuni-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 个散落 itemcover + 上方 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: 创建目录**
```bash
mkdir -p frontend/pages/square/components/StarGalaxy
```
- [ ] **Step 1.2: 写入 config.js**
```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
}
})
}
// 单组 @keyframesCSS 模板字符串)
// 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: 验证文件创建**
```bash
ls -la frontend/pages/square/components/StarGalaxy/config.js
```
Expected: 文件存在,~80 行
- [ ] **Step 1.4: 提交**
```bash
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**
```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: 验证文件创建**
```bash
ls -la frontend/pages/square/components/StarGalaxy/PodiumCard.vue
wc -l frontend/pages/square/components/StarGalaxy/PodiumCard.vue
```
Expected: ~150 行
- [ ] **Step 2.3: 提交**
```bash
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**
```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×72top-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: 验证**
```bash
ls -la frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue
```
- [ ] **Step 3.3: 提交**
```bash
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**
```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 itemTOP 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: 验证文件结构**
```bash
ls -la frontend/pages/square/components/StarGalaxy/
```
Expected: 4 files (config.js, PodiumCard.vue, ScatteredRanks.vue, index.vue)
- [ ] **Step 4.3: 提交**
```bash
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 行附近)添加:
```js
import StarGalaxy from "./components/StarGalaxy/index.vue";
```
- [ ] **Step 5.2: 添加星河分支**
`<view v-if="activeContentTab === 'xingbang'" class="hot-category-wrapper">` 之前,添加新的星河分支(替换原 `<!-- 在线榜单区块 - 仅在 星榜 时显示 -->` 注释块):
```vue
<!-- 星河区块 - 仅在 星河 时显示 -->
<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 语法**
```bash
cd frontend
npx --no-install eslint pages/square/square.vue 2>/dev/null || echo "无 eslint跳过"
```
- [ ] **Step 5.4: 提交**
```bash
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 调试服务**
```bash
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 个 itemTOP 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: 提交验证记录(如有问题修复)**
```bash
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-filter`
- `radial-gradient` — ✅ H5 + APP 支持,小程序有限制
- `@keyframes` + `animation` — ✅ uni-app 全支持
如果小程序有问题,对应的 @media 块需要适配或使用条件编译。
- [ ] **Step 7.2: 文档记录**
`frontend/pages/square/components/StarGalaxy/README.md` 写组件使用说明:
```markdown
# StarGalaxy 组件
square 页「星河」tab 的排行榜组件。
## 视觉特征
- 65° 倾斜椭圆轨道
- TOP 1-3 颁奖台cover + 下方标签 + 钻石外框)
- TOP 4-12 9 个散落 itemcover + 上方标签)
- 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
```
```bash
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 用百分比 + 媒体查询自适应 |
---
## 后续 PRv2 增强,不在本计划范围)
- 缩略图懒加载优化(`<image lazy-load>`
- 9 item 悬停暂停动画
- 减少运动偏好支持进一步增强
- 双指捏合缩放查看详情