阻塞修复: 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>
2889 lines
74 KiB
Markdown
2889 lines
74 KiB
Markdown
# 数据看板前端 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 插件(柱状+折线)+ SCSS(uni.scss 共享 token)+ CSS conic-gradient(5 个等级环)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-02-data-dashboard-frontend-design.md`
|
||
**配套视觉/API 文档:** `docs/figma-analysis-data-dashboard.md`
|
||
|
||
---
|
||
|
||
## 文件总览
|
||
|
||
**Create(13 个)**:
|
||
- `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`
|
||
|
||
**Modify(3 个)**:
|
||
- `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-aware(30 分钟内复用)
|
||
// - 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) // 预期: false(load 完自动归位)
|
||
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 composable(7接口并发+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 console:1.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 1:30 分钟内 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.2),mock 与接口字段同步更新 |
|
||
| 视觉稿 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 |
|