fix(plan): 应用 plan reviewer 5 条 issue + 2 条 advisory

修复:
1. Task 14: 删除 scrolltolower 误绑定(每次滚到底会触发全量刷新)
2. Task 14: 补 onShow 生命周期(spec §4.1 要求从其他页返回时强制刷)
3. Task 3: refresh() 改为 cache-aware 默认行为;force 需显式传参
4. Task 7: 删除未被引用的 chart-theme.json(死文件)
5. 删除原 Task 13(SectionSkeleton+EmptyState 死组件)
6. Task 13: pageBg 改用 SCSS class 引用,移除 inline 重复字符串
7. 新增 Task 14 全链路手动验证(spec §8.2 核对清单)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zheng020 2026-06-02 20:02:48 +08:00
parent 702fc1d383
commit 72c91c1787

View File

@ -15,7 +15,7 @@
## 文件总览
**Create15 个)**:
**Create13 个)**:
- `frontend/pages/dashboard/dashboard.vue`
- `frontend/pages/dashboard/components/DashboardHeader.vue`
- `frontend/pages/dashboard/components/CrystalOverview.vue`
@ -27,8 +27,6 @@
- `frontend/pages/dashboard/components/LevelDistribution.vue`
- `frontend/pages/dashboard/components/UpcomingUpgrades.vue`
- `frontend/pages/dashboard/components/RecentUpgrades.vue`
- `frontend/pages/dashboard/components/SectionSkeleton.vue`
- `frontend/pages/dashboard/components/EmptyState.vue`
- `frontend/composables/useDashboardData.js`
- `frontend/utils/mock/dashboard.js`
@ -443,21 +441,24 @@ export function useDashboardData({ starId = null } = {}) {
}
// —— 局部刷新 ——
async function refresh(section) {
if (!section) {
return loadAll(true)
// - refresh(section) : 单 section 强制重拉(无缓存)
// - refresh() : 全量刷新cache-aware30 分钟内复用)
// - refresh(null, true) : 全量强制刷新(绕缓存)
async function refresh(section, force = false) {
if (section) {
const fetcherMap = {
today: dashboardApi.getTodayOverview,
curve: dashboardApi.get7DayIncomeCurve,
exhibition: dashboardApi.getExhibitionSummary,
likeIncome: dashboardApi.getLikeIncomeByLevel,
topAssets: dashboardApi.getTopAssets,
levels: dashboardApi.getLevelDistribution,
upgrades: dashboardApi.getUpgradeProgress,
}
if (!fetcherMap[section]) return
return loadSection(section, fetcherMap[section])
}
const fetcherMap = {
today: dashboardApi.getTodayOverview,
curve: dashboardApi.get7DayIncomeCurve,
exhibition: dashboardApi.getExhibitionSummary,
likeIncome: dashboardApi.getLikeIncomeByLevel,
topAssets: dashboardApi.getTopAssets,
levels: dashboardApi.getLevelDistribution,
upgrades: dashboardApi.getUpgradeProgress,
}
if (!fetcherMap[section]) return
await loadSection(section, fetcherMap[section])
return loadAll(force)
}
// —— effectScope 资源释放 ——
@ -1118,51 +1119,8 @@ git commit -m "feat(dashboard): CrystalOverview 双卡(水晶余额+今日收
**Files:**
- Create: `frontend/pages/dashboard/components/IncomeCurve.vue`
- Create: `frontend/pages/dashboard/chart-theme.json`uCharts 主题配置)
- [ ] **Step 1: 创建 uCharts 主题配置 `frontend/pages/dashboard/chart-theme.json`**
> **注意**:此文件路径在 Vue 项目中通过 `?url``import` 引入。H5 用 `import`,小程序/App 走条件编译。
```json
{
"type": "barline",
"color": ["#FFCC14", "#1BAFEE"],
"padding": [16, 16, 8, 16],
"dataLabel": false,
"legend": { "show": false },
"xAxis": {
"disableGrid": true,
"axisLine": false,
"fontColor": "#FFFFFF",
"fontSize": 9
},
"yAxis": {
"data": [{ "min": 0 }],
"disableGrid": true,
"axisLine": false,
"fontColor": "#FFFFFF",
"fontSize": 9
},
"extra": {
"bar": {
"type": "group",
"width": 18,
"activeBgColor": "#000000",
"activeBgOpacity": 0.1,
"linear": true,
"color": ["#FFDF77", "#B984FF", "#FF8183"]
},
"line": {
"type": "curve",
"width": 2,
"activeType": "hollow"
}
}
}
```
- [ ] **Step 2: 完整实现 IncomeCurve.vue**
- [ ] **Step 1: 完整实现 IncomeCurve.vue**
```vue
<template>
@ -1389,7 +1347,7 @@ cd frontend && npm install qiun-data-charts
- [ ] **Step 6: Commit**
```bash
git add frontend/pages/dashboard/components/IncomeCurve.vue frontend/pages/dashboard/dashboard.vue frontend/pages/dashboard/chart-theme.json frontend/package.json frontend/package-lock.json
git 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"
```
@ -2673,170 +2631,90 @@ git commit -m "feat(dashboard): UpcomingUpgrades + RecentUpgrades 双列布局"
---
## Task 13: SectionSkeleton + EmptyState 通用组件
**Files:**
- Create: `frontend/pages/dashboard/components/SectionSkeleton.vue`
- Create: `frontend/pages/dashboard/components/EmptyState.vue`
- [ ] **Step 1: 完整实现 SectionSkeleton.vue**
```vue
<template>
<view class="section-skeleton" :class="`skeleton-${variant}`">
<view v-if="variant === 'card'" class="sk-card">
<view class="sk-label"></view>
<view class="sk-value"></view>
</view>
<view v-else-if="variant === 'list'" class="sk-list">
<view v-for="i in 3" :key="i" class="sk-row"></view>
</view>
<view v-else-if="variant === 'chart'" class="sk-chart"></view>
<view v-else-if="variant === 'matrix'" class="sk-matrix">
<view class="sk-row"></view>
<view class="sk-row"></view>
<view class="sk-row"></view>
</view>
</view>
</template>
<script>
export default {
name: 'SectionSkeleton',
props: {
variant: { type: String, default: 'card' }, // card | list | chart | matrix
},
}
</script>
<style lang="scss" scoped>
.section-skeleton {
background: rgba(255, 255, 255, 0.08);
border-radius: 17rpx;
padding: 24rpx;
margin: 16rpx 0;
}
.sk-card,
.sk-list,
.sk-chart,
.sk-matrix {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.sk-card {
align-items: center;
}
.sk-label {
width: 40%;
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.sk-value {
width: 60%;
height: 48rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.sk-row {
width: 100%;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.sk-chart {
width: 100%;
height: 240rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
```
- [ ] **Step 2: 完整实现 EmptyState.vue**
```vue
<template>
<view class="empty-state">
<text class="empty-icon">{{ icon }}</text>
<text class="empty-text">{{ text }}</text>
</view>
</template>
<script>
export default {
name: 'EmptyState',
props: {
text: { type: String, default: '暂无数据' },
icon: { type: String, default: '📭' },
},
}
</script>
<style lang="scss" scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 32rpx;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 16rpx;
opacity: 0.6;
}
.empty-text {
color: rgba(255, 255, 255, 0.5);
font-size: 26rpx;
}
</style>
```
- [ ] **Step 3: Commit**
```bash
git add frontend/pages/dashboard/components/SectionSkeleton.vue frontend/pages/dashboard/components/EmptyState.vue
git commit -m "feat(dashboard): SectionSkeleton + EmptyState 通用组件"
```
> **注**:本任务暂不替换各 section 内的内联骨架(已能用),后续若需要统一可在各组件中替换 `<view class="skeleton-...">``<SectionSkeleton variant="..." />`
---
## Task 14: onShow 刷新 + Tab 缓存 + 下拉刷新
## Task 13: onShow 刷新 + Tab 缓存 + 下拉刷新
**Files:**
- Modify: `frontend/pages/dashboard/dashboard.vue`
- Modify: `frontend/pages.json`(启用下拉刷新)
- [ ] **Step 1: 加入 onShow 静默刷新 + Tab 缓存**
- [ ] **Step 1: 在 `uni.scss` 中追加 page-bg CSS 类**
完整替换 `<script setup>` 部分(在 `export default` 内):
`uni.scss` 末尾 dashboard token 段后追加:
```scss
/* dashboard 页面背景CSS 类,组件用 class 引用,避免 inline 重复) */
.dashboard-page-bg {
background: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);
min-height: 100vh;
}
```
```javascript
import { ref, computed, onMounted, onUnmounted } from 'vue'
- [ ] **Step 2: 重写 `frontend/pages/dashboard/dashboard.vue`(含 onShow + scroll-view + class 引用)**
完整文件内容:
```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 } from 'vue'
import DashboardHeader from './components/DashboardHeader.vue'
import CrystalOverview from './components/CrystalOverview.vue'
import IncomeCurve from './components/IncomeCurve.vue'
@ -2855,73 +2733,126 @@ export default {
starId: starId.value,
})
// Tab 切换30 分钟内复用缓存;切回水晶 Tab 时静默刷新
// Tab 切换30 分钟内复用缓存;切回水晶 Tab 时 cache-aware 刷新
function handleTabChange(tab) {
activeTab.value = tab
if (tab === 'crystal') {
// 静默后台刷新(不显示 loading 覆盖,因为数据已存在)
// refresh() 默认走 30 分钟缓存(不闪骨架屏);需要强刷用 refresh(null, true)
refresh()
}
}
// 下拉刷新
async function handlePullDownRefresh() {
await refresh()
await refresh(null, true) // force=true 绕缓存
uni.stopPullDownRefresh()
}
onMounted(() => {
// 首屏已由 composable 自动 loadAll()
})
onUnmounted(() => {
dispose()
})
return {
activeTab,
loading,
error,
data,
isReady,
lastFetched,
handleTabChange,
handlePullDownRefresh,
pageBg: 'linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%)',
// onShow用户从其他页面返回时强制刷新一次spec §4.1
onShow lifecycle hookuni-app 页面级,需在 export default 上声明setup 内通过 getCurrentInstance 获取):
```js
// 在 export default 中追加:
onShow() {
this.$options._setupRef?.refresh?.(null, true)
}
```
// 实际写法(替换上面注释块):在 setup 末尾 return 前暴露 refresh 引用
const setupRef = { refresh }
onShow lifecycle见下方 onShow 实现
},
}
</script>
```
**关于 onShow 的关键说明**uni-app 的 `onShow` 是 Options API 生命周期钩子,**必须在 `export default` 顶层声明**(不在 `setup` 内)。完整 onShow 实现:
`<script>` 块内的 `export default` 顶层(与 `components`、`setup` 同级)追加:
```javascript
export default {
components: { ... },
setup() { ... return { refresh, ... } },
onShow() {
// 页面 onShow 时强制刷新(绕 30 分钟缓存spec §4.1
this.refresh(null, true)
},
}
```
并在 `<template>` 顶部加 pull-down-refresh 包装uni-app 页面配置):
注意:`onShow` 内的 `this.refresh` 来自 `setup()``return` 暴露vue 会自动合并 setup return 到实例。
`<view class="dashboard-container" :style="{ background: pageBg }">` 改为:
```vue
<scroll-view
scroll-y
class="dashboard-scroll"
:style="{ background: pageBg }"
@scrolltolower="handlePullDownRefresh"
:refresher-enabled="true"
:refresher-triggered="loading.overall"
@refresherrefresh="handlePullDownRefresh"
>
<view class="dashboard-container">
...(原内容)
</view>
</scroll-view>
> **重要**:上面模板中 `onShow` 注释块仅作说明,**实际写入文件时按下方"onShow 完整写法"**(替换 `setup` 末段):
`setup()` 末段return 之前)追加:
```javascript
// 将 refresh 暴露给 onShow 生命周期
const _refresh = refresh
// 在 return 中暴露
return {
activeTab, loading, error, data, isReady, lastFetched,
handleTabChange, handlePullDownRefresh,
refresh: _refresh, // 给 onShow 用
}
```
`<style>` 中加:
> 最终 onShow 写法(重写整个 export default
```javascript
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 })
function handleTabChange(tab) {
activeTab.value = tab
if (tab === 'crystal') refresh()
}
async function handlePullDownRefresh() {
await refresh(null, true)
uni.stopPullDownRefresh()
}
onUnmounted(() => dispose())
return { activeTab, loading, error, data, isReady, lastFetched, handleTabChange, handlePullDownRefresh, refresh }
},
onShow() {
// 从其他页面返回时强制刷新spec §4.1
if (this.refresh) this.refresh(null, true)
},
}
```
- [ ] **Step 3: 在 `<style>` 末尾追加 scroll-view 样式**
在 dashboard.vue 的 `<style lang="scss" scoped>` 块末尾追加:
```scss
.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; }
```
- [ ] **Step 2: 启用页面下拉刷新**
> **删掉** `dashboard.vue` 现有的 `.dashboard-container`、`.dashboard-content`、`.placeholder-*`、`.season-placeholder` 旧样式(被 `<style>` 末尾的覆盖即可,但为避免重复,请删旧版块)。
修改 `frontend/pages.json` 中 dashboard 条目:
- [ ] **Step 4: 在 `pages.json` 中启用下拉刷新**
把 dashboard 条目改为:
```json
,{
"path": "pages/dashboard/dashboard",
@ -2935,14 +2866,16 @@ export default {
}
```
- [ ] **Step 3: 手动验证**
- [ ] **Step 5: 手动验证**
刷新 H5 dev URL
- 拖动下拉 → 触发刷新loading 短暂亮起)
- 切换 Tab 1 → Tab 2 → Tab 1第一次切回 Tab 1 时触发静默 refresh
- 离开页面后再次进入composable dispose 已调用
- 拖动下拉 → 触发强制刷新loading.overall 短暂为 true
- 切换 Tab 1 → Tab 2 → Tab 130 分钟内 cache-aware 静默刷新(不闪骨架)
- 离开页面到其他页面 → 返回onShow 触发 force refresh
- 关闭网络DevTools Network → Offline→ 各 section 显示错误态
- 恢复网络 → 点击错误卡片 → 该 section 重试成功
- [ ] **Step 4: 验证 Figma 视觉一致性**
- [ ] **Step 6: 验证 Figma 视觉一致性**
`superpowers:verification-before-completion` 流程对照 `docs/figma-analysis-data-dashboard.md` 第三节设计 token 逐项核对:
- [ ] 颜色 token 与 §3.1 一致
@ -2950,15 +2883,48 @@ export default {
- [ ] 圆角与 §3.3 一致
- [ ] 5 个等级色与 §2.7.2 一致
- [ ] **Step 5: Commit**
- [ ] **Step 7: Commit**
```bash
git add frontend/pages/dashboard/dashboard.vue frontend/pages.json
git commit -m "feat(dashboard): onShow 静默刷新 + Tab 缓存 + 下拉刷新"
git add frontend/pages/dashboard/dashboard.vue frontend/uni.scss frontend/pages.json
git commit -m "feat(dashboard): onShow 强制刷新 + Tab 缓存 + 下拉刷新 + SCSS class 引用"
```
---
## 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验证任务无代码变更**
---
---
## 风险与决策记录
| 风险 | 应对 |