


HarmonyOS ArkUI 日历范围选择不歧义:本地日期键、闭区间与闰日校验
一句话先说结论:日历筛选表达的是"哪一天",不是某个 UTC 瞬间。用固定宽度 YYYY-MM-DD 日期键承载自然日,按公历规则校验后再比较;选择状态明确区分空、仅开始日和已完成闭区间。这样跨月、闰日和恢复草稿都不会靠系统时区猜结果。
实际项目中,日期控件看起来选中了 2 月 29 日,筛选结果却可能落到 3 月 1 日;或者用户只点了开始日期,页面就把它当成一日区间提交。常见根因是太早把"自然日"变成时间戳、月份切换丢掉 31 日锚点,或用两个可空变量却没有定义状态转换。
本文把范围筛选拆成日期键、日历算术、选择状态与数据查询四个边界。纯逻辑模块不依赖 ArkUI、系统日期 API 或时区环境,配套测试覆盖闰年、跨年、区间端点和失败时保留前态;它不代表 HarmonyOS 构建、ArkUI 联调或真机验证已经完成。
- 先复现:同一个"日期"被当成了不同的东西
用户选择 2024 年 2 月 29 日,只想筛选当天记录。若页面把它构造成 2024-02-29T00:00:00Z,在负时区设备上本地显示可能回到 2 月 28 日;换一个地区或把同一值交给另一个服务,又可能落在不同自然日。问题不是格式化控件,而是业务把"日期"与"时间点"混成了一个类型。
还有两种边界错误:把 2023-02-29 交给宽松日期解析器,运行时可能自动滚到 3 月;用户先点 1 月 31 日,再翻到 2 月,组件把选中日压成 28 后就忘了原始意图,继续翻到 3 月只能停在 28 日。它们看上去都能显示,却已经改变查询含义。
因此需要先回答:产品筛的是自然日还是时间瞬间?起止端是否包含?月份翻页应按当前显示日,还是按用户原始锚点日?这三个答案必须在代码前确定。
- 先定契约:自然日、区间端点与失败语义
本文用公历日期键作为纯逻辑载荷,日期范围采用包含首尾两天的闭区间。日历面板在起始状态接受第一次点击作为开始日;第二次点击若不早于开始日,则完成区间;若早于开始日,则把这次点击视为新的开始,而不是构造反向区间。
契约 约定 不能做的事
日期表示 固定四位年、两位月日的自然日键 绑定设备本地时区或 UTC 时间戳
起止范围 start, end,包含两端 UI 闭区间直接当存储层半开区间
首次点击 仅记录开始日,不触发查询提交 把未完成选择伪装为单日范围
反向第二次点击 以新点击重置待选开始日 自动交换用户两次点击的顺序
无效日期 明确报错,页面保留前态 依赖 Date 自动进位"修复"
如果业务最终需要时间戳,时区转换必须在明确的适配层完成,并说明用哪个业务时区定义一天的开始和结束。本文纯逻辑层不擅自选择 UTC、设备本地时区或服务器时区。
- 日期键不是 Date 对象
YYYY-MM-DD 作为定宽、零填充的日期键,在格式通过校验后可以按字典序比较:2024-02-09 小于 2024-02-10。它不携带时分秒,不代表 UTC,也不需要为了排序先变成毫秒数。固定格式是比较成立的前提,所以字符串 2024-2-9 必须拒绝,而不是自动补零。
type DateKey = string // 经过校验的 YYYY-MM-DD;不含时区与时分秒
type DateParts = {
year: number
month: number
day: number
}
上面只是文档里的语义类型别名;TypeScript 的 string 仍能被任意字符串赋值,因此真正的边界由运行时解析器负责。接收外部参数、恢复本地快照或从 UI 状态提交时,都要经过同一个纯函数验证,不能只在首次点击时检查。
- 闰年规则要完整,世纪年不能只看能否被 4 整除
格里高利历的闰年规则是:能被 4 整除,并且不能被 100 整除;但能被 400 整除的世纪年仍是闰年。所以 2024、2000 是闰年,1900、2100 不是。只写 year % 4 === 0 会让 1900 年 2 月多出一天。
function isLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
}
function daysInMonth(year: number, month: number): number {
if (month === 2 && isLeapYear(year)) return 29
return MONTH_LENGTHSmonth - 1
}
实际模块还要先校验年份范围、月份范围,再访问月份长度数组。函数不能把 month=0 当成数组最后一项,也不能把 13 月折回下一年。示例中的错误码让上层区分日期格式无效、月份越界与范围反转;UI 文案可以友好,但领域错误不要丢失。
- 解析器必须拒绝"看起来能修"的输入
解析时先匹配严格格式,再拆成年、月、日整数,最后用当月实际天数判断。2024-04-31、2023-02-29、0000-12-01、2024-2-09 都是无效日期。不要调用宽松解析后看它是否返回了一个 Date;有些解析器会把越界天数自动滚到下个月,错误就被隐藏了。
function parseDateKey(value: unknown): DateParts {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
throw new DateRangeError('INVALID_DATE')
}
const year, month, day = value.split('-').map(Number)
if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
throw new DateRangeError('INVALID_DATE')
}
return { year, month, day }
}
不依赖系统解析的收益是行为可重复:相同日期键在不同 Node 测试机上得到相同年月日判断。它不解决业务日历差异,例如某些业务采用非公历日历或财务期间;那属于另一个明确的日历模型,不能偷偷塞进公历 helper。
- 固定宽度日期键可以安全比较,但前置条件不能省
通过严格校验的日期键年、月、日都按位补齐,所以普通字符串顺序和公历先后顺序一致。未校验的字符串不具备这个保证;因此 compareDateKeys 在比较前重新调用解析器。重复校验看似多一步,却能保护这个小 API 不被其他调用方误用。
function compareDateKeys(left: DateKey, right: DateKey): -1 | 0 | 1 {
parseDateKey(left)
parseDateKey(right)
return left < right ? -1 : left > right ? 1 : 0
}
不要对可见文案比较,如"2024年2月9日"和"2024年2月10日";也不要把用户本地化格式当作协议键。如果需要显示为"2024/02/09",应由展示层格式化,数据键仍保持标准形式。排序和展示使用不同表示,边界会更清楚。
- 月份导航要保留用户的锚点日
月份翻页的常见直觉是"我正在看 31 日,翻到下个月"。目标月份没有 31 日时,视图只能暂时显示最后一天,但这不意味着用户原先的偏好变成了 28。若状态只保存当前显示日期,从 1 月 31 日翻到 2 月 29 日,再翻到 3 月便回不到 31 日。
function shiftMonth(monthKey: string, offset: number, anchorDay: number): DateKey {
const serial = monthIndex(monthKey) + offset
const target = yearAndMonth(serial)
const day = Math.min(anchorDay, daysInMonth(target.year, target.month))
return formatDate(target.year, target.month, day)
}
页面应把 anchorDay=31 与"本月实际高亮日=29"分开保存。翻到目标月时仅用于展示的 day 被夹到当月末,锚点仍保持 31;下一次翻到 3 月便可回到 31。若用户主动点了新日期,可以按产品约定更新锚点。
月份序号也有边界:0001 年 1 月往前翻、9999 年 12 月往后翻都应返回 OUT_OF_RANGE。不能让年变成 0000 或 10000 后再格式化成看似正常的字符串。
- 用小状态机描述两次点击,而非散落的布尔标记
两个 nullable 字段也能表达状态,但所有调用方都必须理解 start=null/end=null、start有值/end=null 和"两者都有值"三种组合。把状态转换集中成纯函数,页面只消费新状态,能减少一部分完成状态标记不同步的问题。
type RangeSelection = { start: DateKey | null; end: DateKey | null }
function selectCalendarDate(state: RangeSelection, date: DateKey): RangeSelection {
parseDateKey(date)
const current = validateSelection(state)
if (current.start === null || current.end !== null) return { start: date, end: null }
if (date < current.start) return { start: date, end: null }
return { start: current.start, end: date }
}
每次返回全新对象,而不是先改 state.start 再验证结束日。若日期无效,函数抛出领域错误,输入快照不变;如果用户第二次点到了开始日之前,则只重置开始点,不生成反向区间。交互策略简单明确,也方便用表驱动测试穷举。
- "包含首尾"要落实到筛选语义
用户通常理解日期范围为包含开始日与结束日。函数 rangeIncludes 因此使用 date >= start && date <= end。范围若只选一天,start 与 end 相同也是合法的;"只选择了开始日"的待选状态则不是一个范围,必须在提交前拦截。
function makeClosedRange(start: DateKey, end: DateKey): DateRange {
parseDateKey(start)
parseDateKey(end)
if (start > end) throw new DateRangeError('REVERSED_RANGE')
return { start, end }
}
不要把 UI 闭区间不加说明地映射成数据库半开区间。若仓储层按时间戳查询,需要先确定业务时区,再把自然日范围转换为该时区下的起始瞬间和"结束日之后"的排他边界;遇到夏令时切换时,一天未必是固定 24 小时。那个转换应留在平台/仓储适配层,不在 DateKey 上加 86_400_000。
- 快照恢复是一次整体校验与提交
表单页面可以把范围状态作为筛选快照的一部分,但快照需要协议版本。恢复时验证外层键、版本、两个日期和先后顺序,再一次性生成新的范围;错误版本或多余字段不应被静默忽略。否则未来字段变更后,旧快照可能被部分应用,页面表面上恢复成功,查询却已经变义。
function createRangeSnapshot(selection: RangeSelection) {
const range = validateCompleteSelection(selection)
return { version: 1, start: range.start, end: range.end }
}
function restoreRangeSnapshot(raw: unknown): DateRange {
assertExactSnapshotKeys(raw)
if (raw.version !== 1) throw new DateRangeError('UNSUPPORTED_VERSION')
return makeClosedRange(raw.start, raw.end)
}
如果候选快照校验失败,保留用户当前筛选,给出恢复失败提示,并允许重新选择。不要"恢复一半"后再让用户猜剩余字段去哪了。若页面在异步加载旧筛选快照期间允许继续编辑,提交前还要核对页面 revision;这层并发门禁是状态管理工作,本文只验证纯日期状态函数。
- 日期键进入平台日期 API 的边界
自然日不是时间戳,但某些系统控件或存储适配器最终可能只接受时间类型。转换只能在一处发生,并由产品声明时区策略:采用用户设备当前时区、固定业务时区,还是 UTC 日历日。选择不同策略,跨时区旅行或夏令时日期边界可能产生不同结果。
interface DateRangeRepository {
applyCalendarRange(range: { startDate: DateKey; endDateInclusive: DateKey }): Promise<void>
}
// 业务层传入经过验证的自然日;具体平台适配器再按已确认的时区契约转换。
接口把语义限定为包含首尾的日期条件,不把某个 runtime Date 暴露到 UI 状态中。真正实现接口时,应查询对应 HarmonyOS SDK 文档并在目标 SDK 与运行环境中验证;本文没有解析系统 API,也没有编写具体适配实现。
- 纯逻辑验证:从闰日到状态恢复
日期逻辑的价值之一是可以脱离页面做确定性测试。除了 happy path,至少测:2000/1900 年的世纪闰年对照;2 月 29 日合法与非法;月份天数;严格键格式;跨月、跨年加减一天;月份导航保留锚点;闭区间首尾包含;反向点击重置;反向范围拒绝;不完整快照不可提交;失败不修改输入状态。
示例测试可以断言纯函数输出,不依赖设备时钟或时区;这降低了测试偶发性。它仍然只覆盖该逻辑模型,不能代表日期选择组件的焦点、动画、无障碍和路由行为都已验收。
test('leap-day closed range includes both endpoints', () => {
const range = makeClosedRange('2024-02-29', '2024-03-01')
assert.equal(rangeIncludes(range, '2024-02-29'), true)
assert.equal(rangeIncludes(range, '2024-03-01'), true)
})
当前本地纯逻辑模型还覆盖日期键解析、加日 ordinal 边界、月份锚点、选择状态转换和带版本快照恢复。实际 ArkUI 页面测试应另行检查月份按钮、选中态、键盘/触摸、无障碍语义、屏幕尺寸变化与系统返回行为;这里没有运行这些 UI 测试。
- 常见问题与排查顺序
现象 优先检查 稳妥处理
闰年 2 月日历少一天 世纪年条件是否遗漏 400 年规则 为 1900、2000、2100、2400 加边界测试
翻月后选中日逐月变小 是否把夹取后的显示日覆盖锚点日 单独保存原始 anchorDay
2 月 30 日变成 3 月 是否使用宽松 Date 解析器 严格拆分并校验当月天数
点第二个日期后结果倒置 是否允许结束日小于开始日 明确"重置起点"或提示策略,禁止隐式反向
跨时区结果偏一天 是否把 date-only 当作 UTC 午夜 日期逻辑保留 DateKey;仅在适配层按业务时区转换
恢复旧快照后筛选不完整 版本/额外字段是否被忽略 校验版本和完整键集合,失败保留当前状态
排查时先打印安全摘要:协议版本、范围完整性、错误码和字段是否缺失。不要记录账号标识、原始查询明细等不必要数据;调试日志也不应把用户选择的内容无限期留存。
- 接入 ArkUI 前的检查清单
业务确认筛选的是自然日还是时间瞬间,并记录所选时区策略。
UI 状态能明确区分未选、待选起点和已完成范围。
闰年规则覆盖 4/100/400 年边界;严格拒绝不存在的日期。
月份翻页保留独立锚点日,夹取只影响当前月的显示日期。
闭区间包含首尾,未完成状态不提交。
快照恢复先校验版本和完整值,再一次性替换页面状态。
具体平台日期 API 只在确认 SDK 行为后接入,并按目标设备复核。
本文已执行 Node 纯逻辑测试、Markdown 结构与本地图片检查;没有执行 HarmonyOS 编译、系统解析、ArkUI 联调、Profiler 实采或真机验收。真实项目集成时应把这些结果分开记录,避免将逻辑测试通过误当作页面与设备验收。
- 结语:日期范围需要稳定语义,不需要猜测
把自然日表达成固定日期键,把闰年、月份天数与日期范围转换集中在纯逻辑模块,再用明确状态机控制起点和终点,能避开不少"看起来选对了、实际查询偏一天"的问题。规范闭区间和严格快照恢复保证状态边界可讨论、可测试;日期键直到平台适配层才转换,避免时区意外渗进业务模型。
开始落地时先确认自然日与时区契约,再对接页面和仓储;用 1900、2000、2100、2400 这样的边界值测试历法规则。遇到未知日期或版本时保留当前筛选、明确提示,不靠自动修正掩盖数据问题。