鸿蒙原生应用实战(三):首页纪念日卡片与添加事件页面

系列回顾:
(一)项目搭建与导航框架
(二)数据模型与状态管理
(三)UI 构建 — 首页纪念日卡片与添加事件页面 ← 本文


一、引言

在前两篇中,我们完成了项目脚手架搭建、路由配置和全局数据模型设计。从本篇开始,真正进入界面开发。

本文将聚焦「纪念日管家」两个核心页面:

页面 功能 难度
首页(Index) 今日纪念日卡片 + 即将到来列表 ⭐⭐
AddEvent 表单录入 + 分类选择 + 校验保存 ⭐⭐⭐

你将掌握:条件渲染控制空状态/有数据状态、ForEach 列表渲染与排序、Grid 分类选择器、多层表单校验体系。


二、首页(Index.ets)全面拆解

2.1 整体布局

┌──────────────────────────────────────────┐
│  纪念日管家                               │
├──────────────────────────────────────────┤
│  ┌────────────────────────────────────┐  │
│  │  6月8日              2个纪念日     │  │
│  │                                    │  │
│  │  🎂 女朋友生日   第6年            │  │  ← 今日纪念日卡片
│  │  💍 结婚纪念日   第4年            │  │
│  └────────────────────────────────────┘  │
│                                          │
│  ➕添加   全部   日历   我的             │  ← 快捷导航
│                                          │
│  即将到来(30天内)             全部 >   │
│  ┌──────────────────────────────────┐    │
│  │  🏥 体检             98天       │    │
│  │     还有98天                       │    │
│  ├──────────────────────────────────┤    │
│  │  🎂 老爸生日           61天      │    │  ← 倒计时列表
│  │     第71年 还有61天               │    │
│  ├──────────────────────────────────┤    │
│  │  🌕 中秋节           120天      │    │
│  │     还有120天                     │    │
│  └──────────────────────────────────┘    │
└──────────────────────────────────────────┘

2.2 状态变量设计

@State events: AnniversaryEvent[] = [];
@State todayEvents: AnniversaryEvent[] = [];    // 今日事件
@State upcomingEvents: AnniversaryEvent[] = []; // 30天内即将到来
@State todayStr: string = '';                   // 今天的 MM-DD 格式

📌 ArkTS 严格模式注意:所有 @State 变量必须显式类型声明,不可写 @State events = [](会被 lint 报 arkts-no-noninferrable-arr-literals 错误)。

2.3 数据加载与列表计算

aboutToAppear(): void {
  this.loadData();
}

loadData(): void {
  this.todayStr = getToday();  // 工具函数,返回 'MM-DD'

  let stored = AppStorage.get<AnniversaryEvent[]>('events');
  if (stored) {
    this.events = stored;
  } else {
    // 首次运行:加载示例数据
    let samples = getSampleEvents();
    this.events = samples;
    AppStorage.set<AnniversaryEvent[]>('events', samples);
  }
  this.calcLists();
}

calcLists(): void {
  let today: AnniversaryEvent[] = [];
  let upcoming: AnniversaryEvent[] = [];

  for (let i = 0; i < this.events.length; i++) {
    let e = this.events[i];
    let days = getCountdown(e.date, e.startYear).days;

    if (e.date === getToday()) {
      today.push(e);               // ▸ 今天的:放入 today 列表
    } else if (days > 0 && days <= 30) {
      upcoming.push(e);            // ▸ 30天内的:放入 upcoming 列表
    }
    // days === 0 已在 today 中;days < 0 走明年逻辑不显示
  }

  // 按剩余天数升序排列(最近的最靠前)
  upcoming.sort((a, b) => {
    let da = getCountdown(a.date, a.startYear).days;
    let db = getCountdown(b.date, b.startYear).days;
    return da - db;  // 简洁写法
  });

  this.todayEvents = today;
  this.upcomingEvents = upcoming;
}

📐 筛选逻辑说明:

条件 归属 说明
e.date === getToday() todayEvents 日期完全匹配今天
0 < days <= 30 upcomingEvents 未来30天内的事件
days === 0 已在today 今天已在上层处理
days < 0 不显示 已过日期走「明年同一天」逻辑

2.4 今日纪念日卡片(含空状态)

Column() {
  // ── 头部:日期 + 事件数徽章 ──
  Row() {
    Text(' ' + this.todayStr.slice(0, 2) + '月' + this.todayStr.slice(3) + '日')
      .fontSize(15).fontColor('rgba(255,255,255,0.85)')
    Blank()
    if (this.todayEvents.length > 0) {
      Text(this.todayEvents.length + '个纪念日')
        .fontSize(13).fontColor('rgba(255,255,255,0.8)')
        .backgroundColor('rgba(255,255,255,0.2)').borderRadius(10)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
    }
  }.width('100%')

  // ── 主体:有事件 / 无事件 两套 UI ──
  if (this.todayEvents.length === 0) {
    // 空状态
    Text('今天没有特殊日子').fontSize(18).fontColor('#FFFFFF')
      .margin({ top: 16 })
    Text('去添加一个纪念日吧!').fontSize(13).fontColor('rgba(255,255,255,0.7)')
      .margin({ top: 6 })
  } else {
    // 遍历展示今日事件
    ForEach(this.todayEvents, (ev: AnniversaryEvent) => {
      Row() {
        Text(getCategoryById(ev.categoryId).icon).fontSize(28)
          .margin({ right: 8 })
        Column() {
          Text(ev.name).fontSize(18).fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          if (ev.startYear > 0) {
            let years = new Date().getFullYear() - ev.startYear;
            Text('第' + years + '年')
              .fontSize(13).fontColor('rgba(255,255,255,0.7)')
          }
        }.alignItems(HorizontalAlign.Start)
        Blank()
      }.width('100%').margin({ top: 8 })
    }, (ev: AnniversaryEvent) => ev.id)
  }
}
.padding(20)
.backgroundColor('#6C63FF')
.borderRadius(16)

🎯 设计要点:

技巧 实现方式 效果
半透明文字 rgba(255,255,255,0.85) 深紫背景上层次分明
条件渲染 if (todayEvents.length === 0) 空状态 vs 有数据两套UI
日期格式 slice(0,2) + '月' + slice(3) + '日' 06-086月8日
徽章 半透明白色圆角背景 视觉轻量不抢眼
年数计算 new Date().getFullYear() - startYear 动态计算,无需存储

2.5 即将到来列表

@Builder eventCard(ev: AnniversaryEvent) {
  Row() {
    // ── 左:分类图标 ──
    Text(getCategoryById(ev.categoryId).icon).fontSize(24)
      .width(40).height(40).textAlign(TextAlign.Center)
      .backgroundColor('#F5F5F5').borderRadius(20)

    // ── 中:名称 + 年数 + 倒计时标签 ──
    Column() {
      Text(ev.name).fontSize(15).fontWeight(FontWeight.Medium)
      Row() {
        let countdown = getCountdown(ev.date, ev.startYear);
        if (countdown.age) {
          Text(countdown.age)
            .fontSize(11).fontColor(getCategoryById(ev.categoryId).color)
        }
        Text(countdown.label + countdown.days + '天')
          .fontSize(12).fontColor('#999999').margin({ left: 6 })
      }.width('100%').margin({ top: 2 })
    }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })

    // ── 右:大号天数 ──
    Text(getCountdown(ev.date, ev.startYear).days + '天').fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(getCategoryById(ev.categoryId).color)
  }
  .padding({ left: 14, right: 14, top: 8, bottom: 8 }).height(64)
  .onClick(() => {
    router.pushUrl({
      url: 'pages/DetailPage',
      params: { eventId: ev.id }
    });
  })
}

每行三栏布局:

┌──────────┬───────────────────────────┬──────────┐
│  图标    │  事件名 + 年数 + 倒计时   │  98天    │
│ (圆底)   │  (左对齐, 自动填充)       │ (主题色) │
└──────────┴───────────────────────────┴──────────┘

📌 router 导入:API 23 下需使用 import router from '@ohos.router',不可用 @kit.AbilityKit


三、添加纪念日页面(AddEvent.ets)全面拆解

3.1 页面布局

┌──────────────────────────────────────────┐
│  < 返回          添加纪念日               │
├──────────────────────────────────────────┤
│  名称 *                                   │
│  ┌──────────────────────────────────┐    │
│  │  如: 妈妈生日                     │    │
│  └──────────────────────────────────┘    │
│                                           │
│  月份 *          日期 *                   │  ← 并行输入
│  ┌─────┐        ┌─────┐                  │
│  │  01 │        │  29 │                  │
│  └─────┘        └─────┘                  │
│                                           │
│  起始年份(可选)                         │
│  ┌──────────────────────────────────┐    │
│  │  如: 2020                        │    │
│  └──────────────────────────────────┘    │
│                                           │
│  分类                                     │
│  🎂生日  💍纪念日  🎉节日  ✈️旅行       │  ← 4列 Grid
│  💼工作  ❤️健康  🫎其他                  │
│                                           │
│  提前提醒                                 │
│  ┌──────┐  天                             │
│  │  7   │                                 │
│  └──────┘                                 │
│                                           │
│  备注                                     │
│  ┌──────────────────────────────────┐    │
│  │  可选                            │    │
│  └──────────────────────────────────┘    │
│                                           │
│  ┌──────────────────────────────────┐    │
│  │             保存                 │    │
│  └──────────────────────────────────┘    │
└──────────────────────────────────────────┘

3.2 状态变量

@State name: string = '';
@State month: string = '';
@State day: string = '';
@State startYear: string = '';
@State selectedCategory: string = 'birthday';
@State note: string = '';
@State reminder: string = '7';
@State categories: EventCategory[] = [];

3.3 日期输入:为什么不用 DatePicker?

这是一个典型的设计决策。

方式 优点 缺点 适用场景
双 TextInput 自由输入、格式可控 MM-DD 需手动校验 ✅ 仅需月日的纪念日
DatePicker 原生选择、不会输错 含年月日,选择步骤多 需要完整日期的场景

纪念日本质上只需要 月+日(年份仅用于计算「第N年」),用两个 TextInput 比 DatePicker 更直接。

Row() {
  // 月份输入
  Column() {
    Text('月份 *').fontSize(13).fontColor('#999999').width('100%')
    TextInput({ placeholder: '01', text: this.month })
      .fontSize(20).height(44).width('100%').type(InputType.Number)
      .placeholderColor('#CCCCCC').margin({ top: 4 })
      .onChange((v: string) => { this.month = v; })
  }.layoutWeight(1).margin({ right: 8 })

  // 日期输入
  Column() {
    Text('日期 *').fontSize(13).fontColor('#999999').width('100%')
    TextInput({ placeholder: '01', text: this.day })
      .fontSize(20).height(44).width('100%').type(InputType.Number)
      .placeholderColor('#CCCCCC').margin({ top: 4 })
      .onChange((v: string) => { this.day = v; })
  }.layoutWeight(1)
}.width('90%')

3.4 分类选择器(Grid)

7 个分类,用 4 列 Grid 排成两行,选中的高亮显示:

Grid() {
  ForEach(this.categories, (c: EventCategory) => {
    GridItem() {
      Column() {
        Text(c.icon).fontSize(24)
        Text(c.name).fontSize(11)
          .fontColor(this.selectedCategory === c.id ? '#6C63FF' : '#666666')
          .margin({ top: 2 })
      }.width('100%').padding({ top: 8, bottom: 8 })
      .backgroundColor(this.selectedCategory === c.id ? '#EEEAFF' : '#F5F5F5')
      .borderRadius(10).alignItems(HorizontalAlign.Center)
    }.onClick(() => { this.selectedCategory = c.id; })
  }, (c: EventCategory) => c.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr')  // 4列等宽
.columnsGap(8).rowsGap(8).width('90%')

选中态变化:

  • 背景色:#F5F5F5(灰)→ #EEEAFF(浅紫)
  • 文字色:#666666#6C63FF(主题紫)

3.5 保存逻辑:多层校验体系

saveEvent(): void {
  // 1️⃣ 非空校验
  if (this.name.trim() === '' || this.month.trim() === '' || this.day.trim() === '') {
    // 可在此弹出 Toast 提示
    return;
  }

  // 2️⃣ 数值范围校验
  let mm = Number.parseInt(this.month);
  let dd = Number.parseInt(this.day);
  if (isNaN(mm) || mm < 1 || mm > 12) return;
  if (isNaN(dd) || dd < 1 || dd > 31) return;

  // 3️⃣ 可选字段处理(空字符串转默认值)
  let sy = Number.parseInt(this.startYear);
  if (isNaN(sy)) sy = 0;

  let rd = Number.parseInt(this.reminder);
  if (isNaN(rd) || rd < 0) rd = 0;

  // 4️⃣ 构造事件对象
  let ev: AnniversaryEvent = {
    id: generateId(),
    name: this.name.trim(),
    date: mm.toString().padStart(2, '0') + '-' + dd.toString().padStart(2, '0'),
    startYear: sy,
    categoryId: this.selectedCategory,
    note: this.note.trim(),
    reminderDays: rd
  };

  // 5️⃣ 保存到 AppStorage 并返回
  let stored = AppStorage.get<AnniversaryEvent[]>('events');
  let list: AnniversaryEvent[] = stored ? stored : [];
  list.push(ev);
  AppStorage.set<AnniversaryEvent[]>('events', list);
  router.back();
}

校验体系一览:

层级 检查项 工具
1️⃣ 空值 name / month / day 非空 trim() === ''
2️⃣ 类型 数字型字段 Number.parseInt + isNaN
3️⃣ 范围 月 1-12,日 1-31 不等式判断
4️⃣ 格式化 MM-DD 统一两位 padStart(2, '0')
5️⃣ 默认值 年份 / 提醒天数 空值回退 sy = 0

四、通用数据模型与工具函数

两个页面依赖同一套数据模型和工具函数,定义在 model/AnniversaryData.ets 中:

// ── 事件分类 ──
interface EventCategory {
  id: string;
  name: string;
  icon: string;
  color: string;
}

// ── 纪念日事件 ──
interface AnniversaryEvent {
  id: string;
  name: string;
  date: string;         // 'MM-DD' 格式
  startYear: number;    // 0 表示不计算年数
  categoryId: string;
  note: string;
  reminderDays: number;
}

// ── 示例数据(首次运行填充) ──
function getSampleEvents(): AnniversaryEvent[] {
  return [
    { id: '1', name: '女朋友生日', date: '06-08', startYear: 2019,
      categoryId: 'birthday', note: '', reminderDays: 7 },
    { id: '2', name: '结婚纪念日', date: '06-08', startYear: 2021,
      categoryId: 'anniversary', note: '', reminderDays: 7 },
    { id: '3', name: '体检', date: '09-15', startYear: 0,
      categoryId: 'health', note: '记得空腹', reminderDays: 3 },
    // ...
  ];
}
// ── 倒计时计算 ──
function getCountdown(date: string, startYear: number): CountdownResult {
  let now = new Date();
  let [m, d] = date.split('-').map(Number);

  // 今年的目标日期
  let target = new Date(now.getFullYear(), m - 1, d);
  let diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));

  let label = '还有';

  if (diff < 0) {
    // 已过 → 算明年的
    target = new Date(now.getFullYear() + 1, m - 1, d);
    diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
    label = '还有';
  } else if (diff === 0) {
    label = '就是今天!';
  }

  let age = startYear > 0 ? '第' + (target.getFullYear() - startYear) + '年' : '';

  return { days: diff, label: label, age: age };
}

五、踩坑记录

🕳️ 坑1:@State 数组字面量必须显式类型

// ❌ 编译报 arkts-no-noninferrable-arr-literals
@State todayEvents = [];

// ✅ 必须显式声明类型
@State todayEvents: AnniversaryEvent[] = [];

🕳️ 坑2:ForEachkeyGenerator 参数不能省略

ForEach(this.todayEvents, (ev) => { ... })
// 需要第三个参数作为 key
ForEach(this.todayEvents, (ev: AnniversaryEvent) => { ... }, (ev: AnniversaryEvent) => ev.id)

🕳️ 坑3:router 导入方式

API 23 下必须用旧式导入:

import router from '@ohos.router';          // ✅ 正确
// import { router } from '@kit.AbilityKit'; // ❌ API 23 不导出

🕳️ 坑4:TextInputtype 参数

// 数字键盘
TextInput({ placeholder: '01' }).type(InputType.Number)

// 别忘了在构造参数里传 text,否则无法双向绑定
TextInput({ placeholder: '01', text: this.month })

六、下篇预告

本篇完成了首页和添加纪念日页面的开发。下一篇将是系列中最复杂的部分:

页面 核心挑战
全部列表页(EventList) 分类筛选标签栏 + swipeAction 滑动删除 + 空状态
详情页(DetailPage) 大号倒计时动画 + 第N年展示 + router.getParams 参数接收

你将学到:

  • Swiper + swipeAction 滑动删除的最佳实践
  • 多标签筛选的 @State + computed 模式
  • router.getParams() 的类型安全写法(as Record<string, Object>

敬请期待!
在这里插入图片描述

如果本文对你有帮助,欢迎点赞收藏。有任何 UI 设计建议,评论区见!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐