1022 lines
28 KiB
Markdown
1022 lines
28 KiB
Markdown
# 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: 创建目录**
|
||
|
||
```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
|
||
}
|
||
})
|
||
}
|
||
|
||
// 单组 @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: 验证文件创建**
|
||
|
||
```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×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: 验证**
|
||
|
||
```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 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: 验证文件结构**
|
||
|
||
```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 个 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: 提交验证记录(如有问题修复)**
|
||
|
||
```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 个散落 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
|
||
```
|
||
|
||
```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 用百分比 + 媒体查询自适应 |
|
||
|
||
---
|
||
|
||
## 后续 PR(v2 增强,不在本计划范围)
|
||
|
||
- 缩略图懒加载优化(`<image lazy-load>`)
|
||
- 9 item 悬停暂停动画
|
||
- 减少运动偏好支持进一步增强
|
||
- 双指捏合缩放查看详情
|