阻塞修复: 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>
74 KiB
数据看板前端 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.vuefrontend/pages/dashboard/components/DashboardHeader.vuefrontend/pages/dashboard/components/CrystalOverview.vuefrontend/pages/dashboard/components/IncomeCurve.vuefrontend/pages/dashboard/components/ExhibitionCenter.vuefrontend/pages/dashboard/components/LikeIncomeBoard.vuefrontend/pages/dashboard/components/CollectionMatrix.vuefrontend/pages/dashboard/components/TopFiveAssets.vuefrontend/pages/dashboard/components/LevelDistribution.vuefrontend/pages/dashboard/components/UpcomingUpgrades.vuefrontend/pages/dashboard/components/RecentUpgrades.vuefrontend/composables/useDashboardData.jsfrontend/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)之后,逗号之后追加:
,{
"path": "pages/dashboard/dashboard",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
}
- Step 2: 在
uni.scss末尾追加 dashboard token
/* ==================== Dashboard 设计 Token ==================== */
/* 颜色(与 spec §3.1 一致) */
$d-text-data: #FFFABD;
$d-text-white: #FFFFFF;
$d-tab-active: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
$d-card-crystal: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
$d-card-today: linear-gradient(137deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
$d-progress-cyan: linear-gradient(90deg, #5EDFFF 0%, #FFC8C8 100%);
$d-progress-pink: linear-gradient(90deg, #FFF375 0%, #FF6B84 100%);
$d-bar-blue-yellow: linear-gradient(90deg, #1BAFEE 0%, #FFCC14 100%);
$d-bar-fill: linear-gradient(135deg, #FFDF77 0%, #B984FF 60%, #FF8183 100%);
$d-page-bg: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);
/* 5 个等级专属渐变(环图、徽章、TOP 徽章) */
$d-level-ur: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%);
$d-level-ssr: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%);
$d-level-sr: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%);
$d-level-r: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%);
$d-level-n: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%);
/* 阴影 */
$d-shadow-card: 0px 4px 4px rgba(189, 50, 50, 0.25);
$d-shadow-data: -1px 1px 4px rgba(206, 9, 9, 0.84);
$d-shadow-mascot: 2px 2px 4px rgba(242, 21, 21, 0.47);
$d-shadow-tab: 0px 4px 4px rgba(0, 0, 0, 0.25);
$d-shadow-inner: 0px 4px 4px rgba(96, 13, 13, 0.25);
/* 圆角 */
$d-radius-thumb: 3px;
$d-radius-progress: 6px;
$d-radius-card-sm: 14px;
$d-radius-card-md: 17px;
$d-radius-card-lg: 22px;
/* 字号 */
$d-fs-num-xl: 35px;
$d-fs-num-lg: 18px;
$d-fs-num-md: 14px;
$d-fs-num-sm: 9px;
$d-fs-title-lg: 20px;
$d-fs-title-md: 18px;
$d-fs-title-sm: 15px;
$d-fs-label: 12px;
/* 间距 */
$d-space-card-x: 16px;
$d-space-section: 24px;
$d-space-cell: 12px;
- Step 3: 手动验证 - 路由可达
启动 H5 dev server(如果已在跑则跳过):
cd frontend && npm run dev:h5
在 H5 dev URL 末尾加 /pages/dashboard/dashboard 访问:
-
预期:进入空页面(白屏即可,组件尚未创建)
-
不预期:路由 404、JSON 解析错误
-
Step 4: Commit
git add frontend/pages.json frontend/uni.scss
git commit -m "feat(dashboard): 注册页面 + 追加 SCSS 设计 token"
Task 2: Mock 数据 + dashboardApi 命名空间
Files:
-
Create:
frontend/utils/mock/dashboard.js -
Modify:
frontend/utils/api.js(追加dashboardApi+ 注入 mock 触发逻辑) -
Step 1: 创建 mock 数据文件
frontend/utils/mock/dashboard.js
// 数据看板 mock 数据工厂
// 后端就绪后,将 utils/api.js 顶部 USE_MOCK_API 改为 false 即可
const randomDelay = (min = 200, max = 600) =>
new Promise((resolve) => setTimeout(resolve, Math.random() * (max - min) + min))
// 1. 今日概览
export async function mockTodayOverview({ star_id }) {
await randomDelay()
return {
code: 200,
data: {
crystal_balance: 2713,
today_income: 213,
week_rank: 12,
},
}
}
// 2. 七日收益曲线
export async function mock7DayIncomeCurve({ star_id }) {
await randomDelay()
const today = new Date()
const points = []
const incomes = [180, 245, 198, 312, 276, 221, 189] // 最后一个是今天
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const income = incomes[6 - i]
points.push({
date: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`,
income,
is_today: i === 0,
is_peak: income === 312,
})
}
return {
code: 200,
data: {
points,
total_income: incomes.reduce((a, b) => a + b, 0),
avg_income: Math.round(incomes.reduce((a, b) => a + b, 0) / 7),
},
}
}
// 3. 展出收益中心
export async function mockExhibitionSummary({ star_id }) {
await randomDelay()
return {
code: 200,
data: {
exhibiting_count: 21,
starbook_count: 33,
total_duration: '712:13:56',
total_earnings: 39721,
top5: [
{ asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', duration_7d: '144:13:56', earnings_7d: 2173, avg_earnings: 15 },
{ asset_id: 2, asset_name: '夏日微风', asset_thumb: '', duration_7d: '77:13:56', earnings_7d: 1332, avg_earnings: 15 },
{ asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', duration_7d: '64:15:37', earnings_7d: 1201, avg_earnings: 12 },
{ asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', duration_7d: '51:22:12', earnings_7d: 783, avg_earnings: 12 },
{ asset_id: 5, asset_name: '深海回响', asset_thumb: '', duration_7d: '51:22:12', earnings_7d: 783, avg_earnings: 12 },
],
},
}
}
// 4. 点赞收益按等级
export async function mockLikeIncomeByLevel({ star_id }) {
await randomDelay()
return {
code: 200,
data: {
total_like_count: 231,
total_income: 12719,
levels: [
{ level: 'UR', asset_count: 1, total_income: 723, thumb: '' },
{ level: 'SSR', asset_count: 2, total_income: 381, thumb: '' },
{ level: 'SR', asset_count: 5, total_income: 233, thumb: '' },
{ level: 'SR', asset_count: 4, total_income: 169, thumb: '' },
{ level: 'R', asset_count: 6, total_income: 57, thumb: '' },
],
},
}
}
// 5. 藏品 TOP5
export async function mockTopAssets({ star_id }) {
await randomDelay()
return {
code: 200,
data: {
items: [
{ asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', total_earnings: 8420, rank: 1 },
{ asset_id: 2, asset_name: '夏日微风', asset_thumb: '', total_earnings: 6230, rank: 2 },
{ asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', total_earnings: 5180, rank: 3 },
{ asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', total_earnings: 4320, rank: 4 },
{ asset_id: 5, asset_name: '深海回响', asset_thumb: '', total_earnings: 3980, rank: 5 },
],
},
}
}
// 6. 藏品等级分布
export async function mockLevelDistribution({ star_id }) {
await randomDelay()
const total = 33
return {
code: 200,
data: {
items: [
{ level: 'UR', count: 1, total },
{ level: 'SSR', count: 2, total },
{ level: 'SR', count: 5, total },
{ level: 'R', count: 6, total },
{ level: 'N', count: 0, total },
],
},
}
}
// 7. 升级进度
export async function mockUpgradeProgress({ star_id }) {
await randomDelay()
return {
code: 200,
data: {
upcoming: [
{ asset_id: 1, asset_name: '璀璨星河', asset_thumb: '', like_progress: 73, duration_progress: 92 },
{ asset_id: 2, asset_name: '夏日微风', asset_thumb: '', like_progress: 75, duration_progress: 96 },
{ asset_id: 3, asset_name: '夜色霓虹', asset_thumb: '', like_progress: 97, duration_progress: 71 },
],
recent: [
{ asset_id: 4, asset_name: '黎明序曲', asset_thumb: '', new_level: 'SSR', upgrade_time: Date.now() - 3600000 },
{ asset_id: 5, asset_name: '深海回响', asset_thumb: '', new_level: 'SR', upgrade_time: Date.now() - 86400000 },
{ asset_id: 6, asset_name: '晨曦微光', asset_thumb: '', new_level: 'SR', upgrade_time: Date.now() - 172800000 },
],
},
}
}
// 路由表:endpoints 字符串 → mock 工厂
export const mockRouter = {
'/api/v1/dashboard/today-overview': mockTodayOverview,
'/api/v1/dashboard/income-curve': mock7DayIncomeCurve,
'/api/v1/dashboard/exhibition-summary': mockExhibitionSummary,
'/api/v1/dashboard/like-income-by-level': mockLikeIncomeByLevel,
'/api/v1/dashboard/top-assets': mockTopAssets,
'/api/v1/dashboard/level-distribution': mockLevelDistribution,
'/api/v1/dashboard/upgrade-progress': mockUpgradeProgress,
}
- Step 2: 在
frontend/utils/api.js末尾追加dashboardApi命名空间
// ==================== 数据看板 ====================
import { mockRouter } from './mock/dashboard'
const DASHBOARD_PREFIX = '/api/v1/dashboard'
// mock 触发器:当 USE_MOCK_API 为 true 时短路返回 mock 数据
// 后端就绪后将 USE_MOCK_API 改为 false 即可
async function dashboardRequest(endpoint, params = {}) {
if (USE_MOCK_API) {
const factory = mockRouter[`${DASHBOARD_PREFIX}${endpoint}`]
if (factory) return factory(params)
}
return request({ url: `${DASHBOARD_PREFIX}${endpoint}`, method: 'GET', data: params })
}
export const dashboardApi = {
getTodayOverview: (starId) => dashboardRequest('/today-overview', { star_id: starId }).then((r) => r.data),
get7DayIncomeCurve: (starId) => dashboardRequest('/income-curve', { star_id: starId }).then((r) => r.data),
getExhibitionSummary: (starId) => dashboardRequest('/exhibition-summary', { star_id: starId }).then((r) => r.data),
getLikeIncomeByLevel: (starId) => dashboardRequest('/like-income-by-level', { star_id: starId }).then((r) => r.data),
getTopAssets: (starId) => dashboardRequest('/top-assets', { star_id: starId }).then((r) => r.data),
getLevelDistribution: (starId) => dashboardRequest('/level-distribution', { star_id: starId }).then((r) => r.data),
getUpgradeProgress: (starId) => dashboardRequest('/upgrade-progress', { star_id: starId }).then((r) => r.data),
}
- Step 3: 打开
USE_MOCK_API开关(关键前置步骤)
重要:项目
frontend/utils/api.js第 9 行USE_MOCK_API默认是false,会直接请求真实后端。后端尚未实现,必须先翻成true才能用 mock 验证。
修改 frontend/utils/api.js:
// 第 9 行:
// 原始: const USE_MOCK_API = false
// 改为:
const USE_MOCK_API = true
切回真实接口:后端
/api/v1/dashboard/*7 个接口联调通过后,把这里改回false即可。
- Step 4: 手动验证 mock 触发
在 H5 dev console 跑:
const { dashboardApi } = await import('/utils/api.js')
const today = await dashboardApi.getTodayOverview(1)
console.log(today) // 预期: { crystal_balance: 2713, today_income: 213, week_rank: 12 }
- Step 5: Commit
git add frontend/utils/mock/dashboard.js frontend/utils/api.js
git commit -m "feat(dashboard): 追加 mock 数据工厂 + dashboardApi 命名空间 + 打开 USE_MOCK_API"
Task 3: useDashboardData Composable
Files:
-
Create:
frontend/composables/useDashboardData.js -
Step 1: 完整实现 composable
import { ref, computed, onScopeDisposal } from 'vue'
import { dashboardApi } from '@/utils/api'
/**
* 数据看板聚合 composable
* - 7 个接口并发(Promise.allSettled,单失败不阻塞)
* - 按 section 维度暴露 loading/error/data
* - 30 分钟内 refresh 不重发请求(lastFetched 缓存)
* - effectScope + onScopeDisposal 自动清理(页面 onUnmounted 调用 dispose)
*
* @param {object} options
* @param {number|null} options.starId 顶粉星城 ID
* @returns {UseDashboardDataReturn}
*/
export function useDashboardData({ starId = null } = {}) {
// —— 内部 state ——
const loading = ref({
overall: false,
today: false,
curve: false,
exhibition: false,
likeIncome: false,
topAssets: false,
levels: false,
upgrades: false,
})
const error = ref({
today: null,
curve: null,
exhibition: null,
likeIncome: null,
topAssets: null,
levels: null,
upgrades: null,
})
const data = ref({
today: null,
curve: null,
exhibition: null,
likeIncome: null,
topAssets: null,
levels: null,
upgrades: null,
})
const lastFetched = ref(0)
const STALE_MS = 30 * 60 * 1000 // 30 分钟
const isReady = computed(() => Object.values(data.value).every((v) => v !== null))
// —— 内部辅助:单 section 加载 ——
async function loadSection(section, fetcher) {
loading.value[section] = true
error.value[section] = null
try {
const result = await fetcher(starId)
data.value[section] = result
} catch (e) {
error.value[section] = e.message || '加载失败'
data.value[section] = null
} finally {
loading.value[section] = false
}
}
// —— 全量加载 ——
async function loadAll(force = false) {
if (!force && Date.now() - lastFetched.value < STALE_MS) return
loading.value.overall = true
try {
// 并发执行,单个失败由 loadSection 内部捕获
await Promise.allSettled([
loadSection('today', dashboardApi.getTodayOverview),
loadSection('curve', dashboardApi.get7DayIncomeCurve),
loadSection('exhibition', dashboardApi.getExhibitionSummary),
loadSection('likeIncome', dashboardApi.getLikeIncomeByLevel),
loadSection('topAssets', dashboardApi.getTopAssets),
loadSection('levels', dashboardApi.getLevelDistribution),
loadSection('upgrades', dashboardApi.getUpgradeProgress),
])
lastFetched.value = Date.now()
} finally {
loading.value.overall = false
}
}
// —— 局部刷新 ——
// - refresh(section) : 单 section 强制重拉(无缓存)
// - refresh() : 全量刷新,cache-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 跑:
const { useDashboardData } = await import('/composables/useDashboardData.js')
const { data, loading, isReady, refresh } = useDashboardData({ starId: 1 })
// 等 ~1 秒
setTimeout(() => {
console.log('isReady:', isReady.value) // true
console.log('today:', data.value.today) // { crystal_balance: 2713, ... }
console.log('curve.points.length:', data.value.curve.points.length) // 7
console.log('exhibition.top5.length:', data.value.exhibition.top5.length) // 5
}, 1500)
预期:所有数据加载完成,形状如 mock。
- Step 3: 验证局部刷新
// 在同一 console 继续
await refresh('curve')
console.log('loading.curve:', loading.value.curve) // 预期: false(load 完自动归位)
console.log('data.curve:', data.value.curve !== null) // true
- Step 4: Commit
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: 完整实现页面骨架
<template>
<view class="dashboard-container" :style="{ background: pageBg }">
<!-- 顶部装饰背景 -->
<DashboardHeader
:active-tab="activeTab"
@update:active-tab="handleTabChange"
/>
<!-- Tab 1: 水晶相关 -->
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<view class="placeholder-section">
<text class="placeholder-text">骨架页(组件将在 Task 5-10 添加)</text>
</view>
</view>
<!-- Tab 2: 赛季总览(占位) -->
<view v-else class="dashboard-content">
<view class="season-placeholder">
<text class="placeholder-icon">🏆</text>
<text class="placeholder-title">赛季总览 · 即将上线</text>
<text class="placeholder-sub">历史赛季数据正在筹备中</text>
</view>
</view>
</view>
</template>
<script>
import { ref, onMounted, onUnmounted } from 'vue'
import DashboardHeader from './components/DashboardHeader.vue'
import { useDashboardData } from '@/composables/useDashboardData'
export default {
components: { DashboardHeader },
setup() {
const activeTab = ref('crystal')
const starId = ref(uni.getStorageSync('star_id') || null)
const { loading, error, data, refresh, isReady, dispose } = useDashboardData({
starId: starId.value,
})
function handleTabChange(tab) {
activeTab.value = tab
}
onMounted(() => {
// 首屏已由 composable 内部自动调用 loadAll()
// 此处预留 onMounted 钩子供未来扩展
})
onUnmounted(() => {
dispose()
})
return {
activeTab,
loading,
error,
data,
isReady,
handleTabChange,
pageBg: 'linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%)',
}
},
}
</script>
<style lang="scss" scoped>
.dashboard-container {
min-height: 100vh;
padding: 0;
}
.dashboard-content {
padding: 24rpx 32rpx 80rpx;
}
.placeholder-section {
background: rgba(255, 255, 255, 0.1);
border-radius: 22rpx;
padding: 80rpx 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
.season-placeholder {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 22rpx;
padding: 120rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.placeholder-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
color: #ffffff;
font-size: 36rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.placeholder-sub {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
}
</style>
- Step 2: 创建
DashboardHeader.vue占位(Task 5 会完整实现)
文件 frontend/pages/dashboard/components/DashboardHeader.vue:
<template>
<view class="dashboard-header">
<view class="header-bg"></view>
<view class="header-content">
<text class="header-title">数据看板</text>
<view class="header-tabs">
<view
:class="['tab', activeTab === 'crystal' ? 'tab-active' : '']"
@tap="$emit('update:activeTab', 'crystal')"
>
水晶相关
</view>
<view
:class="['tab', activeTab === 'season' ? 'tab-active' : '']"
@tap="$emit('update:activeTab', 'season')"
>
赛季总览
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'DashboardHeader',
props: {
activeTab: { type: String, default: 'crystal' },
},
emits: ['update:activeTab'],
}
</script>
<style lang="scss" scoped>
.dashboard-header {
position: relative;
height: 280rpx;
overflow: hidden;
}
.header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 700rpx;
background: linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 50%, #FF9C9C 86%, #FF2024 100%);
filter: blur(4px);
z-index: 0;
}
.header-content {
position: relative;
z-index: 1;
padding: 80rpx 32rpx 32rpx;
}
.header-title {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4);
text-align: center;
margin-bottom: 32rpx;
}
.header-tabs {
display: flex;
justify-content: center;
gap: 0;
background: rgba(0, 0, 0, 0.15);
border-radius: 22rpx;
padding: 6rpx;
margin: 0 100rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 16rpx 0;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.7);
border-radius: 22rpx;
transition: all 0.25s ease;
}
.tab-active {
background: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
color: #ffffff;
font-weight: 600;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
}
</style>
- Step 3: 手动验证骨架页
访问 H5 dev URL /pages/dashboard/dashboard:
-
预期:红色装饰背景 + "数据看板" 标题 + 两个 Tab + "骨架页" 占位文字
-
切换 Tab:右半部分在占位和"赛季总览"间切换
-
打开 devtools console:1.5 秒后
data.today等字段非空(可通过 Vue devtools 检查) -
Step 4: Commit
git add frontend/pages/dashboard/dashboard.vue frontend/pages/dashboard/components/DashboardHeader.vue
git commit -m "feat(dashboard): 页面骨架 + Header 占位"
Task 5: DashboardHeader 完整实现(毛绒怪 + 渐变标题)
Files:
-
Modify:
frontend/pages/dashboard/components/DashboardHeader.vue(替换占位为完整实现) -
Step 1: 替换为完整实现
<template>
<view class="dashboard-header">
<!-- 装饰渐变背景 -->
<view class="header-deco-bg"></view>
<!-- 装饰光晕(红粉色) -->
<view class="header-glow"></view>
<!-- 状态栏占位(iPhone 44px) -->
<view class="status-bar-placeholder"></view>
<view class="header-content">
<!-- 毛绒怪头像(占位:纯色块 + emoji) -->
<view class="mascot">
<text class="mascot-emoji">🐾</text>
</view>
<!-- 渐变标题 -->
<view class="title-wrap">
<text class="header-title">数据看板</text>
</view>
<!-- Tab 胶囊 -->
<view class="header-tabs">
<view
:class="['tab', activeTab === 'crystal' ? 'tab-active' : '']"
@tap="$emit('update:activeTab', 'crystal')"
>
水晶相关
</view>
<view
:class="['tab', activeTab === 'season' ? 'tab-active' : '']"
@tap="$emit('update:activeTab', 'season')"
>
赛季总览
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'DashboardHeader',
props: {
activeTab: { type: String, default: 'crystal' },
},
emits: ['update:activeTab'],
}
</script>
<style lang="scss" scoped>
.dashboard-header {
position: relative;
height: 360rpx;
overflow: hidden;
}
.status-bar-placeholder {
height: 44px;
}
.header-deco-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 700rpx;
background: linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 50%, #FF9C9C 86%, #FF2024 100%);
filter: blur(4px);
z-index: 0;
}
.header-glow {
position: absolute;
top: 100rpx;
left: 50%;
transform: translateX(-50%);
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba(255, 200, 100, 0.5) 0%, transparent 70%);
filter: blur(30px);
z-index: 1;
}
.header-content {
position: relative;
z-index: 2;
padding: 0 32rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.mascot {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 2px 2px 30px rgba(242, 21, 21, 0.47);
margin-bottom: 16rpx;
}
.mascot-emoji {
font-size: 64rpx;
}
.title-wrap {
margin-bottom: 24rpx;
}
.header-title {
font-size: 48rpx;
font-weight: 700;
background: linear-gradient(90deg, #FFE5B4 0%, #FFB199 50%, #FF8A95 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.4);
}
.header-tabs {
display: flex;
background: rgba(0, 0, 0, 0.15);
border-radius: 22rpx;
padding: 6rpx;
width: 100%;
max-width: 500rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 14rpx 0;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.75);
border-radius: 22rpx;
transition: all 0.25s ease;
}
.tab-active {
background: linear-gradient(231deg, #A8A6ED 0%, #88C8D8 64%, #F380EF 100%);
color: #ffffff;
font-weight: 600;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
}
</style>
- Step 2: 手动验证
访问 H5 dev URL /pages/dashboard/dashboard:
-
预期:粉红渐变背景 + 红色光晕 + 圆形毛绒怪占位(带红色光晕阴影)+ 渐变文字"数据看板"+ 紫蓝粉渐变 Tab 胶囊
-
切换 Tab:胶囊平滑滑动
-
Step 3: Commit
git add frontend/pages/dashboard/components/DashboardHeader.vue
git commit -m "feat(dashboard): Header 完整视觉(毛绒怪占位+渐变标题+Tab胶囊)"
Task 6: CrystalOverview 组件(顶部双卡)
Files:
-
Create:
frontend/pages/dashboard/components/CrystalOverview.vue -
Step 1: 完整实现
<template>
<view class="crystal-overview">
<!-- 错误态 -->
<view v-if="error" class="error-card" @tap="$emit('retry')">
<text class="error-text">加载失败,点击重试</text>
</view>
<!-- 骨架态 -->
<view v-else-if="loading || !data" class="skeleton-row">
<view v-for="i in 2" :key="i" class="skeleton-card"></view>
</view>
<!-- 正常态 -->
<view v-else class="card-row">
<view class="data-card card-crystal">
<text class="card-label">水晶余额</text>
<text class="card-value">{{ data.crystal_balance }}</text>
</view>
<view class="data-card card-today">
<text class="card-label">今日收益</text>
<text class="card-value">+ {{ data.today_income }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CrystalOverview',
props: {
data: { type: Object, default: null }, // { crystal_balance, today_income, week_rank? }
loading: { type: Boolean, default: false },
error: { type: String, default: null },
},
emits: ['retry'],
}
</script>
<style lang="scss" scoped>
.crystal-overview {
margin: 24rpx 0;
}
.card-row {
display: flex;
gap: 16rpx;
}
.data-card {
flex: 1;
height: 200rpx;
border-radius: 22rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.card-crystal {
background: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
}
.card-today {
background: linear-gradient(137deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
}
.card-label {
font-size: 24rpx;
color: #ffffff;
text-shadow: 0px 4px 4px rgba(164, 60, 60, 0.55);
margin-bottom: 12rpx;
}
.card-value {
font-size: 70rpx;
font-weight: 700;
color: #FFFABD;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: 'Baloo Bhai', sans-serif;
line-height: 1;
}
/* 骨架态 */
.skeleton-row {
display: flex;
gap: 16rpx;
}
.skeleton-card {
flex: 1;
height: 200rpx;
border-radius: 22rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 错误态 */
.error-card {
height: 200rpx;
border-radius: 22rpx;
background: rgba(255, 100, 100, 0.15);
border: 2rpx solid rgba(255, 100, 100, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.error-text {
color: #ff8080;
font-size: 28rpx;
}
</style>
- Step 2: 在 dashboard.vue 中挂载组件
修改 frontend/pages/dashboard/dashboard.vue 的 template:
把
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<view class="placeholder-section">
<text class="placeholder-text">骨架页(组件将在 Task 5-10 添加)</text>
</view>
</view>
替换为:
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<CrystalOverview
:data="data.today"
:loading="loading.today"
:error="error.today"
@retry="refresh('today')"
/>
</view>
并在 components 中追加:
components: { DashboardHeader, CrystalOverview },
- Step 3: 手动验证
刷新 H5 dev URL:
-
预期:双卡显示"水晶余额 2713"和"今日收益 + 213",金黄色数字 + 渐变背景
-
1.5s 后才显示(loading 期间是骨架屏)
-
Step 4: Commit
git add frontend/pages/dashboard/components/CrystalOverview.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): CrystalOverview 双卡(水晶余额+今日收益)"
Task 7: IncomeCurve 组件(uCharts 七日柱状+折线)
Files:
-
Create:
frontend/pages/dashboard/components/IncomeCurve.vue -
Step 1: 完整实现 IncomeCurve.vue
<template>
<view class="income-curve-card">
<text class="curve-title">七日收益曲线</text>
<!-- 错误态 -->
<view v-if="error" class="error-box" @tap="$emit('retry')">
<text class="error-text">加载失败,点击重试</text>
</view>
<!-- 骨架态 -->
<view v-else-if="loading || !points || points.length === 0" class="skeleton-chart"></view>
<!-- 图表 -->
<view v-else class="chart-wrap">
<view class="chart-header">
<text class="chart-peak-value" v-if="peak">+ {{ peak.income }}</text>
<text class="chart-peak-date" v-if="peak">{{ peak.date }}</text>
</view>
<view class="chart-canvas">
<!-- #ifdef H5 -->
<qiun-data-charts
type="barline"
:opts="chartOpts"
:chartData="chartData"
:ontouch="true"
canvas2d
:ontap="handleChartTap"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS || MP-WEIXIN -->
<view class="chart-placeholder-fallback">
<text>📊 {{ points.length }} 天数据</text>
<text class="chart-fallback-sub">App 端图表组件配置中</text>
</view>
<!-- #endif -->
</view>
</view>
</view>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'IncomeCurve',
props: {
points: { type: Array, default: () => [] }, // DailyIncomePoint[]
loading: { type: Boolean, default: false },
error: { type: String, default: null },
},
emits: ['retry'],
setup(props) {
// 高亮当日(peak)
const peak = computed(() => props.points.find((p) => p.is_peak) || null)
// 平台条件:仅 H5 编译图表所需变量
let chartData = null
let chartOpts = null
// #ifdef H5
chartData = computed(() => {
const categories = props.points.map((p) => p.date.slice(5)) // MM.DD
const barData = props.points.map((p) => p.income)
const lineData = props.points.map((p) => p.income)
return {
categories,
series: [
{ name: '收益', data: barData, color: '#FFDF77' },
{ name: '趋势', data: lineData, color: '#1BAFEE', type: 'line' },
],
}
})
chartOpts = {
color: ['#FFCC14', '#1BAFEE'],
padding: [16, 16, 8, 16],
dataLabel: false,
legend: { show: false },
xAxis: { disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
yAxis: { data: [{ min: 0 }], disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
extra: {
bar: { type: 'group', width: 18, linear: true, color: ['#FFDF77', '#B984FF', '#FF8183'] },
line: { type: 'curve', width: 2, activeType: 'hollow' },
},
}
// #endif
function handleChartTap(e) {
// 后续可扩展:点击柱子查看当日明细
console.log('[IncomeCurve] chart tap', e)
}
return { peak, handleChartTap, chartData, chartOpts }
},
}
</script>
<style lang="scss" scoped>
.income-curve-card {
background: linear-gradient(135deg, #FFDF77 0%, #8E95E2 40%, #F48CFF 100%);
border-radius: 22rpx;
padding: 24rpx;
margin: 24rpx 0;
box-shadow: 0px 4px 4px rgba(189, 50, 50, 0.25);
min-height: 360rpx;
}
.curve-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
text-shadow: 0px 2px 2px rgba(0, 0, 0, 0.2);
}
.chart-wrap {
background: rgba(255, 255, 255, 0.15);
border-radius: 17rpx;
padding: 16rpx;
backdrop-filter: blur(10px);
}
.chart-header {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-bottom: 12rpx;
}
.chart-peak-value {
font-size: 40rpx;
font-weight: 700;
color: #FFFABD;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: 'Baloo Bhai', sans-serif;
}
.chart-peak-date {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.8);
}
.chart-canvas {
width: 100%;
height: 240rpx;
}
.chart-placeholder-fallback {
height: 240rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28rpx;
}
.chart-fallback-sub {
margin-top: 8rpx;
font-size: 22rpx;
opacity: 0.7;
}
.skeleton-chart {
height: 360rpx;
border-radius: 17rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
}
.error-box {
height: 360rpx;
border-radius: 17rpx;
background: rgba(255, 100, 100, 0.15);
border: 2rpx solid rgba(255, 100, 100, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.error-text {
color: #ff8080;
font-size: 28rpx;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
- Step 3: 在 dashboard.vue 中挂载组件
在 CrystalOverview 之后追加:
<IncomeCurve
:points="data.curve?.points || []"
:loading="loading.curve"
:error="error.curve"
@retry="refresh('curve')"
/>
components 追加 IncomeCurve。
- Step 4: 安装 uCharts
cd frontend && npm install qiun-data-charts
如项目无 H5 组件库依赖关系导致构建失败,回退到 chart-theme.json + 占位文字(Step 2 中已留 fallback)。
- Step 5: 手动验证
刷新 H5 dev URL:
-
预期:渐变卡片 + "七日收益曲线" 标题 + 柱状图(7 根柱,渐变填充)+ 折线(蓝黄渐变)+ 高亮当日 "+ 312" + 日期
-
加载中:渐变骨架屏闪烁
-
Step 6: Commit
git add frontend/pages/dashboard/components/IncomeCurve.vue frontend/pages/dashboard/dashboard.vue frontend/package.json frontend/package-lock.json
git commit -m "feat(dashboard): IncomeCurve 七日柱状+折线(uCharts H5)"
Task 8: ExhibitionCenter 组件(3 联 + 5 行表格)
Files:
-
Create:
frontend/pages/dashboard/components/ExhibitionCenter.vue -
Step 1: 完整实现
<template>
<view class="exhibition-center">
<text class="section-title">展出收益中心</text>
<!-- 错误态 -->
<view v-if="error" class="error-box" @tap="$emit('retry')">
<text class="error-text">加载失败,点击重试</text>
</view>
<!-- 骨架态 -->
<view v-else-if="loading || !data" class="skeleton-section">
<view class="skeleton-stats"></view>
<view class="skeleton-rows">
<view v-for="i in 5" :key="i" class="skeleton-row"></view>
</view>
</view>
<!-- 正常态 -->
<view v-else>
<!-- 顶部 3 联统计 -->
<view class="stats-row">
<view class="stat-cell">
<text class="stat-value">{{ data.exhibiting_count }} / {{ data.starbook_count }}</text>
<text class="stat-label">展出中 / 星册中</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ data.total_duration }}</text>
<text class="stat-label">累计展出时长</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ data.total_earnings }}</text>
<text class="stat-label">累计展出收益</text>
</view>
</view>
<!-- 5 行表格 -->
<view class="table">
<view class="table-header">
<text class="th th-thumb"></text>
<text class="th th-duration">七日展出时长</text>
<text class="th th-earnings">七日收益</text>
<text class="th th-avg">平均收益</text>
</view>
<view
v-for="(item, idx) in data.top5"
:key="item.asset_id"
class="table-row"
>
<view class="td td-thumb">
<view class="thumb-placeholder" :class="`thumb-grad-${idx % 5}`">
<text class="thumb-emoji">🎨</text>
</view>
</view>
<text class="td td-duration">{{ item.duration_7d }}</text>
<text class="td td-earnings">{{ item.earnings_7d }}</text>
<text class="td td-avg">{{ item.avg_earnings }} / H</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ExhibitionCenter',
props: {
data: { type: Object, default: null }, // ExhibitionIncomeSummary
loading: { type: Boolean, default: false },
error: { type: String, default: null },
},
emits: ['retry'],
}
</script>
<style lang="scss" scoped>
.exhibition-center {
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
border-radius: 22rpx;
padding: 24rpx;
margin: 24rpx 0;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.section-title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 24rpx;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}
.stats-row {
display: flex;
background: rgba(255, 255, 255, 0.08);
border-radius: 17rpx;
padding: 24rpx 0;
margin-bottom: 24rpx;
}
.stat-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-right: none;
}
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #FFFABD;
font-family: 'Baloo Bhai', sans-serif;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
}
.table {
background: rgba(255, 255, 255, 0.05);
border-radius: 14rpx;
overflow: hidden;
}
.table-header,
.table-row {
display: flex;
align-items: center;
padding: 16rpx 12rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.table-row:last-child {
border-bottom: none;
}
.th,
.td {
font-size: 24rpx;
color: #ffffff;
text-align: center;
}
.th-thumb,
.td-thumb {
width: 80rpx;
flex-shrink: 0;
}
.th-duration,
.td-duration,
.th-earnings,
.td-earnings {
flex: 1;
}
.th-avg,
.td-avg {
width: 100rpx;
flex-shrink: 0;
}
.th {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.6);
}
.thumb-placeholder {
width: 56rpx;
height: 56rpx;
border-radius: 3rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.thumb-grad-0 { background: linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%); }
.thumb-grad-1 { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.thumb-grad-2 { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.thumb-grad-3 { background: linear-gradient(135deg, #FFE066 0%, #FFB199 100%); }
.thumb-grad-4 { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }
.thumb-emoji {
font-size: 32rpx;
}
.td-earnings {
color: #FFFABD;
font-weight: 600;
font-family: 'Baloo Bhai', sans-serif;
}
/* 骨架 */
.skeleton-section {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-stats {
height: 120rpx;
border-radius: 17rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-row {
height: 80rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-box {
height: 240rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 100, 100, 0.15);
border: 2rpx solid rgba(255, 100, 100, 0.4);
border-radius: 17rpx;
}
.error-text {
color: #ff8080;
font-size: 28rpx;
}
</style>
- Step 2: 在 dashboard.vue 中挂载
在 IncomeCurve 后追加:
<ExhibitionCenter
:data="data.exhibition"
:loading="loading.exhibition"
:error="error.exhibition"
@retry="refresh('exhibition')"
/>
components 追加 ExhibitionCenter。
- Step 3: 手动验证
刷新 H5 dev URL:
-
预期:玻璃拟态卡片 + "展出收益中心" 标题 + 3 联统计(21/33、712:13:56、39721)+ 5 行表格(每行 4×5 缩略图 + 时长 + 收益 + 平均)
-
Step 4: Commit
git add frontend/pages/dashboard/components/ExhibitionCenter.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): ExhibitionCenter 3联+5行表格"
Task 9: LikeIncomeBoard 组件
Files:
-
Create:
frontend/pages/dashboard/components/LikeIncomeBoard.vue -
Step 1: 完整实现
<template>
<view class="like-income-board">
<text class="section-title">点赞收益看板</text>
<!-- 错误/骨架态 -->
<view v-if="error" class="error-box" @tap="$emit('retry')">
<text class="error-text">加载失败,点击重试</text>
</view>
<view v-else-if="loading || !stats" class="skeleton-board">
<view class="skeleton-stats"></view>
<view class="skeleton-list"></view>
</view>
<!-- 正常态 -->
<view v-else class="board-row">
<!-- 左侧统计 -->
<view class="left-stats">
<view class="stat-block">
<text class="stat-num">{{ stats.total_like_count }}</text>
<text class="stat-text">累积点赞</text>
</view>
<view class="stat-block">
<text class="stat-num">{{ stats.total_income }}</text>
<text class="stat-text">累计收益</text>
</view>
</view>
<!-- 右侧等级列表 -->
<view class="right-list">
<view
v-for="(item, idx) in levels"
:key="idx"
class="level-row"
>
<view class="level-thumb">
<view class="thumb-circle" :class="`level-${item.level}`">
<text class="thumb-letter">{{ item.level }}</text>
</view>
</view>
<text class="level-name">{{ item.level }}</text>
<text class="level-income">{{ item.total_income }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'LikeIncomeBoard',
props: {
stats: { type: Object, default: null }, // { total_like_count, total_income }
levels: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
error: { type: String, default: null },
},
emits: ['retry'],
}
</script>
<style lang="scss" scoped>
.like-income-board {
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
border-radius: 22rpx;
padding: 24rpx;
margin: 24rpx 0;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.section-title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 24rpx;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}
.board-row {
display: flex;
gap: 16rpx;
}
.left-stats {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 24rpx;
padding-right: 16rpx;
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-block {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 40rpx;
font-weight: 700;
color: #FFFABD;
font-family: 'Baloo Bhai', sans-serif;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
margin-bottom: 6rpx;
}
.stat-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
}
.right-list {
flex: 1.5;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.level-row {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.06);
border-radius: 12rpx;
padding: 12rpx;
gap: 16rpx;
}
.level-thumb {
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
}
.thumb-circle {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
.thumb-letter {
font-size: 18rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.level-UR { background: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%); }
.level-SSR { background: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%); }
.level-SR { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.level-R { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.level-N { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }
.level-name {
flex: 0 0 60rpx;
font-size: 22rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.level-income {
flex: 1;
font-size: 28rpx;
font-weight: 700;
color: #FFFABD;
font-family: 'Baloo Bhai', sans-serif;
text-align: right;
}
/* 骨架/错误 */
.skeleton-board {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-stats,
.skeleton-list {
height: 200rpx;
border-radius: 17rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.error-box {
height: 240rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 100, 100, 0.15);
border: 2rpx solid rgba(255, 100, 100, 0.4);
border-radius: 17rpx;
}
.error-text {
color: #ff8080;
font-size: 28rpx;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
- Step 2: 在 dashboard.vue 中挂载
在 ExhibitionCenter 后追加:
<LikeIncomeBoard
:stats="data.likeIncome ? { total_like_count: data.likeIncome.total_like_count, total_income: data.likeIncome.total_income } : null"
:levels="data.likeIncome?.levels || []"
:loading="loading.likeIncome"
:error="error.likeIncome"
@retry="refresh('likeIncome')"
/>
components 追加 LikeIncomeBoard。
- Step 3: 手动验证 + Commit
git add frontend/pages/dashboard/components/LikeIncomeBoard.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): LikeIncomeBoard 左侧统计+右侧等级列表"
Task 10: CollectionMatrix + TopFiveAssets
Files:
-
Create:
frontend/pages/dashboard/components/CollectionMatrix.vue -
Create:
frontend/pages/dashboard/components/TopFiveAssets.vue -
Step 1: 完整实现 TopFiveAssets.vue
<template>
<view class="top-five-card">
<text class="card-title">历史藏品收益 TOP 5</text>
<view v-if="!items || items.length === 0" class="empty-row">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="top-five-row">
<view
v-for="item in items"
:key="item.asset_id"
class="top-cell"
>
<view class="top-thumb" :class="`top-grad-${item.rank}`">
<text class="top-emoji">🏆</text>
</view>
<view class="top-badge" :class="`top-badge-${item.rank}`">
<text class="top-badge-text">TOP {{ item.rank }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'TopFiveAssets',
props: {
items: { type: Array, default: () => [] }, // TopAssetItem[]
},
}
</script>
<style lang="scss" scoped>
.top-five-card {
background: rgba(255, 255, 255, 0.1);
border-radius: 17rpx;
padding: 20rpx;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.card-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
}
.top-five-row {
display: flex;
justify-content: space-between;
gap: 8rpx;
}
.top-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.top-thumb {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
box-shadow: 0 0 12px rgba(255, 200, 100, 0.3);
}
.top-grad-1 { background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); }
.top-grad-2 { background: linear-gradient(135deg, #C0C0C0 0%, #808080 100%); }
.top-grad-3 { background: linear-gradient(135deg, #CD7F32 0%, #8B4513 100%); }
.top-grad-4 { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.top-grad-5 { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.top-emoji {
font-size: 48rpx;
}
.top-badge {
padding: 4rpx 12rpx;
border-radius: 8rpx;
opacity: 0.85;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.top-badge-1 { background: linear-gradient(90deg, #FFD700 0%, #FFA500 100%); }
.top-badge-2 { background: linear-gradient(90deg, #C0C0C0 0%, #808080 100%); }
.top-badge-3 { background: linear-gradient(90deg, #CD7F32 0%, #8B4513 100%); }
.top-badge-4 { background: linear-gradient(90deg, #B17BFF 0%, #FF8FE6 100%); }
.top-badge-5 { background: linear-gradient(90deg, #5EDFFF 0%, #6FA9FF 100%); }
.top-badge-text {
font-size: 18rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.empty-row {
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
}
</style>
- Step 2: 完整实现 CollectionMatrix.vue(容器,先只接 TopFiveAssets)
<template>
<view class="collection-matrix">
<text class="section-title">藏品矩阵</text>
<!-- TOP 5 -->
<TopFiveAssets :items="topFive || []" />
<!-- 后续 Task 11/12 在此追加 LevelDistribution / UpcomingUpgrades / RecentUpgrades -->
</view>
</template>
<script>
import TopFiveAssets from './TopFiveAssets.vue'
export default {
name: 'CollectionMatrix',
components: { TopFiveAssets },
props: {
topFive: { type: Array, default: () => [] },
levels: { type: Array, default: () => [] },
upgrades: { type: Object, default: () => ({ upcoming: [], recent: [] }) },
},
}
</script>
<style lang="scss" scoped>
.collection-matrix {
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
border-radius: 22rpx;
padding: 24rpx;
margin: 24rpx 0;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.section-title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 24rpx;
text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3);
}
</style>
- Step 3: 在 dashboard.vue 中挂载
在 LikeIncomeBoard 后追加:
<CollectionMatrix
:top-five="data.topAssets"
:levels="data.levels"
:upgrades="data.upgrades"
/>
components 追加 CollectionMatrix。
- Step 4: 手动验证 + Commit
git add frontend/pages/dashboard/components/TopFiveAssets.vue frontend/pages/dashboard/components/CollectionMatrix.vue frontend/pages/dashboard/dashboard.vue
git commit -m "feat(dashboard): CollectionMatrix 容器 + TopFiveAssets"
Task 11: LevelDistribution 组件(5 个 conic-gradient 环形图)
Files:
-
Create:
frontend/pages/dashboard/components/LevelDistribution.vue -
Step 1: 完整实现
<template>
<view class="level-distribution">
<text class="card-title">藏品等级分布</text>
<view v-if="!items || items.length === 0" class="empty-row">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="ring-row">
<view
v-for="item in items"
:key="item.level"
class="ring-cell"
>
<view
class="ring-outer"
:style="getRingStyle(item)"
>
<view class="ring-inner">
<text class="ring-count">{{ item.count }}</text>
</view>
</view>
<text class="ring-label">{{ item.level }}</text>
<text class="ring-pct">{{ getPercent(item) }}%</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'LevelDistribution',
props: {
items: { type: Array, default: () => [] }, // AssetLevelItem[]
},
setup() {
function getPercent(item) {
if (!item.total) return 0
return Math.round((item.count / item.total) * 100)
}
function getRingStyle(item) {
const pct = getPercent(item)
const colorMap = {
UR: '#FF8A65, #FFD740',
SSR: '#FF5E9C, #FFB199',
SR: '#B17BFF, #FF8FE6',
R: '#5EDFFF, #6FA9FF',
N: '#C5C5C5, #8C8C8C',
}
const colors = colorMap[item.level] || '#999, #ccc'
// 占位 0 显示灰色环
if (pct === 0) {
return {
background: 'conic-gradient(rgba(255,255,255,0.1) 0deg, rgba(255,255,255,0.1) 360deg)',
}
}
// 渐变环:从顶部开始,pct% 处停止
return {
background: `conic-gradient(${colors} 0deg ${pct * 3.6}deg, rgba(255,255,255,0.1) ${pct * 3.6}deg 360deg)`,
}
}
return { getPercent, getRingStyle }
},
}
</script>
<style lang="scss" scoped>
.level-distribution {
background: rgba(255, 255, 255, 0.1);
border-radius: 17rpx;
padding: 20rpx;
margin-top: 16rpx;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.card-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
}
.ring-row {
display: flex;
justify-content: space-around;
align-items: center;
padding: 16rpx 0;
}
.ring-cell {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.ring-outer {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.15);
}
.ring-inner {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.ring-count {
font-size: 24rpx;
font-weight: 700;
color: #FFFABD;
font-family: 'Baloo Bhai', sans-serif;
}
.ring-label {
margin-top: 8rpx;
font-size: 20rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.ring-pct {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.6);
margin-top: 2rpx;
}
.empty-row {
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
}
</style>
- Step 2: 在 CollectionMatrix.vue 中挂载
在 <TopFiveAssets> 后追加:
<LevelDistribution :items="levels" />
并追加 import 和 components 注册。
- Step 3: 手动验证 + Commit
git add frontend/pages/dashboard/components/LevelDistribution.vue frontend/pages/dashboard/components/CollectionMatrix.vue
git commit -m "feat(dashboard): LevelDistribution 5个conic-gradient环形图"
Task 12: UpcomingUpgrades + RecentUpgrades
Files:
-
Create:
frontend/pages/dashboard/components/UpcomingUpgrades.vue -
Create:
frontend/pages/dashboard/components/RecentUpgrades.vue -
Step 1: 完整实现 UpcomingUpgrades.vue
<template>
<view class="upcoming-upgrades">
<text class="card-title">即将升级</text>
<view v-if="!items || items.length === 0" class="empty-row">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="upgrades-list">
<view
v-for="item in items"
:key="item.asset_id"
class="upgrade-row"
>
<view class="upgrade-thumb">
<view class="thumb-circle" :style="{ background: 'linear-gradient(135deg, #FF6B6B 0%, #FFB199 100%)' }">
<text class="thumb-letter">{{ item.asset_name[0] }}</text>
</view>
</view>
<view class="upgrade-progress">
<view class="progress-bar progress-cyan">
<view class="progress-fill" :style="{ width: item.like_progress + '%' }"></view>
<text class="progress-text">{{ item.like_progress }}%</text>
</view>
<view class="progress-bar progress-pink">
<view class="progress-fill" :style="{ width: item.duration_progress + '%' }"></view>
<text class="progress-text">{{ item.duration_progress }}%</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'UpcomingUpgrades',
props: {
items: { type: Array, default: () => [] }, // UpcomingLevelUpItem[]
},
}
</script>
<style lang="scss" scoped>
.upcoming-upgrades {
background: rgba(255, 255, 255, 0.1);
border-radius: 17rpx;
padding: 20rpx;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.card-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
}
.upgrades-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.upgrade-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.upgrade-thumb {
width: 64rpx;
height: 64rpx;
flex-shrink: 0;
}
.thumb-circle {
width: 100%;
height: 100%;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
.thumb-letter {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.upgrade-progress {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.progress-bar {
position: relative;
height: 16rpx;
border-radius: 6rpx;
background: rgba(217, 217, 217, 0.2);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.3s ease;
}
.progress-cyan .progress-fill {
background: linear-gradient(90deg, #5EDFFF 0%, #FFC8C8 100%);
}
.progress-pink .progress-fill {
background: linear-gradient(90deg, #FFF375 0%, #FF6B84 100%);
}
.progress-text {
position: absolute;
right: 6rpx;
top: 50%;
transform: translateY(-50%);
font-size: 16rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.empty-row {
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
}
</style>
- Step 2: 完整实现 RecentUpgrades.vue
<template>
<view class="recent-upgrades">
<text class="card-title">最近升级</text>
<view v-if="!items || items.length === 0" class="empty-row">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="upgrades-list">
<view
v-for="item in items"
:key="item.asset_id"
class="upgrade-row"
>
<view class="upgrade-thumb">
<view class="thumb-circle" :style="{ background: 'linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%)' }">
<text class="thumb-letter">{{ item.asset_name[0] }}</text>
</view>
</view>
<view class="upgrade-info">
<text class="upgrade-name">{{ item.asset_name }}</text>
<text class="upgrade-time">{{ formatTime(item.upgrade_time) }}</text>
</view>
<view class="level-badge" :class="`level-${item.new_level}`">
<text class="level-letter">{{ item.new_level }}</text>
<text class="lv-up">Lv UP</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'RecentUpgrades',
props: {
items: { type: Array, default: () => [] }, // RecentLevelUpItem[]
},
setup() {
function formatTime(ts) {
const d = new Date(ts)
const now = Date.now()
const diff = now - ts
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)} 小时前`
return `${Math.floor(diff / 86400000)} 天前`
}
return { formatTime }
},
}
</script>
<style lang="scss" scoped>
.recent-upgrades {
background: rgba(255, 255, 255, 0.1);
border-radius: 17rpx;
padding: 20rpx;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15);
}
.card-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
}
.upgrades-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.upgrade-row {
display: flex;
align-items: center;
gap: 12rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
padding: 10rpx;
}
.upgrade-thumb {
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
}
.thumb-circle {
width: 100%;
height: 100%;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
.thumb-letter {
font-size: 22rpx;
font-weight: 700;
color: #ffffff;
}
.upgrade-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.upgrade-name {
font-size: 22rpx;
color: #ffffff;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upgrade-time {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.6);
}
.level-badge {
display: flex;
flex-direction: column;
align-items: center;
padding: 6rpx 12rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.level-UR { background: linear-gradient(135deg, #FF8A65 0%, #FFD740 100%); }
.level-SSR { background: linear-gradient(135deg, #FF5E9C 0%, #FFB199 100%); }
.level-SR { background: linear-gradient(135deg, #B17BFF 0%, #FF8FE6 100%); }
.level-R { background: linear-gradient(135deg, #5EDFFF 0%, #6FA9FF 100%); }
.level-N { background: linear-gradient(135deg, #C5C5C5 0%, #8C8C8C 100%); }
.level-letter {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.lv-up {
font-size: 14rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.empty-row {
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
}
</style>
- Step 3: 在 CollectionMatrix.vue 中挂载(两列布局)
替换 CollectionMatrix.vue 的 template 为:
<template>
<view class="collection-matrix">
<text class="section-title">藏品矩阵</text>
<TopFiveAssets :items="topFive || []" />
<LevelDistribution :items="levels || []" />
<view class="upgrades-two-col">
<UpcomingUpgrades :items="upgrades?.upcoming || []" />
<RecentUpgrades :items="upgrades?.recent || []" />
</view>
</view>
</template>
并在 <style> 中追加:
.upgrades-two-col {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
> * {
flex: 1;
}
}
在 <script> 中追加 import 与 components 注册:
import LevelDistribution from './LevelDistribution.vue'
import UpcomingUpgrades from './UpcomingUpgrades.vue'
import RecentUpgrades from './RecentUpgrades.vue'
components: { TopFiveAssets, LevelDistribution, UpcomingUpgrades, RecentUpgrades },
- Step 4: 手动验证 + Commit
git add frontend/pages/dashboard/components/UpcomingUpgrades.vue frontend/pages/dashboard/components/RecentUpgrades.vue frontend/pages/dashboard/components/CollectionMatrix.vue
git commit -m "feat(dashboard): UpcomingUpgrades + RecentUpgrades 双列布局"
Task 13: onShow 刷新 + Tab 缓存 + 下拉刷新
Files:
-
Modify:
frontend/pages/dashboard/dashboard.vue(整文件重写为完整版) -
Modify:
frontend/pages.json(不启用enablePullDownRefresh;下拉刷新由 scroll-view 的:refresher-enabled接管,避免双触发冲突) -
Step 1: 整文件替换
frontend/pages/dashboard/dashboard.vue
直接用以下完整内容覆盖整个文件(删除 Task 4 写入的占位版本、Task 6/8/9/10/12 追加挂载的中间版本):
<template>
<scroll-view
scroll-y
class="dashboard-page-bg dashboard-scroll"
:refresher-enabled="true"
:refresher-triggered="loading.overall"
@refresherrefresh="handlePullDownRefresh"
>
<view class="dashboard-container">
<DashboardHeader
:active-tab="activeTab"
@update:active-tab="handleTabChange"
/>
<!-- Tab 1: 水晶相关 -->
<view v-if="activeTab === 'crystal'" class="dashboard-content">
<CrystalOverview
:data="data.today"
:loading="loading.today"
:error="error.today"
@retry="refresh('today')"
/>
<IncomeCurve
:points="data.curve?.points || []"
:loading="loading.curve"
:error="error.curve"
@retry="refresh('curve')"
/>
<ExhibitionCenter
:data="data.exhibition"
:loading="loading.exhibition"
:error="error.exhibition"
@retry="refresh('exhibition')"
/>
<LikeIncomeBoard
:stats="data.likeIncome ? { total_like_count: data.likeIncome.total_like_count, total_income: data.likeIncome.total_income } : null"
:levels="data.likeIncome?.levels || []"
:loading="loading.likeIncome"
:error="error.likeIncome"
@retry="refresh('likeIncome')"
/>
<CollectionMatrix
:top-five="data.topAssets"
:levels="data.levels"
:upgrades="data.upgrades"
/>
</view>
<!-- Tab 2: 赛季总览(占位) -->
<view v-else class="dashboard-content">
<view class="season-placeholder">
<text class="placeholder-icon">🏆</text>
<text class="placeholder-title">赛季总览 · 即将上线</text>
<text class="placeholder-sub">历史赛季数据正在筹备中</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script>
import { ref, onUnmounted } from 'vue'
import DashboardHeader from './components/DashboardHeader.vue'
import CrystalOverview from './components/CrystalOverview.vue'
import IncomeCurve from './components/IncomeCurve.vue'
import ExhibitionCenter from './components/ExhibitionCenter.vue'
import LikeIncomeBoard from './components/LikeIncomeBoard.vue'
import CollectionMatrix from './components/CollectionMatrix.vue'
import { useDashboardData } from '@/composables/useDashboardData'
export default {
components: { DashboardHeader, CrystalOverview, IncomeCurve, ExhibitionCenter, LikeIncomeBoard, CollectionMatrix },
setup() {
const activeTab = ref('crystal')
const starId = ref(uni.getStorageSync('star_id') || null)
const { loading, error, data, refresh, isReady, lastFetched, dispose } = useDashboardData({
starId: starId.value,
})
// Tab 切换:30 分钟内复用缓存;切回水晶 Tab 时 cache-aware 刷新
function handleTabChange(tab) {
activeTab.value = tab
if (tab === 'crystal') {
// refresh() 默认走 30 分钟缓存(不闪骨架屏);需要强刷用 refresh(null, true)
refresh()
}
}
// 下拉刷新
async function handlePullDownRefresh() {
await refresh(null, true) // force=true 绕缓存
uni.stopPullDownRefresh()
}
onUnmounted(() => dispose())
return {
activeTab,
loading,
error,
data,
isReady,
lastFetched,
handleTabChange,
handlePullDownRefresh,
refresh,
}
},
// uni-app 页面级生命周期(必须在 export default 顶层,不能放 setup 内)
onShow() {
// 从其他页面返回时强制刷新(绕 30 分钟缓存,spec §4.1)
if (this.refresh) this.refresh(null, true)
},
}
</script>
<style lang="scss" scoped>
.dashboard-page-bg {
background: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);
min-height: 100vh;
}
.dashboard-scroll {
height: 100vh;
}
.dashboard-container {
min-height: 100vh;
}
.dashboard-content {
padding: 24rpx 32rpx 80rpx;
}
.season-placeholder {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 22rpx;
padding: 120rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.placeholder-icon { font-size: 96rpx; margin-bottom: 24rpx; }
.placeholder-title { color: #ffffff; font-size: 36rpx; font-weight: 700; margin-bottom: 16rpx; }
.placeholder-sub { color: rgba(255, 255, 255, 0.7); font-size: 26rpx; }
</style>
- Step 2: 确认
pages.json中 dashboard 条目未启用enablePullDownRefresh
Task 1 写入的 dashboard 条目已正确(不包含 enablePullDownRefresh)。不要手动加这个字段——下拉刷新由 scroll-view 的 :refresher-enabled="true" 接管,两者重复会冲突。
- Step 3: 手动验证
刷新 H5 dev URL:
-
拖动下拉 → 触发强制刷新(loading.overall 短暂为 true)
-
切换 Tab 1 → Tab 2 → Tab 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
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 |