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

2889 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 数据看板前端 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 个聚合 composable`useDashboardData`+ 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`)之后,逗号之后追加:
```json
,{
"path": "pages/dashboard/dashboard",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
}
```
- [ ] **Step 2: 在 `uni.scss` 末尾追加 dashboard token**
```scss
/* ==================== 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如果已在跑则跳过
```bash
cd frontend && npm run dev:h5
```
在 H5 dev URL 末尾加 `/pages/dashboard/dashboard` 访问:
- 预期:进入空页面(白屏即可,组件尚未创建)
- 不预期:路由 404、JSON 解析错误
- [ ] **Step 4: Commit**
```bash
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`**
```javascript
// 数据看板 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` 命名空间**
```javascript
// ==================== 数据看板 ====================
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`
```javascript
// 第 9 行:
// 原始: const USE_MOCK_API = false
// 改为:
const USE_MOCK_API = true
```
> **切回真实接口**:后端 `/api/v1/dashboard/*` 7 个接口联调通过后,把这里改回 `false` 即可。
- [ ] **Step 4: 手动验证 mock 触发**
在 H5 dev console 跑:
```js
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**
```bash
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**
```javascript
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 跑:
```js
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: 验证局部刷新**
```js
// 在同一 console 继续
await refresh('curve')
console.log('loading.curve:', loading.value.curve) // 预期: falseload 完自动归位)
console.log('data.curve:', data.value.curve !== null) // true
```
- [ ] **Step 4: Commit**
```bash
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: 完整实现页面骨架**
```vue
<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`
```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**
```bash
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: 替换为完整实现**
```vue
<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**
```bash
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: 完整实现**
```vue
<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
```vue
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<view class="placeholder-section">
<text class="placeholder-text">骨架页组件将在 Task 5-10 添加</text>
</view>
</view>
```
替换为:
```vue
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<CrystalOverview
:data="data.today"
:loading="loading.today"
:error="error.today"
@retry="refresh('today')"
/>
</view>
```
并在 components 中追加:
```js
components: { DashboardHeader, CrystalOverview },
```
- [ ] **Step 3: 手动验证**
刷新 H5 dev URL
- 预期:双卡显示"水晶余额 2713"和"今日收益 + 213",金黄色数字 + 渐变背景
- 1.5s 后才显示loading 期间是骨架屏)
- [ ] **Step 4: Commit**
```bash
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**
```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 之后追加:
```vue
<IncomeCurve
:points="data.curve?.points || []"
:loading="loading.curve"
:error="error.curve"
@retry="refresh('curve')"
/>
```
components 追加 `IncomeCurve`
- [ ] **Step 4: 安装 uCharts**
```bash
cd frontend && npm install qiun-data-charts
```
如项目无 H5 组件库依赖关系导致构建失败,回退到 `chart-theme.json` + 占位文字Step 2 中已留 fallback
- [ ] **Step 5: 手动验证**
刷新 H5 dev URL
- 预期:渐变卡片 + "七日收益曲线" 标题 + 柱状图7 根柱,渐变填充)+ 折线(蓝黄渐变)+ 高亮当日 "+ 312" + 日期
- 加载中:渐变骨架屏闪烁
- [ ] **Step 6: Commit**
```bash
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: 完整实现**
```vue
<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 后追加:
```vue
<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**
```bash
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: 完整实现**
```vue
<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 后追加:
```vue
<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**
```bash
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**
```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**
```vue
<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 后追加:
```vue
<CollectionMatrix
:top-five="data.topAssets"
:levels="data.levels"
:upgrades="data.upgrades"
/>
```
components 追加 `CollectionMatrix`
- [ ] **Step 4: 手动验证 + Commit**
```bash
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: 完整实现**
```vue
<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>` 后追加:
```vue
<LevelDistribution :items="levels" />
```
并追加 import 和 components 注册。
- [ ] **Step 3: 手动验证 + Commit**
```bash
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**
```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**
```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 为:
```vue
<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>` 中追加:
```scss
.upgrades-two-col {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
> * {
flex: 1;
}
}
```
`<script>` 中追加 import 与 components 注册:
```js
import LevelDistribution from './LevelDistribution.vue'
import UpcomingUpgrades from './UpcomingUpgrades.vue'
import RecentUpgrades from './RecentUpgrades.vue'
components: { TopFiveAssets, LevelDistribution, UpcomingUpgrades, RecentUpgrades },
```
- [ ] **Step 4: 手动验证 + Commit**
```bash
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 追加挂载的中间版本):
```vue
<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**
```bash
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 |