Files
wx_pbzc/docs/superpowers/plans/2026-07-08-visual-upgrade.md

1451 lines
35 KiB
Markdown

# 视觉与动效全量升级 — 实施计划
> **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:** 将平板支撑训练小程序从"主流小程序"提升至"独立精品 App"视觉水平,包含深色模式、骨架屏、悬浮 TabBar、数字滚动、页面转场、排名动画、全屏彩带、空状态插画等 8 项升级。
**Architecture:** 复用现有 `utils/theme.js` CSS 变量体系;新增 1 个组件 (`ui-skeleton`) + 2 个工具 (`countUp`, `darkMode`);改 10 个现有文件;零第三方依赖,零预算。
**Tech Stack:** 微信小程序原生 (WXML/WXSS/JS)、CSS3 keyframes、Canvas 2D、`wx.onThemeChange``wx.getSystemInfoSync`
**Spec:** `docs/superpowers/specs/2026-07-08-visual-upgrade-design.md`
---
## 文件结构
### 新增
- `utils/countUp.js` — 数字滚动工具
- `utils/darkMode.js` — 系统主题监听
- `components/ui-skeleton/ui-skeleton.js` — 骨架屏组件逻辑
- `components/ui-skeleton/ui-skeleton.json` — 组件配置
- `components/ui-skeleton/ui-skeleton.wxml` — 组件模板
- `components/ui-skeleton/ui-skeleton.wxss` — 组件样式
### 修改
- `app.json` — 加 `darkmode: true` 与 theme 配置
- `app.wxss` — 加深色变量、渐变背景、页面入场动画类
- `utils/theme.js` — 扩展 `getThemeStyle(theme, isDark)` 支持深色
- `custom-tab-bar/index.wxml` + `index.wxss` — 悬浮 Dock 样式
- `pages/index/index.wxml` + `.wxss` — 适配渐变 + 入场动画
- `pages/timer/timer.wxml` + `.wxss` + `.js` — countUp + 全屏彩带
- `pages/leaderboard/leaderboard.wxml` + `.wxss` — 骨架屏 + 排名动画 + 空状态
- `pages/records/records.wxml` + `.wxss` — 骨架屏 + 空状态
- `pages/settings/settings.wxml` + `.js` — 深色模式开关
### 不改
- `cloudfunctions/``utils/storage.js``utils/plan.js``utils/timer.js``utils/voice.js``utils/util.js``utils/cloud.js`、训练业务逻辑
---
## Task 顺序与依赖
```
Task 1 (深色模式基础设施)
Task 2 (渐变背景)
Task 3 (页面级入场动画)
Task 4 (ui-skeleton 组件)
Task 5 (骨架屏 → leaderboard)
Task 6 (骨架屏 → records)
Task 7 (countUp 工具)
Task 8 (timer 接入 countUp)
Task 9 (timer 全屏彩带)
Task 9.5 (ui-btn 涟漪)
Task 10 (浮动 Dock TabBar)
Task 11 (排名位移动画)
Task 12 (空状态插画)
Task 13 (设置页深色开关)
Task 14 (最终验收)
```
---
## Task 1: 深色模式基础设施
**Files:**
- Create: `utils/darkMode.js`
- Modify: `utils/theme.js`
- Modify: `app.wxss`
- Modify: `app.json`
- Modify: `app.js`
### Step 1: 创建 `utils/darkMode.js`
```js
/**
* 系统深色模式监听 — 提供初始值与变更回调
* 旧设备 wx.onThemeChange 失败时退化为只读一次
*/
const STORAGE_KEY = 'dark_mode_pref'
function _readSystemTheme() {
try {
const info = wx.getSystemInfoSync()
return info.theme === 'dark' ? true : false
} catch (e) {
return false
}
}
function getInitialDarkMode() {
const pref = wx.getStorageSync(STORAGE_KEY) || 'system'
if (pref === 'system') return _readSystemTheme()
return pref === 'dark'
}
function getPref() {
return wx.getStorageSync(STORAGE_KEY) || 'system'
}
function setPref(value) {
if (!['system', 'light', 'dark'].includes(value)) return
wx.setStorageSync(STORAGE_KEY, value)
}
function watchDarkMode(onChange) {
if (typeof wx.onThemeChange !== 'function') return
try {
wx.onThemeChange(({ theme }) => {
const pref = getPref()
if (pref === 'system') onChange(theme === 'dark')
})
} catch (e) {
// 旧设备不支持,忽略
}
}
module.exports = {
getInitialDarkMode,
getPref,
setPref,
watchDarkMode
}
```
### Step 2: 扩展 `utils/theme.js`
`utils/theme.js` 中替换 `getThemeStyle` 函数(第 97-100 行):
```js
const getThemeStyle = (theme, isDark = false) => {
const t = theme || getCurrentTheme()
const baseVars = isDark
? '--text:#E5E5E7;--text-secondary:#98989F;--bg:#0F0F12;--card-bg:#1C1C1E;--border:#2C2C2E;--success:#30D158;'
: '--text:#333333;--text-secondary:#999999;--bg:#F5F5F5;--card-bg:#FFFFFF;--border:#EEEEEE;--success:#4CAF50;'
return `${baseVars}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};`
}
```
`applyThemeToPage` 函数(第 102-111 行)改为:
```js
const applyThemeToPage = (self) => {
const darkMod = require('./darkMode')
const theme = getCurrentTheme()
const isDark = darkMod.getInitialDarkMode()
const nextStyle = getThemeStyle(theme, isDark)
if (self.data && self.data.themeStyle === nextStyle) {
return
}
self.setData({ theme, themeStyle: nextStyle, isDark })
_setNavBarColor(theme.primary)
}
```
### Step 3: 在 `app.wxss` 顶部追加深色安全区
`app.wxss` 第 1-14 行替换为:
```css
page {
--text: #333333;
--text-secondary: #999999;
--bg: #F5F5F5;
--card-bg: #FFFFFF;
--success: #4CAF50;
--border: #EEEEEE;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 28rpx;
color: var(--text);
background-color: var(--bg) !important;
transition: background-color 0.3s ease, color 0.3s ease;
}
```
### Step 4: 修改 `app.json` 启用深色模式
`app.json` 在第 28 行 `sitemapLocation` 之后加一行(注意末尾无逗号):
```json
{
"pages": [...],
"window": {...},
"tabBar": {...},
"darkmode": true,
"themeLocation": "theme.json",
"sitemapLocation": "sitemap.json"
}
```
### Step 5: 创建 `theme.json`
新建 `theme.json`(与 `app.json` 同级):
```json
{
"light": {
"navigationBarBackgroundColor": "#FF6B35",
"navigationBarTextStyle": "white",
"backgroundColor": "#F5F5F5",
"backgroundColorTop": "#F5F5F5",
"backgroundColorBottom": "#F5F5F5"
},
"dark": {
"navigationBarBackgroundColor": "#FF6B35",
"navigationBarTextStyle": "white",
"backgroundColor": "#0F0F12",
"backgroundColorTop": "#0F0F12",
"backgroundColorBottom": "#0F0F12"
}
}
```
### Step 6: 修改 `app.js` 启动监听
`app.js``onLaunch` 函数开头(第 6 行)插入:
```js
async onLaunch() {
// Init dark mode listener
const darkMod = require('./utils/darkMode')
darkMod.watchDarkMode((isDark) => {
// System theme changed; refresh all open pages
const pages = getCurrentPages()
pages.forEach(p => {
if (p && typeof p.applyThemeToPage === 'function') {
p.applyThemeToPage()
}
})
})
// Init cloud storage (no-op if not configured)
cloud.init()
// ... rest of existing onLaunch
```
### Step 7: 验证
在微信开发者工具中:
1. 重新编译
2. 顶部模拟器设置切到「深色模式」(或「Dark」)
3. 首页背景应变成 `#0F0F12` 深色
4. Console 应无报错
### Step 8: 提交
```bash
git add utils/darkMode.js utils/theme.js app.wxss app.json theme.json app.js
git commit -m "feat: 深色模式基础设施 (system theme + 跟随系统)"
```
---
## Task 2: 渐变背景
**Files:**
- Modify: `app.wxss`
### Step 1: 在 `app.wxss` 的 `page` 选择器后追加渐变背景
`app.wxss` 找到 `page { ... }` 块(第 1-14 行),在闭合 `}` 后追加:
```css
page {
--text: #333333;
--text-secondary: #999999;
--bg: #F5F5F5;
--bg-gradient-start: #F5F5F5;
--bg-gradient-end: #FAFAFA;
--card-bg: #FFFFFF;
--success: #4CAF50;
--border: #EEEEEE;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 28rpx;
color: var(--text);
background: linear-gradient(180deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%) !important;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* dark mode overrides */
@media (prefers-color-scheme: dark) {
page {
--bg-gradient-start: #0F0F12;
--bg-gradient-end: #14141A;
}
}
```
### Step 2: 验证
- 浅色:背景从 `#F5F5F5` 渐变到 `#FAFAFA`(极淡,几乎看不出但是有渐变感)
- 深色:背景从 `#0F0F12` 渐变到 `#14141A`
- 切换瞬间平滑过渡
### Step 3: 提交
```bash
git add app.wxss
git commit -m "feat: 渐变背景 + 渐变变量"
```
---
## Task 3: 页面级入场动画
**Files:**
- Modify: `app.wxss`
- Modify: 5 个页面 WXML: `pages/index/index.wxml``pages/timer/timer.wxml``pages/records/records.wxml``pages/leaderboard/leaderboard.wxml``pages/settings/settings.wxml`
### Step 1: 在 `app.wxss` 末尾追加页面入场动画
```css
/* ---- page-level transition ---- */
.page-enter {
animation: pageIn 0.35s cubic-bezier(0.2, 0, 0.2, 1) both;
}
@keyframes pageIn {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
```
### Step 2: 给每个页面的根 `view` 加 `page-enter` 类
5 个页面 WXML 文件的根 `<view class="container ...">` 改为:
**pages/index/index.wxml** 第 1 行:
```xml
<view class="container page-enter" style="{{themeStyle}}">
```
**pages/timer/timer.wxml** 第 1 行:
```xml
<view class="container timer-page page-enter" style="{{themeStyle}}">
```
**pages/records/records.wxml** 第 1 行:
```xml
<view class="container page-enter" style="{{themeStyle}}">
```
**pages/leaderboard/leaderboard.wxml** 第 1 行:
```xml
<view class="container page-enter" style="{{themeStyle}}">
```
**pages/settings/settings.wxml** 第 1 行:
```xml
<view class="container page-enter" style="{{themeStyle}}">
```
### Step 3: 验证
- 切换 Tab 4 个页面,每个页面有 ~20rpx 向上滑动 + 渐入效果
- 不应阻塞交互
### Step 4: 提交
```bash
git add app.wxss pages/*/index.wxml pages/*/*.wxml
git commit -m "feat: 页面级入场动画 (fade + translateY)"
```
---
## Task 4: 新建 `ui-skeleton` 骨架屏组件
**Files:**
- Create: `components/ui-skeleton/ui-skeleton.js`
- Create: `components/ui-skeleton/ui-skeleton.json`
- Create: `components/ui-skeleton/ui-skeleton.wxml`
- Create: `components/ui-skeleton/ui-skeleton.wxss`
### Step 1: `components/ui-skeleton/ui-skeleton.js`
```js
Component({
properties: {
variant: { type: String, value: 'card' },
count: { type: Number, value: 1 },
active: { type: Boolean, value: true }
},
data: {
_minDisplayShown: false
},
lifetimes: {
attached() {
// 200ms 最小显示,避免骨架闪一下
this._minTimer = setTimeout(() => {
this.setData({ _minDisplayShown: true })
}, 200)
},
detached() {
if (this._minTimer) clearTimeout(this._minTimer)
}
}
})
```
### Step 2: `components/ui-skeleton/ui-skeleton.json`
```json
{
"component": true,
"usingComponents": {}
}
```
### Step 3: `components/ui-skeleton/ui-skeleton.wxml`
```xml
<view class="skeleton {{active ? 'skeleton--active' : ''}}" wx:if="{{_minDisplayShown || !active}}">
<!-- card variant -->
<block wx:if="{{variant === 'card'}}">
<view class="sk-card">
<view class="sk-bar sk-bar--title"></view>
<view class="sk-bar sk-bar--body"></view>
<view class="sk-bar sk-bar--small"></view>
</view>
</block>
<!-- list-row variant -->
<block wx:elif="{{variant === 'list-row'}}">
<view class="sk-list" wx:for="{{count}}" wx:key="*this">
<view class="sk-circle"></view>
<view class="sk-list-text">
<view class="sk-bar sk-bar--title"></view>
<view class="sk-bar sk-bar--small"></view>
</view>
</view>
</block>
<!-- circle variant (timer) -->
<block wx:elif="{{variant === 'circle'}}">
<view class="sk-circle sk-circle--big">
<view class="sk-bar sk-bar--center"></view>
</view>
</block>
</view>
```
### Step 4: `components/ui-skeleton/ui-skeleton.wxss`
```css
.skeleton {
width: 100%;
}
.skeleton--active .sk-bar,
.skeleton--active .sk-circle {
animation: skShimmer 1.4s ease-in-out infinite;
background: linear-gradient(90deg,
rgba(0, 0, 0, 0.06) 0%,
rgba(0, 0, 0, 0.12) 50%,
rgba(0, 0, 0, 0.06) 100%);
background-size: 200% 100%;
}
@keyframes skShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.sk-bar {
height: 24rpx;
border-radius: 8rpx;
margin-bottom: 16rpx;
background-color: rgba(0, 0, 0, 0.08);
}
.sk-bar--title { width: 60%; height: 32rpx; }
.sk-bar--body { width: 100%; }
.sk-bar--small { width: 40%; height: 20rpx; margin-bottom: 0; }
.sk-bar--center { width: 60%; height: 40rpx; }
.sk-card {
background: var(--card-bg, #FFFFFF);
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
}
.sk-circle {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.sk-circle--big {
width: 360rpx;
height: 360rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.sk-list {
display: flex;
align-items: center;
padding: 24rpx 0;
gap: 24rpx;
}
.sk-list-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
/* dark mode */
@media (prefers-color-scheme: dark) {
.sk-card { background: var(--card-bg, #1C1C1E); }
.skeleton--active .sk-bar,
.skeleton--active .sk-circle {
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.10) 50%,
rgba(255, 255, 255, 0.05) 100%);
background-size: 200% 100%;
}
.sk-bar,
.sk-circle {
background-color: rgba(255, 255, 255, 0.06);
}
}
```
### Step 5: 验证
- 在某个页面临时放 `<ui-skeleton variant="card" />`
- 看到一张带 3 条灰色横条的占位卡片
- 切换深色模式,占位颜色应自动转深
### Step 6: 提交
```bash
git add components/ui-skeleton/
git commit -m "feat: 新增 ui-skeleton 骨架屏组件 (card / list-row / circle)"
```
---
## Task 5: 骨架屏应用到 leaderboard 页面
**Files:**
- Modify: `pages/leaderboard/leaderboard.wxml`
### Step 1: 替换加载状态区块
`pages/leaderboard/leaderboard.wxml` 第 13-16 行:
```xml
<!-- Loading -->
<view class="status-wrap" wx:if="{{loading}}">
<text class="status-text">加载中...</text>
</view>
```
替换为:
```xml
<!-- Loading skeleton -->
<view class="leaderboard-skeleton" wx:if="{{loading}}">
<ui-skeleton variant="list-row" count="{{5}}" />
</view>
```
### Step 2: 验证
- 进入排行页,先看到 5 行骨架,数据到达后切换到真实列表
- 切换 < 200ms 时骨架不闪烁(被 200ms 最小定时器遮蔽)
### Step 3: 提交
```bash
git add pages/leaderboard/leaderboard.wxml
git commit -m "feat: leaderboard 页面接入骨架屏"
```
---
## Task 6: 骨架屏应用到 records 页面
**Files:**
- Modify: `pages/records/records.wxml`
### Step 1: 在统计卡片和日历热力图位置插入骨架
`pages/records/records.wxml` 第 1-2 行 `<view class="container page-enter" style="{{themeStyle}}">` 后立即插入:
```xml
<block wx:if="{{loading}}">
<ui-skeleton variant="card" />
<view style="height: 24rpx;" />
<ui-skeleton variant="card" />
</block>
<block wx:else>
<!-- existing content -->
```
`loading` 字段可能不存在于 records 页。需先在 `pages/records/records.js` 顶部加 `loading: true,` 初始值,并在 `onShow` 末尾 setData 加载完成后置 false。
### Step 2: 在 `pages/records/records.js` 加 loading 状态
文件顶部 data 段加:
```js
data: {
loading: true,
// ... existing fields
}
```
找到 `onShow` 末尾,`setData` 调用后追加:
```js
this.setData({ loading: false })
```
(具体位置需根据实际 JS 文件确认,详见 `pages/records/records.js` 第 ~40 行附近)
### Step 3: 验证
- 进入记录页,先看到 2 张骨架卡片
- 数据加载完切换到真实统计+日历
### Step 4: 提交
```bash
git add pages/records/records.wxml pages/records/records.js
git commit -m "feat: records 页面接入骨架屏"
```
---
## Task 7: 创建 `utils/countUp.js`
**Files:**
- Create: `utils/countUp.js`
### Step 1: 完整内容
```js
/**
* 数字滚动工具 — 替代第三方库
* 用 setInterval(fn, 16) 模拟 60fps,兼容性最稳
* 使用 easeOut 缓动: 1 - (1-t)^3
*
* 使用:
* const task = countUp({ from: 0, to: 30, duration: 800, onUpdate: setDisplayTime })
* // 不再需要时:
* task.cancel()
*/
function countUp({ from = 0, to, duration = 800, decimals = 0, onUpdate, onComplete } = {}) {
// 边界:数值相同 或 时长 <= 0,跳过动画
if (from === to || duration <= 0) {
if (typeof onUpdate === 'function') onUpdate(to)
if (typeof onComplete === 'function') onComplete()
return { cancel: () => {} }
}
const start = Date.now()
const delta = to - from
let cancelled = false
const tick = () => {
if (cancelled) return
const elapsed = Date.now() - start
let t = Math.min(elapsed / duration, 1)
// easeOut cubic
t = 1 - Math.pow(1 - t, 3)
const current = from + delta * t
const value = decimals > 0
? Number(current.toFixed(decimals))
: Math.round(current)
if (typeof onUpdate === 'function') onUpdate(value)
if (elapsed >= duration) {
if (typeof onComplete === 'function') onComplete()
return
}
setTimeout(tick, 16)
}
setTimeout(tick, 16)
return {
cancel() { cancelled = true }
}
}
module.exports = { countUp }
```
### Step 2: 验证(开发者工具 Console 测试)
临时在某页 onLoad 中执行:
```js
const { countUp } = require('../../utils/countUp')
countUp({
from: 0, to: 100, duration: 1000,
onUpdate: (v) => console.log('count:', v)
})
```
Console 应看到数字 0 → 100 平滑递增,约 1 秒完成。
### Step 3: 提交
```bash
git add utils/countUp.js
git commit -m "feat: utils/countUp 数字滚动工具 (easeOut cubic)"
```
---
## Task 8: timer 页面接入 countUp
**Files:**
- Modify: `pages/timer/timer.js`
### Step 1: 在 timer.js 引入 countUp
文件顶部加:
```js
const { countUp } = require('../../utils/countUp')
```
### Step 2: 在 data 中加 `_countUpTask` 持有任务
data 段加:
```js
_countUpTask: null
```
### Step 3: 在 `onStart` (或开始训练的入口) 触发 countUp
找到计时器开始函数,在 `setData({ displayTime: duration })` 之前改为:
```js
// 取消上一个 task
if (this.data._countUpTask) this.data._countUpTask.cancel()
const task = countUp({
from: 0,
to: duration,
duration: 600,
onUpdate: (v) => this.setData({ displayTime: v }),
onComplete: () => { this.setData({ _countUpTask: null }) }
})
this.setData({ _countUpTask: task })
```
### Step 4: 在 `onUnload` 取消
```js
onUnload() {
if (this.data._countUpTask) this.data._countUpTask.cancel()
}
```
### Step 5: 验证
- 进入计时页,看到 0 滚到目标秒数
- 反复开始/停止/开始 10 次,Console 无 setData 报错
### Step 6: 提交
```bash
git add pages/timer/timer.js
git commit -m "feat: timer 页面接入 countUp 数字滚动"
```
---
## Task 9: timer 完成全屏彩带
**Files:**
- Modify: `pages/timer/timer.wxml`
- Modify: `pages/timer/timer.wxss`
### Step 1: 在 timer.wxml 顶部 view 内追加彩带容器
`pages/timer/timer.wxml` 在第 1 行 `<view class="container timer-page page-enter" style="{{themeStyle}}">` 之后插入:
```xml
<!-- 全屏彩带完成庆祝 -->
<view class="confetti" wx:if="{{isCompleted}}">
<view class="confetti-piece c1"></view>
<view class="confetti-piece c2"></view>
<view class="confetti-piece c3"></view>
<view class="confetti-piece c4"></view>
<view class="confetti-piece c5"></view>
<view class="confetti-piece c6"></view>
<view class="confetti-piece c7"></view>
<view class="confetti-piece c8"></view>
<view class="confetti-piece c9"></view>
<view class="confetti-piece c10"></view>
</view>
```
### Step 2: 在 timer.wxss 末尾追加彩带样式
```css
/* ---- full-screen confetti ---- */
.confetti {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.confetti-piece {
position: absolute;
top: -20rpx;
width: 16rpx;
height: 32rpx;
border-radius: 4rpx;
animation: confettiFall 2.4s ease-out forwards;
}
.c1 { left: 10%; background: #FF6B35; animation-delay: 0.00s; transform: rotate(15deg); }
.c2 { left: 22%; background: #FFD93D; animation-delay: 0.10s; transform: rotate(-20deg); }
.c3 { left: 34%; background: #4CAF50; animation-delay: 0.20s; transform: rotate(30deg); }
.c4 { left: 46%; background: #2979FF; animation-delay: 0.05s; transform: rotate(-10deg); }
.c5 { left: 58%; background: #EC407A; animation-delay: 0.15s; transform: rotate(45deg); }
.c6 { left: 70%; background: #7E57C2; animation-delay: 0.25s; transform: rotate(-30deg); }
.c7 { left: 82%; background: #FF8C5A; animation-delay: 0.08s; transform: rotate(20deg); }
.c8 { left: 14%; background: #5C9CFF; animation-delay: 0.18s; transform: rotate(-40deg); }
.c9 { left: 38%; background: #6EC072; animation-delay: 0.28s; transform: rotate(50deg); }
.c10 { left: 90%; background: #FFD93D; animation-delay: 0.12s; transform: rotate(-25deg); }
@keyframes confettiFall {
0% { opacity: 1; transform: translateY(0) rotate(0deg); }
100% { opacity: 0.6; transform: translateY(100vh) rotate(720deg); }
}
```
### Step 3: 验证
- 完成训练,看到 10 条彩色碎片从顶部飘落到屏幕外
- 不阻塞其他交互(`pointer-events: none`)
### Step 4: 提交
```bash
git add pages/timer/timer.wxml pages/timer/timer.wxss
git commit -m "feat: timer 完成全屏彩带动画"
```
---
## Task 10: 浮动 Dock TabBar
**Files:**
- Modify: `custom-tab-bar/index.wxml`
- Modify: `custom-tab-bar/index.wxss`
### Step 1: 替换 `custom-tab-bar/index.wxml`
完整内容:
```xml
<view class="dock" style="{{themeStyle}}">
<view class="dock-inner">
<view
class="dock-item {{selected === 0 ? 'active' : ''}}"
data-index="0"
bindtap="switchTab"
>
<view class="dock-icon" style="background-image: url({{selected === 0 ? svgIcons.homeFill : svgIcons.home}})"></view>
<text class="dock-text">训练</text>
</view>
<view
class="dock-item {{selected === 1 ? 'active' : ''}}"
data-index="1"
bindtap="switchTab"
>
<view class="dock-icon" style="background-image: url({{selected === 1 ? svgIcons.calendarFill : svgIcons.calendar}})"></view>
<text class="dock-text">记录</text>
</view>
<view
class="dock-item {{selected === 2 ? 'active' : ''}}"
data-index="2"
bindtap="switchTab"
>
<view class="dock-icon" style="background-image: url({{selected === 2 ? svgIcons.rankFill : svgIcons.rank}})"></view>
<text class="dock-text">排行</text>
</view>
<view
class="dock-item {{selected === 3 ? 'active' : ''}}"
data-index="3"
bindtap="switchTab"
>
<view class="dock-icon" style="background-image: url({{selected === 3 ? svgIcons.settingsFill : svgIcons.settings}})"></view>
<text class="dock-text">设置</text>
</view>
</view>
</view>
```
### Step 2: 替换 `custom-tab-bar/index.wxss`
完整内容:
```css
.dock {
position: fixed;
bottom: calc(40rpx + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
width: calc(100% - 80rpx);
max-width: 600rpx;
z-index: 100;
}
.dock-inner {
display: flex;
align-items: center;
justify-content: space-around;
background: var(--card-bg, #FFFFFF);
border-radius: 100rpx;
padding: 12rpx 0;
box-shadow:
0 8rpx 32rpx rgba(0, 0, 0, 0.12),
0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: background-color 0.3s ease;
}
.dock-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8rpx 0;
transition: transform 0.2s ease;
}
.dock-item:active {
transform: scale(0.92);
}
.dock-icon {
width: 44rpx;
height: 44rpx;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: 4rpx;
opacity: 0.5;
transition: opacity 0.2s ease;
}
.dock-text {
font-size: 20rpx;
color: var(--text-secondary, #999);
line-height: 1;
transition: color 0.2s ease;
}
.dock-item.active .dock-icon {
opacity: 1;
}
.dock-item.active .dock-text {
color: var(--primary, #FF6B35);
font-weight: 600;
}
/* dark mode */
@media (prefers-color-scheme: dark) {
.dock-inner {
background: var(--card-bg, #1C1C1E);
box-shadow:
0 8rpx 32rpx rgba(0, 0, 0, 0.5),
0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
}
```
### Step 3: 验证
- 4 个 Tab 浮在屏幕底部,圆角胶囊状
- 上下留有安全区,不被 Home Indicator 遮挡
- 切深色,自动转深色背景
### Step 4: 提交
```bash
git add custom-tab-bar/
git commit -m "feat: 浮动 Dock TabBar 样式"
```
---
## Task 11: 排名位移动画
**Files:**
- Modify: `pages/leaderboard/leaderboard.wxml`
- Modify: `pages/leaderboard/leaderboard.wxss`
### Step 1: 在 leaderboard.wxml 给每个 rank-item 加动画延迟
`pages/leaderboard/leaderboard.wxml` 第 27-31 行 (rank-item 容器) 改为:
```xml
<view
wx:for="{{rankedList}}"
wx:key="openid"
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
```
### Step 2: 在 leaderboard.wxss 末尾追加位移动画
```css
/* ---- rank item entrance ---- */
.rank-item--enter {
animation: rankIn 0.4s cubic-bezier(0.2, 0, 0.2, 1) both;
}
@keyframes rankIn {
from { opacity: 0; transform: translateX(-20rpx); }
to { opacity: 1; transform: translateX(0); }
}
```
### Step 3: 验证
- 排行榜加载,前 10 行依次滑入
- 第 11 行以后无延迟(避免最后一行等 600ms)
- "我的"bar 也参与
### Step 4: 提交
```bash
git add pages/leaderboard/leaderboard.wxml pages/leaderboard/leaderboard.wxss
git commit -m "feat: 排行榜行依次滑入动画"
```
---
## Task 12: 空状态插画
**Files:**
- Modify: `pages/leaderboard/leaderboard.wxml`
- Modify: `pages/leaderboard/leaderboard.wxss`
- Modify: `pages/records/records.wxml`
- Modify: `pages/records/records.wxss`
### Step 1: 替换 leaderboard 空状态
`pages/leaderboard/leaderboard.wxml` 第 19-23 行 (empty 区块):
```xml
<!-- Empty -->
<view class="status-wrap" wx:elif="{{empty}}">
<image class="empty-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<text class="empty-text">暂无排行数据</text>
<text class="empty-sub">快去训练吧!</text>
</view>
```
替换为:
```xml
<!-- Empty with CSS illustration -->
<view class="empty-state" wx:elif="{{empty}}">
<view class="empty-illus">
<view class="empty-illus-body"></view>
<view class="empty-illus-arm empty-illus-arm--l"></view>
<view class="empty-illus-arm empty-illus-arm--r"></view>
<view class="empty-illus-bubble">加油</view>
</view>
<text class="empty-text">暂无排行数据</text>
<text class="empty-sub">快去训练吧,争当第一名!</text>
</view>
```
### Step 2: 在 leaderboard.wxss 末尾追加空状态插画
```css
/* ---- empty state illustration ---- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx;
}
.empty-illus {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 40rpx;
}
.empty-illus-body {
position: absolute;
bottom: 30rpx;
left: 50%;
transform: translateX(-50%);
width: 160rpx;
height: 30rpx;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
border-radius: 16rpx;
}
.empty-illus-arm {
position: absolute;
bottom: 36rpx;
width: 20rpx;
height: 60rpx;
background: var(--primary);
border-radius: 10rpx;
}
.empty-illus-arm--l {
left: 30rpx;
transform: rotate(30deg);
}
.empty-illus-arm--r {
right: 30rpx;
transform: rotate(-30deg);
}
.empty-illus-bubble {
position: absolute;
top: 0;
right: 0;
background: var(--primary-bg);
color: var(--primary);
font-size: 22rpx;
font-weight: 600;
padding: 8rpx 16rpx;
border-radius: 16rpx;
line-height: 1;
}
.empty-text {
font-size: 30rpx;
color: var(--text);
margin-bottom: 12rpx;
}
.empty-sub {
font-size: 26rpx;
color: var(--text-secondary);
}
```
### Step 3: 同样改造 records 页面 empty 状态
`pages/records/records.wxml` 第 58-60 行 (empty 区块) 替换为:
```xml
<view wx:if="{{historyList.length === 0}}" class="empty-state">
<view class="empty-illus">
<view class="empty-illus-body"></view>
<view class="empty-illus-arm empty-illus-arm--l"></view>
<view class="empty-illus-arm empty-illus-arm--r"></view>
<view class="empty-illus-bubble">开始</view>
</view>
<text class="empty-text">还没有训练记录</text>
<text class="empty-sub">完成第一次训练,开启你的坚持!</text>
</view>
```
### Step 4: 在 records.wxss 末尾追加相同插画样式
复制 Task 12 Step 2 的 `.empty-*` 系列样式粘贴到 `pages/records/records.wxss` 末尾(优先复用,如样式已存在可跳过)。
### Step 5: 验证
- 排行榜 / 记录页无数据时,显示平板支撑小人简笔 + 气泡
- 5 套主题色小人身体都跟随主题色
- 深色模式正常
### Step 6: 提交
```bash
git add pages/leaderboard/leaderboard.wxml pages/leaderboard/leaderboard.wxss
git add pages/records/records.wxml pages/records/records.wxss
git commit -m "feat: 空状态插画 (CSS 简笔小人)"
```
---
## Task 13: 设置页加深色模式开关
**Files:**
- Modify: `pages/settings/settings.wxml`
- Modify: `pages/settings/settings.js`
### Step 1: 在 settings.wxml "配色方案" 区块之后插入深色模式卡片
找到 `<text class="section-title">配色方案</text>` 之后,定位到"训练计划选择"之前,插入:
```xml
<!-- 深色模式 -->
<ui-card variant="in">
<view class="section-header">
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">深色模式</text>
</view>
<view class="theme-list">
<view
class="theme-item {{darkModePref === item.key ? 'active' : ''}}"
wx:for="{{darkModeOptions}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onSelectDarkMode"
>
<text class="theme-name">{{item.name}}</text>
<image class="theme-check-img" wx:if="{{darkModePref === item.key}}" src="{{icons.check}}" mode="aspectFit"></image>
</view>
</view>
</ui-card>
```
### Step 2: 在 settings.js data 段加 darkMode 相关字段
```js
const darkMod = require('../../utils/darkMode')
data: {
// ... existing fields
darkModePref: 'system',
darkModeOptions: [
{ key: 'system', name: '跟随系统' },
{ key: 'light', name: '浅色' },
{ key: 'dark', name: '深色' }
]
}
```
### Step 3: 在 settings.js onShow 读 pref
```js
onShow() {
// ... existing
this.setData({ darkModePref: darkMod.getPref() })
}
```
### Step 4: 在 settings.js 加 onSelectDarkMode 处理函数
```js
onSelectDarkMode(e) {
const key = e.currentTarget.dataset.key
darkMod.setPref(key)
// 重新计算 isDark 并通知所有页面
const isDark = darkMod.getInitialDarkMode()
this.setData({ darkModePref: key })
// 触发所有页面重渲染
const pages = getCurrentPages()
pages.forEach(p => {
if (p && typeof p.applyThemeToPage === 'function') {
p.applyThemeToPage()
}
})
// 同时应用到本页面
this.applyThemeToPage()
}
```
### Step 5: 验证
- 设置页出现 3 个选项:跟随系统 / 浅色 / 深色
- 选"深色",所有页面立即切到深色
- 选"跟随系统",跟随系统切换
- 选"浅色",系统切深色时仍保持浅色
- 重启后选择被记住(Storage 中 `dark_mode_pref`)
### Step 6: 提交
```bash
git add pages/settings/settings.wxml pages/settings/settings.js
git commit -m "feat: 设置页深色模式开关 (3 档)"
```
---
## Task 9.5: ui-btn 涟漪效果
**Files:**
- Modify: `components/ui-btn/ui-btn.wxss`
### Step 1: 在 ui-btn.wxss 末尾追加涟漪
```css
/* ---- ripple effect ---- */
.ui-btn {
position: relative;
overflow: hidden;
}
.ui-btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
transform: translate(-50%, -50%);
transition: width 0.5s ease, height 0.5s ease, opacity 0.5s ease;
opacity: 0;
pointer-events: none;
}
.ui-btn--primary::before,
.ui-btn--ghost::before {
background: rgba(255, 255, 255, 0.4);
}
.ui-btn--outline::before,
.ui-btn--danger::before {
background: rgba(var(--primary-rgb, 255, 107, 53), 0.15);
}
.ui-btn:active::before {
width: 400rpx;
height: 400rpx;
opacity: 1;
transition: 0s;
}
```
### Step 2: 验证
- 点击 ui-btn(任意 variant),从手指触点扩散出圆形涟漪
- 不影响原有 `transform: scale(0.96)` 按下效果
- 深色模式 ripple 仍可见(透明度足够)
### Step 3: 提交
```bash
git add components/ui-btn/ui-btn.wxss
git commit -m "feat: ui-btn 按下涟漪效果"
```
---
## Task 14: 最终验收
**Files:**
- (仅验收,无代码改动)
### Step 1: 跑通 6.1 视觉验收清单
对照 spec §6.1,逐项检查:
- [ ] 浅色模式背景是渐变(非纯灰)
- [ ] 深色模式所有元素正常转深色
- [ ] 5 套主题 × 2 套明暗 = 10 种组合都正常
- [ ] TabBar 悬浮、圆角、阴影明显
- [ ] 计时器数字完成时滚动归零
- [ ] 完成时全屏彩带飘落
- [ ] 页面级入场动画所有页面正常
- [ ] 排行榜行依次滑入(前 10 行)
### Step 2: 跑通 6.2 性能验收
在微信开发者工具:
- 编译 → 性能 → 查看首屏时间
- 切换深色:用 Stopwatch 计时,应 < 100ms
- 计时器运行时,Console 无 setData 报错
### Step 3: 跑通 6.3 功能验收
- [ ] 排行榜/记录页:先骨架再真实数据,无闪
- [ ] 排行榜行依次滑入
- [ ] 2 个空状态有插画
- [ ] 设置页深色开关:切到 dark → 已打开页面立即响应
- [ ] 系统切深色:当前页立即响应(选"跟随系统"时)
- [ ] countUp 取消 0 报错(开始/停止循环 10 次)
### Step 4: 跑通 6.4 设备兼容
如有真机条件,在 iOS / Android 真机预览全部 Task 效果。
### Step 5: 包体积检查
```bash
ls -la dist/ 2>/dev/null || du -sh miniprogram/
```
体积增量应 < 5KB(无第三方包)。
### Step 6: 整体提交
```bash
git status # 确认无未提交
git log --oneline -20 # 确认 14 个 commit 都有
```
### Step 7: 写一个验收报告
新建 `docs/superpowers/notes/2026-07-08-visual-upgrade-verify.md`,记录:
- 哪些验收项通过 / 不通过
- 截图(可选)
- 已知问题 / 后续优化建议
---
## 总结
**14 个 Task**,每个 Task 一次 commit,总 ~16 个 commit。
**回滚策略**(任意 Task 出问题):
```bash
# 回滚某个 Task 的所有文件
git revert <commit-hash>
# 或完全回滚(还原到 main):
git checkout HEAD -- app.wxss app.json theme.json app.js
git checkout HEAD -- custom-tab-bar/ pages/*/
rm -rf components/ui-skeleton/ utils/countUp.js utils/darkMode.js
```