【句匠|03】HarmonyOS ArkTS 每日打卡实战:实现连续天数、补签边界和本地日期判断
学习类应用做“每日打卡”时,最容易把它写成一个按钮:点一下,累计次数加一。真正放到 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 刷新 | 打卡后让日历格子重新计算 key |
navigationIndicatorHeightPx | 系统底部安全区高度 | 保证底部内容不贴手势区 |
这种状态划分比较克制。它不声称支持云同步、补签审批、排行榜或多设备合并,只围绕本地学习记录完成“今天能不能签、连续几天、月历怎么亮”这三个问题。
二、本地日期统一成 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 包含 tick:
Grid() {
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) 是否大于 1 | gap 不等于 1 时强制置 1 |
| 月历今天不高亮 | now 是否在当前月份 | 当前源码不含月份切换,扩展时要同步 now |
| 已签日期不显示对勾 | records 日期格式不一致 | 统一使用 yyyy-MM-dd |
| 未来日期看起来可操作 | isFuture 没有弱化展示 | 未来日期用提示色且不绑定打卡事件 |
| 底部奖励项被遮挡 | 没有加安全区空白 | 保留 bottomSafePadding() 和末尾 Blank |
小结
句匠每日打卡页的实现思路是把事实状态压缩到两类数据:records 保存已签日期,streak 保存当前连续天数。页面展示不再额外保存一堆 UI 标记,而是通过 todayStr()、diffDays() 和 monthCells() 从事实数据推导。这样写的好处是边界清楚:今天能不能签、连续天数怎么算、未来日期如何弱化、奖励是否达成都能在源码里直接复核。
当前源码没有补签、云同步、真实积分流水和防改时间能力。把这些边界讲清楚,反而能让实现更可信。后续如果要扩展补签,应该先把日期规则抽成独立服务,再重新计算包含今天的连续区间,而不是在现有 checkIn() 里直接塞一个任意日期参数。
更多推荐



所有评论(0)