topfans/docs/superpowers/plans/2026-06-02-data-dashboard-frontend.md
zheng020 8f84b2ad58 fix(plan): 4 条阻塞 + 2 条 advisory 修复(re-review 反馈)
阻塞修复:
1. Task 7: chartData/chartOpts 加入 return,H5 模板可访问
2. Task 13: 补 onUnmounted import
3. Task 13: 删除 4 个重复的 onShow 草稿块,保留唯一规范版本
4. Task 2: 新增 USE_MOCK_API 翻 true 的步骤(api.js 默认 false)

advisory 修复:
- Task 10: margin: 24r 0 → 24rpx 0
- Task 13: 删 enablePullDownRefresh(与 scroll-view refresher 冲突)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 01:20:47 +08:00

74 KiB
Raw Blame History

数据看板前端 Implementation Plan

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: 实现数据看板页面6 大模块),在 mock 数据下完整跑通水晶收益、藏品表现、升级进度的可视化展示。

Architecture: 单页面 + 1 个聚合 composableuseDashboardData+ 11 个展示子组件。composable 内部用 effectScope 包裹 7 个并发请求,按 section 维度独立暴露 loading/error/refresh。后端未就绪时通过 USE_MOCK_API 开关走 mock 数据,接真实接口只需翻常量。

Tech Stack: Vue 3 (Composition API) + uni-app + Vuex 4已有不新增模块+ uCharts 插件(柱状+折线)+ SCSSuni.scss 共享 token+ CSS conic-gradient5 个等级环)

Spec: docs/superpowers/specs/2026-06-02-data-dashboard-frontend-design.md 配套视觉/API 文档: docs/figma-analysis-data-dashboard.md


文件总览

Create13 个):

  • frontend/pages/dashboard/dashboard.vue
  • frontend/pages/dashboard/components/DashboardHeader.vue
  • frontend/pages/dashboard/components/CrystalOverview.vue
  • frontend/pages/dashboard/components/IncomeCurve.vue
  • frontend/pages/dashboard/components/ExhibitionCenter.vue
  • frontend/pages/dashboard/components/LikeIncomeBoard.vue
  • frontend/pages/dashboard/components/CollectionMatrix.vue
  • frontend/pages/dashboard/components/TopFiveAssets.vue
  • frontend/pages/dashboard/components/LevelDistribution.vue
  • frontend/pages/dashboard/components/UpcomingUpgrades.vue
  • frontend/pages/dashboard/components/RecentUpgrades.vue
  • frontend/composables/useDashboardData.js
  • frontend/utils/mock/dashboard.js

Modify3 个):

  • frontend/pages.json(注册 dashboard 页面)
  • frontend/utils/api.js(追加 dashboardApi 命名空间 + mock 触发)
  • frontend/uni.scss(追加设计 token 变量)

无新增测试文件:项目未引入测试框架,验证走 superpowers:verification-before-completion 流程的手动核对清单。


Task 1: 页面注册 + SCSS 设计 token

Files:

  • Modify: frontend/pages.json(在 pages 数组末尾追加一项)

  • Modify: frontend/uni.scss(在文件末尾追加 dashboard 专用 token

  • Step 1: 在 pages.json 末尾注册 dashboard 页面

pages 数组最后一个对象(tasks/revenue)之后,逗号之后追加:

		,{
			"path": "pages/dashboard/dashboard",
			"style": {
				"navigationStyle": "custom",
				"app-plus": {
					"bounce": "none"
				}
			}
		}
  • Step 2: 在 uni.scss 末尾追加 dashboard token
/* ==================== Dashboard 设计 Token ==================== */

/* 颜色(与 spec §3.1 一致) */
$d-text-data: #FFFABD;
$d-text-white: #FFFFFF;
$d-tab-active: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
$d-card-crystal: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
$d-card-today: linear-gradient(137deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
$d-progress-cyan: linear-gradient(90deg, #5EDFFF 0%, #FFC8C8 100%);
$d-progress-pink: linear-gradient(90deg, #FFF375 0%, #FF6B84 100%);
$d-bar-blue-yellow: linear-gradient(90deg, #1BAFEE 0%, #FFCC14 100%);
$d-bar-fill: linear-gradient(135deg, #FFDF77 0%, #B984FF 60%, #FF8183 100%);
$d-page-bg: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);

/* 5 个等级专属渐变环图、徽章、TOP 徽章) */
$d-level-ur: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%);
$d-level-ssr: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%);
$d-level-sr: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%);
$d-level-r: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%);
$d-level-n: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%);

/* 阴影 */
$d-shadow-card: 0px 4px 4px rgba(189, 50, 50, 0.25);
$d-shadow-data: -1px 1px 4px rgba(206, 9, 9, 0.84);
$d-shadow-mascot: 2px 2px 4px rgba(242, 21, 21, 0.47);
$d-shadow-tab: 0px 4px 4px rgba(0, 0, 0, 0.25);
$d-shadow-inner: 0px 4px 4px rgba(96, 13, 13, 0.25);

/* 圆角 */
$d-radius-thumb: 3px;
$d-radius-progress: 6px;
$d-radius-card-sm: 14px;
$d-radius-card-md: 17px;
$d-radius-card-lg: 22px;

/* 字号 */
$d-fs-num-xl: 35px;
$d-fs-num-lg: 18px;
$d-fs-num-md: 14px;
$d-fs-num-sm: 9px;
$d-fs-title-lg: 20px;
$d-fs-title-md: 18px;
$d-fs-title-sm: 15px;
$d-fs-label: 12px;

/* 间距 */
$d-space-card-x: 16px;
$d-space-section: 24px;
$d-space-cell: 12px;
  • Step 3: 手动验证 - 路由可达

启动 H5 dev server如果已在跑则跳过

cd frontend && npm run dev:h5

在 H5 dev URL 末尾加 /pages/dashboard/dashboard 访问:

  • 预期:进入空页面(白屏即可,组件尚未创建)

  • 不预期:路由 404、JSON 解析错误

  • Step 4: Commit

git add frontend/pages.json frontend/uni.scss
git commit -m "feat(dashboard): 注册页面 + 追加 SCSS 设计 token"

Task 2: Mock 数据 + dashboardApi 命名空间

Files:

  • Create: frontend/utils/mock/dashboard.js

  • Modify: frontend/utils/api.js(追加 dashboardApi + 注入 mock 触发逻辑)

  • Step 1: 创建 mock 数据文件 frontend/utils/mock/dashboard.js

// 数据看板 mock 数据工厂
// 后端就绪后,将 utils/api.js 顶部 USE_MOCK_API 改为 false 即可

const randomDelay = (min = 200, max = 600) =>
  new Promise((resolve) => setTimeout(resolve, Math.random() * (max - min) + min))

// 1. 今日概览
export async function mockTodayOverview({ star_id }) {
  await randomDelay()
  return {
    code: 200,
    data: {
      crystal_balance: 2713,
      today_income: 213,
      week_rank: 12,
    },
  }
}

// 2. 七日收益曲线
export async function mock7DayIncomeCurve({ star_id }) {
  await randomDelay()
  const today = new Date()
  const points = []
  const incomes = [180, 245, 198, 312, 276, 221, 189] // 最后一个是今天
  for (let i = 6; i >= 0; i--) {
    const d = new Date(today)
    d.setDate(d.getDate() - i)
    const income = incomes[6 - i]
    points.push({
      date: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`,
      income,
      is_today: i === 0,
      is_peak: income === 312,
    })
  }
  return {
    code: 200,
    data: {
      points,
      total_income: incomes.reduce((a, b) => a + b, 0),
      avg_income: Math.round(incomes.reduce((a, b) => a + b, 0) / 7),
    },
  }
}

// 3. 展出收益中心
export async function mockExhibitionSummary({ star_id }) {
  await randomDelay()
  return {
    code: 200,
    data: {
      exhibiting_count: 21,
      starbook_count: 33,
      total_duration: '712:13:56',
      total_earnings: 39721,
      top5: [
        { asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', duration_7d: '144:13:56', earnings_7d: 2173, avg_earnings: 15 },
        { asset_id: 2, asset_name: '夏日微风', asset_thumb: '', duration_7d: '77:13:56', earnings_7d: 1332, avg_earnings: 15 },
        { asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', duration_7d: '64:15:37', earnings_7d: 1201, avg_earnings: 12 },
        { asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', duration_7d: '51:22:12', earnings_7d: 783, avg_earnings: 12 },
        { asset_id: 5, asset_name: '深海回响', asset_thumb: '', duration_7d: '51:22:12', earnings_7d: 783, avg_earnings: 12 },
      ],
    },
  }
}

// 4. 点赞收益按等级
export async function mockLikeIncomeByLevel({ star_id }) {
  await randomDelay()
  return {
    code: 200,
    data: {
      total_like_count: 231,
      total_income: 12719,
      levels: [
        { level: 'UR', asset_count: 1, total_income: 723, thumb: '' },
        { level: 'SSR', asset_count: 2, total_income: 381, thumb: '' },
        { level: 'SR', asset_count: 5, total_income: 233, thumb: '' },
        { level: 'SR', asset_count: 4, total_income: 169, thumb: '' },
        { level: 'R', asset_count: 6, total_income: 57, thumb: '' },
      ],
    },
  }
}

// 5. 藏品 TOP5
export async function mockTopAssets({ star_id }) {
  await randomDelay()
  return {
    code: 200,
    data: {
      items: [
        { asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', total_earnings: 8420, rank: 1 },
        { asset_id: 2, asset_name: '夏日微风', asset_thumb: '', total_earnings: 6230, rank: 2 },
        { asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', total_earnings: 5180, rank: 3 },
        { asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', total_earnings: 4320, rank: 4 },
        { asset_id: 5, asset_name: '深海回响', asset_thumb: '', total_earnings: 3980, rank: 5 },
      ],
    },
  }
}

// 6. 藏品等级分布
export async function mockLevelDistribution({ star_id }) {
  await randomDelay()
  const total = 33
  return {
    code: 200,
    data: {
      items: [
        { level: 'UR', count: 1, total },
        { level: 'SSR', count: 2, total },
        { level: 'SR', count: 5, total },
        { level: 'R', count: 6, total },
        { level: 'N', count: 0, total },
      ],
    },
  }
}

// 7. 升级进度
export async function mockUpgradeProgress({ star_id }) {
  await randomDelay()
  return {
    code: 200,
    data: {
      upcoming: [
        { asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', like_progress: 73, duration_progress: 92 },
        { asset_id: 2, asset_name: '夏日微风', asset_thumb: '', like_progress: 75, duration_progress: 96 },
        { asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', like_progress: 97, duration_progress: 71 },
      ],
      recent: [
        { asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', new_level: 'SSR', upgrade_time: Date.now() - 3600000 },
        { asset_id: 5, asset_name: '深海回响', asset_thumb: '', new_level: 'SR', upgrade_time: Date.now() - 86400000 },
        { asset_id: 6, asset_name: '晨曦微光', asset_thumb: '', new_level: 'SR', upgrade_time: Date.now() - 172800000 },
      ],
    },
  }
}

// 路由表endpoints 字符串 → mock 工厂
export const mockRouter = {
  '/api/v1/dashboard/today-overview': mockTodayOverview,
  '/api/v1/dashboard/income-curve': mock7DayIncomeCurve,
  '/api/v1/dashboard/exhibition-summary': mockExhibitionSummary,
  '/api/v1/dashboard/like-income-by-level': mockLikeIncomeByLevel,
  '/api/v1/dashboard/top-assets': mockTopAssets,
  '/api/v1/dashboard/level-distribution': mockLevelDistribution,
  '/api/v1/dashboard/upgrade-progress': mockUpgradeProgress,
}
  • Step 2: 在 frontend/utils/api.js 末尾追加 dashboardApi 命名空间
// ==================== 数据看板 ====================
import { mockRouter } from './mock/dashboard'

const DASHBOARD_PREFIX = '/api/v1/dashboard'

// mock 触发器:当 USE_MOCK_API 为 true 时短路返回 mock 数据
// 后端就绪后将 USE_MOCK_API 改为 false 即可
async function dashboardRequest(endpoint, params = {}) {
  if (USE_MOCK_API) {
    const factory = mockRouter[`${DASHBOARD_PREFIX}${endpoint}`]
    if (factory) return factory(params)
  }
  return request({ url: `${DASHBOARD_PREFIX}${endpoint}`, method: 'GET', data: params })
}

export const dashboardApi = {
  getTodayOverview: (starId) => dashboardRequest('/today-overview', { star_id: starId }).then((r) => r.data),
  get7DayIncomeCurve: (starId) => dashboardRequest('/income-curve', { star_id: starId }).then((r) => r.data),
  getExhibitionSummary: (starId) => dashboardRequest('/exhibition-summary', { star_id: starId }).then((r) => r.data),
  getLikeIncomeByLevel: (starId) => dashboardRequest('/like-income-by-level', { star_id: starId }).then((r) => r.data),
  getTopAssets: (starId) => dashboardRequest('/top-assets', { star_id: starId }).then((r) => r.data),
  getLevelDistribution: (starId) => dashboardRequest('/level-distribution', { star_id: starId }).then((r) => r.data),
  getUpgradeProgress: (starId) => dashboardRequest('/upgrade-progress', { star_id: starId }).then((r) => r.data),
}
  • Step 3: 打开 USE_MOCK_API 开关(关键前置步骤)

重要:项目 frontend/utils/api.js 第 9 行 USE_MOCK_API 默认是 false,会直接请求真实后端。后端尚未实现,必须先翻成 true 才能用 mock 验证。

修改 frontend/utils/api.js

// 第 9 行:
// 原始: const USE_MOCK_API = false
// 改为:
const USE_MOCK_API = true

切回真实接口:后端 /api/v1/dashboard/* 7 个接口联调通过后,把这里改回 false 即可。

  • Step 4: 手动验证 mock 触发

在 H5 dev console 跑:

const { dashboardApi } = await import('/utils/api.js')
const today = await dashboardApi.getTodayOverview(1)
console.log(today)  // 预期: { crystal_balance: 2713, today_income: 213, week_rank: 12 }
  • Step 5: Commit
git add frontend/utils/mock/dashboard.js frontend/utils/api.js
git commit -m "feat(dashboard): 追加 mock 数据工厂 + dashboardApi 命名空间 + 打开 USE_MOCK_API"

Task 3: useDashboardData Composable

Files:

  • Create: frontend/composables/useDashboardData.js

  • Step 1: 完整实现 composable

import { ref, computed, onScopeDisposal } from 'vue'
import { dashboardApi } from '@/utils/api'

/**
 * 数据看板聚合 composable
 * - 7 个接口并发Promise.allSettled单失败不阻塞
 * - 按 section 维度暴露 loading/error/data
 * - 30 分钟内 refresh 不重发请求lastFetched 缓存)
 * - effectScope + onScopeDisposal 自动清理(页面 onUnmounted 调用 dispose
 *
 * @param {object} options
 * @param {number|null} options.starId 顶粉星城 ID
 * @returns {UseDashboardDataReturn}
 */
export function useDashboardData({ starId = null } = {}) {
  // —— 内部 state ——
  const loading = ref({
    overall: false,
    today: false,
    curve: false,
    exhibition: false,
    likeIncome: false,
    topAssets: false,
    levels: false,
    upgrades: false,
  })

  const error = ref({
    today: null,
    curve: null,
    exhibition: null,
    likeIncome: null,
    topAssets: null,
    levels: null,
    upgrades: null,
  })

  const data = ref({
    today: null,
    curve: null,
    exhibition: null,
    likeIncome: null,
    topAssets: null,
    levels: null,
    upgrades: null,
  })

  const lastFetched = ref(0)
  const STALE_MS = 30 * 60 * 1000 // 30 分钟

  const isReady = computed(() => Object.values(data.value).every((v) => v !== null))

  // —— 内部辅助:单 section 加载 ——
  async function loadSection(section, fetcher) {
    loading.value[section] = true
    error.value[section] = null
    try {
      const result = await fetcher(starId)
      data.value[section] = result
    } catch (e) {
      error.value[section] = e.message || '加载失败'
      data.value[section] = null
    } finally {
      loading.value[section] = false
    }
  }

  // —— 全量加载 ——
  async function loadAll(force = false) {
    if (!force && Date.now() - lastFetched.value < STALE_MS) return
    loading.value.overall = true
    try {
      // 并发执行,单个失败由 loadSection 内部捕获
      await Promise.allSettled([
        loadSection('today', dashboardApi.getTodayOverview),
        loadSection('curve', dashboardApi.get7DayIncomeCurve),
        loadSection('exhibition', dashboardApi.getExhibitionSummary),
        loadSection('likeIncome', dashboardApi.getLikeIncomeByLevel),
        loadSection('topAssets', dashboardApi.getTopAssets),
        loadSection('levels', dashboardApi.getLevelDistribution),
        loadSection('upgrades', dashboardApi.getUpgradeProgress),
      ])
      lastFetched.value = Date.now()
    } finally {
      loading.value.overall = false
    }
  }

  // —— 局部刷新 ——
  // - refresh(section)          : 单 section 强制重拉(无缓存)
  // - refresh()                 : 全量刷新cache-aware30 分钟内复用)
  // - refresh(null, true)       : 全量强制刷新(绕缓存)
  async function refresh(section, force = false) {
    if (section) {
      const fetcherMap = {
        today: dashboardApi.getTodayOverview,
        curve: dashboardApi.get7DayIncomeCurve,
        exhibition: dashboardApi.getExhibitionSummary,
        likeIncome: dashboardApi.getLikeIncomeByLevel,
        topAssets: dashboardApi.getTopAssets,
        levels: dashboardApi.getLevelDistribution,
        upgrades: dashboardApi.getUpgradeProgress,
      }
      if (!fetcherMap[section]) return
      return loadSection(section, fetcherMap[section])
    }
    return loadAll(force)
  }

  // —— effectScope 资源释放 ——
  // 调用方在页面 onUnmounted 中调用 dispose()
  function dispose() {
    // ref 状态由 Vue 自动 GC此处保留接口给未来清理如取消 in-flight 请求)
    // 当前实现:无 in-flight 引用,无需额外清理
  }

  // 自动调用一次首屏加载
  loadAll()

  return {
    loading,
    error,
    data,
    refresh,
    isReady,
    lastFetched,
    dispose,
  }
}
  • Step 2: 手动验证 composable 行为

在 H5 dev console 跑:

const { useDashboardData } = await import('/composables/useDashboardData.js')
const { data, loading, isReady, refresh } = useDashboardData({ starId: 1 })
// 等 ~1 秒
setTimeout(() => {
  console.log('isReady:', isReady.value)        // true
  console.log('today:', data.value.today)        // { crystal_balance: 2713, ... }
  console.log('curve.points.length:', data.value.curve.points.length)  // 7
  console.log('exhibition.top5.length:', data.value.exhibition.top5.length)  // 5
}, 1500)

预期:所有数据加载完成,形状如 mock。

  • Step 3: 验证局部刷新
// 在同一 console 继续
await refresh('curve')
console.log('loading.curve:', loading.value.curve)  // 预期: falseload 完自动归位)
console.log('data.curve:', data.value.curve !== null)  // true
  • Step 4: Commit
git add frontend/composables/useDashboardData.js
git commit -m "feat(dashboard): useDashboardData composable7接口并发+section级refresh"

Task 4: dashboard.vue 页面骨架

Files:

  • Create: frontend/pages/dashboard/dashboard.vue

  • Step 1: 完整实现页面骨架

<template>
  <view class="dashboard-container" :style="{ background: pageBg }">
    <!-- 顶部装饰背景 -->
    <DashboardHeader
      :active-tab="activeTab"
      @update:active-tab="handleTabChange"
    />

    <!-- Tab 1: 水晶相关 -->
    <view v-if="activeTab === 'crystal'" class="dashboard-content">
      <view class="placeholder-section">
        <text class="placeholder-text">骨架页(组件将在 Task 5-10 添加)</text>
      </view>
    </view>

    <!-- Tab 2: 赛季总览(占位) -->
    <view v-else class="dashboard-content">
      <view class="season-placeholder">
        <text class="placeholder-icon">🏆</text>
        <text class="placeholder-title">赛季总览 · 即将上线</text>
        <text class="placeholder-sub">历史赛季数据正在筹备中</text>
      </view>
    </view>
  </view>
</template>

<script>
import { ref, onMounted, onUnmounted } from 'vue'
import DashboardHeader from './components/DashboardHeader.vue'
import { useDashboardData } from '@/composables/useDashboardData'

export default {
  components: { DashboardHeader },
  setup() {
    const activeTab = ref('crystal')
    const starId = ref(uni.getStorageSync('star_id') || null)

    const { loading, error, data, refresh, isReady, dispose } = useDashboardData({
      starId: starId.value,
    })

    function handleTabChange(tab) {
      activeTab.value = tab
    }

    onMounted(() => {
      // 首屏已由 composable 内部自动调用 loadAll()
      // 此处预留 onMounted 钩子供未来扩展
    })

    onUnmounted(() => {
      dispose()
    })

    return {
      activeTab,
      loading,
      error,
      data,
      isReady,
      handleTabChange,
      pageBg: 'linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%)',
    }
  },
}
</script>

<style lang="scss" scoped>
.dashboard-container {
  min-height: 100vh;
  padding: 0;
}

.dashboard-content {
  padding: 24rpx 32rpx 80rpx;
}

.placeholder-section {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 22rpx;
  padding: 80rpx 32rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.placeholder-text {
  color: rgba(255, 255, 255, 0.6);
  font-size: 28rpx;
}

.season-placeholder {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(10px);
  border-radius: 22rpx;
  padding: 120rpx 32rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.placeholder-icon {
  font-size: 96rpx;
  margin-bottom: 24rpx;
}

.placeholder-title {
  color: #ffffff;
  font-size: 36rpx;
  font-weight: 700;
  margin-bottom: 16rpx;
}

.placeholder-sub {
  color: rgba(255, 255, 255, 0.7);
  font-size: 26rpx;
}
</style>
  • Step 2: 创建 DashboardHeader.vue 占位Task 5 会完整实现)

文件 frontend/pages/dashboard/components/DashboardHeader.vue

<template>
  <view class="dashboard-header">
    <view class="header-bg"></view>
    <view class="header-content">
      <text class="header-title">数据看板</text>
      <view class="header-tabs">
        <view
          :class="['tab', activeTab === 'crystal' ? 'tab-active' : '']"
          @tap="$emit('update:activeTab', 'crystal')"
        >
          水晶相关
        </view>
        <view
          :class="['tab', activeTab === 'season' ? 'tab-active' : '']"
          @tap="$emit('update:activeTab', 'season')"
        >
          赛季总览
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'DashboardHeader',
  props: {
    activeTab: { type: String, default: 'crystal' },
  },
  emits: ['update:activeTab'],
}
</script>

<style lang="scss" scoped>
.dashboard-header {
  position: relative;
  height: 280rpx;
  overflow: hidden;
}

.header-bg {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 700rpx;
  background: linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 50%, #FF9C9C 86%, #FF2024 100%);
  filter: blur(4px);
  z-index: 0;
}

.header-content {
  position: relative;
  z-index: 1;
  padding: 80rpx 32rpx 32rpx;
}

.header-title {
  display: block;
  font-size: 48rpx;
  font-weight: 700;
  color: #ffffff;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4);
  text-align: center;
  margin-bottom: 32rpx;
}

.header-tabs {
  display: flex;
  justify-content: center;
  gap: 0;
  background: rgba(0, 0, 0, 0.15);
  border-radius: 22rpx;
  padding: 6rpx;
  margin: 0 100rpx;
}

.tab {
  flex: 1;
  text-align: center;
  padding: 16rpx 0;
  font-size: 28rpx;
  color: rgba(255, 255, 255, 0.7);
  border-radius: 22rpx;
  transition: all 0.25s ease;
}

.tab-active {
  background: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
  color: #ffffff;
  font-weight: 600;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
}
</style>
  • Step 3: 手动验证骨架页

访问 H5 dev URL /pages/dashboard/dashboard

  • 预期:红色装饰背景 + "数据看板" 标题 + 两个 Tab + "骨架页" 占位文字

  • 切换 Tab右半部分在占位和"赛季总览"间切换

  • 打开 devtools console1.5 秒后 data.today 等字段非空(可通过 Vue devtools 检查)

  • Step 4: Commit

git add frontend/pages/dashboard/dashboard.vue frontend/pages/dashboard/components/DashboardHeader.vue
git commit -m "feat(dashboard): 页面骨架 + Header 占位"

Task 5: DashboardHeader 完整实现(毛绒怪 + 渐变标题)

Files:

  • Modify: frontend/pages/dashboard/components/DashboardHeader.vue(替换占位为完整实现)

  • Step 1: 替换为完整实现

<template>
  <view class="dashboard-header">
    <!-- 装饰渐变背景 -->
    <view class="header-deco-bg"></view>

    <!-- 装饰光晕红粉色 -->
    <view class="header-glow"></view>

    <!-- 状态栏占位iPhone 44px -->
    <view class="status-bar-placeholder"></view>

    <view class="header-content">
      <!-- 毛绒怪头像占位纯色块 + emoji -->
      <view class="mascot">
        <text class="mascot-emoji">🐾</text>
      </view>

      <!-- 渐变标题 -->
      <view class="title-wrap">
        <text class="header-title">数据看板</text>
      </view>

      <!-- Tab 胶囊 -->
      <view class="header-tabs">
        <view
          :class="['tab', activeTab === 'crystal' ? 'tab-active' : '']"
          @tap="$emit('update:activeTab', 'crystal')"
        >
          水晶相关
        </view>
        <view
          :class="['tab', activeTab === 'season' ? 'tab-active' : '']"
          @tap="$emit('update:activeTab', 'season')"
        >
          赛季总览
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'DashboardHeader',
  props: {
    activeTab: { type: String, default: 'crystal' },
  },
  emits: ['update:activeTab'],
}
</script>

<style lang="scss" scoped>
.dashboard-header {
  position: relative;
  height: 360rpx;
  overflow: hidden;
}

.status-bar-placeholder {
  height: 44px;
}

.header-deco-bg {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 700rpx;
  background: linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 50%, #FF9C9C 86%, #FF2024 100%);
  filter: blur(4px);
  z-index: 0;
}

.header-glow {
  position: absolute;
  top: 100rpx;
  left: 50%;
  transform: translateX(-50%);
  width: 400rpx;
  height: 400rpx;
  background: radial-gradient(circle, rgba(255, 200, 100, 0.5) 0%, transparent 70%);
  filter: blur(30px);
  z-index: 1;
}

.header-content {
  position: relative;
  z-index: 2;
  padding: 0 32rpx 32rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.mascot {
  width: 104rpx;
  height: 104rpx;
  border-radius: 50%;
  background: linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 2px 2px 30px rgba(242, 21, 21, 0.47);
  margin-bottom: 16rpx;
}

.mascot-emoji {
  font-size: 64rpx;
}

.title-wrap {
  margin-bottom: 24rpx;
}

.header-title {
  font-size: 48rpx;
  font-weight: 700;
  background: linear-gradient(90deg, #FFE5B4 0%, #FFB199 50%, #FF8A95 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.4);
}

.header-tabs {
  display: flex;
  background: rgba(0, 0, 0, 0.15);
  border-radius: 22rpx;
  padding: 6rpx;
  width: 100%;
  max-width: 500rpx;
}

.tab {
  flex: 1;
  text-align: center;
  padding: 14rpx 0;
  font-size: 28rpx;
  color: rgba(255, 255, 255, 0.75);
  border-radius: 22rpx;
  transition: all 0.25s ease;
}

.tab-active {
  background: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
  color: #ffffff;
  font-weight: 600;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
}
</style>
  • Step 2: 手动验证

访问 H5 dev URL /pages/dashboard/dashboard

  • 预期:粉红渐变背景 + 红色光晕 + 圆形毛绒怪占位(带红色光晕阴影)+ 渐变文字"数据看板"+ 紫蓝粉渐变 Tab 胶囊

  • 切换 Tab胶囊平滑滑动

  • Step 3: Commit

git add frontend/pages/dashboard/components/DashboardHeader.vue
git commit -m "feat(dashboard): Header 完整视觉(毛绒怪占位+渐变标题+Tab胶囊"

Task 6: CrystalOverview 组件(顶部双卡)

Files:

  • Create: frontend/pages/dashboard/components/CrystalOverview.vue

  • Step 1: 完整实现

<template>
  <view class="crystal-overview">
    <!-- 错误态 -->
    <view v-if="error" class="error-card" @tap="$emit('retry')">
      <text class="error-text">加载失败,点击重试</text>
    </view>

    <!-- 骨架态 -->
    <view v-else-if="loading || !data" class="skeleton-row">
      <view v-for="i in 2" :key="i" class="skeleton-card"></view>
    </view>

    <!-- 正常态 -->
    <view v-else class="card-row">
      <view class="data-card card-crystal">
        <text class="card-label">水晶余额</text>
        <text class="card-value">{{ data.crystal_balance }}</text>
      </view>
      <view class="data-card card-today">
        <text class="card-label">今日收益</text>
        <text class="card-value">+ {{ data.today_income }}</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'CrystalOverview',
  props: {
    data: { type: Object, default: null }, // { crystal_balance, today_income, week_rank? }
    loading: { type: Boolean, default: false },
    error: { type: String, default: null },
  },
  emits: ['retry'],
}
</script>

<style lang="scss" scoped>
.crystal-overview {
  margin: 24rpx 0;
}

.card-row {
  display: flex;
  gap: 16rpx;
}

.data-card {
  flex: 1;
  height: 200rpx;
  border-radius: 22rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

.card-crystal {
  background: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
  box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
}

.card-today {
  background: linear-gradient(137deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
  box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
}

.card-label {
  font-size: 24rpx;
  color: #ffffff;
  text-shadow: 0px 4px 4px rgba(164, 60, 60, 0.55);
  margin-bottom: 12rpx;
}

.card-value {
  font-size: 70rpx;
  font-weight: 700;
  color: #FFFABD;
  text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
  font-family: 'Baloo Bhai', sans-serif;
  line-height: 1;
}

/* 骨架态 */
.skeleton-row {
  display: flex;
  gap: 16rpx;
}

.skeleton-card {
  flex: 1;
  height: 200rpx;
  border-radius: 22rpx;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s infinite;
}

@keyframes skeleton-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* 错误态 */
.error-card {
  height: 200rpx;
  border-radius: 22rpx;
  background: rgba(255, 100, 100, 0.15);
  border: 2rpx solid rgba(255, 100, 100, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
}

.error-text {
  color: #ff8080;
  font-size: 28rpx;
}
</style>
  • Step 2: 在 dashboard.vue 中挂载组件

修改 frontend/pages/dashboard/dashboard.vue 的 template

<view v-if="activeTab === 'crystal'" class="dashboard-content">
  <view class="placeholder-section">
    <text class="placeholder-text">骨架页组件将在 Task 5-10 添加</text>
  </view>
</view>

替换为:

<view v-if="activeTab === 'crystal'" class="dashboard-content">
  <CrystalOverview
    :data="data.today"
    :loading="loading.today"
    :error="error.today"
    @retry="refresh('today')"
  />
</view>

并在 components 中追加:

components: { DashboardHeader, CrystalOverview },
  • Step 3: 手动验证

刷新 H5 dev URL

  • 预期:双卡显示"水晶余额 2713"和"今日收益 + 213",金黄色数字 + 渐变背景

  • 1.5s 后才显示loading 期间是骨架屏)

  • Step 4: Commit

git add frontend/pages/dashboard/components/CrystalOverview.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): CrystalOverview 双卡(水晶余额+今日收益)"

Task 7: IncomeCurve 组件uCharts 七日柱状+折线)

Files:

  • Create: frontend/pages/dashboard/components/IncomeCurve.vue

  • Step 1: 完整实现 IncomeCurve.vue

<template>
  <view class="income-curve-card">
    <text class="curve-title">七日收益曲线</text>

    <!-- 错误态 -->
    <view v-if="error" class="error-box" @tap="$emit('retry')">
      <text class="error-text">加载失败,点击重试</text>
    </view>

    <!-- 骨架态 -->
    <view v-else-if="loading || !points || points.length === 0" class="skeleton-chart"></view>

    <!-- 图表 -->
    <view v-else class="chart-wrap">
      <view class="chart-header">
        <text class="chart-peak-value" v-if="peak">+ {{ peak.income }}</text>
        <text class="chart-peak-date" v-if="peak">{{ peak.date }}</text>
      </view>
      <view class="chart-canvas">
        <!-- #ifdef H5 -->
        <qiun-data-charts
          type="barline"
          :opts="chartOpts"
          :chartData="chartData"
          :ontouch="true"
          canvas2d
          :ontap="handleChartTap"
        />
        <!-- #endif -->
        <!-- #ifdef APP-PLUS || MP-WEIXIN -->
        <view class="chart-placeholder-fallback">
          <text>📊 {{ points.length }} 天数据</text>
          <text class="chart-fallback-sub">App 端图表组件配置中</text>
        </view>
        <!-- #endif -->
      </view>
    </view>
  </view>
</template>

<script>
import { computed } from 'vue'

export default {
  name: 'IncomeCurve',
  props: {
    points: { type: Array, default: () => [] }, // DailyIncomePoint[]
    loading: { type: Boolean, default: false },
    error: { type: String, default: null },
  },
  emits: ['retry'],

  setup(props) {
    // 高亮当日peak
    const peak = computed(() => props.points.find((p) => p.is_peak) || null)

    // 平台条件:仅 H5 编译图表所需变量
    let chartData = null
    let chartOpts = null
    // #ifdef H5
    chartData = computed(() => {
      const categories = props.points.map((p) => p.date.slice(5)) // MM.DD
      const barData = props.points.map((p) => p.income)
      const lineData = props.points.map((p) => p.income)
      return {
        categories,
        series: [
          { name: '收益', data: barData, color: '#FFDF77' },
          { name: '趋势', data: lineData, color: '#1BAFEE', type: 'line' },
        ],
      }
    })

    chartOpts = {
      color: ['#FFCC14', '#1BAFEE'],
      padding: [16, 16, 8, 16],
      dataLabel: false,
      legend: { show: false },
      xAxis: { disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
      yAxis: { data: [{ min: 0 }], disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
      extra: {
        bar: { type: 'group', width: 18, linear: true, color: ['#FFDF77', '#B984FF', '#FF8183'] },
        line: { type: 'curve', width: 2, activeType: 'hollow' },
      },
    }
    // #endif

    function handleChartTap(e) {
      // 后续可扩展:点击柱子查看当日明细
      console.log('[IncomeCurve] chart tap', e)
    }

    return { peak, handleChartTap, chartData, chartOpts }
  },
}
</script>

<style lang="scss" scoped>
.income-curve-card {
  background: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
  border-radius: 22rpx;
  padding: 24rpx;
  margin: 24rpx 0;
  box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
  min-height: 360rpx;
}

.curve-title {
  display: block;
  font-size: 28rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
  text-shadow: 0px 2px 2px rgba(0, 0, 0, 0.2);
}

.chart-wrap {
  background: rgba(255, 255, 255, 0.15);
  border-radius: 17rpx;
  padding: 16rpx;
  backdrop-filter: blur(10px);
}

.chart-header {
  display: flex;
  align-items: baseline;
  gap: 12rpx;
  margin-bottom: 12rpx;
}

.chart-peak-value {
  font-size: 40rpx;
  font-weight: 700;
  color: #FFFABD;
  text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
  font-family: 'Baloo Bhai', sans-serif;
}

.chart-peak-date {
  font-size: 18rpx;
  color: rgba(255, 255, 255, 0.8);
}

.chart-canvas {
  width: 100%;
  height: 240rpx;
}

.chart-placeholder-fallback {
  height: 240rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  font-size: 28rpx;
}

.chart-fallback-sub {
  margin-top: 8rpx;
  font-size: 22rpx;
  opacity: 0.7;
}

.skeleton-chart {
  height: 360rpx;
  border-radius: 17rpx;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s infinite;
}

.error-box {
  height: 360rpx;
  border-radius: 17rpx;
  background: rgba(255, 100, 100, 0.15);
  border: 2rpx solid rgba(255, 100, 100, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
}

.error-text {
  color: #ff8080;
  font-size: 28rpx;
}

@keyframes skeleton-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>
  • Step 3: 在 dashboard.vue 中挂载组件

在 CrystalOverview 之后追加:

<IncomeCurve
  :points="data.curve?.points || []"
  :loading="loading.curve"
  :error="error.curve"
  @retry="refresh('curve')"
/>

components 追加 IncomeCurve

  • Step 4: 安装 uCharts
cd frontend && npm install qiun-data-charts

如项目无 H5 组件库依赖关系导致构建失败,回退到 chart-theme.json + 占位文字Step 2 中已留 fallback

  • Step 5: 手动验证

刷新 H5 dev URL

  • 预期:渐变卡片 + "七日收益曲线" 标题 + 柱状图7 根柱,渐变填充)+ 折线(蓝黄渐变)+ 高亮当日 "+ 312" + 日期

  • 加载中:渐变骨架屏闪烁

  • Step 6: Commit

git add frontend/pages/dashboard/components/IncomeCurve.vue frontend/pages/dashboard/dashboard.vue frontend/package.json frontend/package-lock.json
git commit -m "feat(dashboard): IncomeCurve 七日柱状+折线uCharts H5"

Task 8: ExhibitionCenter 组件3 联 + 5 行表格)

Files:

  • Create: frontend/pages/dashboard/components/ExhibitionCenter.vue

  • Step 1: 完整实现

<template>
  <view class="exhibition-center">
    <text class="section-title">展出收益中心</text>

    <!-- 错误态 -->
    <view v-if="error" class="error-box" @tap="$emit('retry')">
      <text class="error-text">加载失败,点击重试</text>
    </view>

    <!-- 骨架态 -->
    <view v-else-if="loading || !data" class="skeleton-section">
      <view class="skeleton-stats"></view>
      <view class="skeleton-rows">
        <view v-for="i in 5" :key="i" class="skeleton-row"></view>
      </view>
    </view>

    <!-- 正常态 -->
    <view v-else>
      <!-- 顶部 3 联统计 -->
      <view class="stats-row">
        <view class="stat-cell">
          <text class="stat-value">{{ data.exhibiting_count }} / {{ data.starbook_count }}</text>
          <text class="stat-label">展出中 / 星册中</text>
        </view>
        <view class="stat-cell">
          <text class="stat-value">{{ data.total_duration }}</text>
          <text class="stat-label">累计展出时长</text>
        </view>
        <view class="stat-cell">
          <text class="stat-value">{{ data.total_earnings }}</text>
          <text class="stat-label">累计展出收益</text>
        </view>
      </view>

      <!-- 5 行表格 -->
      <view class="table">
        <view class="table-header">
          <text class="th th-thumb"></text>
          <text class="th th-duration">七日展出时长</text>
          <text class="th th-earnings">七日收益</text>
          <text class="th th-avg">平均收益</text>
        </view>
        <view
          v-for="(item, idx) in data.top5"
          :key="item.asset_id"
          class="table-row"
        >
          <view class="td td-thumb">
            <view class="thumb-placeholder" :class="`thumb-grad-${idx % 5}`">
              <text class="thumb-emoji">🎨</text>
            </view>
          </view>
          <text class="td td-duration">{{ item.duration_7d }}</text>
          <text class="td td-earnings">{{ item.earnings_7d }}</text>
          <text class="td td-avg">{{ item.avg_earnings }} / H</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'ExhibitionCenter',
  props: {
    data: { type: Object, default: null }, // ExhibitionIncomeSummary
    loading: { type: Boolean, default: false },
    error: { type: String, default: null },
  },
  emits: ['retry'],
}
</script>

<style lang="scss" scoped>
.exhibition-center {
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(10px);
  border-radius: 22rpx;
  padding: 24rpx;
  margin: 24rpx 0;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.section-title {
  display: block;
  font-size: 36rpx;
  font-weight: 700;
  color: #ffffff;
  margin-bottom: 24rpx;
  text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}

.stats-row {
  display: flex;
  background: rgba(255, 255, 255, 0.08);
  border-radius: 17rpx;
  padding: 24rpx 0;
  margin-bottom: 24rpx;
}

.stat-cell {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  border-right: 1px solid rgba(255, 255, 255, 0.1);

  &:last-child {
    border-right: none;
  }
}

.stat-value {
  font-size: 32rpx;
  font-weight: 700;
  color: #FFFABD;
  font-family: 'Baloo Bhai', sans-serif;
  margin-bottom: 8rpx;
}

.stat-label {
  font-size: 22rpx;
  color: rgba(255, 255, 255, 0.7);
}

.table {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 14rpx;
  overflow: hidden;
}

.table-header,
.table-row {
  display: flex;
  align-items: center;
  padding: 16rpx 12rpx;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.table-row:last-child {
  border-bottom: none;
}

.th,
.td {
  font-size: 24rpx;
  color: #ffffff;
  text-align: center;
}

.th-thumb,
.td-thumb {
  width: 80rpx;
  flex-shrink: 0;
}

.th-duration,
.td-duration,
.th-earnings,
.td-earnings {
  flex: 1;
}

.th-avg,
.td-avg {
  width: 100rpx;
  flex-shrink: 0;
}

.th {
  font-size: 20rpx;
  color: rgba(255, 255, 255, 0.6);
}

.thumb-placeholder {
  width: 56rpx;
  height: 56rpx;
  border-radius: 3rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto;
}

.thumb-grad-0 { background: linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%); }
.thumb-grad-1 { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.thumb-grad-2 { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.thumb-grad-3 { background: linear-gradient(135deg, #FFE066 0%, #FFB199 100%); }
.thumb-grad-4 { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }

.thumb-emoji {
  font-size: 32rpx;
}

.td-earnings {
  color: #FFFABD;
  font-weight: 600;
  font-family: 'Baloo Bhai', sans-serif;
}

/* 骨架 */
.skeleton-section {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}

.skeleton-stats {
  height: 120rpx;
  border-radius: 17rpx;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-rows {
  display: flex;
  flex-direction: column;
  gap: 12rpx;
}

.skeleton-row {
  height: 80rpx;
  border-radius: 14rpx;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.error-box {
  height: 240rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 100, 100, 0.15);
  border: 2rpx solid rgba(255, 100, 100, 0.4);
  border-radius: 17rpx;
}

.error-text {
  color: #ff8080;
  font-size: 28rpx;
}
</style>
  • Step 2: 在 dashboard.vue 中挂载

在 IncomeCurve 后追加:

<ExhibitionCenter
  :data="data.exhibition"
  :loading="loading.exhibition"
  :error="error.exhibition"
  @retry="refresh('exhibition')"
/>

components 追加 ExhibitionCenter

  • Step 3: 手动验证

刷新 H5 dev URL

  • 预期:玻璃拟态卡片 + "展出收益中心" 标题 + 3 联统计21/33、712:13:56、39721+ 5 行表格(每行 4×5 缩略图 + 时长 + 收益 + 平均)

  • Step 4: Commit

git add frontend/pages/dashboard/components/ExhibitionCenter.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): ExhibitionCenter 3联+5行表格"

Task 9: LikeIncomeBoard 组件

Files:

  • Create: frontend/pages/dashboard/components/LikeIncomeBoard.vue

  • Step 1: 完整实现

<template>
  <view class="like-income-board">
    <text class="section-title">点赞收益看板</text>

    <!-- 错误/骨架态 -->
    <view v-if="error" class="error-box" @tap="$emit('retry')">
      <text class="error-text">加载失败,点击重试</text>
    </view>
    <view v-else-if="loading || !stats" class="skeleton-board">
      <view class="skeleton-stats"></view>
      <view class="skeleton-list"></view>
    </view>

    <!-- 正常态 -->
    <view v-else class="board-row">
      <!-- 左侧统计 -->
      <view class="left-stats">
        <view class="stat-block">
          <text class="stat-num">{{ stats.total_like_count }}</text>
          <text class="stat-text">累积点赞</text>
        </view>
        <view class="stat-block">
          <text class="stat-num">{{ stats.total_income }}</text>
          <text class="stat-text">累计收益</text>
        </view>
      </view>

      <!-- 右侧等级列表 -->
      <view class="right-list">
        <view
          v-for="(item, idx) in levels"
          :key="idx"
          class="level-row"
        >
          <view class="level-thumb">
            <view class="thumb-circle" :class="`level-${item.level}`">
              <text class="thumb-letter">{{ item.level }}</text>
            </view>
          </view>
          <text class="level-name">{{ item.level }}</text>
          <text class="level-income">{{ item.total_income }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'LikeIncomeBoard',
  props: {
    stats: { type: Object, default: null }, // { total_like_count, total_income }
    levels: { type: Array, default: () => [] },
    loading: { type: Boolean, default: false },
    error: { type: String, default: null },
  },
  emits: ['retry'],
}
</script>

<style lang="scss" scoped>
.like-income-board {
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(10px);
  border-radius: 22rpx;
  padding: 24rpx;
  margin: 24rpx 0;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.section-title {
  display: block;
  font-size: 36rpx;
  font-weight: 700;
  color: #ffffff;
  margin-bottom: 24rpx;
  text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}

.board-row {
  display: flex;
  gap: 16rpx;
}

.left-stats {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 24rpx;
  padding-right: 16rpx;
  border-right: 1px solid rgba(255, 255, 255, 0.1);
}

.stat-block {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.stat-num {
  font-size: 40rpx;
  font-weight: 700;
  color: #FFFABD;
  font-family: 'Baloo Bhai', sans-serif;
  text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
  margin-bottom: 6rpx;
}

.stat-text {
  font-size: 22rpx;
  color: rgba(255, 255, 255, 0.7);
}

.right-list {
  flex: 1.5;
  display: flex;
  flex-direction: column;
  gap: 12rpx;
}

.level-row {
  display: flex;
  align-items: center;
  background: rgba(255, 255, 255, 0.06);
  border-radius: 12rpx;
  padding: 12rpx;
  gap: 16rpx;
}

.level-thumb {
  width: 56rpx;
  height: 56rpx;
  flex-shrink: 0;
}

.thumb-circle {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}

.thumb-letter {
  font-size: 18rpx;
  font-weight: 700;
  color: #ffffff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}

.level-UR { background: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%); }
.level-SSR { background: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%); }
.level-SR { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.level-R { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.level-N { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }

.level-name {
  flex: 0 0 60rpx;
  font-size: 22rpx;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.9);
}

.level-income {
  flex: 1;
  font-size: 28rpx;
  font-weight: 700;
  color: #FFFABD;
  font-family: 'Baloo Bhai', sans-serif;
  text-align: right;
}

/* 骨架/错误 */
.skeleton-board {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}

.skeleton-stats,
.skeleton-list {
  height: 200rpx;
  border-radius: 17rpx;
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.error-box {
  height: 240rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 100, 100, 0.15);
  border: 2rpx solid rgba(255, 100, 100, 0.4);
  border-radius: 17rpx;
}

.error-text {
  color: #ff8080;
  font-size: 28rpx;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>
  • Step 2: 在 dashboard.vue 中挂载

在 ExhibitionCenter 后追加:

<LikeIncomeBoard
  :stats="data.likeIncome ? { total_like_count: data.likeIncome.total_like_count, total_income: data.likeIncome.total_income } : null"
  :levels="data.likeIncome?.levels || []"
  :loading="loading.likeIncome"
  :error="error.likeIncome"
  @retry="refresh('likeIncome')"
/>

components 追加 LikeIncomeBoard

  • Step 3: 手动验证 + Commit
git add frontend/pages/dashboard/components/LikeIncomeBoard.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): LikeIncomeBoard 左侧统计+右侧等级列表"

Task 10: CollectionMatrix + TopFiveAssets

Files:

  • Create: frontend/pages/dashboard/components/CollectionMatrix.vue

  • Create: frontend/pages/dashboard/components/TopFiveAssets.vue

  • Step 1: 完整实现 TopFiveAssets.vue

<template>
  <view class="top-five-card">
    <text class="card-title">历史藏品收益 TOP 5</text>
    <view v-if="!items || items.length === 0" class="empty-row">
      <text class="empty-text">暂无数据</text>
    </view>
    <view v-else class="top-five-row">
      <view
        v-for="item in items"
        :key="item.asset_id"
        class="top-cell"
      >
        <view class="top-thumb" :class="`top-grad-${item.rank}`">
          <text class="top-emoji">🏆</text>
        </view>
        <view class="top-badge" :class="`top-badge-${item.rank}`">
          <text class="top-badge-text">TOP {{ item.rank }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'TopFiveAssets',
  props: {
    items: { type: Array, default: () => [] }, // TopAssetItem[]
  },
}
</script>

<style lang="scss" scoped>
.top-five-card {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 17rpx;
  padding: 20rpx;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.card-title {
  display: block;
  font-size: 28rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
}

.top-five-row {
  display: flex;
  justify-content: space-between;
  gap: 8rpx;
}

.top-cell {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.top-thumb {
  width: 100rpx;
  height: 100rpx;
  border-radius: 12rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 8rpx;
  box-shadow: 0 0 12px rgba(255, 200, 100, 0.3);
}

.top-grad-1 { background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); }
.top-grad-2 { background: linear-gradient(135deg, #C0C0C0 0%, #808080 100%); }
.top-grad-3 { background: linear-gradient(135deg, #CD7F32 0%, #8B4513 100%); }
.top-grad-4 { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.top-grad-5 { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }

.top-emoji {
  font-size: 48rpx;
}

.top-badge {
  padding: 4rpx 12rpx;
  border-radius: 8rpx;
  opacity: 0.85;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.top-badge-1 { background: linear-gradient(90deg, #FFD700 0%, #FFA500 100%); }
.top-badge-2 { background: linear-gradient(90deg, #C0C0C0 0%, #808080 100%); }
.top-badge-3 { background: linear-gradient(90deg, #CD7F32 0%, #8B4513 100%); }
.top-badge-4 { background: linear-gradient(90deg, #B17BFF 0%, #FF8FE6 100%); }
.top-badge-5 { background: linear-gradient(90deg, #5EDFFF 0%, #6FA9FF 100%); }

.top-badge-text {
  font-size: 18rpx;
  font-weight: 700;
  color: #ffffff;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}

.empty-row {
  height: 120rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.empty-text {
  color: rgba(255, 255, 255, 0.5);
  font-size: 24rpx;
}
</style>
  • Step 2: 完整实现 CollectionMatrix.vue容器先只接 TopFiveAssets
<template>
  <view class="collection-matrix">
    <text class="section-title">藏品矩阵</text>

    <!-- TOP 5 -->
    <TopFiveAssets :items="topFive || []" />

    <!-- 后续 Task 11/12 在此追加 LevelDistribution / UpcomingUpgrades / RecentUpgrades -->
  </view>
</template>

<script>
import TopFiveAssets from './TopFiveAssets.vue'

export default {
  name: 'CollectionMatrix',
  components: { TopFiveAssets },
  props: {
    topFive: { type: Array, default: () => [] },
    levels: { type: Array, default: () => [] },
    upgrades: { type: Object, default: () => ({ upcoming: [], recent: [] }) },
  },
}
</script>

<style lang="scss" scoped>
.collection-matrix {
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(10px);
  border-radius: 22rpx;
  padding: 24rpx;
  margin: 24rpx 0;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.section-title {
  display: block;
  font-size: 36rpx;
  font-weight: 700;
  color: #ffffff;
  margin-bottom: 24rpx;
  text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}
</style>
  • Step 3: 在 dashboard.vue 中挂载

在 LikeIncomeBoard 后追加:

<CollectionMatrix
  :top-five="data.topAssets"
  :levels="data.levels"
  :upgrades="data.upgrades"
/>

components 追加 CollectionMatrix

  • Step 4: 手动验证 + Commit
git add frontend/pages/dashboard/components/TopFiveAssets.vue frontend/pages/dashboard/components/CollectionMatrix.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): CollectionMatrix 容器 + TopFiveAssets"

Task 11: LevelDistribution 组件5 个 conic-gradient 环形图)

Files:

  • Create: frontend/pages/dashboard/components/LevelDistribution.vue

  • Step 1: 完整实现

<template>
  <view class="level-distribution">
    <text class="card-title">藏品等级分布</text>
    <view v-if="!items || items.length === 0" class="empty-row">
      <text class="empty-text">暂无数据</text>
    </view>
    <view v-else class="ring-row">
      <view
        v-for="item in items"
        :key="item.level"
        class="ring-cell"
      >
        <view
          class="ring-outer"
          :style="getRingStyle(item)"
        >
          <view class="ring-inner">
            <text class="ring-count">{{ item.count }}</text>
          </view>
        </view>
        <text class="ring-label">{{ item.level }}</text>
        <text class="ring-pct">{{ getPercent(item) }}%</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'LevelDistribution',
  props: {
    items: { type: Array, default: () => [] }, // AssetLevelItem[]
  },
  setup() {
    function getPercent(item) {
      if (!item.total) return 0
      return Math.round((item.count / item.total) * 100)
    }
    function getRingStyle(item) {
      const pct = getPercent(item)
      const colorMap = {
        UR: '#FF8A65, #FFD740',
        SSR: '#FF5E9C, #FFB199',
        SR: '#B17BFF, #FF8FE6',
        R: '#5EDFFF, #6FA9FF',
        N: '#C5C5C5, #8C8C8C',
      }
      const colors = colorMap[item.level] || '#999, #ccc'
      // 占位 0 显示灰色环
      if (pct === 0) {
        return {
          background: 'conic-gradient(rgba(255,255,255,0.1) 0deg, rgba(255,255,255,0.1) 360deg)',
        }
      }
      // 渐变环从顶部开始pct% 处停止
      return {
        background: `conic-gradient(${colors} 0deg ${pct * 3.6}deg, rgba(255,255,255,0.1) ${pct * 3.6}deg 360deg)`,
      }
    }
    return { getPercent, getRingStyle }
  },
}
</script>

<style lang="scss" scoped>
.level-distribution {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 17rpx;
  padding: 20rpx;
  margin-top: 16rpx;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.card-title {
  display: block;
  font-size: 28rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
}

.ring-row {
  display: flex;
  justify-content: space-around;
  align-items: center;
  padding: 16rpx 0;
}

.ring-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  flex: 1;
}

.ring-outer {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  box-shadow: 0 0 12px rgba(255, 255, 255, 0.15);
}

.ring-inner {
  width: 60rpx;
  height: 60rpx;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  backdrop-filter: blur(4px);
}

.ring-count {
  font-size: 24rpx;
  font-weight: 700;
  color: #FFFABD;
  font-family: 'Baloo Bhai', sans-serif;
}

.ring-label {
  margin-top: 8rpx;
  font-size: 20rpx;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.9);
}

.ring-pct {
  font-size: 18rpx;
  color: rgba(255, 255, 255, 0.6);
  margin-top: 2rpx;
}

.empty-row {
  height: 120rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.empty-text {
  color: rgba(255, 255, 255, 0.5);
  font-size: 24rpx;
}
</style>
  • Step 2: 在 CollectionMatrix.vue 中挂载

<TopFiveAssets> 后追加:

<LevelDistribution :items="levels" />

并追加 import 和 components 注册。

  • Step 3: 手动验证 + Commit
git add frontend/pages/dashboard/components/LevelDistribution.vue frontend/pages/dashboard/components/CollectionMatrix.vue
git commit -m "feat(dashboard): LevelDistribution 5个conic-gradient环形图"

Task 12: UpcomingUpgrades + RecentUpgrades

Files:

  • Create: frontend/pages/dashboard/components/UpcomingUpgrades.vue

  • Create: frontend/pages/dashboard/components/RecentUpgrades.vue

  • Step 1: 完整实现 UpcomingUpgrades.vue

<template>
  <view class="upcoming-upgrades">
    <text class="card-title">即将升级</text>
    <view v-if="!items || items.length === 0" class="empty-row">
      <text class="empty-text">暂无数据</text>
    </view>
    <view v-else class="upgrades-list">
      <view
        v-for="item in items"
        :key="item.asset_id"
        class="upgrade-row"
      >
        <view class="upgrade-thumb">
          <view class="thumb-circle" :style="{ background: 'linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%)' }">
            <text class="thumb-letter">{{ item.asset_name[0] }}</text>
          </view>
        </view>
        <view class="upgrade-progress">
          <view class="progress-bar progress-cyan">
            <view class="progress-fill" :style="{ width: item.like_progress + '%' }"></view>
            <text class="progress-text">{{ item.like_progress }}%</text>
          </view>
          <view class="progress-bar progress-pink">
            <view class="progress-fill" :style="{ width: item.duration_progress + '%' }"></view>
            <text class="progress-text">{{ item.duration_progress }}%</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'UpcomingUpgrades',
  props: {
    items: { type: Array, default: () => [] }, // UpcomingLevelUpItem[]
  },
}
</script>

<style lang="scss" scoped>
.upcoming-upgrades {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 17rpx;
  padding: 20rpx;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.card-title {
  display: block;
  font-size: 28rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
}

.upgrades-list {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}

.upgrade-row {
  display: flex;
  align-items: center;
  gap: 16rpx;
}

.upgrade-thumb {
  width: 64rpx;
  height: 64rpx;
  flex-shrink: 0;
}

.thumb-circle {
  width: 100%;
  height: 100%;
  border-radius: 12rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}

.thumb-letter {
  font-size: 24rpx;
  font-weight: 700;
  color: #ffffff;
}

.upgrade-progress {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 6rpx;
}

.progress-bar {
  position: relative;
  height: 16rpx;
  border-radius: 6rpx;
  background: rgba(217, 217, 217, 0.2);
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  border-radius: 6rpx;
  transition: width 0.3s ease;
}

.progress-cyan .progress-fill {
  background: linear-gradient(90deg, #5EDFFF 0%, #FFC8C8 100%);
}

.progress-pink .progress-fill {
  background: linear-gradient(90deg, #FFF375 0%, #FF6B84 100%);
}

.progress-text {
  position: absolute;
  right: 6rpx;
  top: 50%;
  transform: translateY(-50%);
  font-size: 16rpx;
  font-weight: 700;
  color: #ffffff;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}

.empty-row {
  height: 120rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.empty-text {
  color: rgba(255, 255, 255, 0.5);
  font-size: 24rpx;
}
</style>
  • Step 2: 完整实现 RecentUpgrades.vue
<template>
  <view class="recent-upgrades">
    <text class="card-title">最近升级</text>
    <view v-if="!items || items.length === 0" class="empty-row">
      <text class="empty-text">暂无数据</text>
    </view>
    <view v-else class="upgrades-list">
      <view
        v-for="item in items"
        :key="item.asset_id"
        class="upgrade-row"
      >
        <view class="upgrade-thumb">
          <view class="thumb-circle" :style="{ background: 'linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%)' }">
            <text class="thumb-letter">{{ item.asset_name[0] }}</text>
          </view>
        </view>
        <view class="upgrade-info">
          <text class="upgrade-name">{{ item.asset_name }}</text>
          <text class="upgrade-time">{{ formatTime(item.upgrade_time) }}</text>
        </view>
        <view class="level-badge" :class="`level-${item.new_level}`">
          <text class="level-letter">{{ item.new_level }}</text>
          <text class="lv-up">Lv UP</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'RecentUpgrades',
  props: {
    items: { type: Array, default: () => [] }, // RecentLevelUpItem[]
  },
  setup() {
    function formatTime(ts) {
      const d = new Date(ts)
      const now = Date.now()
      const diff = now - ts
      if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)} 分钟前`
      if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)} 小时前`
      return `${Math.floor(diff / 86400000)} 天前`
    }
    return { formatTime }
  },
}
</script>

<style lang="scss" scoped>
.recent-upgrades {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 17rpx;
  padding: 20rpx;
  box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}

.card-title {
  display: block;
  font-size: 28rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
}

.upgrades-list {
  display: flex;
  flex-direction: column;
  gap: 12rpx;
}

.upgrade-row {
  display: flex;
  align-items: center;
  gap: 12rpx;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 12rpx;
  padding: 10rpx;
}

.upgrade-thumb {
  width: 56rpx;
  height: 56rpx;
  flex-shrink: 0;
}

.thumb-circle {
  width: 100%;
  height: 100%;
  border-radius: 12rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}

.thumb-letter {
  font-size: 22rpx;
  font-weight: 700;
  color: #ffffff;
}

.upgrade-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 4rpx;
  min-width: 0;
}

.upgrade-name {
  font-size: 22rpx;
  color: #ffffff;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.upgrade-time {
  font-size: 18rpx;
  color: rgba(255, 255, 255, 0.6);
}

.level-badge {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 6rpx 12rpx;
  border-radius: 8rpx;
  flex-shrink: 0;
}

.level-UR { background: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%); }
.level-SSR { background: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%); }
.level-SR { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.level-R { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.level-N { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }

.level-letter {
  font-size: 20rpx;
  font-weight: 700;
  color: #ffffff;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}

.lv-up {
  font-size: 14rpx;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.9);
}

.empty-row {
  height: 120rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.empty-text {
  color: rgba(255, 255, 255, 0.5);
  font-size: 24rpx;
}
</style>
  • Step 3: 在 CollectionMatrix.vue 中挂载(两列布局)

替换 CollectionMatrix.vue 的 template 为:

<template>
  <view class="collection-matrix">
    <text class="section-title">藏品矩阵</text>
    <TopFiveAssets :items="topFive || []" />
    <LevelDistribution :items="levels || []" />
    <view class="upgrades-two-col">
      <UpcomingUpgrades :items="upgrades?.upcoming || []" />
      <RecentUpgrades :items="upgrades?.recent || []" />
    </view>
  </view>
</template>

并在 <style> 中追加:

.upgrades-two-col {
  display: flex;
  gap: 12rpx;
  margin-top: 16rpx;

  > * {
    flex: 1;
  }
}

<script> 中追加 import 与 components 注册:

import LevelDistribution from './LevelDistribution.vue'
import UpcomingUpgrades from './UpcomingUpgrades.vue'
import RecentUpgrades from './RecentUpgrades.vue'

components: { TopFiveAssets, LevelDistribution, UpcomingUpgrades, RecentUpgrades },
  • Step 4: 手动验证 + Commit
git add frontend/pages/dashboard/components/UpcomingUpgrades.vue frontend/pages/dashboard/components/RecentUpgrades.vue frontend/pages/dashboard/components/CollectionMatrix.vue
git commit -m "feat(dashboard): UpcomingUpgrades + RecentUpgrades 双列布局"

Task 13: onShow 刷新 + Tab 缓存 + 下拉刷新

Files:

  • Modify: frontend/pages/dashboard/dashboard.vue(整文件重写为完整版)

  • Modify: frontend/pages.json启用 enablePullDownRefresh;下拉刷新由 scroll-view 的 :refresher-enabled 接管,避免双触发冲突)

  • Step 1: 整文件替换 frontend/pages/dashboard/dashboard.vue

直接用以下完整内容覆盖整个文件(删除 Task 4 写入的占位版本、Task 6/8/9/10/12 追加挂载的中间版本):

<template>
  <scroll-view
    scroll-y
    class="dashboard-page-bg dashboard-scroll"
    :refresher-enabled="true"
    :refresher-triggered="loading.overall"
    @refresherrefresh="handlePullDownRefresh"
  >
    <view class="dashboard-container">
      <DashboardHeader
        :active-tab="activeTab"
        @update:active-tab="handleTabChange"
      />

      <!-- Tab 1: 水晶相关 -->
      <view v-if="activeTab === 'crystal'" class="dashboard-content">
        <CrystalOverview
          :data="data.today"
          :loading="loading.today"
          :error="error.today"
          @retry="refresh('today')"
        />
        <IncomeCurve
          :points="data.curve?.points || []"
          :loading="loading.curve"
          :error="error.curve"
          @retry="refresh('curve')"
        />
        <ExhibitionCenter
          :data="data.exhibition"
          :loading="loading.exhibition"
          :error="error.exhibition"
          @retry="refresh('exhibition')"
        />
        <LikeIncomeBoard
          :stats="data.likeIncome ? { total_like_count: data.likeIncome.total_like_count, total_income: data.likeIncome.total_income } : null"
          :levels="data.likeIncome?.levels || []"
          :loading="loading.likeIncome"
          :error="error.likeIncome"
          @retry="refresh('likeIncome')"
        />
        <CollectionMatrix
          :top-five="data.topAssets"
          :levels="data.levels"
          :upgrades="data.upgrades"
        />
      </view>

      <!-- Tab 2: 赛季总览(占位) -->
      <view v-else class="dashboard-content">
        <view class="season-placeholder">
          <text class="placeholder-icon">🏆</text>
          <text class="placeholder-title">赛季总览 · 即将上线</text>
          <text class="placeholder-sub">历史赛季数据正在筹备中</text>
        </view>
      </view>
    </view>
  </scroll-view>
</template>

<script>
import { ref, onUnmounted } from 'vue'
import DashboardHeader from './components/DashboardHeader.vue'
import CrystalOverview from './components/CrystalOverview.vue'
import IncomeCurve from './components/IncomeCurve.vue'
import ExhibitionCenter from './components/ExhibitionCenter.vue'
import LikeIncomeBoard from './components/LikeIncomeBoard.vue'
import CollectionMatrix from './components/CollectionMatrix.vue'
import { useDashboardData } from '@/composables/useDashboardData'

export default {
  components: { DashboardHeader, CrystalOverview, IncomeCurve, ExhibitionCenter, LikeIncomeBoard, CollectionMatrix },
  setup() {
    const activeTab = ref('crystal')
    const starId = ref(uni.getStorageSync('star_id') || null)

    const { loading, error, data, refresh, isReady, lastFetched, dispose } = useDashboardData({
      starId: starId.value,
    })

    // Tab 切换30 分钟内复用缓存;切回水晶 Tab 时 cache-aware 刷新
    function handleTabChange(tab) {
      activeTab.value = tab
      if (tab === 'crystal') {
        // refresh() 默认走 30 分钟缓存(不闪骨架屏);需要强刷用 refresh(null, true)
        refresh()
      }
    }

    // 下拉刷新
    async function handlePullDownRefresh() {
      await refresh(null, true)  // force=true 绕缓存
      uni.stopPullDownRefresh()
    }

    onUnmounted(() => dispose())

    return {
      activeTab,
      loading,
      error,
      data,
      isReady,
      lastFetched,
      handleTabChange,
      handlePullDownRefresh,
      refresh,
    }
  },
  // uni-app 页面级生命周期(必须在 export default 顶层,不能放 setup 内)
  onShow() {
    // 从其他页面返回时强制刷新(绕 30 分钟缓存spec §4.1
    if (this.refresh) this.refresh(null, true)
  },
}
</script>

<style lang="scss" scoped>
.dashboard-page-bg {
  background: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);
  min-height: 100vh;
}
.dashboard-scroll {
  height: 100vh;
}
.dashboard-container {
  min-height: 100vh;
}
.dashboard-content {
  padding: 24rpx 32rpx 80rpx;
}
.season-placeholder {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(10px);
  border-radius: 22rpx;
  padding: 120rpx 32rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.placeholder-icon { font-size: 96rpx; margin-bottom: 24rpx; }
.placeholder-title { color: #ffffff; font-size: 36rpx; font-weight: 700; margin-bottom: 16rpx; }
.placeholder-sub { color: rgba(255, 255, 255, 0.7); font-size: 26rpx; }
</style>
  • Step 2: 确认 pages.json 中 dashboard 条目未启用 enablePullDownRefresh

Task 1 写入的 dashboard 条目已正确(不包含 enablePullDownRefresh)。不要手动加这个字段——下拉刷新由 scroll-view 的 :refresher-enabled="true" 接管,两者重复会冲突。

  • Step 3: 手动验证

刷新 H5 dev URL

  • 拖动下拉 → 触发强制刷新loading.overall 短暂为 true

  • 切换 Tab 1 → Tab 2 → Tab 130 分钟内 cache-aware 静默刷新(不闪骨架)

  • 离开页面到其他页面 → 返回onShow 触发 force refresh

  • 关闭网络DevTools Network → Offline→ 各 section 显示错误态

  • 恢复网络 → 点击错误卡片 → 该 section 重试成功

  • Step 4: 验证 Figma 视觉一致性

superpowers:verification-before-completion 流程对照 docs/figma-analysis-data-dashboard.md 第三节设计 token 逐项核对:

  • 颜色 token 与 §3.1 一致

  • 阴影 token 与 §3.2 一致

  • 圆角与 §3.3 一致

  • 5 个等级色与 §2.7.2 一致

  • Step 5: Commit

git add frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): onShow 强制刷新 + Tab 缓存 + 下拉刷新scroll-view refresher"

Task 14: 全链路手动验证

Files: 无(验证任务)

  • Step 1: 执行 superpowers:verification-before-completion 流程

逐项对照 spec §8.2 验收清单:

预期 实际 通过
视觉对齐 Figma 稿 6 模块结构、token 一致
Tab 切换不重发请求 30 分钟内切回 Tab 1无网络请求
下拉刷新拉新数据 拖动触发loading.overall 亮起
飞行模式各 section 独立重试 DevTools Offline → 各 section 显示错误态 → 恢复后点击重试成功
后端 401 跳登录 临时改 mock 抛 401 → 跳登录页
移动端 320px 小屏无溢出 Chrome DevTools 切到 320×568无横向滚动条
uCharts 渲染正常 七日柱状+折线显示peak 高亮
5 个等级环渐变正确 UR/SSR/SR/R/N 渐变色与 spec 一致
Tab 2 占位 "赛季总览 · 即将上线",无网络请求
onShow 强制刷新 离开页面到任意页 → 返回 → 看到新数据
  • Step 2: 通过所有项 → 标记 done

如全部通过,本任务可视为完成;如有失败项,回到对应 Task 修复后重跑本表

如有任何项需要修复但没有对应 Task例如"uCharts 主题色微调"),追加新 Task 而非挤入本任务。

  • Step 3: 不需要 commit验证任务无代码变更


风险与决策记录

风险 应对
uCharts 在小程序/App 端需单独配置 Task 7 留 fallback 占位App 端后续用 qiun-data-charts 原生插件补充
后端接口契约变更 proto 是单一信源spec §4.2mock 与接口字段同步更新
视觉稿 1:1 像素级还原 Task 14 末提供核对清单;超出 token 范围的微调由 UI 评审
7 接口并发 401 处理 utils/api.js 已有统一处理(清除 token + 跳登录),无需 dashboard 额外处理

后续可扩展(不在本计划)

  • Tab 2 "赛季总览" 真实数据spec §7.3
  • IncomeCurve 点击柱子弹窗spec §7.3
  • 升级提醒推送spec §7.3
  • 视觉资源Figma 素材)批量下载与替换占位

变更记录

日期 变更 作者
2026-06-02 初版 Claude