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

28 KiB
Raw Blame History

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.vueTOP 1-3 大卡)+ ScatteredRanks.vueTOP 4-12 9 个散落 item+ config.js9 slot 位置公式)。单组 @keyframes orbit 配合 9 个不同 animation-delay 实现旋转。复用现有 getHotRankingApigetAssetCoverRealUrl

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: 创建目录

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
    }
  })
}

// 单组 @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: 验证文件创建
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×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: 验证
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 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: 验证文件结构
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 个 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: 提交验证记录(如有问题修复)
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 写组件使用说明:

# 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
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 悬停暂停动画
  • 减少运动偏好支持进一步增强
  • 双指捏合缩放查看详情