学习类应用做“每日打卡”时,最容易把它写成一个按钮:点一下,累计次数加一。真正放到 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-242026-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 状态,而是每次根据 nowrecords 计算。这样做更稳: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() 里直接塞一个任意日期参数。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐