学习类应用做“每日打卡”时最容易把它写成一个按钮点一下累计次数加一。真正放到 HarmonyOS 页面里这个功能会马上暴露几个工程边界今天是否已经打过卡连续天数是否应该递增昨天漏签后是否要重置月历里未来日期能不能显示为可签用户切换系统日期后页面展示如何判断。句匠源码没有把打卡包装成复杂服务而是用一个本地页面把这些规则压到可复核的状态链路里。本文基于D:\huawei\one18-11\entry\src\main\ets\pages\DailyCheckInPage.ets复盘 HarmonyOS 5.0 ArkTS 每日打卡页的实现方式StorageLink(checkInRecords)保存已签日期StorageLink(checkInStreak)保存连续天数todayStr()和diffDays()负责本地日期判断monthCells()把 records 映射成月历格子。正文唯一复核标记com.jiaweikang.one18。这篇文章聚焦源码里真实存在的能力用yyyy-MM-dd字符串保存每日打卡记录。用diffDays(today, last)判断连续还是断签。明确当前源码没有补签入口补签边界只能作为后续扩展点处理。用DayCell[]生成月历网格并区分今天、已签和未来日期。通过底部安全区留白避免月历和奖励区域被系统手势区遮挡。一、先把打卡页的状态边界定清楚句匠的每日打卡页不是全局成长系统只是一个本地连续学习页面。源码中真正持久化的状态只有两个StorageLink(checkInRecords) records: string[] [] StorageLink(checkInStreak) streak: number 0 State now: Date new Date() State tick: number 0 StorageLink(navigationIndicatorHeightPx) navigationIndicatorHeightPx: number 0这几个字段分别承担不同职责字段作用工程边界records保存所有已打卡日期字符串不保存打卡时间、设备、云端账号streak保存当前连续天数由当前打卡与上一条记录间隔决定now当前月历展示月份当前源码没有月份切换按钮但生成月历依赖它tick触发 Grid key 刷新打卡后让日历格子重新计算 keynavigationIndicatorHeightPx系统底部安全区高度保证底部内容不贴手势区这种状态划分比较克制。它不声称支持云同步、补签审批、排行榜或多设备合并只围绕本地学习记录完成“今天能不能签、连续几天、月历怎么亮”这三个问题。二、本地日期统一成 yyyy-MM-dd避免直接保存 Date源码使用todayStr()生成日期键function todayStr(): string { const d new Date() const y d.getFullYear() const m String(d.getMonth() 1).padStart(2, 0) const day String(d.getDate()).padStart(2, 0) return ${y}-${m}-${day} }这里没有保存Date对象也没有保存时间戳。对每日打卡来说yyyy-MM-dd字符串更适合做本地记录键判断今天是否已签时可以直接records.indexOf(todayStr())。月历生成时可以按同样格式拼出每个日期。字符串排序在同一年月日补零后具有稳定顺序。不会因为小时、分钟、秒造成同一天多条记录。这段代码依赖本地系统时间。也就是说当前源码并没有服务端时间校验也没有防止用户手动改系统日期的逻辑。文章必须把这个边界讲清楚它是本地学习工具的打卡页不是金融、考勤或活动风控系统。三、日期间隔只按自然日计算连续天数的核心是diffDays()function diffDays(a: string, b: string): number { const da new Date(a T00:00:00).getTime() const db new Date(b T00:00:00).getTime() return Math.round((da - db) / 86400000) }它把两个yyyy-MM-dd字符串都落到当天零点再除以一天的毫秒数。这样2026-07-24和2026-07-23的间隔就是 1连续天数递增如果间隔大于 1就说明中间断签。这里有一个实际项目里常见的判断是否要使用 UTC句匠源码使用本地Date这与学习应用的用户感知一致。用户按本地日历打卡不需要跨时区统一结算。如果产品面向跨时区活动或服务器奖励则应把这个逻辑移到后端或至少引入可信时间源。当前源码没有做这类能力不能在文章中扩大解读。四、今天是否已签必须单独判断重复打卡是第一条边界。源码用isCheckedToday()做单独判断private isCheckedToday(): boolean { return this.records.indexOf(todayStr()) 0 }这个方法被两个地方复用Hero 卡片显示今日状态以及checkIn()阻止重复写入。页面文案也跟着状态切换Text(this.isCheckedToday() ? 今日已打卡 : 点击打卡) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor(Color.White) Text(this.isCheckedToday() ? 明日继续 : 立即打卡) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.PRIMARY) .onClick(() { this.checkIn() })把判断封装成方法的好处是 UI 和业务入口不会各写一套条件。否则很容易出现卡片显示“今日已打卡”按钮仍能继续加记录的问题。五、打卡入口要先挡住重复再写入新数组真正更新记录的是checkIn()private checkIn(): void { if (this.isCheckedToday()) { promptAction.showToast({ message: 今日已打卡明天见~ }) return } const today todayStr() const newRecords this.records.slice() newRecords.push(today) this.records newRecords if (this.records.length 1) { this.streak 1 } else { const sorted newRecords.slice().sort() const last sorted[sorted.length - 2] const gap diffDays(today, last) this.streak gap 1 ? this.streak 1 : 1 } this.tick this.tick 1 promptAction.showToast({ message: 打卡成功已连续 ${this.streak} 天 }) }这段代码有几个值得保留的细节重复打卡直接return不会污染 records。使用this.records.slice()生成新数组再赋值回records更符合 ArkUI 状态更新习惯。首次打卡直接把streak置为 1。非首次打卡先对新记录排序再取倒数第二条作为上一次打卡日期。tick自增用于刷新日历 key让 GridItem 重新绑定展示状态。这里也能看出当前源码的补签边界checkIn()只写入todayStr()没有传入任意日期的参数也没有“补签昨天”的 UI。换句话说补签不是隐藏能力而是尚未实现的业务分支。如果后续要加补签不能直接复用checkIn()应新增带日期参数的校验方法并重新计算连续区间。六、连续天数只关心今日与上一条记录当前实现的连续天数规则很简单今天和上一条已签日期相差 1 天则streak 1否则重置为 1。const sorted newRecords.slice().sort() const last sorted[sorted.length - 2] const gap diffDays(today, last) this.streak gap 1 ? this.streak 1 : 1这个规则适合“只能今天打卡”的页面因为每次新增的必然是今天。它不需要扫描整个 records也不需要复杂区间合并。但如果加上补签这段逻辑就不够了。比如用户今天补签昨天再签今天连续天数应从补签后的连续区间重新计算如果用户补签上个月某一天当前 streak 可能不该变化。当前源码没有这类入口所以文章只把补签作为边界说明不伪造已实现能力。补签扩展时可以按这张规则表设计场景当前源码行为补签扩展应考虑今日未签点击打卡写入今天计算连续保持不变今日已签再点按钮Toast 提示不写入保持不变昨天漏签今天打卡后 streak 重置为 1是否允许补签昨天补签历史日期当前源码无入口重新计算包含今天的连续区间未来日期月历可标记isFuture无打卡入口禁止补签或打卡未来日期七、月历格子由 records 推导不额外保存 UI 状态月历展示由monthCells()生成interface DayCell { day: number isToday: boolean isChecked: boolean isFuture: boolean } private monthCells(): DayCell[] { const y this.now.getFullYear() const m this.now.getMonth() const first new Date(y, m, 1) const days new Date(y, m 1, 0).getDate() const offset first.getDay() const today new Date() const cells: DayCell[] [] for (let i 0; i offset; i) { cells.push({ day: 0, isToday: false, isChecked: false, isFuture: false }) } for (let d 1; d days; d) { const dStr ${y}-${String(m 1).padStart(2, 0)}-${String(d).padStart(2, 0)} const isFuture (y today.getFullYear()) || (y today.getFullYear() m today.getMonth()) || (y today.getFullYear() m today.getMonth() d today.getDate()) cells.push({ day: d, isToday: y today.getFullYear() m today.getMonth() d today.getDate(), isChecked: this.records.indexOf(dStr) 0, isFuture }) } return cells }这段代码没有保存“某天是否高亮”的 UI 状态而是每次根据now和records计算。这样做更稳records 是事实月历只是事实的视图。打卡成功后 records 变了月历再计算一次即可。offset first.getDay()用于填补月初前面的空格子。空格子day 0不会参与今天、已签、未来的判断。这能保证 Grid 是 7 列结构同时不把上个月的日期混进当前月。八、未来日期只展示弱化状态不提供打卡能力DayCell里的isFuture很关键。源码在生成每个日期时判断它是否晚于今天const isFuture (y today.getFullYear()) || (y today.getFullYear() m today.getMonth()) || (y today.getFullYear() m today.getMonth() d today.getDate())展示时未来日期使用提示色Text(${c.day}) .fontSize(Sizes.CAPTION_FONT) .fontColor(c.isFuture ? Colors.TEXT_HINT : Colors.TEXT_SECONDARY)这就是补签边界的一部分未来日期不能被误认为可操作日期。当前源码的月历格子没有点击事件打卡入口只在 Hero 卡片里因此不会出现“点未来某天打卡”的行为。对于学习打卡页这是一个安全的简化。九、DayBubble 把四类日期状态拆开渲染月历单元的展示集中在DayBubble()Builder DayBubble(c: DayCell) { Stack() { if (c.day 0) { Column().width(36).height(36) } else if (c.isChecked) { Column() .width(36).height(36).borderRadius(18) .backgroundColor(Colors.PRIMARY) Text(✓) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor(Color.White) } else if (c.isToday) { Column() .width(36).height(36).borderRadius(18) .backgroundColor(Colors.PRIMARY_LIGHT) Text(${c.day}) .fontSize(Sizes.CAPTION_FONT) .fontWeight(FontWeight.Bold) .fontColor(Colors.PRIMARY) } else { Text(${c.day}) .fontSize(Sizes.CAPTION_FONT) .fontColor(c.isFuture ? Colors.TEXT_HINT : Colors.TEXT_SECONDARY) } } .width(100%) .height(36) .alignContent(Alignment.Center) }这里的优先级是空格子、已签、今天、普通日期。已签优先于今天意味着今天打卡后显示对勾而不是继续显示普通的今日样式。这个优先级符合用户预期打卡完成后最重要的信息是“已完成”。这种写法也方便排查。若某天没有亮起先看 records 是否包含对应yyyy-MM-dd若今天样式不出现检查now是否为当前月份若未来日期颜色异常检查isFuture判断条件。十、统计卡片直接来自 records 和 streak页面上方的统计区域没有额外维护总积分状态而是直接从已有数据计算Builder StatsRow() { Row({ space: 10 }) { this.StatTile(${this.streak}, 连续天数) this.StatTile(${this.records.length}, 累计打卡) this.StatTile(${this.records.length * 5}, 获得积分) } .width(100%) .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) }积分是records.length * 5说明当前页面里积分是展示型计算不是独立账户资产。这个边界同样要说明清楚源码没有兑换、消耗、服务端积分流水也没有防刷逻辑。把它当成学习激励展示是合理的把它描述成真实积分系统就不准确。十一、奖励项只读 streak不写业务状态奖励卡片根据连续天数判断是否达成this.RewardItem(连续 3 天, 50 积分, this.streak 3) this.RewardItem(连续 7 天, 200 积分 · 解锁“持之以恒”徽章, this.streak 7) this.RewardItem(连续 30 天, 1000 积分 · 解锁“英伦学者”徽章, this.streak 30) this.RewardItem(连续 100 天, 5000 积分 · 解锁“英伦大师”徽章, this.streak 100)RewardItem()只根据achieved改变颜色和文案Builder RewardItem(title: string, desc: string, achieved: boolean) { Row() { Column() {} .width(8).height(8).borderRadius(4) .backgroundColor(achieved ? Colors.SUCCESS : Colors.DIVIDER) Column({ space: 2 }) { Text(title) .fontSize(Sizes.BODY_FONT) .fontWeight(FontWeight.Medium) .fontColor(achieved ? Colors.PRIMARY : Colors.TEXT_PRIMARY) Text(desc) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) } Text(achieved ? 已解锁 : 未达成) .fontSize(Sizes.SMALL_FONT) .fontColor(achieved ? Colors.SUCCESS : Colors.TEXT_HINT) } }这里没有保存“奖励已领取”状态也没有防重复领取。它是成就展示不是奖励发放系统。工程上这样做没问题因为页面目标是学习连续性反馈但如果后续要接入真实奖励就需要新增领取状态、去重规则和数据持久化策略。十二、Grid key 用 tick 帮助打卡后刷新月历 Grid 的 key 包含tickGrid() { ForEach(this.monthCells(), (c: DayCell, i: number) { GridItem() { this.DayBubble(c) } }, (c: DayCell, i: number) cell_${i}_${this.tick}) } .columnsTemplate(1fr 1fr 1fr 1fr 1fr 1fr 1fr) .rowsGap(6) .columnsGap(4) .width(100%) .height(280)records更新后理论上已经能触发 UI 更新但源码仍然用tick把 GridItem key 变掉确保打卡后整组日历格子重新绑定。这个做法适合小规模月历网格因为一个月最多几十个格子重建成本很低换来的是状态展示更确定。如果未来月历扩展到多月滚动或大量历史记录就应避免过度重建改成更稳定的日期 key比如cell_${dStr}。当前页面只有单月网格tick的成本可接受。十三、底部安全区不能省略页面底部有奖励卡片和空白区域源码通过bottomSafePadding()处理系统导航区域private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx) ) }页面内容末尾加了一个空白块Blank().height(this.bottomSafePadding())这对 phone、tablet、2in1 都有意义。打卡页通常是 Scroll 长页如果底部没有留白最后一个奖励项很容易贴近系统手势区尤其在小窗口或横屏下会影响触达。源码把底部安全区作为页面状态的一部分接入而不是靠固定 8vp 或 12vp 硬凑。十四、按源码能力做验证而不是验证不存在的系统基于当前实现可以用下面的清单复核页面行为验证项操作预期首次打卡清空 records 后点击立即打卡records 新增今天streak 为 1重复打卡同一天再次点击Toast 提示今日已打卡records 不增加连续打卡records 中已有昨天再签今天streak 在原值基础上加 1断签后打卡上次记录不是昨天streak 重置为 1月历已签展示records 包含某天对应日期显示对勾今日未签展示records 不含今天今天显示浅色圆形未来日期展示当前月未来日期使用提示色不显示已签底部适配小窗口或手势导航设备奖励卡片底部不被遮挡如果要做自动化测试建议把todayStr()、diffDays()和 streak 计算抽到可测试的纯函数里。当前源码把它们放在页面同文件中适合页面级实现但不利于对补签和跨月连续场景做单元测试。常见问题与处理现象优先排查建议处理同一天出现多条记录isCheckedToday()是否在写入前执行重复打卡必须先 return连续天数没有递增last是否取到上一条记录排序后取倒数第二条而不是取今天昨天漏签但 streak 没重置diffDays(today, last)是否大于 1gap 不等于 1 时强制置 1月历今天不高亮now是否在当前月份当前源码不含月份切换扩展时要同步 now已签日期不显示对勾records 日期格式不一致统一使用yyyy-MM-dd未来日期看起来可操作isFuture没有弱化展示未来日期用提示色且不绑定打卡事件底部奖励项被遮挡没有加安全区空白保留bottomSafePadding()和末尾 Blank小结句匠每日打卡页的实现思路是把事实状态压缩到两类数据records保存已签日期streak保存当前连续天数。页面展示不再额外保存一堆 UI 标记而是通过todayStr()、diffDays()和monthCells()从事实数据推导。这样写的好处是边界清楚今天能不能签、连续天数怎么算、未来日期如何弱化、奖励是否达成都能在源码里直接复核。当前源码没有补签、云同步、真实积分流水和防改时间能力。把这些边界讲清楚反而能让实现更可信。后续如果要扩展补签应该先把日期规则抽成独立服务再重新计算包含今天的连续区间而不是在现有checkIn()里直接塞一个任意日期参数。