日期与时间选择组件

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第 11 篇,对应 Git Tag v0.1.1。承接第 10 篇的 CategorySelector,本篇开发 DateSelector 与 TimeSelector,集成 DatePickerDialog 与 TimePickerDialog,处理日期格式化与默认值。

前言

账单的日期与时间是 统计分析的基础维度。如果不统一封装,每个页面各弹一次原生 Dialog、各写一份格式化代码,最终会变成维护灾难。本章封装 DateSelector、TimeSelector、MonthSelector 三个选择器,集入 AddBillView 与后续统计页。

本文将带你:

  1. 封装 DateSelector 集成 DatePickerDialog
  2. 封装 TimeSelector 集成 TimePickerDialog
  3. 封装 MonthSelector 用于统计页月份选择
  4. 统一通过 DateUtil 格式化展示
  5. 处理默认值与回调

企业级核心原则:日期组件必须 格式统一、默认合理、回调清晰。参考 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 关键实现解析

  1. DatePickerDialog.show:ArkUI 原生弹层,支持 start/end/selected 三个范围参数
  2. onAccept 回调:用户确认后触发,返回 DatePickerResult 含 year/month/day
  3. month 偏移:JS Date 月份从 0 开始,DatePicker 从 1 开始,需 -1 转换
  4. displayText getter:统一用 DateUtil.formatDate 格式化为 YYYY-MM-DD
  5. 默认 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 功能验证

  1. 新增账单页展示日期"2026-07-27"(今日)
  2. 点击日期行弹出 DatePickerDialog
  3. 滑动选择 7 月 15 日,确认后行展示"2026-07-15"
  4. 时间选择同理,弹出 TimePickerDialog
  5. 月份选择器展示"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 数据库打基础。


如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源

Logo

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

更多推荐