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

35 KiB

视觉与动效全量升级 — 实施计划

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.onThemeChangewx.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.jsutils/plan.jsutils/timer.jsutils/voice.jsutils/util.jsutils/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

/**
 * 系统深色模式监听 — 提供初始值与变更回调
 * 旧设备 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 行):

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 行)改为:

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 行替换为:

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 之后加一行(注意末尾无逗号):

{
  "pages": [...],
  "window": {...},
  "tabBar": {...},
  "darkmode": true,
  "themeLocation": "theme.json",
  "sitemapLocation": "sitemap.json"
}

Step 5: 创建 theme.json

新建 theme.json(与 app.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.jsonLaunch 函数开头(第 6 行)插入:

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: 提交

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.wxsspage 选择器后追加渐变背景

app.wxss 找到 page { ... } 块(第 1-14 行),在闭合 } 后追加:

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: 提交

git add app.wxss
git commit -m "feat: 渐变背景 + 渐变变量"

Task 3: 页面级入场动画

Files:

  • Modify: app.wxss
  • Modify: 5 个页面 WXML: pages/index/index.wxmlpages/timer/timer.wxmlpages/records/records.wxmlpages/leaderboard/leaderboard.wxmlpages/settings/settings.wxml

Step 1: 在 app.wxss 末尾追加页面入场动画

/* ---- 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: 给每个页面的根 viewpage-enter

5 个页面 WXML 文件的根 <view class="container ..."> 改为:

pages/index/index.wxml 第 1 行:

<view class="container page-enter" style="{{themeStyle}}">

pages/timer/timer.wxml 第 1 行:

<view class="container timer-page page-enter" style="{{themeStyle}}">

pages/records/records.wxml 第 1 行:

<view class="container page-enter" style="{{themeStyle}}">

pages/leaderboard/leaderboard.wxml 第 1 行:

<view class="container page-enter" style="{{themeStyle}}">

pages/settings/settings.wxml 第 1 行:

<view class="container page-enter" style="{{themeStyle}}">

Step 3: 验证

  • 切换 Tab 4 个页面,每个页面有 ~20rpx 向上滑动 + 渐入效果
  • 不应阻塞交互

Step 4: 提交

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

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

{
  "component": true,
  "usingComponents": {}
}

Step 3: components/ui-skeleton/ui-skeleton.wxml

<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

.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: 提交

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 行:

<!-- Loading -->
<view class="status-wrap" wx:if="{{loading}}">
  <text class="status-text">加载中...</text>
</view>

替换为:

<!-- Loading skeleton -->
<view class="leaderboard-skeleton" wx:if="{{loading}}">
  <ui-skeleton variant="list-row" count="{{5}}" />
</view>

Step 2: 验证

  • 进入排行页,先看到 5 行骨架,数据到达后切换到真实列表
  • 切换 < 200ms 时骨架不闪烁(被 200ms 最小定时器遮蔽)

Step 3: 提交

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}}"> 后立即插入:

<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 段加:

data: {
  loading: true,
  // ... existing fields
}

找到 onShow 末尾,setData 调用后追加:

this.setData({ loading: false })

(具体位置需根据实际 JS 文件确认,详见 pages/records/records.js 第 ~40 行附近)

Step 3: 验证

  • 进入记录页,先看到 2 张骨架卡片
  • 数据加载完切换到真实统计+日历

Step 4: 提交

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: 完整内容

/**
 * 数字滚动工具 — 替代第三方库
 * 用 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 中执行:

const { countUp } = require('../../utils/countUp')
countUp({
  from: 0, to: 100, duration: 1000,
  onUpdate: (v) => console.log('count:', v)
})

Console 应看到数字 0 → 100 平滑递增,约 1 秒完成。

Step 3: 提交

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

文件顶部加:

const { countUp } = require('../../utils/countUp')

Step 2: 在 data 中加 _countUpTask 持有任务

data 段加:

_countUpTask: null

Step 3: 在 onStart (或开始训练的入口) 触发 countUp

找到计时器开始函数,在 setData({ displayTime: duration }) 之前改为:

// 取消上一个 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 取消

onUnload() {
  if (this.data._countUpTask) this.data._countUpTask.cancel()
}

Step 5: 验证

  • 进入计时页,看到 0 滚到目标秒数
  • 反复开始/停止/开始 10 次,Console 无 setData 报错

Step 6: 提交

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}}"> 之后插入:

<!-- 全屏彩带完成庆祝 -->
<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 末尾追加彩带样式

/* ---- 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: 提交

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

完整内容:

<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

完整内容:

.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: 提交

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 容器) 改为:

<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 末尾追加位移动画

/* ---- 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: 提交

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 区块):

<!-- 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>

替换为:

<!-- 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 末尾追加空状态插画

/* ---- 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 区块) 替换为:

<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: 提交

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> 之后,定位到"训练计划选择"之前,插入:

<!-- 深色模式 -->
<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 相关字段

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

onShow() {
  // ... existing
  this.setData({ darkModePref: darkMod.getPref() })
}

Step 4: 在 settings.js 加 onSelectDarkMode 处理函数

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: 提交

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 末尾追加涟漪

/* ---- 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: 提交

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: 包体积检查

ls -la dist/ 2>/dev/null || du -sh miniprogram/

体积增量应 < 5KB(无第三方包)。

Step 6: 整体提交

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 出问题):

# 回滚某个 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