diff --git a/docs/superpowers/plans/2026-07-08-visual-upgrade.md b/docs/superpowers/plans/2026-07-08-visual-upgrade.md new file mode 100644 index 0000000..cd4163e --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-visual-upgrade.md @@ -0,0 +1,1450 @@ +# 视觉与动效全量升级 — 实施计划 + +> **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 文件的根 `` 改为: + +**pages/index/index.wxml** 第 1 行: +```xml + +``` + +**pages/timer/timer.wxml** 第 1 行: +```xml + +``` + +**pages/records/records.wxml** 第 1 行: +```xml + +``` + +**pages/leaderboard/leaderboard.wxml** 第 1 行: +```xml + +``` + +**pages/settings/settings.wxml** 第 1 行: +```xml + +``` + +### 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 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: 验证 + +- 在某个页面临时放 `` +- 看到一张带 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 + + + 加载中... + +``` + +替换为: + +```xml + + + + +``` + +### 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 行 `` 后立即插入: + +```xml + + + + + + + +``` + +但 `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 行 `` 之后插入: + +```xml + + + + + + + + + + + + + +``` + +### 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 + + + + + 训练 + + + + + 记录 + + + + + 排行 + + + + + 设置 + + + +``` + +### 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 + +``` + +### 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 + + + + 暂无排行数据 + 快去训练吧! + +``` + +替换为: + +```xml + + + + + + + 加油 + + 暂无排行数据 + 快去训练吧,争当第一名! + +``` + +### 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 + + + + + + 开始 + + 还没有训练记录 + 完成第一次训练,开启你的坚持! + +``` + +### 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 "配色方案" 区块之后插入深色模式卡片 + +找到 `配色方案` 之后,定位到"训练计划选择"之前,插入: + +```xml + + + + + 深色模式 + + + + {{item.name}} + + + + +``` + +### 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 + +# 或完全回滚(还原到 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 +```