HarmonyOS NEXT 企业级记账APP:日期与时间选择组件
·
日期与时间选择组件
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 11 篇,对应 Git Tag v0.1.1。承接第 10 篇的 CategorySelector,本篇开发 DateSelector 与 TimeSelector,集成 DatePickerDialog 与 TimePickerDialog,处理日期格式化与默认值。
前言
账单的日期与时间是 统计分析的基础维度。如果不统一封装,每个页面各弹一次原生 Dialog、各写一份格式化代码,最终会变成维护灾难。本章封装 DateSelector、TimeSelector、MonthSelector 三个选择器,集入 AddBillView 与后续统计页。
本文将带你:
- 封装 DateSelector 集成 DatePickerDialog
- 封装 TimeSelector 集成 TimePickerDialog
- 封装 MonthSelector 用于统计页月份选择
- 统一通过 DateUtil 格式化展示
- 处理默认值与回调
企业级核心原则:日期组件必须 格式统一、默认合理、回调清晰。参考 ArkUI DatePickerDialog 了解官方约定。
一、DateSelector 完整实现
1.1 组件接口
| Prop/Event | 类型 | 说明 |
|---|---|---|
date |
number | 当前时间戳(@Prop) |
label |
string | 字段标签,默认"日期" |
min |
number | 最小可选时间戳 |
max |
number | 最大可选时间戳,默认今日 |
onSelect |
builder | 选中回调,参数为时间戳 |
1.2 完整源码
// components/selector/DateSelector.ets
import { DatePickerDialog, DatePickerResult } from '@kit.ArkUI';
import { AppColors } from '../../theme/Colors';
import { AppFontSize } from '../../theme/Typography';
import { AppSpace } from '../../theme/Spacing';
import { DateUtil } from '../../utils/DateUtil';
@Component
export struct DateSelector {
@Prop date: number = 0;
@Prop label: string = '日期';
@Prop min: number = 0;
@Prop max: number = Date.now();
@BuilderParam onSelect: (timestamp: number) => void;
private get displayText(): string {
return DateUtil.formatDate(this.date);
}
build() {
Row() {
Text(this.label)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.SecondaryText)
Row() {
Image($r('app.media.icon_calendar'))
.width(16)
.height(16)
.fillColor(AppColors.Budget)
.margin({ right: AppSpace.XS })
Text(this.displayText)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.PrimaryText)
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height(56)
.padding({ left: AppSpace.MD, right: AppSpace.MD })
.backgroundColor(AppColors.CardBackground)
.borderRadius(AppSpace.CardRadius)
.margin({ top: AppSpace.MD })
.onClick(() => {
this.showPicker();
})
}
/** 弹出日期选择 Dialog */
private showPicker(): void {
const current = new Date(this.date);
DatePickerDialog.show({
start: new Date(this.min),
end: new Date(this.max),
selected: current,
onAccept: (value: DatePickerResult) => {
const picked = new Date(value.year, value.month - 1, value.day);
const timestamp = picked.getTime();
this.onSelect(timestamp);
},
onCancel: () => { /* 用户取消 */ },
onChange: (value: DatePickerResult) => { /* 实时变化,可选处理 */ }
});
}
}
1.3 关键实现解析
DatePickerDialog.show:ArkUI 原生弹层,支持 start/end/selected 三个范围参数onAccept回调:用户确认后触发,返回DatePickerResult含 year/month/day- month 偏移:JS Date 月份从 0 开始,DatePicker 从 1 开始,需 -1 转换
displayTextgetter:统一用DateUtil.formatDate格式化为YYYY-MM-DD- 默认 max 为今日:禁止选未来日期(记账应用通用约束)
二、TimeSelector 完整实现
// components/selector/TimeSelector.ets
import { TimePickerDialog, TimePickerResult } from '@kit.ArkUI';
import { AppColors } from '../../theme/Colors';
import { AppFontSize } from '../../theme/Typography';
import { AppSpace } from '../../theme/Spacing';
import { DateUtil } from '../../utils/DateUtil';
@Component
export struct TimeSelector {
@Prop time: number = 0; // 时间戳
@Prop label: string = '时间';
@Prop showLabel: boolean = true;
@BuilderParam onSelect: (timestamp: number) => void;
private get displayText(): string {
return DateUtil.formatTime(this.time);
}
build() {
Row() {
if (this.showLabel) {
Text(this.label)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.SecondaryText)
}
Row() {
Image($r('app.media.icon_clock'))
.width(16)
.height(16)
.fillColor(AppColors.Budget)
Text(this.displayText)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.PrimaryText)
.margin({ left: AppSpace.XS })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}
.width('100%')
.height(48)
.padding({ left: AppSpace.MD, right: AppSpace.MD })
.backgroundColor(AppColors.CardBackground)
.borderRadius(AppSpace.CardRadius)
.margin({ top: AppSpace.SM })
.onClick(() => {
this.showPicker();
})
}
private showPicker(): void {
const current = new Date(this.time);
TimePickerDialog.show({
selected: current,
onAccept: (value: TimePickerResult) => {
const picked = new Date();
picked.setHours(value.hour);
picked.setMinutes(value.minute);
this.onSelect(picked.getTime());
},
onCancel: () => {}
});
}
}
三、MonthSelector 月份选择器
3.1 完整源码
// components/selector/MonthSelector.ets
import { DatePickerDialog, DatePickerResult } from '@kit.ArkUI';
import { AppColors } from '../../theme/Colors';
import { AppFontSize } from '../../theme/Typography';
import { AppSpace } from '../../theme/Spacing';
import { DateUtil } from '../../utils/DateUtil';
@Component
export struct MonthSelector {
@Prop month: string = ''; // 'YYYY-MM'
@Prop label: string = '月份';
@BuilderParam onSelect: (month: string) => void;
private get displayText(): string {
return this.month.length > 0 ? this.month : DateUtil.formatDate(Date.now()).substring(0, 7);
}
build() {
Row() {
Text(this.label)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.SecondaryText)
Row() {
Image($r('app.media.icon_calendar'))
.width(16)
.height(16)
.fillColor(AppColors.Budget)
Text(this.displayText)
.fontSize(AppFontSize.MD)
.fontColor(AppColors.PrimaryText)
.margin({ left: AppSpace.XS })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
}
.width('100%')
.height(56)
.padding({ left: AppSpace.MD, right: AppSpace.MD })
.backgroundColor(AppColors.CardBackground)
.borderRadius(AppSpace.CardRadius)
.margin({ top: AppSpace.SM })
.onClick(() => { this.showPicker(); })
}
/** 弹出月份选择(用 DatePicker 仅取年月) */
private showPicker(): void {
const parts = this.displayText.split('-');
const y = parseInt(parts[0]);
const m = parseInt(parts[1]);
DatePickerDialog.show({
selected: new Date(y, m - 1, 1),
onAccept: (value: DatePickerResult) => {
const month = `${value.year}-${value.month.toString().padStart(2, '0')}`;
this.onSelect(month);
}
});
}
}
3.2 应用场景
| 场景 | 组件 | 用途 |
|---|---|---|
| 新增账单 | DateSelector + TimeSelector | 选账单日期时间 |
| 统计分析 | MonthSelector | 选月份查月报 |
| 预算中心 | MonthSelector | 选月份设预算 |
| 账单搜索 | DateSelector × 2 | 选起止日期范围 |
四、DateUtil 工具类升级
// utils/DateUtil.ets(升级版)
export class DateUtil {
static formatTime(timestamp: number): string { /* 同前 */ }
static formatDate(timestamp: number): string { /* 同前 */ }
static formatDateTime(timestamp: number): string { /* 同前 */ }
static todayStart(): number { /* 同前 */ }
static todayEnd(): number { /* 同前 */ }
/** 获取本月起始时间戳 */
static monthStart(timestamp: number = Date.now()): number {
const d = new Date(timestamp);
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
}
/** 获取本月结束时间戳 */
static monthEnd(timestamp: number = Date.now()): number {
const d = new Date(timestamp);
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59).getTime();
}
/** 获取本年起始 */
static yearStart(timestamp: number = Date.now()): number {
const d = new Date(timestamp);
return new Date(d.getFullYear(), 0, 1).getTime();
}
/** 获取本年起始 */
static yearEnd(timestamp: number = Date.now()): number {
const d = new Date(timestamp);
return new Date(d.getFullYear(), 11, 31, 23, 59, 59).getTime();
}
/** 获取本周起始(周一为开始) */
static weekStart(timestamp: number = Date.now(), firstDay: number = 1): number {
const d = new Date(timestamp);
const day = d.getDay();
const diff = (day === 0 ? 7 : day) - firstDay;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** 友化展示:刚刚 / x分钟前 / x小时前 / x天前 */
static humanize(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 60_000) return '刚刚';
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}分钟前`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}小时前`;
if (diff < 604800_000) return `${Math.floor(diff / 86400_000)}天前`;
return this.formatDate(timestamp);
}
}
| 方法 | 输出 | 用途 |
|---|---|---|
monthStart/End |
时间戳 | 按月查询账单 |
yearStart/End |
时间戳 | 按年统计 |
weekStart |
时间戳 | 按周统计 |
humanize |
“刚刚/x分钟前” | 账单列表展示 |
五、AddBillView 集成升级
5.1 替换占位为真实组件
// pages/AddBillView.ets(关键改动)
import { DateSelector } from '../components/selector/DateSelector';
import { TimeSelector } from '../components/selector/TimeSelector';
@Entry
@Component
struct AddBillView {
@State viewModel: AddBillViewModel = new AddBillViewModel();
build() {
Column() {
// ... TypeSwitch + MoneyInput + CategorySelector ...
// 日期 + 时间选择
DateSelector({
date: this.viewModel.date,
max: Date.now(),
onSelect: (timestamp: number) => {
this.viewModel.date = timestamp;
}
})
TimeSelector({
time: this.viewModel.date,
showLabel: true,
onSelect: (timestamp: number) => {
// 仅取时分部分合并到当前 date
this.viewModel.mergeTime(timestamp);
}
})
// ... 备注输入 + ActionBar ...
}
}
}
5.2 ViewModel 合并日期时间
// viewmodel/AddBillViewModel.ets(新增方法)
mergeTime(timeTimestamp: number): void {
const date = new Date(this.date);
const time = new Date(timeTimestamp);
date.setHours(time.getHours());
date.setMinutes(time.getMinutes());
this.date = date.getTime();
}
六、最佳实践
6.1 日期存储统一用时间戳
// ❌ 错误:用字符串存日期,格式难以统一
date: string = '2026-07-27';
// ✅ 正解:统一用时间戳(毫秒)
date: number = Date.now();
// 展示时调用 DateUtil.formatDate 转字符串
// 查询时直接做数值比较
6.2 时区一致性
// 时间戳本身无时区,但 new Date() 会用本地时区
// 鸿蒙应用默认用本地时区,无需特别处理
// 如需跨国应用,需引入 UTC 转换
6.3 DatePickerDialog 样式定制
// 通过 show 方法的 optional 参数定制
DatePickerDialog.show({
start: ...,
end: ...,
selected: ...,
onAccept: ...,
// 弹层方向(默认底弹)
direction: DialogDirection.AUTO,
// 蒙层点击关闭
maskInputMethod: MaskInputMethod.ALL
});
七、运行验证
7.1 编译检查
hvigorw assembleHap --mode module -p product=default
7.2 功能验证
- 新增账单页展示日期"2026-07-27"(今日)
- 点击日期行弹出 DatePickerDialog
- 滑动选择 7 月 15 日,确认后行展示"2026-07-15"
- 时间选择同理,弹出 TimePickerDialog
- 月份选择器展示"2026-07",切换月份后同步


八、常见问题
8.1 DatePicker 月份偏移
// DatePickerResult.month 是 1-12,JS Date 是 0-11
const picked = new Date(value.year, value.month - 1, value.day);
// 必须减 1,否则选 1 月变成 2 月
8.2 Dialog 不弹
| 原因 | 解决 |
|---|---|
未调用 .show() |
必须用 DatePickerDialog.show({...}) |
| 参数类型错 | start/end/selected 必须是 Date 对象 |
| 沙盒限制 | 部分模拟器不支持 Dialog,需真机测试 |
8.3 时区导致日期差一天
// 部分边缘 case:UTC 时间转本地可能跨日
// 解决:用 setHours(0,0,0,0) 强制本地 0 点
const picked = new Date(value.year, value.month - 1, value.day);
picked.setHours(0, 0, 0, 0);
九、Git 提交
9.1 Commit Message
git add .
git commit -m "feat(component): 开发日期与时间选择组件
- 新增 DateSelector 集成 DatePickerDialog
- 新增 TimeSelector 集成 TimePickerDialog
- 新增 MonthSelector 用于统计页月份选择
- 升级 DateUtil 新增 month/year/week/humanize 等方法
- AddBillView 集成日期时间选择并合并时间戳"
9.2 CHANGELOG
## [v0.1.1] - 2026-07-27
### Added
- components/selector/DateSelector.ets:日期选择
- components/selector/TimeSelector.ets:时间选择
- components/selector/MonthSelector.ets:月份选择
- utils/DateUtil.ets:新增 monthStart/End、yearStart/End、weekStart、humanize
### Changed
- pages/AddBillView.ets:集成真实日期时间组件
- viewmodel/AddBillViewModel.ets:新增 mergeTime 方法
总结
本文完整介绍了 日期与时间选择组件,涵盖 DateSelector、TimeSelector、MonthSelector、DateUtil 升级。通过本篇你可以:
- 集成 ArkUI 原生 DatePickerDialog/TimePickerDialog
- 统一用时间戳存储日期避免格式混乱
- 处理月份偏移、时区、最大值约束
- 封装 MonthSelector 用于统计页月份选择
- 用 humanize 提升账单列表体验
下一篇预告:《Preferences 数据持久化》将接入鸿蒙 Preferences KV 存储,持久化 Setting 设置与首次启动标记,为后续 PersistenceV2 数据库打基础。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 本篇源码:GitHub Tag v0.1.1
- ArkUI DatePickerDialog:date-picker-dialog
- ArkUI TimePickerDialog:time-picker-dialog
- Date 对象规范:MDN Date
- 鸿蒙时间处理指南:time-handling
- ArkUI Dialog 系统:dialog-system
- DateUtil 业界实践:date-utility
更多推荐



所有评论(0)