Bull's blog Bull's blog
Resume
  • MBTI 人格测评
  • SBTI 沙雕人格测评
  • AI 人格测评
  • 工具首页
  • 测试工具箱
  • 测试文件下载中心
  • 图片测试文件下载
  • 音频测试文件下载
  • 视频测试文件下载
  • 文档测试文件下载
  • 拼音听写纸
  • 英语单词日课
  • 纸笔游戏
  • AI 播客生成器
  • AI 音乐生成器
  • 晨起习惯清单
  • 书包收拾挑战
  • 工作笔记
  • 分类
  • 标签
  • 归档
  • zh-CN
  • en-US
  • ja-JP

Bull

Resume
  • MBTI 人格测评
  • SBTI 沙雕人格测评
  • AI 人格测评
  • 工具首页
  • 测试工具箱
  • 测试文件下载中心
  • 图片测试文件下载
  • 音频测试文件下载
  • 视频测试文件下载
  • 文档测试文件下载
  • 拼音听写纸
  • 英语单词日课
  • 纸笔游戏
  • AI 播客生成器
  • AI 音乐生成器
  • 晨起习惯清单
  • 书包收拾挑战
  • 工作笔记
  • 分类
  • 标签
  • 归档
  • zh-CN
  • en-US
  • ja-JP
  • superpowers
  • plans
wangyang
2026-10-01
目录

2026-08-27-21天健康习惯打卡实施计划

# 21 天健康习惯打卡 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 新增独立的 21 天健康习惯打卡工具,固定学习、运动、护眼、护脊四项,使用浏览器本地存储保存进度,并支持单页 A4 打印为 PDF。

Architecture: 中文 Markdown 提供稳定路由和说明,独立 Vue 组件负责响应式界面与浏览器交互,小型 CommonJS 逻辑模块负责可测试的状态规范化、完成统计和存储解析。工具不依赖后端,也不修改现有晨起清单;打印沿用仓库现有的 window.print() 与打印媒体样式。

Tech Stack: VuePress 1.9.9、Vue 2 单文件组件、CommonJS、Node.js 20、localStorage、CSS @media print、Puppeteer、Node assert。


# 执行边界

  • 从同时包含设计文档和本实施计划的最新 main 创建独立工作树和 codex/health-habit-checklist 分支后执行。
  • 开始前运行 git status --short,保留主工作区的 .playwright-mcp/、independent project/ 和其他既有修改。
  • 项目命令统一先执行 source /Users/ywang/.nvm/nvm.sh 和 nvm use 20;非交互终端不会自动加载 nvm。
  • 本计划只授权本地实现、测试、构建和本地浏览器验收;不授权部署、Git push、远程同步或服务重启。
  • 不修改 MorningPilotChecklist.vue、晨起清单 Markdown、晨起移动页面或 morning_pilot_checklist_v1 数据结构。

# 文件结构

# 新增文件

文件 单一职责
docs/.vuepress/components/HealthHabitChecklistLogic.js 固定习惯、默认状态、数据规范化、完整天数统计、里程碑和存储解析
docs/.vuepress/components/HealthHabitChecklist.vue 页面交互、响应式布局、本地存储适配、错误提示和打印入口
docs/07.工具/20.学习工具/80.21天健康习惯打卡.md 中文公开页面、SEO、使用说明和组件入口
docs/en/tools/health-habit-checklist.md 英文自动回退骨架
docs/ja/tools/health-habit-checklist.md 日文自动回退骨架
scripts/test_health_habit_checklist_logic.js 纯逻辑回归测试
scripts/test_health_habit_checklist_component.js Vue 组件契约和隐私边界接线测试
scripts/test_health_habit_checklist_wiring.js 页面、总览、多语言、Wiki 和既有晨起清单隔离测试
scripts/test_health_habit_checklist_browser.js 本地存储、移动布局和单页 A4 打印验收

# 修改文件

文件 修改内容
docs/07.工具/00.概览.md 在学习工具中增加入口
docs/.vuepress/lang-map.json 通过 scripts/build-i18n.js 生成新路由映射
LLM_WIKI/20_内容体系/在线工具.md 增加工具、组件、逻辑和测试映射
LLM_WIKI/10_博客结构/页面与路由地图.md 增加 /tools/health-habit-checklist/
LLM_WIKI/30_资产库/资产总表.md 登记新公开工具资产

# Task 1: 以测试先行实现状态逻辑

Files:

  • Create: scripts/test_health_habit_checklist_logic.js

  • Create: docs/.vuepress/components/HealthHabitChecklistLogic.js

  • [ ] Step 1: 写逻辑模块的失败测试

创建 scripts/test_health_habit_checklist_logic.js:

const assert = require('assert')
const logic = require('../docs/.vuepress/components/HealthHabitChecklistLogic')

assert.deepStrictEqual(logic.HABIT_IDS, ['study', 'exercise', 'eyes', 'spine'])
assert.strictEqual(logic.DAY_COUNT, 21)

const defaults = logic.createDefaultState()
assert.strictEqual(defaults.version, 1)
assert.deepStrictEqual(defaults.profile, {
  className: '',
  studentName: '',
  startDate: '',
})
assert.strictEqual(Object.keys(defaults.days).length, 21)
assert.deepStrictEqual(defaults.days['1'], [])
assert.deepStrictEqual(defaults.review, {
  bestHabit: '',
  parentMessage: '',
  rating: 0,
})

assert.strictEqual(logic.isDayComplete(['study', 'exercise', 'eyes']), false)
assert.strictEqual(logic.isDayComplete(['study', 'exercise', 'eyes', 'spine']), true)
assert.strictEqual(logic.isDayComplete(['study', 'exercise', 'eyes', 'unknown']), false)
assert.strictEqual(logic.isDayComplete(['study', 'study', 'exercise', 'eyes', 'spine']), false)
assert.strictEqual(logic.isDayComplete(['study', 'exercise', 'eyes', 'spine', 'unknown']), false)
assert.deepStrictEqual(logic.normalizeHabitIds(['study', 'exercise']), ['study', 'exercise'])
assert.deepStrictEqual(logic.normalizeHabitIds(['study', 'study']), [])
assert.deepStrictEqual(logic.normalizeHabitIds(['study', 'unknown']), [])

const sampleDays = logic.createDefaultDays()
sampleDays['1'] = ['study', 'exercise', 'eyes', 'spine']
sampleDays['7'] = ['study', 'exercise', 'eyes', 'spine']
sampleDays['22'] = ['study', 'exercise', 'eyes', 'spine']
assert.strictEqual(logic.getCompletedDayCount(sampleDays), 2)
assert.strictEqual(logic.isMilestone(7), true)
assert.strictEqual(logic.isMilestone(14), true)
assert.strictEqual(logic.isMilestone(21), true)
assert.strictEqual(logic.isMilestone(6), false)

const toggledOnce = logic.toggleHabit(sampleDays, 2, 'study')
assert.deepStrictEqual(toggledOnce['2'], ['study'])
const toggledTwice = logic.toggleHabit(toggledOnce, 2, 'study')
assert.deepStrictEqual(toggledTwice['2'], [])
assert.deepStrictEqual(logic.toggleHabit(toggledTwice, 0, 'study'), toggledTwice)
assert.deepStrictEqual(logic.toggleHabit(toggledTwice, 1, 'unknown'), toggledTwice)

const valid = logic.parseStoredState(JSON.stringify({
  version: 1,
  profile: { className: '一(3)班', studentName: '测试姓名' },
  days: {
    1: ['study', 'study', 'exercise', 'unknown'],
    7: ['study', 'exercise', 'eyes', 'spine'],
    99: ['study'],
  },
  review: { bestHabit: '运动', parentMessage: '继续坚持', rating: 9 },
}))
assert.strictEqual(valid.status, 'valid')
assert.deepStrictEqual(valid.state.days['1'], [])
assert.deepStrictEqual(valid.state.days['7'], ['study', 'exercise', 'eyes', 'spine'])
assert.strictEqual(valid.state.days['21'].length, 0)
assert.strictEqual(valid.state.profile.startDate, '')
assert.strictEqual(valid.state.review.rating, 5)

assert.strictEqual(logic.parseStoredState(null).status, 'empty')
assert.strictEqual(logic.parseStoredState('{bad json').status, 'invalid')
assert.strictEqual(logic.parseStoredState(JSON.stringify({ version: 2 })).status, 'invalid')
assert.strictEqual(logic.parseStoredState(JSON.stringify([])).status, 'invalid')

const serialized = logic.serializeState(valid.state)
assert.strictEqual(logic.parseStoredState(serialized).status, 'valid')

console.log('health habit checklist logic tests passed')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
  • [ ] Step 2: 运行测试并确认因模块缺失而失败

Run:

source /Users/ywang/.nvm/nvm.sh
nvm use 20
node scripts/test_health_habit_checklist_logic.js
1
2
3

Expected: FAIL,包含 Cannot find module '../docs/.vuepress/components/HealthHabitChecklistLogic'。

  • [ ] Step 3: 实现最小完整逻辑模块

创建 docs/.vuepress/components/HealthHabitChecklistLogic.js:

const VERSION = 1
const DAY_COUNT = 21
const HABIT_IDS = ['study', 'exercise', 'eyes', 'spine']
const MILESTONE_DAYS = [7, 14, 21]

function isPlainObject(value) {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}

function createDefaultDays() {
  const days = {}
  for (let day = 1; day <= DAY_COUNT; day += 1) {
    days[String(day)] = []
  }
  return days
}

function createDefaultState() {
  return {
    version: VERSION,
    profile: {
      className: '',
      studentName: '',
      startDate: '',
    },
    days: createDefaultDays(),
    review: {
      bestHabit: '',
      parentMessage: '',
      rating: 0,
    },
  }
}

function normalizeText(value) {
  return typeof value === 'string' ? value : ''
}

function normalizeHabitIds(value) {
  if (!Array.isArray(value)) return []
  if (value.some((habitId) => !HABIT_IDS.includes(habitId))) return []
  if (new Set(value).size !== value.length) return []
  return HABIT_IDS.filter((habitId) => value.includes(habitId))
}

function normalizeRating(value) {
  const rating = Number(value)
  if (!Number.isFinite(rating)) return 0
  return Math.max(0, Math.min(5, Math.round(rating)))
}

function normalizeState(payload) {
  if (!isPlainObject(payload) || payload.version !== VERSION) return null

  const profile = isPlainObject(payload.profile) ? payload.profile : {}
  const review = isPlainObject(payload.review) ? payload.review : {}
  const sourceDays = isPlainObject(payload.days) ? payload.days : {}
  const days = createDefaultDays()

  for (let day = 1; day <= DAY_COUNT; day += 1) {
    days[String(day)] = normalizeHabitIds(sourceDays[String(day)])
  }

  return {
    version: VERSION,
    profile: {
      className: normalizeText(profile.className),
      studentName: normalizeText(profile.studentName),
      startDate: normalizeText(profile.startDate),
    },
    days,
    review: {
      bestHabit: normalizeText(review.bestHabit),
      parentMessage: normalizeText(review.parentMessage),
      rating: normalizeRating(review.rating),
    },
  }
}

function parseStoredState(raw) {
  if (raw === null || raw === undefined || raw === '') {
    return { status: 'empty', state: createDefaultState() }
  }
  try {
    const state = normalizeState(JSON.parse(raw))
    if (!state) return { status: 'invalid', state: createDefaultState() }
    return { status: 'valid', state }
  } catch (error) {
    return { status: 'invalid', state: createDefaultState() }
  }
}

function serializeState(state) {
  const normalized = normalizeState(state)
  return JSON.stringify(normalized || createDefaultState())
}

function isDayComplete(habitIds) {
  if (!Array.isArray(habitIds) || habitIds.length !== HABIT_IDS.length) return false
  const unique = new Set(habitIds)
  if (unique.size !== HABIT_IDS.length) return false
  return HABIT_IDS.every((habitId) => unique.has(habitId))
}

function getCompletedDayCount(days) {
  if (!isPlainObject(days)) return 0
  let total = 0
  for (let day = 1; day <= DAY_COUNT; day += 1) {
    if (isDayComplete(days[String(day)])) total += 1
  }
  return total
}

function isMilestone(day) {
  return MILESTONE_DAYS.includes(Number(day))
}

function toggleHabit(days, day, habitId) {
  const normalizedDay = Number(day)
  if (!Number.isInteger(normalizedDay) || normalizedDay < 1 || normalizedDay > DAY_COUNT) {
    return days
  }
  if (!HABIT_IDS.includes(habitId)) return days

  const key = String(normalizedDay)
  const current = normalizeHabitIds(days && days[key])
  const next = current.includes(habitId)
    ? current.filter((item) => item !== habitId)
    : HABIT_IDS.filter((item) => current.includes(item) || item === habitId)

  return { ...days, [key]: next }
}

module.exports = {
  VERSION,
  DAY_COUNT,
  HABIT_IDS,
  MILESTONE_DAYS,
  createDefaultDays,
  createDefaultState,
  normalizeHabitIds,
  normalizeState,
  parseStoredState,
  serializeState,
  isDayComplete,
  getCompletedDayCount,
  isMilestone,
  toggleHabit,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
  • [ ] Step 4: 运行逻辑测试并确认通过

Run:

node scripts/test_health_habit_checklist_logic.js
1

Expected: health habit checklist logic tests passed,退出码 0。

  • [ ] Step 5: 提交逻辑与测试
git add scripts/test_health_habit_checklist_logic.js docs/.vuepress/components/HealthHabitChecklistLogic.js
git commit -m "feat: add health habit checklist state logic"
1
2

# Task 2: 以组件契约测试驱动交互界面

Files:

  • Create: scripts/test_health_habit_checklist_component.js

  • Create: docs/.vuepress/components/HealthHabitChecklist.vue

  • Read only: docs/.vuepress/components/MorningPilotChecklist.vue

  • [ ] Step 1: 写组件失败测试

创建 scripts/test_health_habit_checklist_component.js:

const assert = require('assert')
const fs = require('fs')
const path = require('path')

const root = path.resolve(__dirname, '..')

function read(relativePath) {
  const absolutePath = path.join(root, relativePath)
  assert.ok(fs.existsSync(absolutePath), `missing required file: ${relativePath}`)
  return fs.readFileSync(absolutePath, 'utf8')
}

const component = read('docs/.vuepress/components/HealthHabitChecklist.vue')
assert.match(component, /name: 'HealthHabitChecklist'/)
assert.match(component, /require\('\.\/HealthHabitChecklistLogic'\)/)
assert.match(component, /health_habit_checklist_v1/)
assert.match(component, /学习/)
assert.match(component, /运动/)
assert.match(component, /护眼/)
assert.match(component, /护脊/)
assert.match(component, /v-for="day in 21"/)
assert.match(component, /四项全部完成/)
assert.match(component, /清空异常数据并重新开始/)
assert.match(component, /localStorage\.getItem/)
assert.match(component, /localStorage\.setItem/)
assert.match(component, /window\.print\(\)/)
assert.match(component, /body\.classList\.add\('health-habit-printing'\)/)
assert.match(component, /@page[\s\S]*size: A4 portrait/)
assert.match(component, /grid-template-columns: repeat\(7, 1fr\)/)
assert.match(component, /name="className"/)
assert.match(component, /name="studentName"/)
assert.match(component, /name="startDate"/)
assert.match(component, /data-testid="generate-pdf"/)
assert.match(component, /当前浏览器无法打开打印界面/)
assert.doesNotMatch(component, /axios|fetch\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource/)

const morning = read('docs/.vuepress/components/MorningPilotChecklist.vue')
assert.match(morning, /morning_pilot_checklist_v1/)

console.log('health habit checklist component contract passed')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  • [ ] Step 2: 运行测试并确认因组件缺失而失败

Run:

node scripts/test_health_habit_checklist_component.js
1

Expected: FAIL,包含 missing required file: docs/.vuepress/components/HealthHabitChecklist.vue。

  • [ ] Step 3: 创建完整 Vue 组件

创建 docs/.vuepress/components/HealthHabitChecklist.vue:

<template>
  <section class="health-habit-page">
    <div v-if="storageCorrupt" class="storage-alert no-print" role="alert">
      <span>本地打卡数据无法读取,已暂停保存,原数据尚未覆盖。</span>
      <button type="button" @click="clearCorruptState">清空异常数据并重新开始</button>
    </div>
    <div v-else-if="storageUnavailable" class="storage-alert warning no-print" role="status">
      当前浏览器无法保存进度,本次仍可临时使用和打印。
    </div>

    <div class="health-habit-print-sheet">
      <header class="habit-hero">
        <h1>21 天健康好习惯训练清单</h1>
        <p class="hero-en">HEALTHY HABIT CHECKLIST SYSTEM</p>
        <p>原则:按标准执行 → 逐项确认 → 每天坚持 → 养成习惯</p>
        <p>目标:坚持 21 天,让学习、运动、护眼、护脊成为每天的好习惯</p>
      </header>

      <div class="rule-strip">每天完成四项后逐一打勾,四项全部完成才算当天完整打卡</div>

      <div class="profile-grid">
        <label>
          <span>班级</span>
          <input
            v-model.trim="state.profile.className"
            name="className"
            type="text"
            :disabled="storageCorrupt"
            placeholder="请填写班级"
            @input="saveState"
          >
        </label>
        <label>
          <span>姓名</span>
          <input
            v-model.trim="state.profile.studentName"
            name="studentName"
            type="text"
            :disabled="storageCorrupt"
            placeholder="请填写姓名"
            @input="saveState"
          >
        </label>
        <label>
          <span>开始日期</span>
          <input
            v-model="state.profile.startDate"
            name="startDate"
            type="date"
            :disabled="storageCorrupt"
            @input="saveState"
          >
        </label>
      </div>

      <section class="progress-section" aria-label="21天进度">
        <div class="section-heading">
          <h2>21 天习惯养成进度</h2>
          <strong>已坚持 {{ completedDayCount }} 天</strong>
        </div>
        <div class="progress-grid">
          <span
            v-for="day in 21"
            :key="`progress-${day}`"
            class="progress-cell"
            :class="{
              complete: isDayComplete(day),
              milestone: isMilestone(day),
            }"
          >{{ isMilestone(day) ? `★${day}` : day }}</span>
        </div>
      </section>

      <section class="standards-section">
        <div class="section-heading compact">
          <h2>每日四项标准</h2>
          <span>四项都完成,才算当天完整打卡</span>
        </div>
        <div class="standards-grid">
          <article
            v-for="habit in habits"
            :key="habit.id"
            class="standard-card"
            :class="`habit-${habit.id}`"
          >
            <span class="habit-number">{{ habit.number }}</span>
            <div>
              <h3>{{ habit.name }}</h3>
              <p>{{ habit.description }}</p>
            </div>
            <span class="standard-check">✓</span>
          </article>
        </div>
      </section>

      <section class="days-section">
        <div class="section-heading compact">
          <h2>21 天打卡区</h2>
          <span>每个彩色方框代表一项习惯</span>
        </div>
        <div class="days-grid">
          <article
            v-for="day in 21"
            :key="`day-${day}`"
            class="day-card"
            :class="{ complete: isDayComplete(day), milestone: isMilestone(day) }"
          >
            <header>
              <span>第{{ day }}天</span>
              <b v-if="isMilestone(day)">★</b>
            </header>
            <div class="habit-toggle-grid">
              <button
                v-for="habit in habits"
                :key="`${day}-${habit.id}`"
                type="button"
                class="habit-toggle"
                :class="[`habit-${habit.id}`, { checked: isHabitChecked(day, habit.id) }]"
                :data-day="day"
                :data-habit="habit.id"
                :aria-pressed="isHabitChecked(day, habit.id) ? 'true' : 'false'"
                :disabled="storageCorrupt"
                @click="toggleHabit(day, habit.id)"
              >
                <span>{{ isHabitChecked(day, habit.id) ? '✓' : '' }}</span>
                {{ habit.shortName }}
              </button>
            </div>
          </article>
        </div>
      </section>

      <section class="review-section">
        <div class="review-heading">
          <h2>21 天小复盘</h2>
          <div class="rating" aria-label="五星评分">
            <button
              v-for="star in 5"
              :key="`rating-${star}`"
              type="button"
              :data-rating="star"
              :class="{ active: state.review.rating >= star }"
              :aria-pressed="state.review.rating === star ? 'true' : 'false'"
              :disabled="storageCorrupt"
              @click="setRating(star)"
            >★</button>
          </div>
        </div>
        <div class="review-grid">
          <p>我坚持了 <strong>{{ completedDayCount }}</strong> 天</p>
          <label>
            <span>我最棒的习惯是</span>
            <input
              v-model.trim="state.review.bestHabit"
              name="bestHabit"
              type="text"
              :disabled="storageCorrupt"
              @input="saveState"
            >
          </label>
          <label class="parent-message">
            <span>家长鼓励</span>
            <input
              v-model.trim="state.review.parentMessage"
              name="parentMessage"
              type="text"
              :disabled="storageCorrupt"
              @input="saveState"
            >
          </label>
        </div>
      </section>

      <footer>真实记录每一天,坚持本身就是进步。</footer>
    </div>

    <div class="action-bar no-print">
      <span>{{ saveStatusText }}</span>
      <button data-testid="generate-pdf" type="button" @click="startPrintMode">
        生成 PDF
      </button>
      <p v-if="printError" role="alert">{{ printError }}</p>
    </div>
  </section>
</template>

<script>
const logic = require('./HealthHabitChecklistLogic')

const STORAGE_KEY = 'health_habit_checklist_v1'

export default {
  name: 'HealthHabitChecklist',
  data() {
    return {
      state: logic.createDefaultState(),
      storageUnavailable: false,
      storageCorrupt: false,
      printError: '',
      habits: [
        { id: 'study', number: 1, name: '学习', shortName: '学', description: '阅读或复习,认真完成' },
        { id: 'exercise', number: 2, name: '运动', shortName: '动', description: '户外运动 2 小时,注意安全' },
        { id: 'eyes', number: 3, name: '护眼', shortName: '眼', description: '一尺一拳一寸 + 20-20-20' },
        { id: 'spine', number: 4, name: '护脊', shortName: '脊', description: '坐姿端正,完成护脊操' },
      ],
    }
  },
  computed: {
    completedDayCount() {
      return logic.getCompletedDayCount(this.state.days)
    },
    saveStatusText() {
      if (this.storageCorrupt) return '本地数据异常,保存已暂停'
      if (this.storageUnavailable) return '当前浏览器无法保存进度'
      return '进度已自动保存至当前浏览器'
    },
  },
  mounted() {
    this.loadState()
    if (typeof window !== 'undefined') {
      window.addEventListener('afterprint', this.stopPrintMode)
    }
  },
  beforeDestroy() {
    if (typeof window !== 'undefined') {
      window.removeEventListener('afterprint', this.stopPrintMode)
    }
    this.stopPrintMode()
  },
  methods: {
    isMilestone(day) {
      return logic.isMilestone(day)
    },
    isDayComplete(day) {
      return logic.isDayComplete(this.state.days[String(day)])
    },
    isHabitChecked(day, habitId) {
      return (this.state.days[String(day)] || []).includes(habitId)
    },
    toggleHabit(day, habitId) {
      if (this.storageCorrupt) return
      this.state.days = logic.toggleHabit(this.state.days, day, habitId)
      this.saveState()
    },
    setRating(rating) {
      if (this.storageCorrupt) return
      this.state.review.rating = rating
      this.saveState()
    },
    loadState() {
      if (typeof window === 'undefined') return
      try {
        const result = logic.parseStoredState(window.localStorage.getItem(STORAGE_KEY))
        this.state = result.state
        this.storageCorrupt = result.status === 'invalid'
      } catch (error) {
        this.storageUnavailable = true
      }
    },
    saveState() {
      if (typeof window === 'undefined' || this.storageCorrupt || this.storageUnavailable) return
      try {
        window.localStorage.setItem(STORAGE_KEY, logic.serializeState(this.state))
      } catch (error) {
        this.storageUnavailable = true
      }
    },
    clearCorruptState() {
      if (typeof window === 'undefined') return
      const confirmed = window.confirm('异常数据将被清空,是否重新开始?')
      if (!confirmed) return
      try {
        window.localStorage.removeItem(STORAGE_KEY)
        this.state = logic.createDefaultState()
        this.storageCorrupt = false
        this.storageUnavailable = false
        this.saveState()
      } catch (error) {
        this.storageUnavailable = true
      }
    },
    startPrintMode() {
      if (typeof window === 'undefined' || typeof window.print !== 'function') {
        this.printError = '当前浏览器无法打开打印界面。'
        return
      }
      this.printError = ''
      document.body.classList.add('health-habit-printing')
      this.$nextTick(() => window.print())
    },
    stopPrintMode() {
      if (typeof document !== 'undefined') {
        document.body.classList.remove('health-habit-printing')
      }
    },
  },
}
</script>

<style scoped>
.health-habit-page {
  --ink: #2c4055;
  --muted: #7b8794;
  --line: #dce3e8;
  --navy: #2f4358;
  --green: #25ae63;
  --gold: #f5b63e;
  max-width: 1180px;
  margin: 0 auto;
  color: var(--ink);
}

.health-habit-print-sheet {
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 26px;
  background: #fff;
  box-shadow: 0 14px 38px rgba(47, 67, 88, 0.12);
}

.habit-hero {
  padding: 34px 30px 24px;
  background: var(--navy);
  color: #fff;
  text-align: center;
}

.habit-hero h1 { margin: 0; font-size: 34px; letter-spacing: 2px; }
.habit-hero p { margin: 6px 0 0; color: #bec8d1; }
.habit-hero .hero-en { letter-spacing: 1px; color: #8797a5; }

.rule-strip {
  padding: 14px 24px;
  background: var(--green);
  color: #fff;
  text-align: center;
  font-weight: 700;
}

.profile-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
  padding: 18px 28px;
  background: #eef2f4;
}

.profile-grid label { display: flex; align-items: center; gap: 10px; font-weight: 700; }
.profile-grid input,
.review-grid input {
  min-width: 0;
  flex: 1;
  border: 0;
  border-bottom: 1px solid #9aa8b4;
  background: transparent;
  color: var(--ink);
  font: inherit;
  outline: none;
}

.progress-section,
.standards-section,
.days-section,
.review-section { padding: 18px 28px; }

.progress-section { background: #fff9e9; }

.section-heading,
.review-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 14px;
}

.section-heading h2,
.review-heading h2 { margin: 0; font-size: 22px; }
.section-heading strong { color: #c95f0c; font-size: 20px; }
.section-heading.compact span { color: var(--muted); }

.progress-grid {
  display: grid;
  grid-template-columns: repeat(21, minmax(28px, 1fr));
  gap: 6px;
}

.progress-cell {
  display: grid;
  min-height: 36px;
  place-items: center;
  border-radius: 8px;
  background: #e5eaed;
  color: #88939d;
  font-weight: 800;
}

.progress-cell.complete { background: var(--green); color: #fff; }
.progress-cell.milestone.complete { background: var(--gold); }

.standards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.standard-card {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 14px;
  align-items: center;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: 16px;
}
.standard-card h3,
.standard-card p { margin: 0; }
.standard-card p { margin-top: 4px; color: var(--muted); }
.habit-number,
.standard-check {
  display: grid;
  width: 38px;
  height: 38px;
  place-items: center;
  border-radius: 50%;
  font-weight: 900;
}
.standard-check { border-radius: 9px; color: #fff; }
.habit-study .habit-number { background: #fff0bd; color: #c77c00; }
.habit-exercise .habit-number { background: #dff5e7; color: #159553; }
.habit-eyes .habit-number { background: #dff1fb; color: #2f88b7; }
.habit-spine .habit-number { background: #f4dfef; color: #a34d89; }
.habit-study .standard-check,
.habit-toggle.habit-study { background: #d68900; }
.habit-exercise .standard-check,
.habit-toggle.habit-exercise { background: #1d9854; }
.habit-eyes .standard-check,
.habit-toggle.habit-eyes { background: #3d8eb8; }
.habit-spine .standard-check,
.habit-toggle.habit-spine { background: #a4518e; }

.days-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 9px; }
.day-card { overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: #fff; }
.day-card.complete { border-color: #75c99a; box-shadow: inset 0 0 0 1px #75c99a; }
.day-card header {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding: 9px 6px;
  background: #edf1f3;
  font-weight: 800;
}
.day-card.milestone header b { color: var(--gold); }
.habit-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; padding: 12px 8px; }
.habit-toggle {
  border: 0;
  border-radius: 7px;
  padding: 6px 4px;
  color: rgba(255, 255, 255, 0.62);
  cursor: pointer;
  font-weight: 800;
}
.habit-toggle span { display: inline-block; width: 12px; }
.habit-toggle.checked { color: #fff; box-shadow: 0 0 0 2px rgba(44, 64, 85, 0.18); }

.review-section { margin: 18px 28px 26px; border: 1px solid var(--line); border-radius: 16px; }
.rating { display: flex; }
.rating button { border: 0; background: transparent; color: #c8d0d6; font-size: 28px; cursor: pointer; }
.rating button.active { color: var(--gold); }
.review-grid { display: grid; grid-template-columns: auto 1fr; gap: 16px 24px; }
.review-grid p { margin: 0; }
.review-grid label { display: flex; gap: 10px; }
.review-grid .parent-message { grid-column: 1 / -1; }

.health-habit-print-sheet footer {
  padding: 0 28px 22px;
  color: #9aa4ad;
  text-align: center;
}

.action-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  align-items: center;
  justify-content: flex-end;
  padding: 18px 0;
}
.action-bar button,
.storage-alert button {
  border: 0;
  border-radius: 10px;
  padding: 11px 20px;
  background: var(--green);
  color: #fff;
  cursor: pointer;
  font-weight: 800;
}
.action-bar p { flex-basis: 100%; margin: 0; color: #b42318; text-align: right; }
.storage-alert { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 16px; padding: 14px; border-radius: 12px; background: #fff1f0; color: #b42318; }
.storage-alert.warning { background: #fff8e6; color: #9a6700; }

@media (max-width: 760px) {
  .habit-hero { padding: 24px 16px 18px; }
  .habit-hero h1 { font-size: 25px; }
  .profile-grid,
  .standards-grid { grid-template-columns: 1fr; }
  .profile-grid,
  .progress-section,
  .standards-section,
  .days-section { padding-left: 14px; padding-right: 14px; }
  .progress-grid { grid-template-columns: repeat(7, 1fr); }
  .days-grid { grid-template-columns: repeat(3, 1fr); }
  .section-heading.compact { align-items: flex-start; flex-direction: column; }
  .review-section { margin: 14px; }
  .review-grid { grid-template-columns: 1fr; }
  .review-grid .parent-message { grid-column: auto; }
}

@media (max-width: 430px) {
  .days-grid { grid-template-columns: repeat(2, 1fr); }
  .storage-alert { align-items: stretch; flex-direction: column; }
}
</style>

<style>
@media print {
  @page { size: A4 portrait; margin: 0; }
  body.health-habit-printing * { visibility: hidden !important; }
  body.health-habit-printing .health-habit-print-sheet,
  body.health-habit-printing .health-habit-print-sheet * { visibility: visible !important; }
  body.health-habit-printing .health-habit-print-sheet {
    position: absolute !important;
    inset: 0 auto auto 0 !important;
    width: 210mm !important;
    height: 297mm !important;
    box-sizing: border-box !important;
    border: 0 !important;
    border-radius: 0 !important;
    box-shadow: none !important;
    overflow: hidden !important;
    -webkit-print-color-adjust: exact !important;
    print-color-adjust: exact !important;
  }
  body.health-habit-printing .habit-hero { padding: 8mm 7mm 5mm !important; }
  body.health-habit-printing .habit-hero h1 { font-size: 22pt !important; }
  body.health-habit-printing .habit-hero p { font-size: 8pt !important; margin-top: 2mm !important; }
  body.health-habit-printing .rule-strip { padding: 3mm 6mm !important; font-size: 9pt !important; }
  body.health-habit-printing .profile-grid { padding: 4mm 7mm !important; gap: 4mm !important; font-size: 8pt !important; }
  body.health-habit-printing .progress-section,
  body.health-habit-printing .standards-section,
  body.health-habit-printing .days-section { padding: 3mm 7mm !important; }
  body.health-habit-printing .section-heading { margin-bottom: 2mm !important; }
  body.health-habit-printing .section-heading h2,
  body.health-habit-printing .review-heading h2 { font-size: 11pt !important; }
  body.health-habit-printing .section-heading strong { font-size: 10pt !important; }
  body.health-habit-printing .section-heading span { font-size: 7pt !important; }
  body.health-habit-printing .progress-grid { grid-template-columns: repeat(21, 1fr) !important; gap: 1mm !important; }
  body.health-habit-printing .progress-cell { min-height: 7mm !important; border-radius: 2mm !important; font-size: 7pt !important; }
  body.health-habit-printing .standards-grid { grid-template-columns: 1fr 1fr !important; gap: 2mm !important; }
  body.health-habit-printing .standard-card { padding: 2mm !important; gap: 2mm !important; border-radius: 2mm !important; }
  body.health-habit-printing .standard-card h3 { font-size: 9pt !important; }
  body.health-habit-printing .standard-card p { font-size: 6.5pt !important; }
  body.health-habit-printing .habit-number,
  body.health-habit-printing .standard-check { width: 7mm !important; height: 7mm !important; }
  body.health-habit-printing .days-grid { grid-template-columns: repeat(7, 1fr) !important; gap: 1.5mm !important; }
  body.health-habit-printing .day-card { border-radius: 2mm !important; break-inside: avoid !important; }
  body.health-habit-printing .day-card header { padding: 1.5mm !important; font-size: 7pt !important; }
  body.health-habit-printing .habit-toggle-grid { gap: 1mm !important; padding: 2mm !important; }
  body.health-habit-printing .habit-toggle { padding: 1.2mm 0.6mm !important; border-radius: 1mm !important; font-size: 6pt !important; }
  body.health-habit-printing .review-section { margin: 3mm 7mm !important; padding: 3mm !important; }
  body.health-habit-printing .review-heading { margin-bottom: 2mm !important; }
  body.health-habit-printing .rating button { font-size: 14pt !important; padding: 0 !important; }
  body.health-habit-printing .review-grid { gap: 2mm 4mm !important; font-size: 7pt !important; }
  body.health-habit-printing .health-habit-print-sheet footer { padding: 1mm 7mm 0 !important; font-size: 6.5pt !important; }
  body.health-habit-printing .no-print { display: none !important; }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
  • [ ] Step 4: 运行组件契约和逻辑回归

Run:

node scripts/test_health_habit_checklist_component.js
node scripts/test_health_habit_checklist_logic.js
1
2

Expected: 两个脚本均打印 passed,退出码均为 0。

  • [ ] Step 5: 提交组件与契约测试
git add scripts/test_health_habit_checklist_component.js docs/.vuepress/components/HealthHabitChecklist.vue
git commit -m "feat: add health habit checklist component"
1
2

# Task 3: 接入公开页面、总览和多语言回退

Files:

  • Create: scripts/test_health_habit_checklist_wiring.js

  • Create: docs/07.工具/20.学习工具/80.21天健康习惯打卡.md

  • Create: docs/en/tools/health-habit-checklist.md

  • Create: docs/ja/tools/health-habit-checklist.md

  • Modify: docs/07.工具/00.概览.md

  • Modify: docs/.vuepress/lang-map.json

  • [ ] Step 1: 写页面与入口失败测试

创建 scripts/test_health_habit_checklist_wiring.js:

const assert = require('assert')
const fs = require('fs')
const path = require('path')

const root = path.resolve(__dirname, '..')

function read(relativePath) {
  const absolutePath = path.join(root, relativePath)
  assert.ok(fs.existsSync(absolutePath), `missing required file: ${relativePath}`)
  return fs.readFileSync(absolutePath, 'utf8')
}

const page = read('docs/07.工具/20.学习工具/80.21天健康习惯打卡.md')
assert.match(page, /title: 21天健康好习惯打卡/)
assert.match(page, /permalink: \/tools\/health-habit-checklist\//)
assert.match(page, /<HealthHabitChecklist \/>/)
assert.match(page, /数据仅保存在当前浏览器/)
assert.match(page, /另存为 PDF/)

const overview = read('docs/07.工具/00.概览.md')
assert.match(overview, /\[21天健康好习惯打卡\]\(\/tools\/health-habit-checklist\/\)/)

for (const locale of ['en', 'ja']) {
  const fallback = read(`docs/${locale}/tools/health-habit-checklist.md`)
  assert.match(fallback, /permalink: \/tools\/health-habit-checklist\//)
  assert.match(fallback, /autoGenerated: true/)
  assert.match(fallback, /http-equiv: refresh/)
}

const langMap = JSON.parse(read('docs/.vuepress/lang-map.json'))
const route = langMap['/tools/health-habit-checklist/']
assert.ok(route, 'lang-map must contain health habit route')
assert.strictEqual(route.zh, true)
assert.strictEqual(route.en, true)
assert.strictEqual(route.ja, true)
assert.strictEqual(route.enTodo, true)
assert.strictEqual(route.jaTodo, true)

const morningPage = read('docs/07.工具/20.学习工具/60.晨起习惯训练清单.md')
const morningComponent = read('docs/.vuepress/components/MorningPilotChecklist.vue')
assert.match(morningPage, /permalink: \/tools\/morning-pilot-checklist\//)
assert.match(morningComponent, /morning_pilot_checklist_v1/)
assert.doesNotMatch(morningComponent, /health_habit_checklist_v1/)

console.log('health habit checklist page wiring passed')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
  • [ ] Step 2: 运行测试并确认因页面缺失而失败

Run:

node scripts/test_health_habit_checklist_wiring.js
1

Expected: FAIL,包含 missing required file: docs/07.工具/20.学习工具/80.21天健康习惯打卡.md。

  • [ ] Step 3: 新增中文公开页面

创建 docs/07.工具/20.学习工具/80.21天健康习惯打卡.md:

---
title: 21天健康好习惯打卡
date: 2026-08-27T00:00:00.000Z
permalink: /tools/health-habit-checklist/
article: false
comment: false
author:
  name: wangyang
description: 面向学校作业的21天健康好习惯打卡工具,固定学习、运动、护眼、护脊四项,数据仅保存在当前浏览器,并支持A4打印为PDF。
keywords: 21天习惯打卡,健康习惯,学习打卡,运动打卡,护眼,护脊,学校作业,A4打卡表
---

按照学校作业常用的 21 天训练表设计,固定记录学习、运动、护眼和护脊四项习惯。姓名、班级、开始日期和打卡进度只保存在当前浏览器,不会上传到服务器。

<ClientOnly>
  <HealthHabitChecklist />
</ClientOnly>

## 使用说明

1. 填写班级、姓名和开始日期。
2. 每天完成一项就点击对应彩色方框;四项全部完成才计为完整一天。
3. 需要补录时可直接选择任意一天,不限制必须当天操作。
4. 点击“生成 PDF”,在浏览器打印界面中选择“另存为 PDF”或直接打印。

## 数据与隐私

- 数据仅保存在当前浏览器的本地存储中。
- 换设备、无痕模式或清理浏览器数据后无法自动恢复。
- 页面不会把姓名、班级和打卡记录发送到服务器。

## 相关工具

- [晨起习惯训练清单](/tools/morning-pilot-checklist/)
- [飞行员清单:书包收拾挑战](/tools/schoolbag-pilot-challenge/)
- [工具总览](/tools/overview/)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
  • [ ] Step 4: 新增英日自动回退骨架

创建 docs/en/tools/health-habit-checklist.md:

---
permalink: /tools/health-habit-checklist/
i18nStatus: todo
autoGenerated: true
head:
  - - meta
    - http-equiv: refresh
      content: 0;url=/tools/health-habit-checklist/
title: health-habit-checklist
date: 2026-08-27 00:00:00
categories:
  - en
  - tools
tags:
  -
author:
  name: wangyang
  link: https://github.com/
---

<!-- English translation pending -->
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

创建 docs/ja/tools/health-habit-checklist.md:

---
permalink: /tools/health-habit-checklist/
i18nStatus: todo
autoGenerated: true
head:
  - - meta
    - http-equiv: refresh
      content: 0;url=/tools/health-habit-checklist/
title: health-habit-checklist
date: 2026-08-27 00:00:00
categories:
  - ja
  - tools
tags:
  -
author:
  name: wangyang
  link: https://github.com/
---

<!-- Japanese translation pending -->
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • [ ] Step 5: 在工具总览增加入口

在 docs/07.工具/00.概览.md 的“学习工具”列表中,紧接晨起清单后加入:

- [21天健康好习惯打卡](/tools/health-habit-checklist/)(固定学习、运动、护眼、护脊四项,支持本地保存和A4 PDF)
1
  • [ ] Step 6: 生成并核对语言映射

Run:

source /Users/ywang/.nvm/nvm.sh
nvm use 20
node scripts/build-i18n.js
git diff -- docs/.vuepress/lang-map.json docs/en/tools/health-habit-checklist.md docs/ja/tools/health-habit-checklist.md
1
2
3
4

Expected: lang-map.json 只增加 /tools/health-habit-checklist/ 映射;英日骨架保持 autoGenerated: true 和回退到中文路由。

  • [ ] Step 7: 运行页面接线测试

Run:

node scripts/test_health_habit_checklist_wiring.js
node scripts/test_health_habit_checklist_component.js
1
2

Expected: 两个脚本均打印 passed,退出码 0。

  • [ ] Step 8: 提交页面、入口和多语言映射
git add \
  scripts/test_health_habit_checklist_wiring.js \
  'docs/07.工具/20.学习工具/80.21天健康习惯打卡.md' \
  'docs/07.工具/00.概览.md' \
  docs/en/tools/health-habit-checklist.md \
  docs/ja/tools/health-habit-checklist.md \
  docs/.vuepress/lang-map.json
git commit -m "feat: publish health habit checklist page"
1
2
3
4
5
6
7
8

# Task 4: 以失败断言驱动 Wiki 同步

Files:

  • Modify: scripts/test_health_habit_checklist_wiring.js

  • Modify: LLM_WIKI/20_内容体系/在线工具.md

  • Modify: LLM_WIKI/10_博客结构/页面与路由地图.md

  • Modify: LLM_WIKI/30_资产库/资产总表.md

  • [ ] Step 1: 在接线测试中增加 Wiki 失败断言

在 scripts/test_health_habit_checklist_wiring.js 的最终 console.log 前加入:

const toolsWiki = read('LLM_WIKI/20_内容体系/在线工具.md')
assert.match(toolsWiki, /健康习惯打卡/)
assert.match(toolsWiki, /HealthHabitChecklist\.vue/)
assert.match(toolsWiki, /HealthHabitChecklistLogic\.js/)
assert.match(toolsWiki, /test_health_habit_checklist_logic\.js/)
assert.match(toolsWiki, /test_health_habit_checklist_component\.js/)
assert.match(toolsWiki, /test_health_habit_checklist_wiring\.js/)

const routeWiki = read('LLM_WIKI/10_博客结构/页面与路由地图.md')
assert.match(routeWiki, /\/tools\/health-habit-checklist\//)

const assetWiki = read('LLM_WIKI/30_资产库/资产总表.md')
assert.match(assetWiki, /21 天健康习惯打卡/)
assert.match(assetWiki, /HealthHabitChecklist\.vue/)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • [ ] Step 2: 运行测试并确认因 Wiki 尚未同步而失败

Run:

node scripts/test_health_habit_checklist_wiring.js
1

Expected: FAIL,首个失败为 toolsWiki 中缺少“健康习惯打卡”。

  • [ ] Step 3: 更新在线工具映射

在 LLM_WIKI/20_内容体系/在线工具.md 的“学习工具”表格中,紧接“书包挑战”行加入:

| 健康习惯打卡 | `80.21天健康习惯打卡.md` | `HealthHabitChecklist.vue` | `HealthHabitChecklistLogic.js`;浏览器本地状态 | `test_health_habit_checklist_logic.js`、`test_health_habit_checklist_component.js`、`test_health_habit_checklist_wiring.js` |
1
  • [ ] Step 4: 更新路由地图

在 LLM_WIKI/10_博客结构/页面与路由地图.md 的工具路由代码块中,在晨起清单后加入:

/tools/health-habit-checklist/
1
  • [ ] Step 5: 登记新公开资产

在 LLM_WIKI/30_资产库/资产总表.md 的“工具组件”行后加入:

| 21 天健康习惯打卡 | `docs/07.工具/20.学习工具/80.21天健康习惯打卡.md`、`docs/.vuepress/components/HealthHabitChecklist.vue`、`HealthHabitChecklistLogic.js` | 源文件 | 已公开 | 当前有效 | 普通 | 已跟踪 |
1
  • [ ] Step 6: 运行 Wiki 接线与通用 Wiki 校验

Run:

node scripts/test_health_habit_checklist_wiring.js
node scripts/test_llm_wiki.js
git diff --check
1
2
3

Expected: 两个脚本打印 passed,git diff --check 无输出,全部退出码 0。

  • [ ] Step 7: 提交 Wiki 同步
git add \
  scripts/test_health_habit_checklist_wiring.js \
  'LLM_WIKI/20_内容体系/在线工具.md' \
  'LLM_WIKI/10_博客结构/页面与路由地图.md' \
  'LLM_WIKI/30_资产库/资产总表.md'
git commit -m "docs: register health habit checklist"
1
2
3
4
5
6

# Task 5: 增加浏览器和单页 A4 验收

Files:

  • Create: scripts/test_health_habit_checklist_browser.js

  • Modify only if the acceptance test exposes a defect: docs/.vuepress/components/HealthHabitChecklist.vue

  • Modify: scripts/test_health_habit_checklist_wiring.js

  • Modify: LLM_WIKI/20_内容体系/在线工具.md

  • [ ] Step 1: 创建端到端验收脚本

创建 scripts/test_health_habit_checklist_browser.js:

const assert = require('assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { execFileSync } = require('child_process')
const puppeteer = require('puppeteer')

const baseUrl = process.env.HEALTH_HABIT_BASE_URL || 'http://127.0.0.1:4173'
const pageUrl = `${baseUrl}/tools/health-habit-checklist/`
const storageKey = 'health_habit_checklist_v1'
const requestedPdfPath = process.env.HEALTH_HABIT_PDF_OUTPUT || ''

async function setInput(page, selector, value) {
  await page.$eval(selector, (input, nextValue) => {
    input.value = nextValue
    input.dispatchEvent(new Event('input', { bubbles: true }))
  }, value)
}

;(async () => {
  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
  const tempDir = requestedPdfPath ? '' : fs.mkdtempSync(path.join(os.tmpdir(), 'health-habit-checklist-'))
  const pdfPath = requestedPdfPath || path.join(tempDir, 'health-habit-checklist.pdf')

  try {
    const page = await browser.newPage()
    await page.goto(pageUrl, { waitUntil: 'networkidle0' })
    await page.evaluate((key) => localStorage.removeItem(key), storageKey)
    await page.reload({ waitUntil: 'networkidle0' })

    await setInput(page, 'input[name="className"]', '一年级三班')
    await setInput(page, 'input[name="studentName"]', '浏览器测试')
    await setInput(page, 'input[name="startDate"]', '2026-08-01')

    const dayOneButtons = await page.$$('button[data-day="1"][data-habit]')
    assert.strictEqual(dayOneButtons.length, 4)
    for (const button of dayOneButtons) await button.click()

    await page.waitForFunction(() => document.body.innerText.includes('已坚持 1 天'))
    await page.reload({ waitUntil: 'networkidle0' })

    assert.strictEqual(await page.$eval('input[name="className"]', (el) => el.value), '一年级三班')
    assert.strictEqual(await page.$eval('input[name="studentName"]', (el) => el.value), '浏览器测试')
    assert.strictEqual(await page.$eval('input[name="startDate"]', (el) => el.value), '2026-08-01')
    assert.strictEqual(await page.$$eval('button[data-day="1"][aria-pressed="true"]', (nodes) => nodes.length), 4)
    assert.strictEqual(await page.$eval('.days-grid', (el) => (
      getComputedStyle(el).gridTemplateColumns.split(' ').length
    )), 7)

    await page.evaluate((key) => localStorage.setItem(key, '{bad json'), storageKey)
    await page.reload({ waitUntil: 'networkidle0' })
    assert.ok(await page.$('.storage-alert'))
    assert.strictEqual(await page.$eval('input[name="className"]', (el) => el.disabled), true)
    assert.strictEqual(await page.evaluate((key) => localStorage.getItem(key), storageKey), '{bad json')

    page.once('dialog', (dialog) => dialog.accept())
    await page.click('.storage-alert button')
    await page.waitForFunction(() => !document.querySelector('.storage-alert'))

    await setInput(page, 'input[name="className"]', '一年级三班')
    await setInput(page, 'input[name="studentName"]', '打印测试')
    await setInput(page, 'input[name="startDate"]', '2026-08-01')
    await setInput(page, 'input[name="bestHabit"]', '运动')
    await setInput(page, 'input[name="parentMessage"]', '继续坚持')
    const milestoneButtons = await page.$$('button[data-day="7"][data-habit]')
    for (const button of milestoneButtons) await button.click()
    await page.click('button[data-rating="5"]')

    await page.evaluate(() => {
      window.__healthHabitPrintCalled = false
      window.print = () => { window.__healthHabitPrintCalled = true }
    })
    await page.click('[data-testid="generate-pdf"]')
    await page.waitForFunction(() => window.__healthHabitPrintCalled === true)
    assert.strictEqual(await page.evaluate(() => document.body.classList.contains('health-habit-printing')), true)

    await page.emulateMediaType('print')
    await page.pdf({
      path: pdfPath,
      format: 'A4',
      printBackground: true,
      margin: { top: 0, right: 0, bottom: 0, left: 0 },
    })
    const info = execFileSync('pdfinfo', [pdfPath], { encoding: 'utf8' })
    assert.match(info, /Pages:\s+1\b/)
    assert.match(info, /Page size:\s+595(?:\.\d+)? x 841(?:\.\d+)? pts \(A4\)/)

    await page.evaluate(() => window.dispatchEvent(new Event('afterprint')))
    await page.waitForFunction(() => !document.body.classList.contains('health-habit-printing'))

    await page.emulateMediaType('screen')
    await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 })
    await page.reload({ waitUntil: 'networkidle0' })
    assert.match(page.url(), /\/tools\/health-habit-checklist\/$/)
    assert.strictEqual(await page.$$eval('.day-card', (nodes) => nodes.length), 21)
    assert.strictEqual(await page.evaluate(() => (
      document.documentElement.scrollWidth <= document.documentElement.clientWidth
    )), true)
    assert.strictEqual(await page.$eval('.days-grid', (el) => (
      getComputedStyle(el).gridTemplateColumns.split(' ').length
    )), 2)
    await setInput(page, 'input[name="studentName"]', '手机测试')
    await page.click('button[data-day="8"][data-habit="study"]')
    await page.click('button[data-rating="4"]')
    await page.waitForFunction((key) => {
      const state = JSON.parse(localStorage.getItem(key))
      return state.profile.studentName === '手机测试'
        && state.days['8'].includes('study')
        && state.review.rating === 4
    }, {}, storageKey)

    console.log(`health habit checklist browser and PDF acceptance passed: ${pdfPath}`)
  } finally {
    await browser.close()
    if (!requestedPdfPath && fs.existsSync(pdfPath)) fs.unlinkSync(pdfPath)
    if (tempDir && fs.existsSync(tempDir)) fs.rmdirSync(tempDir)
  }
})().catch((error) => {
  console.error(error)
  process.exit(1)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
  • [ ] Step 2: 构建公开站点

Run:

source /Users/ywang/.nvm/nvm.sh
nvm use 20
npm run build
1
2
3

Expected: VuePress client/server compile 成功,静态文件生成到 docs/.vuepress/dist/。

  • [ ] Step 3: 清理构建器已确认的 frontmatter 副作用

先运行 git status --short 和 git diff -- docs/superpowers,确认变更只是构建器写入的 frontmatter 后,只恢复以下文件:

git restore --source=HEAD -- \
  'docs/superpowers/plans/2026-08-25-FDE当前实践PDF与部署实施计划.md' \
  'docs/superpowers/plans/2026-08-25-测试与FDE独立在线简历实施计划.md' \
  'docs/superpowers/plans/2026-08-25-测试简历技术栈增强对比版实施计划.md' \
  'docs/superpowers/specs/2026-08-25-测试与FDE独立在线简历设计.md' \
  'docs/superpowers/specs/2026-08-25-测试简历技术栈增强对比版设计.md' \
  'docs/superpowers/specs/2026-08-27-21天健康习惯打卡设计.md' \
  'docs/superpowers/plans/2026-08-27-21天健康习惯打卡实施计划.md'
1
2
3
4
5
6
7
8

Expected: 任务源文件仍保留,以上计划/规格文件恢复到提交状态。不得清理任何其他未跟踪目录。

  • [ ] Step 4: 启动只读静态站点服务

在独立终端运行:

python3 -m http.server 4173 --directory docs/.vuepress/dist
1

Expected: Serving HTTP on ... port 4173。该进程只服务本地构建产物。

  • [ ] Step 5: 运行浏览器与 PDF 验收

Run:

HEALTH_HABIT_BASE_URL=http://127.0.0.1:4173 \
HEALTH_HABIT_PDF_OUTPUT=/tmp/health-habit-checklist-acceptance.pdf \
node scripts/test_health_habit_checklist_browser.js
1
2
3

Expected: health habit checklist browser and PDF acceptance passed: /tmp/health-habit-checklist-acceptance.pdf,退出码 0,并保留一份临时 PDF 供视觉核对。

  • [ ] Step 6: 渲染并目视检查临时 PDF

Run:

pdftoppm -png -r 150 \
  /tmp/health-habit-checklist-acceptance.pdf \
  /tmp/health-habit-checklist-acceptance
1
2
3

然后使用本地图片查看工具打开 /tmp/health-habit-checklist-acceptance-1.png,逐项确认:

  • 只有一页,A4 纵向。
  • 标题、身份区、21 天进度、四项标准、21 个天数卡片和小复盘全部可见。
  • 班级“一年级三班”、姓名“打印测试”、开始日期、完成的第 7 天和五星评分均在打印稿中可辨认。
  • 无裁切、重叠、黑块、字体缺失或网页导航。
  • 四种习惯颜色和第 7、14、21 天里程碑可辨认。

若任一项失败,停止计划并使用 superpowers:systematic-debugging 定位根因;修复后从 Task 5 Step 2 重新构建并重复 Step 3 的精确副作用清理,再从 Step 5 开始验收。不得通过删除打印内容来强行满足单页要求。

视觉检查通过后,删除本任务创建的两个临时文件:

unlink /tmp/health-habit-checklist-acceptance.pdf
unlink /tmp/health-habit-checklist-acceptance-1.png
1
2
  • [ ] Step 7: 将新增验收入口同步到 Wiki 和接线测试

在 LLM_WIKI/20_内容体系/在线工具.md 的“健康习惯打卡”行,将验证列替换为:

`test_health_habit_checklist_logic.js`、`test_health_habit_checklist_component.js`、`test_health_habit_checklist_wiring.js`、`test_health_habit_checklist_browser.js`
1

在 scripts/test_health_habit_checklist_wiring.js 的 toolsWiki 断言组中加入:

assert.match(toolsWiki, /test_health_habit_checklist_browser\.js/)
1

Run:

node scripts/test_health_habit_checklist_wiring.js
node scripts/test_llm_wiki.js
1
2

Expected: 两个脚本退出码 0。

  • [ ] Step 8: 关闭本地静态服务并提交验收脚本

关闭本任务启动的 python3 -m http.server 4173 进程,不终止其他服务。然后:

git add \
  scripts/test_health_habit_checklist_browser.js \
  scripts/test_health_habit_checklist_wiring.js \
  docs/.vuepress/components/HealthHabitChecklist.vue \
  'LLM_WIKI/20_内容体系/在线工具.md'
git diff --cached --check
git commit -m "test: cover health habit browser and PDF flow"
1
2
3
4
5
6
7

如果组件在 Task 5 没有变化,git add 只会暂存浏览器脚本。

# Task 6: 完整回归、构建和交付检查

Files:

  • Verify only: all task files

  • [ ] Step 1: 运行全部定向测试

Run:

source /Users/ywang/.nvm/nvm.sh
nvm use 20
node scripts/test_health_habit_checklist_logic.js
node scripts/test_health_habit_checklist_component.js
node scripts/test_health_habit_checklist_wiring.js
node scripts/test_schoolbag_pilot_wiring.js
node scripts/test_navigation_i18n_theme.js
node scripts/test_tools_entry_pages.js
node scripts/test_llm_wiki.js
1
2
3
4
5
6
7
8
9

Expected: 所有脚本退出码 0;新脚本和既有脚本均打印通过信息。

  • [ ] Step 2: 运行差异检查和完整构建

Run:

git diff --check
npm run build
1
2

Expected: git diff --check 无输出;VuePress client/server 编译成功。

  • [ ] Step 3: 再次清理并审查构建副作用

先确认构建只改写了已知计划/规格文件的 frontmatter:

git status --short
git diff -- docs/superpowers
1
2

确认无正文或其他文件被意外改写后,执行精确恢复:

git restore --source=HEAD -- \
  'docs/superpowers/plans/2026-08-25-FDE当前实践PDF与部署实施计划.md' \
  'docs/superpowers/plans/2026-08-25-测试与FDE独立在线简历实施计划.md' \
  'docs/superpowers/plans/2026-08-25-测试简历技术栈增强对比版实施计划.md' \
  'docs/superpowers/specs/2026-08-25-测试与FDE独立在线简历设计.md' \
  'docs/superpowers/specs/2026-08-25-测试简历技术栈增强对比版设计.md' \
  'docs/superpowers/specs/2026-08-27-21天健康习惯打卡设计.md' \
  'docs/superpowers/plans/2026-08-27-21天健康习惯打卡实施计划.md'
1
2
3
4
5
6
7
8

随后运行:

git status --short
git diff --check
find docs/.vuepress/dist -path '*LLM_WIKI*' -print
test -f docs/.vuepress/dist/tools/health-habit-checklist/index.html
1
2
3
4

Expected:

  • git status --short 不包含意外修改或新生成骨架;只保留已知且明确保护的既有内容。

  • find 不输出任何 Wiki 发布路径。

  • 新工具静态页面存在。

  • [ ] Step 4: 核对提交范围和隐私边界

Run:

git log --oneline --decorate -8
git diff main...HEAD --name-status
rg -n "studentName:\\s*'[^']+'|className:\\s*'[^']+'|startDate:\\s*'[^']+'|[0-9]{11}|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" \
  'docs/07.工具/20.学习工具/80.21天健康习惯打卡.md' \
  docs/.vuepress/components/HealthHabitChecklist.vue \
  docs/.vuepress/components/HealthHabitChecklistLogic.js \
  docs/en/tools/health-habit-checklist.md \
  docs/ja/tools/health-habit-checklist.md
1
2
3
4
5
6
7
8

Expected: 分支只包含计划列出的功能、测试、入口和 Wiki 文件;隐私搜索无输出。

  • [ ] Step 5: 最终汇报

报告必须区分:

  • 已实现:独立路由、固定四项、21 天打卡、本地保存、异常保护、A4 打印入口、响应式布局。
  • 已验证:逻辑测试、组件契约、页面/Wiki 接线、本地浏览器、单页 A4 PDF、Node.js 20 构建。
  • 未执行:生产部署、Git push、远程同步、服务器重启。
  • 工作区状态:明确列出仍存在的既有修改和未跟踪目录,不将其纳入提交。

如果用户随后明确授权部署,必须重新读取 Wiki 发布流程并单独执行生产部署与线上验收,不能把本地构建当作线上证据。

上次更新: 2026/08/27, 21:53:54
最近更新
01
07613b
10-05
02
91d9ee
10-05
03
8112f1
10-05
更多文章>
Theme by Vdoing | Copyright © 2018-2026 Evan Xu | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式