ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【句匠|03】HarmonyOS ArkTS 每日打卡实战:实现连续天数、补签边界和本地日期判断

【句匠|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)是否大于 1gap 不等于 1 时强制置 1
月历今天不高亮now是否在当前月份当前源码不含月份切换,扩展时要同步 now
已签日期不显示对勾records 日期格式不一致统一使用yyyy-MM-dd
未来日期看起来可操作isFuture没有弱化展示未来日期用提示色且不绑定打卡事件
底部奖励项被遮挡没有加安全区空白保留bottomSafePadding()和末尾 Blank

小结

句匠每日打卡页的实现思路是把事实状态压缩到两类数据:records保存已签日期,streak保存当前连续天数。页面展示不再额外保存一堆 UI 标记,而是通过todayStr()、diffDays()和monthCells()从事实数据推导。这样写的好处是边界清楚:今天能不能签、连续天数怎么算、未来日期如何弱化、奖励是否达成都能在源码里直接复核。

当前源码没有补签、云同步、真实积分流水和防改时间能力。把这些边界讲清楚,反而能让实现更可信。后续如果要扩展补签,应该先把日期规则抽成独立服务,再重新计算包含今天的连续区间,而不是在现有checkIn()里直接塞一个任意日期参数。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进