做数据采集工具时,我经常看到一个看似很合理的表单:开始时间选“23:45”,结束时间选“00:15”,系统直接弹出“结束时间早于开始时间”。站在表单实现者的角度,这条判断没有错;站在要做夜间巡检的使用者角度,它却把一个本来只持续三十分钟的窗口当成了错误输入。反过来,如果只要结束时钟较小就自动加一天,又可能把用户误选的时间悄悄变成隔天任务。
因此,我给这次示例单独起名NightWindow,页面叫WindowPlanPage.ets,任务号NW-1632。表单选的是本地日期2026-10-13,开始23:45,结束00:15,明确标记“跨日 +1”;采样间隔选15 分钟。界面预览会显示30 分钟、预计 2 个采样点、待确认。这些数字由公开的时间运算规则推导,仅用于方案演示,既不是已经执行的采集任务,也不表示系统定时服务已经启动。
一、问题不在滚轮,而在时间模型
只在HH:mm字符串之间做大小比较,等于主动丢掉了日期信息。对当天任务来说还算勉强;一到午夜,或者用户连续修改开始、结束时间,就会暴露出歧义。更实际的风险是:第一次选择了跨日,第二次将结束时间改到当天晚上,程序却保留上一次的自动加一天逻辑,最终显示一段长度离谱的时间窗。界面里的“看起来选好了”并不等于业务参数已经通过验证。
这里需要先定下三个规则。第一,开始日期必须明确,示例限定为本地日期而不是 UTC 日期;第二,结束日期偏移量只能由用户显式选择0或1,不能凭结束时钟的大小偷偷猜测;第三,允许的窗口长度要有上限,示例约定大于零且不超过120 分钟。如果输入不满足这些约定,界面保持草稿并显示原因,不生成预览记录,更不会发送任何后台采集动作。
第一段代码把表单值变成时间戳,再把比较结果封装为纯业务值。Date是 JavaScript/ArkTS 的时间运算对象,和系统任务调度不是一回事。这里把月转成从零起算的格式,并检查日期是否被构造器自动滚动到别的日期,避免“二月三十日”被默默当成三月的某一天。
interfaceWindowDraft{year:number;month:number;day:numberstartHour:number;startMinute:numberendHour:number;endMinute:numberendDayOffset:number;intervalMinutes:number}interfaceWindowCheck{ok:boolean;minutes:number;message:stringstartMs:number;endMs:number}functioncheckWindow(d:WindowDraft):WindowCheck{constbad=(m:string):WindowCheck=>({ok:false,minutes:0,message:m,startMs:0,endMs:0})if(d.endDayOffset!==0&&d.endDayOffset!==1)returnbad('跨日偏移非法')if(![5,15,30].includes(d.intervalMinutes))returnbad('间隔档位非法')conststart=newDate(d.year,d.month-1,d.day,d.startHour,d.startMinute)constend=newDate(d.year,d.month-1,d.day+d.endDayOffset,d.endHour,d.endMinute)if(start.getFullYear()!==d.year||start.getMonth()!==d.month-1||start.getDate()!==d.day||start.getHours()!==d.startHour||start.getMinutes()!==d.startMinute)returnbad('起点日期或时间无效')if(end.getHours()!==d.endHour||end.getMinutes()!==d.endMinute){returnbad('终点落入无效本地时间')}constminutes=(end.getTime()-start.getTime())/60000if(!Number.isFinite(minutes)||minutes<=0||minutes>120){returnbad('窗口需在1至120分钟内')}return{ok:true,minutes,message:'待确认',startMs:start.getTime(),endMs:end.getTime()}}这里不使用endHour < startHour来推断跨日,因此“误选 00:15”不会自动生效。也不假设某一天一定是固定的 24 小时:时区夏令时切换可能让本地墙上时间和真实经过的分钟数不一致,Date得出的差值反映当前运行环境的本地时区规则。示例仅说明客户端预览的校验策略,跨地区部署还需要明确时区标识、重复的墙上时间如何选择以及服务端时间准则。
二、TextPicker 只负责选档,不负责提交
我故意没有把间隔做成能输入任意数字的文本框。采集任务对间隔有统一规则,给出5、15、30 分钟三个合法档位,比接收任意浮点值后再各处打补丁更容易维护。华为官方的 ArkUITextPicker提供range、selected和onChange,这里选择单列范围,业务层仍必须检查返回索引的类型和取值,防止将滚轮事件等同于已经提交的计划。
第二段代码专注于状态入口,而不是完整页面模板。选择滚轮只修改草稿的档位,并将已有预览标记为过期;即使用户来回滚动多次,也不应该先生成十个后台任务再撤销其中九个。这里使用@State让界面反映草稿变化,展示层与真正的采集执行层保持分离。
@Entry@Componentstruct WindowPlanPage{@StateintervalIndex:number=1@Statestatus:string='待确认'@StateslotLabels:string[]=['23:45','00:00']privatereadonlytaskId:string='NW-1632'privatereadonlyintervals:string[]=['5分钟','15分钟','30分钟']build(){Column({space:14}){Text(`NightWindow ·${this.taskId}`).fontSize(22)Text('2026-10-13 23:45 → 次日 00:15').fontSize(17)Text('跨日 +1 · 30分钟 · 预计2个采样点')TextPicker({range:this.intervals,selected:this.intervalIndex}).onChange((_value:string|string[],index:number|number[])=>{if(typeofindex!=='number'||index<0||index>2)returnthis.intervalIndex=indexthis.status='草稿已修改,需重新预览'})Text(this.status)Text('示意界面 · 未执行系统定时采集').fontSize(12)}.padding(20).width('100%')}}这段 UI 截取的是“初始选择十五分钟”的一帧。若用户把滚轮改成五分钟,原来标注的两个采样点马上变成过期预览,不能继续显示成可提交结果;真正项目里应该给slotLabels增加一个与草稿版本号绑定的更新入口。TextPicker本身不会创建后台计划,页面aboutToDisappear也不需要取消一个根本没有创建的系统任务,切勿把 UI 控件误写成后台调度 API。
三、用时间戳算采样点,结束时刻不重复入队
确认前的预览还有一个容易遗漏的小细节:窗口内每隔十五分钟采一次,到底要不要把结束时刻算在内?假设 23:45 到 00:15 共三十分钟,若按包含终点计算,就会得到 23:45、00:00、00:15 三条;若按半开区间[start, end)计算,就只有两条。两种约定不分对错,但不能让前端显示两个、服务端实际执行三个。
NightWindow 采用半开区间。开始点算一次,结束点是关闭边界,不再调度。第三段代码接受已经校验成功的时间戳,严格从开始时刻推进,最多生成 24 个预览点;即便后续把档位扩展为五分钟,原先 120 分钟的上限也能控制数据量。
functionpreviewSlots(check:WindowCheck,intervalMinutes:number):number[]{if(!check.ok||![5,15,30].includes(intervalMinutes))return[]constresult:number[]=[]conststep=intervalMinutes*60000for(lett=check.startMs;t<check.endMs&&result.length<24;t+=step){result.push(t)}returnresult}constsampleDraft:WindowDraft={year:2026,month:10,day:13,startHour:23,startMinute:45,endHour:0,endMinute:15,endDayOffset:1,intervalMinutes:15}constchecked=checkWindow(sampleDraft)constslots=previewSlots(checked,sampleDraft.intervalMinutes)// 本地时间示例:23:45、次日00:00;总计2个,状态待确认实际提交时应复制一份不可变的草稿快照,把任务 ID、起止时间戳、时区信息、间隔以及草稿版本一起交给执行服务;如果在异步确认对话框里继续滚动选择器,旧确认结果必须拒绝回写。取消页面或者应用进入后台后,这里的slots只是内存中的数组,没有自动运行能力;正式定时任务要另选系统允许的执行机制,并处理权限、系统省电和任务错过等边界。本篇不讨论也不冒充此类能力。
四、让“跨日”成为明确的操作,而不是隐藏规则
界面上“跨日 +1”必须始终可见,不能只藏在一个细小的图标里。用户选择开始时间和结束时间时,应该分别看到日期标签;在确认弹层中再次展示2026-10-13 23:45 → 2026-10-14 00:15,使任何人都能核对实际的两个日历日。若结束时间改到当天的 23:00,而跨日开关仍保持加一天,界面应即时提示“新窗口过长”并要求重新确认,而不是替用户做更正。
验收可以完全用纯函数数据完成,不需要虚构真机测试。先传入本篇样本,期待三十分钟、两个采样点;将endDayOffset改为零,期待窗口非法;将结束时间改为翌日 02:15,超过一百二十分钟时拒绝;把间隔设为七分钟,应该提示“间隔档位非法”;再传入不存在的二月日期,核对Date自动进位是否被业务判断挡住。最后在不同时区环境中查看输出,确认页面不把本地格式化文字当成存储时区的唯一证据。
配图只对应第一条受控样本:任务NW-1632、开始23:45、终点00:15、跨日+1、间隔15分钟、窗口30分钟、预计2个采样点,状态待确认。IDE 图底部只给出NW-1632 preview slots=2 status=PENDING的示意 HiLog;纯屏幕图展示同一张预览卡片。它们不应出现“任务已创建”“后台正常采集”之类无法证明的字眼。
五、工程边界是这篇的重点
TextPicker 只让用户在合法档位间选择,Date 只帮助我们在运行时环境内计算日期和经过时间,二者都不直接替业务判断“这是不是用户本意”。一旦涉及夏令时、时区变更、跨设备同步或者真正的精确定时,这个客户端纯函数只能算第一道输入校验,后端需要使用明确的时间标准和幂等任务号重新验证。若项目只是单一时区的课堂练习,也应把这个假设直接写在设计说明里,而不是悄悄埋在代码中。
此次文章并没有运行模拟器、没有编译完整 ArkTS 工程,更没有申请后台任务执行许可。它提供的是可讨论、可检查的时间窗状态模型。华为官方TextPicker文档可参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V13/ts-basic-components-textpicker-V13 。做工程复盘时我更愿意把“没有执行”的边界讲清楚,因为它能提醒后续开发者:预览是预览,排程是排程;跨日必须由用户明确授权。