topfans/docs/superpowers/plans/2026-06-02-data-dashboard-frontend.md
zheng020 702fc1d383 docs: 数据看板前端实现 plan
14 个任务,2-3 天工作量(前端部分),按 M2-M6 阶段分组。
不引入测试框架,验证走手动核对清单。

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

2983 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`
---
## 文件总览
**Create15 个)**:
- `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/pages/dashboard/components/SectionSkeleton.vue`
- `frontend/pages/dashboard/components/EmptyState.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: 手动验证 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 4: Commit**
```bash
git add frontend/utils/mock/dashboard.js frontend/utils/api.js
git commit -m "feat(dashboard): 追加 mock 数据工厂 + dashboardApi 命名空间"
```
---
## 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
}
}
// —— 局部刷新 ——
async function refresh(section) {
if (!section) {
return loadAll(true)
}
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
await loadSection(section, fetcherMap[section])
}
// —— 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`
- Create: `frontend/pages/dashboard/chart-theme.json`uCharts 主题配置)
- [ ] **Step 1: 创建 uCharts 主题配置 `frontend/pages/dashboard/chart-theme.json`**
> **注意**:此文件路径在 Vue 项目中通过 `?url` 或 `import` 引入。H5 用 `import`,小程序/App 走条件编译。
```json
{
"type": "barline",
"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,
"activeBgColor": "#000000",
"activeBgOpacity": 0.1,
"linear": true,
"color": ["#FFDF77", "#B984FF", "#FF8183"]
},
"line": {
"type": "curve",
"width": 2,
"activeType": "hollow"
}
}
}
```
- [ ] **Step 2: 完整实现 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>
// #ifdef H5
import { ref, computed } from 'vue'
// #endif
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)
// #ifdef H5
const 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' },
],
}
})
const 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 }
},
}
</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/pages/dashboard/chart-theme.json 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: 24r 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: SectionSkeleton + EmptyState 通用组件
**Files:**
- Create: `frontend/pages/dashboard/components/SectionSkeleton.vue`
- Create: `frontend/pages/dashboard/components/EmptyState.vue`
- [ ] **Step 1: 完整实现 SectionSkeleton.vue**
```vue
<template>
<view class="section-skeleton" :class="`skeleton-${variant}`">
<view v-if="variant === 'card'" class="sk-card">
<view class="sk-label"></view>
<view class="sk-value"></view>
</view>
<view v-else-if="variant === 'list'" class="sk-list">
<view v-for="i in 3" :key="i" class="sk-row"></view>
</view>
<view v-else-if="variant === 'chart'" class="sk-chart"></view>
<view v-else-if="variant === 'matrix'" class="sk-matrix">
<view class="sk-row"></view>
<view class="sk-row"></view>
<view class="sk-row"></view>
</view>
</view>
</template>
<script>
export default {
name: 'SectionSkeleton',
props: {
variant: { type: String, default: 'card' }, // card | list | chart | matrix
},
}
</script>
<style lang="scss" scoped>
.section-skeleton {
background: rgba(255, 255, 255, 0.08);
border-radius: 17rpx;
padding: 24rpx;
margin: 16rpx 0;
}
.sk-card,
.sk-list,
.sk-chart,
.sk-matrix {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.sk-card {
align-items: center;
}
.sk-label {
width: 40%;
height: 24rpx;
border-radius: 6rpx;
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: shimmer 1.5s infinite;
}
.sk-value {
width: 60%;
height: 48rpx;
border-radius: 8rpx;
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: shimmer 1.5s infinite;
}
.sk-row {
width: 100%;
height: 64rpx;
border-radius: 12rpx;
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: shimmer 1.5s infinite;
}
.sk-chart {
width: 100%;
height: 240rpx;
border-radius: 14rpx;
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: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
```
- [ ] **Step 2: 完整实现 EmptyState.vue**
```vue
<template>
<view class="empty-state">
<text class="empty-icon">{{ icon }}</text>
<text class="empty-text">{{ text }}</text>
</view>
</template>
<script>
export default {
name: 'EmptyState',
props: {
text: { type: String, default: '暂无数据' },
icon: { type: String, default: '📭' },
},
}
</script>
<style lang="scss" scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 32rpx;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 16rpx;
opacity: 0.6;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 26rpx;
}
</style>
```
- [ ] **Step 3: Commit**
```bash
git add frontend/pages/dashboard/components/SectionSkeleton.vue frontend/pages/dashboard/components/EmptyState.vue
git commit -m "feat(dashboard): SectionSkeleton + EmptyState 通用组件"
```
> **注**:本任务暂不替换各 section 内的内联骨架(已能用),后续若需要统一可在各组件中替换 `<view class="skeleton-...">` 为 `<SectionSkeleton variant="..." />`。
---
## Task 14: onShow 刷新 + Tab 缓存 + 下拉刷新
**Files:**
- Modify: `frontend/pages/dashboard/dashboard.vue`
- [ ] **Step 1: 加入 onShow 静默刷新 + Tab 缓存**
完整替换 `<script setup>` 部分(在 `export default` 内):
```javascript
import { ref, computed, onMounted, 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 时静默刷新
function handleTabChange(tab) {
activeTab.value = tab
if (tab === 'crystal') {
// 静默后台刷新(不显示 loading 覆盖,因为数据已存在)
refresh()
}
}
// 下拉刷新
async function handlePullDownRefresh() {
await refresh()
uni.stopPullDownRefresh()
}
onMounted(() => {
// 首屏已由 composable 自动 loadAll()
})
onUnmounted(() => {
dispose()
})
return {
activeTab,
loading,
error,
data,
isReady,
lastFetched,
handleTabChange,
handlePullDownRefresh,
pageBg: 'linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%)',
}
},
}
```
并在 `<template>` 顶部加 pull-down-refresh 包装uni-app 页面配置):
`<view class="dashboard-container" :style="{ background: pageBg }">` 改为:
```vue
<scroll-view
scroll-y
class="dashboard-scroll"
:style="{ background: pageBg }"
@scrolltolower="handlePullDownRefresh"
:refresher-enabled="true"
:refresher-triggered="loading.overall"
@refresherrefresh="handlePullDownRefresh"
>
<view class="dashboard-container">
...原内容
</view>
</scroll-view>
```
`<style>` 中加:
```scss
.dashboard-scroll {
height: 100vh;
}
```
- [ ] **Step 2: 启用页面下拉刷新**
修改 `frontend/pages.json` 中 dashboard 条目:
```json
,{
"path": "pages/dashboard/dashboard",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"enablePullDownRefresh": true
}
}
```
- [ ] **Step 3: 手动验证**
刷新 H5 dev URL
- 拖动下拉 → 触发刷新loading 短暂亮起)
- 切换 Tab 1 → Tab 2 → Tab 1第一次切回 Tab 1 时触发静默 refresh
- 离开页面后再次进入composable dispose 已调用
- [ ] **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 frontend/pages.json
git commit -m "feat(dashboard): onShow 静默刷新 + Tab 缓存 + 下拉刷新"
```
---
## 风险与决策记录
| 风险 | 应对 |
|------|------|
| 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 |