日历日程管理工具 — 基于 HarmonyOS NEXT (API 23+) 的 ArkTS 纯声明式实现

🗓️ 日历日程管理工具 --- 基于 HarmonyOS NEXT (API 23+) 的 ArkTS 纯声明式实现

项目源码entry/src/main/ets/pages/CalendarTool.ets

运行平台 :HarmonyOS NEXT(Phone / 2in1)

框架语言 :ArkTS(声明式 UI)

API 版本 :API 23+

开发工具 :DevEco Studio 5.0+ / OpenHarmony SDK

代码行数 :613 行(单文件组件)

核心依赖:无第三方库,仅使用 HarmonyOS 官方 Kit


📌 一、应用概述

日历日程管理工具是一款面向 HarmonyOS NEXT 系统原生的工具类应用,采用 ArkTS 纯声明式 UI 语法编写,完整实现了日历浏览、日程管理、本地持久化存储、系统通知提醒等核心功能。应用针对 PC(2in1)和手机双平台做了适配,支持 月视图周视图 两种浏览模式,提供直观的点击交互、流畅的列表滚动体验和友好的空状态引导。

本工具不依赖任何第三方 UI 库或运行时框架,完全基于 HarmonyOS 官方 @kit.ArkUI 组件体系构建,是 ArkTS 声明式开发在「工具类应用」场景中的完整参考实现。从数据建模、状态管理、视图计算,到用户交互、持久化存储、系统通知集成,覆盖了企业级应用开发的完整链路。

1.1 核心功能一览

功能模块 子功能 实现方式
日历浏览 月视图 6×7 网格、周视图 7 列滚动 ForEach + buildGrid 算法
日期导航 上/下翻页、今天快捷跳转 nav(dir) / goToday()
视图切换 月视图 / 周视图 mode 状态 + 条件渲染
日程管理 添加 / 编辑 / 删除 / 查看 表单弹窗 + CRUD 方法
日程标记 彩色小圆点提示、颜色标记条 color 字段 + 自定义样式
数据持久化 自动保存、重启恢复 dataPreferences 键值存储
系统通知 日程提醒推送 notificationManager.publish()
空状态引导 无日程时的友好提示 条件渲染空状态占位
用户反馈 Toast 操作提示、删除确认弹窗 promptAction + AlertDialog
交互细节 时间循环选择、颜色选择器 @Builder 封装的可复用组件

1.2 设计目标

  • 轻量高效:单文件 613 行,无冗余依赖,编译产物极小,加载速度极快
  • 声明式驱动 :充分利用 ArkTS 的 @State + @Builder 机制实现响应式 UI,告别手动 DOM 操作
  • 数据持久化 :使用 @kit.ArkUIdataPreferences API 实现本地键值对存储,数据随应用生命周期自动管理
  • 系统集成 :对接 notificationManager 通知服务,实现日程提醒推送至系统通知中心
  • 多端适配 :同时支持手机竖屏和 PC 2in1 横屏操作,布局使用 layoutWeight + 百分比 弹性适配

📌 二、技术栈与整体架构

2.1 技术选型明细

层级 技术选型
语言 ArkTS(基于 TypeScript 的超集语言,支持静态类型 + 声明式 UI)
UI 框架 @kit.ArkUI --- Column / Row / List / Scroll / Stack / Flex
状态管理 @State 装饰器驱动的单向数据流
组件复用 @Builder 自定义构建函数(参数化组件)
数据持久化 @kit.ArkUIdataPreferences 键值对存储 API
通知服务 @kit.NotificationKitnotificationManager
弹窗交互 AlertDialog + promptAction.showToast
编译构建 hvigor + ArkTS 编译器 + 资源打包工具
模块管理 main_pages.json 页面路由注册

2.2 架构分层

工具采用经典的三层架构设计,每一层职责清晰、依赖方向明确:

复制代码
┌─────────────────────────────────────────────────────┐
│               视图层 (View Layer)                     │
│   build() / @Builder monthView / weekView /          │
│   schedPanel / formOverlay / notifOverlay            │
│   └── 读取 @State,渲染 UI,绑定交互事件             │
├─────────────────────────────────────────────────────┤
│              逻辑层 (Logic Layer)                     │
│   rebuild / buildGrid / getWkDates / getScheds /     │
│   nav / goToday / pickDate / openAdd / onSave /      │
│   confirmDel / doNotify                              │
│   └── 处理用户操作,更新 @State 状态                 │
├─────────────────────────────────────────────────────┤
│              数据层 (Data Layer)                      │
│   loadData / saveData                                │
│   └── dataPreferences 持久化 + JSON 序列化           │
├─────────────────────────────────────────────────────┤
│              工具函数层 (Utility Layer)               │
│   daysInM / firstDow / pad2 / fmtDate /              │
│   parseDate / fmtDisp / toMin                        │
│   └── 纯函数,无副作用,可独立测试                   │
└─────────────────────────────────────────────────────┘

2.3 声明式数据流

理解 ArkTS 声明式 UI 的核心,需要掌握 @State 的工作机制:

复制代码
用户操作 → 事件回调 → 修改 @State 变量
                                    ↓
                       ArkTS 框架自动标记脏节点
                                    ↓
                       重新执行 build() 及依赖的 @Builder
                                    ↓
                       Virtual DOM Diff → 增量更新实际节点

开发者只需要关心「数据是什么」和「数据怎么变」,至于「界面怎么更新」完全由框架自动完成。这与 React / Vue 的理念一脉相承,但 ArkTS 更激进------它在编译期就完成了响应式依赖的分析,运行时不需要虚拟 DOM 的完整 Diff,性能开销更低。


📌 三、数据模型深度解析

3.1 Schedule --- 日程实体

typescript 复制代码
interface Schedule {
  id: number;           // 自增唯一 ID,用于 CRUD 定位
  title: string;        // 日程标题(必填)
  date: string;         // 日期 "YYYY-MM-DD"
  startTime: string;    // 开始时间 "HH:MM"
  endTime: string;      // 结束时间 "HH:MM"
  reminder: number;     // 提醒提前量(分钟):0=不提醒,5/15/30/60/1440
  note: string;         // 备注信息(选填)
  color: string;        // 颜色标记,如 "#0078D4"
  remindEnabled: boolean;
}

每个日程包含完整的元信息,足以支撑日历中最常见的五项核心需求:什么时间、做什么事、持续多久、是否提醒、颜色分类id 字段使用全局自增变量 gNextId 生成,即使删除日程也不会导致 ID 复用。

3.2 DayInfo --- 日期格子描述

typescript 复制代码
interface DayInfo {
  year: number;
  month: number;
  day: number;
  isCurrentMonth: boolean;  // 是否属于当前月(用于灰显非本月日期)
  isToday: boolean;         // 是否为今天(用于高亮)
  isSelected: boolean;      // 是否为用户选中(用于蓝色背景)
  schedules: Schedule[];    // 该日期的所有日程(按开始时间排序)
}

此接口专为月视图的日历网格设计。三个布尔标志位控制着日期格子的五种视觉状态组合:

isCurrentMonth isToday isSelected 显示效果
false false false 灰色文字 (#C0C0C0),表示非本月日期
true false false 黑色/红色文字,正常显示
true true false 蓝色文字 + 粗体,高亮今天
true false true 白色文字 + 蓝色圆形背景
true true true 白色文字 + 蓝色圆形背景(选中优先)

3.3 WeekViewItem --- 周视图列描述

typescript 复制代码
interface WeekViewItem {
  dayName: string;       // 星期名 "日" / "一" / ... / "六"
  dayStr: string;        // 日期数字的字符串形式
  isToday: boolean;
  isSelected: boolean;
  isWeekend: boolean;    // 是否周末(控制文字红色)
  schedules: Schedule[];
  dt: DateTriple;        // 完整的年月日三元组
}

周视图将一周 7 天水平排列,每列独立可垂直滚动,尤其适合在 PC 宽屏上一次查看整周的日程分布。dt 字段用于在点击列时快速获取完整的年月日信息。

3.4 辅助类型

typescript 复制代码
interface DateTriple {
  year: number;
  month: number;
  day: number;
}

轻量结构,避免频繁创建 Date 对象带来的 GC 压力和时区问题。在循环中大量使用时优势明显。


📌 四、工具函数层详解

工具函数全部定义为模块级纯函数(不依赖组件实例),便于测试和复用:

4.1 日期计算函数

typescript 复制代码
// 获取指定月份的天数(处理闰年 2 月)
function daysInM(y: number, m: number): number {
  return new Date(y, m, 0).getDate();
}

利用 JavaScript Date 的溢出自动修正特性:new Date(2025, 2, 0) 会返回 2025 年 2 月的最后一天(28 或 29),无需手动判断闰年。

typescript 复制代码
// 获取每月第一天是星期几
function firstDow(y: number, m: number): number {
  return new Date(y, m - 1, 1).getDay();
}

getDay() 返回 0(周日)到 6(周六),正好对应 DAY_NAMES 数组的索引。

4.2 格式化函数

typescript 复制代码
function pad2(n: number): string {
  return n < 10 ? '0' + n : '' + n;
}

function fmtDate(y: number, m: number, d: number): string {
  return y + '-' + pad2(m) + '-' + pad2(d);
}

function parseDate(s: string): DateTriple {
  const p = s.split('-');
  return { year: parseInt(p[0]), month: parseInt(p[1]), day: parseInt(p[2]) };
}

function fmtDisp(s: string): string {
  const d = parseDate(s);
  return d.year + '年' + d.month + '月' + d.day + '日';
}

fmtDate 生成标准 ISO 格式 YYYY-MM-DD 作为存储键,fmtDisp 生成中文显示格式 2025年6月7日 用于界面展示。

4.3 时间比较函数

typescript 复制代码
function toMin(t: string): number {
  const p = t.split(':');
  return parseInt(p[0]) * 60 + parseInt(p[1]);
}

"HH:MM" 时间字符串转换为分钟数,方便比较大小。例如 "09:30" → 570 分钟。


📌 五、核心状态管理

5.1 状态声明全景

typescript 复制代码
struct CalendarTool {

  // ── 视图导航状态 ──
  @State yr: number = new Date().getFullYear();
  @State mo: number = new Date().getMonth() + 1;
  @State dy: number = new Date().getDate();
  @State mode: string = 'month';          // 'month' | 'week'
  @State selDate: string = fmtDate(...);  // 当前选中日期

  // ── 数据状态 ──
  @State list: Schedule[] = [];           // 完整日程列表
  @State grid: DayInfo[][] = [];          // 月视图网格(预计算)
  @State wkDates: DateTriple[] = [];      // 周视图日期(预计算)

  // ── 表单状态(双向绑定) ──
  @State showForm: boolean = false;
  @State editId: number | null = null;
  @State fTitle: string = '';
  @State fDate: string = '';
  @State fSTime: string = '09:00';
  @State fETime: string = '10:00';
  @State fRemind: number = 0;
  @State fNote: string = '';
  @State fColor: string = COLORS[0];

  // ── 通知弹窗状态 ──
  @State showNotif: boolean = false;
  @State notifTitle: string = '';
  @State notifBody: string = '';
}

共计 20 个 @State 变量,分为四类。需要注意:在 ArkTS 中,@State 修饰的变量必须是组件内部声明的简单类型或数组/对象,其变化深度监听支持数组元素替换和对象属性修改。

5.2 生命周期

typescript 复制代码
aboutToAppear(): void {
  this.loadData();   // 从本地存储恢复日程数据
  this.rebuild();    // 重新计算月/周视图
}

aboutToAppear 是 ArkTS 组件最重要的生命周期回调之一,在组件即将挂载到视图树时调用。此时组件上下文已可用,可以安全地调用 getContext(this) 和异步方法。


📌 六、月视图的网格算法

月视图是整个工具最复杂的计算逻辑,它需要将一个月的所有日期正确地排列成 6 行 × 7 列的网格。

6.1 算法流程

typescript 复制代码
private buildGrid(): void {
  const y = this.yr, m = this.mo;
  const total = daysInM(y, m);          // ① 本月总天数
  const start = firstDow(y, m);         // ② 本月 1 号是星期几

  // ③ 上月信息(用于填充首行空白)
  const pm = m === 1 ? 12 : m - 1;
  const py = m === 1 ? y - 1 : y;
  const ptotal = daysInM(py, pm);

  const today = fmtDate(new Date().getFullYear(),
    new Date().getMonth() + 1, new Date().getDate());
  const g: DayInfo[][] = [];
  let row: DayInfo[] = [];

  // ④ 填充上月的尾部日期(start 个格子)
  for (let i = start - 1; i >= 0; i--) {
    const d = ptotal - i;
    const ds = fmtDate(py, pm, d);
    row.push({ year: py, month: pm, day: d,
      isCurrentMonth: false, isToday: ds === today,
      isSelected: ds === this.selDate,
      schedules: this.getScheds(ds) });
  }

  // ⑤ 填充本月所有日期
  for (let d = 1; d <= total; d++) {
    const ds = fmtDate(y, m, d);
    row.push({ year: y, month: m, day: d,
      isCurrentMonth: true, isToday: ds === today,
      isSelected: ds === this.selDate,
      schedules: this.getScheds(ds) });
    if (row.length === 7) { g.push(row); row = []; }
  }

  // ⑥ 用下月的日期补齐最后一行
  const nm = m === 12 ? 1 : m + 1;
  const ny = m === 12 ? y + 1 : y;
  let fill = 1;
  while (row.length < 7) {
    const ds = fmtDate(ny, nm, fill);
    row.push({ year: ny, month: nm, day: fill,
      isCurrentMonth: false, isToday: ds === today,
      isSelected: ds === this.selDate,
      schedules: this.getScheds(ds) });
    fill++;
  }
  if (row.length > 0) g.push(row);
  this.grid = g;
}

6.2 算法图解

以 2025 年 6 月为例(6 月 1 日是周日,共 30 天):

复制代码
第 1 行: [ 1  2  3  4  5  6  7 ]  ← 全部为本月日期
第 2 行: [ 8  9 10 11 12 13 14 ]
第 3 行: [15 16 17 18 19 20 21 ]
第 4 行: [22 23 24 25 26 27 28 ]
第 5 行: [29 30  1  2  3  4  5 ]  ← 29/30 为本月,1~5 为下月

如果某月 1 号是周三(start = 3),第一行前三个格子会显示上月末的三天:

复制代码
第 1 行: [28 29 30  1  2  3  4 ]  ← 28~30 为上月末,1~4 为本月

6.3 为什么不用 Date 直接算?

有经验的开发者可能会问:「为什么不直接用 Date 对象逐日递增?」原因有二:

  1. 性能buildGrid 单次创建 35~42 个 DayInfo 对象,使用纯算术运算比反复构造 Date 对象快一个数量级
  2. 可控性 :手算可以精确控制跨年/跨月的边界条件,避免 Date 自动溢出带来的隐式行为

📌 七、周视图实现详解

7.1 周范围计算

typescript 复制代码
private getWkDates(): DateTriple[] {
  const d = parseDate(this.selDate);
  const dt = new Date(d.year, d.month - 1, d.day);
  const start = new Date(dt);
  start.setDate(dt.getDate() - dt.getDay());  // 回到周日
  const r: DateTriple[] = [];
  for (let i = 0; i < 7; i++) {
    const dd = new Date(start);
    dd.setDate(start.getDate() + i);
    r.push({
      year: dd.getFullYear(),
      month: dd.getMonth() + 1,
      day: dd.getDate()
    });
  }
  return r;
}

选取逻辑:以选中日期所在周的周日为起点,依次获取周一至周六。例如选中 6 月 18 日(周三),则返回 6 月 15 日(周日)至 6 月 21 日(周六)共 7 天。

7.2 周视图渲染结构

周视图是整个 UI 中最考验布局技巧的部分------需要实现「水平 7 列,每列独立垂直滚动」的效果:

复制代码
Scroll (水平方向)
└── Row
    ├── Column (周日) ←───────────────────────────────┐
    │   ├── Text("日")  ← 星期名                      │
    │   ├── Text("15")  ← 日期数字                    │
    │   └── Scroll (垂直) ←── 每列内嵌独立滚动容器    │
    │       └── Column                                │
    │           ├── 日程卡片                          │
    │           │   ├── Text("09:00") ← 时间          │
    │           │   └── Text("团队周会") ← 标题       │
    │           ├── 日程卡片                          │
    │           └── ...                               │
    ├── Column (周一) ←── 同样结构                    │
    ├── Column (周二)                                  │
    ├── ... 直到周六                                   │
    └── Column (周六)                                  │

外层 Scroll 实现水平方向滚动(日期较多时),内层 7 个 Scroll 各负责一列的垂直滚动。每列高度固定 380px,通过 .height(380) + .alignItems(HorizontalAlign.Center) 保证列内元素居中。

7.3 周视图数据准备

typescript 复制代码
private getWeekViewData(): WeekViewItem[] {
  const r: WeekViewItem[] = [];
  for (let i = 0; i < this.wkDates.length; i++) {
    const dt = this.wkDates[i];
    const ds = fmtDate(dt.year, dt.month, dt.day);
    r.push({
      dayName: DAY_NAMES[i],  // "日", "一", ..., "六"
      dayStr: '' + dt.day,
      isToday: ds === this.getToday(),
      isSelected: ds === this.selDate,
      isWeekend: this.isWeekend(i),
      schedules: this.getScheds(ds),
      dt: dt
    });
  }
  return r;
}

注意这里对 isWeekend 的判断直接复用 i 参数------DAY_NAMES[0] === '日'DAY_NAMES[6] === '六',所以 isWeekend(i) = (i === 0 || i === 6)


📌 八、日程管理与 CRUD 操作

8.1 添加日程

typescript 复制代码
private openAdd(): void {
  this.editId = null;           // 标记为「新增」模式
  this.fTitle = '';
  this.fDate = this.selDate;    // 自动填入当前选中日期
  this.fSTime = '09:00';
  this.fETime = '10:00';
  this.fRemind = 0;
  this.fNote = '';
  this.fColor = COLORS[0];
  this.showForm = true;         // 弹出表单
}

8.2 编辑日程

typescript 复制代码
private openEdit(s: Schedule): void {
  this.editId = s.id;           // 标记为「编辑」模式
  this.fTitle = s.title;
  this.fDate = s.date;
  this.fSTime = s.startTime;
  this.fETime = s.endTime;
  this.fRemind = s.reminder;
  this.fNote = s.note;
  this.fColor = s.color;
  this.showForm = true;
}

添加和编辑共用同一个表单弹窗 formOverlay,通过 editId 区分模式。表单标题文案跟随模式变化:

typescript 复制代码
Text(this.editId !== null ? '✏️ 编辑日程' : '➕ 添加日程')

8.3 保存逻辑

typescript 复制代码
private onSave(): void {
  // 表单验证
  if (!this.fTitle.trim()) {
    this.toast('请输入日程标题');
    return;
  }
  if (toMin(this.fSTime) >= toMin(this.fETime)) {
    this.toast('结束时间需晚于开始时间');
    return;
  }

  if (this.editId !== null) {
    // 编辑模式:在原数组上修改
    for (let i = 0; i < this.list.length; i++) {
      if (this.list[i].id === this.editId) {
        this.list[i].title = this.fTitle.trim();
        this.list[i].date = this.fDate;
        this.list[i].startTime = this.fSTime;
        this.list[i].endTime = this.fETime;
        this.list[i].reminder = this.fRemind;
        this.list[i].note = this.fNote;
        this.list[i].color = this.fColor;
        break;
      }
    }
    this.toast('日程已更新');
  } else {
    // 新增模式:构造新对象添加
    const ns: Schedule = {
      id: gNextId++, title: this.fTitle.trim(),
      date: this.fDate, startTime: this.fSTime,
      endTime: this.fETime, reminder: this.fRemind,
      note: this.fNote, color: this.fColor,
      remindEnabled: false
    };
    this.list.push(ns);
    if (this.fRemind > 0) this.doNotify(ns);  // 注册系统通知
    this.toast('日程已添加');
  }

  this.saveData();        // 持久化
  this.showForm = false;  // 关闭弹窗
  this.rebuild();         // 刷新视图
}

验证规则:

  • 标题不能为空(trim() 后检查)
  • 结束时间必须严格晚于开始时间(分钟数比较)
  • 编辑模式下直接修改原数组元素(@State 监听数组元素属性变化)

8.4 删除逻辑

typescript 复制代码
private confirmDel(id: number, title: string): void {
  AlertDialog.show({
    title: '删除日程',
    message: '确定删除「' + title + '」?',
    primaryButton: {
      value: '取消',
      action: () => {}  // 不做任何操作
    },
    secondaryButton: {
      value: '删除',
      fontColor: '#E81224',  // 红色强调
      action: () => {
        this.list = this.list.filter(s => s.id !== id);
        this.saveData();
        this.rebuild();
        this.toast('日程已删除');
      }
    }
  });
}

使用 AlertDialog 二次确认防止误删。删除按钮用红色字体 #E81224 作为视觉警告。确认后使用 filter 生成新数组赋值给 this.list,触发 @State 的数组替换监听。


📌 九、日程面板与列表渲染

日程面板位于主界面底部,展示选中日期的所有日程:

9.1 面板结构

typescript 复制代码
@Builder schedPanel() {
  Column() {
    // 标题行
    Row() {
      Text('📋 ' + fmtDisp(this.selDate))
      Text('(共 ' + this.getSelScheds().length + ' 项)')
      Blank()
      Button('+ 添加日程')
    }

    // 内容区域
    if (this.getSelScheds().length === 0) {
      // 空状态
      Column() {
        Text('📭').fontSize(40)
        Text('暂无日程安排')
        Text('点击「+ 添加日程」创建新日程')
      }
    } else {
      // 日程列表
      List() {
        ForEach(this.getSelScheds(), (s: Schedule) => {
          ListItem() {
            // 日程卡片
            Row() {
              // 左侧时间
              Column() {
                Text(s.startTime)
                Text(s.endTime)
              }
              // 中间颜色条
              Column().width(4).height(40).backgroundColor(s.color)
              // 右侧内容
              Column() {
                Row() {
                  Text(s.title)
                  if (s.reminder > 0) Text('⏰')
                  Button('✏️')  // 编辑
                  Button('🗑️') // 删除
                }
                if (s.note) Text(s.note)
              }
            }
          }
        })
      }
    }
  }
}

9.2 空状态设计

当选中日期没有任何日程时,显示三层引导信息:

复制代码
        📭          ← 大号 emoji,视觉吸引力
    暂无日程安排      ← 主文案,温和告知
  点击「+ 添加日程」  ← 操作引导,降低使用门槛

空状态的容器使用 justifyContent(FlexAlign.Center) 垂直居中,高度 160px,确保视觉上舒适不突兀。

9.3 日程卡片样式

每张日程卡片采用时间线风格布局:

复制代码
┌──────────────────────────────────────────────────────────┐
│  09:00  │ ■ │ 团队周会     ⏰  ✏️  🗑️                    │
│  10:00  │   │ 每周例会讨论项目进度与风险                   │
└──────────────────────────────────────────────────────────┘

视觉层次:

  • 左列:开始时间用 16px 粗体,结束时间用 12px 灰色,形成清晰的时间区间
  • 中间 :4px 宽的竖条,颜色取自日程的 color 字段,实现分类标记
  • 右列:标题 + 提醒图标 + 操作按钮 + 备注文字

使用 List 组件而非 Column + ForEach,因为 List 在 HarmonyOS 中具有内置的懒加载能力------只渲染可见区域内的 ListItem,在日程数量较多时显著降低渲染开销。


📌 十、表单覆盖层设计

10.1 层次结构

表单覆盖层使用 Stack 容器的层次特性实现:

复制代码
Stack                ← 整个页面最外层
├── Column (主界面)  ← 正常内容
└── Column (formOverlay)  ← 覆盖层
    ├── 半透明背景 (#88000000)
    └── 白色表单卡片 (width: 420, height: 70%)
        ├── 标题
        ├── 分割线
        ├── Scroll (表单内容,可滚动)
        │   ├── 标题输入
        │   ├── 日期显示
        │   ├── 时间选择
        │   ├── 提醒选择
        │   ├── 颜色选择
        │   └── 备注输入
        ├── 分割线
        └── 取消 / 保存 按钮

10.2 时间循环选择器

typescript 复制代码
@Builder timeBtn(cur: string, isStart: boolean) {
  Button({ type: ButtonType.Normal }) {
    Text(cur)
      .fontSize(15).fontWeight(FontWeight.Medium).fontColor('#0078D4')
  }.onClick(() => {
    const times = ['08:00', '09:00', '10:00', '11:00', '12:00',
                   '13:00', '14:00', '15:00', '16:00', '17:00', '18:00'];
    let idx = times.indexOf(cur);
    if (idx === -1) idx = 4;  // 默认跳到 12:00
    const nIdx = (idx + 1) % times.length;
    const nt = times[nIdx];

    if (isStart) {
      this.fSTime = nt;
      // 自动调整结束时间
      if (toMin(this.fETime) <= toMin(nt)) {
        this.fETime = times[(nIdx + 1) % times.length];
      }
    } else {
      // 结束时间不能小于开始时间
      if (toMin(nt) <= toMin(this.fSTime)) {
        this.toast('结束时间需晚于开始时间');
        return;
      }
      this.fETime = nt;
    }
  })
}

设计亮点:

  • 循环切换:点击一次 +1 个时段,到头后回到起点
  • 智能联动:调整开始时间时,若结束时间不再晚于开始时间,自动顺延结束时间
  • 即时验证:调整结束时间时,如果小于开始时间,拒绝修改并给出 Toast 提示

10.3 颜色圆形选择器

typescript 复制代码
ForEach(COLORS, (c: string) => {
  Column()
    .width(28).height(28).borderRadius(14)  // 正圆形
    .backgroundColor(c)
    .border({
      width: this.fColor === c ? 3 : 0,     // 选中时显示边框
      color: this.fColor === c ? '#333' : Color.Transparent
    })
    .onClick(() => { this.fColor = c; })
})

5 种预设颜色覆盖了常见的日程分类场景:

色值 色名 适用场景
#0078D4 蓝色 会议、正式事项
#E81224 红色 紧急、截止日期
#107C10 绿色 个人事务、休息
#FF8C00 橙色 提醒、购物
#9334E6 紫色 创意、学习

10.4 点击遮罩关闭

typescript 复制代码
Column()
  .width('100%').height('100%')
  .backgroundColor('#88000000')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .onClick(() => { this.showForm = false; })

点击半透明遮罩区域关闭表单,与主流桌面应用交互一致。卡片内部的点击事件通过事件冒泡机制不会被遮罩捕获,只有点击卡片外部区域才会触发关闭。


📌 十一、数据持久化

11.1 存储方案

使用 HarmonyOS 的 dataPreferences 键值对存储,它相比文件存储的优势在于:

  • 自动管理缓存和写入时机
  • 支持异步读写,不阻塞 UI 线程
  • 数据随应用卸载自动清理,无需手动维护

11.2 数据加载

typescript 复制代码
private async loadData(): Promise<void> {
  try {
    const ctx: common.Context = getContext(this) as common.Context;
    const pref = await dataPreferences.getPreferences(ctx, PREF_NAME);
    const v = await pref.get(DATA_KEY, '[]');
    const data: Schedule[] = JSON.parse(v as string) as Schedule[];
    this.list = data;

    // 恢复全局 ID 计数器
    let mx = 0;
    for (const s of data) {
      if (s.id > mx) mx = s.id;
    }
    gNextId = mx + 1;

    this.rebuild();
  } catch (e) {
    console.error('[Cal] load err:', e);
    this.list = [];
  }
}

关键的恢复逻辑:遍历已存储的日程找到最大 ID,确保新日程的 ID 不会与已有日程冲突(即使删除过旧日程也不会复用 ID)。

11.3 数据保存

typescript 复制代码
private async saveData(): Promise<void> {
  try {
    const ctx: common.Context = getContext(this) as common.Context;
    const pref = await dataPreferences.getPreferences(ctx, PREF_NAME);
    await pref.put(DATA_KEY, JSON.stringify(this.list));
    await pref.flush();  // 强制写入磁盘
  } catch (e) {
    console.error('[Cal] save err:', e);
  }
}

每次 CRUD 操作后都调用 saveData(),确保数据即时持久化。flush() 调用确保数据写入物理存储而非仅停留在内存缓存。


📌 十二、系统通知集成

12.1 通知发布

typescript 复制代码
private async pubNotify(s: Schedule): Promise<void> {
  try {
    const rl = this.getRemindLabel(s.reminder);
    await notificationManager.publish({
      content: {
        contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '📅 日程提醒: ' + s.title,
          text: '时间: ' + s.startTime + ' - ' + s.endTime
                + (rl ? ' (' + rl + ')' : '')
        }
      },
      id: s.id,
      slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION
    } as notificationManager.NotificationRequest);
    this.toast('⏰ 提醒已设置');
  } catch (e) {
    console.error('[Cal] notify err:', e);
    // 降级处理:应用内弹窗
    this.notifTitle = '⏰ 日程提醒已设置';
    this.notifBody = '「' + s.title + '」将在 ' + s.startTime
                     + ' ' + this.getRemindLabel(s.reminder);
    this.showNotif = true;
  }
}

12.2 通知参数说明

参数 说明
id 日程 ID 用于更新/取消通知
slotType SOCIAL_COMMUNICATION 归入社交通知类别,优先级较高
contentType BASIC_TEXT 纯文本通知
title 📅 日程提醒: {标题} 通知标题
text 时间: HH:MM - HH:MM (提醒) 通知正文

12.3 降级策略

notificationManager.publish() 抛出异常(常见于模拟器不支持通知、权限未授予等场景),工具不会静默失败,而是自动降级为应用内弹窗:

typescript 复制代码
// 降级弹窗 @Builder
@Builder notifOverlay() {
  Column() {
    Column() {
      Text(this.notifTitle).fontSize(16).fontWeight(FontWeight.Bold)
      Text(this.notifBody).fontSize(14).fontColor('#666')
        .textAlign(TextAlign.Center)
      Button('我知道了')
        .onClick(() => { this.showNotif = false; })
    }.width(380).backgroundColor('#FFFFFF').borderRadius(12)
  }
  .backgroundColor('#66000000')
  .onClick(() => { this.showNotif = false; })
}

弹窗样式与表单覆盖层一致,使用半透明遮罩 + 白色圆形卡片,点击外部区域或「我知道了」按钮关闭。


📌 十三、响应式布局与适配

13.1 百分比宽度

typescript 复制代码
Text(nm).width((100 / 7) + '%')    // 星期头:每周 7 列均分
Column().width((100 / 7) + '%')    // 日期格子:每月 7 列均分
Column().width((100 / 7) + '%')    // 周视图:每周 7 列均分

使用百分比宽度保证在不同屏幕宽度下始终均分 7 列,无需媒体查询。

13.2 弹性布局

typescript 复制代码
Row() {
  Text($title)    // 固定内容
    .layoutWeight(1)  // 自动填充剩余空间
  Button($edit)   // 固定宽度按钮
  Button($delete) // 固定宽度按钮
}

日程卡片中的标题使用 layoutWeight(1) 自动填充剩余宽度,操作按钮使用固定宽度,确保标题过长时内容不会溢出。

13.3 固定宽度的弹窗

表单卡片和通知弹窗分别使用固定宽度 420px380px,在手机竖屏(360px 宽度)下会自动适配较小尺寸。对于 2in1 大屏设备,居中显示的表单卡片视觉上更紧凑。


📌 十四、交互细节与体验优化

14.1 Toast 反馈系统

typescript 复制代码
private toast(msg: string): void {
  try {
    promptAction.showToast({ message: msg, duration: 2000 });
  } catch (e) {
    console.warn('[Cal] toast fail');
  }
}

每个关键操作后都有 Toast 反馈:

操作 Toast 文案 时机
添加成功 ✅ 日程已添加 onSave() 新增分支末尾
更新成功 ✅ 日程已更新 onSave() 编辑分支末尾
删除成功 ✅ 日程已删除 confirmDel 确认回调中
提醒设置 ⏰ 提醒已设置 pubNotify 成功后
标题为空 ⚠️ 请输入日程标题 onSave() 验证失败
时间错误 ⚠️ 结束时间需晚于开始时间 onSave() + timeBtn 验证

14.2 删除确认弹窗

typescript 复制代码
AlertDialog.show({
  title: '删除日程',
  message: '确定删除「' + title + '」?',
  primaryButton: { value: '取消', action: () => {} },
  secondaryButton: {
    value: '删除',
    fontColor: '#E81224',    // 红色文字警示
    action: () => {
      this.list = this.list.filter(s => s.id !== id);
      this.saveData();
      this.rebuild();
      this.toast('日程已删除');
    }
  }
});

使用系统原生 AlertDialog,无需自定义弹窗样式。"删除"按钮使用品牌红色 #E81224 作为视觉警告。

14.3 智能时间联动

typescript 复制代码
if (isStart) {
  this.fSTime = nt;
  // 当开始时间调后,如果结束时间不再大于开始时间,自动顺延
  if (toMin(this.fETime) <= toMin(nt)) {
    this.fETime = times[(nIdx + 1) % times.length];
  }
} else {
  // 结束时间不能小于开始时间
  if (toMin(nt) <= toMin(this.fSTime)) {
    this.toast('结束时间需晚于开始时间');
    return;
  }
  this.fETime = nt;
}

这一设计大幅减少了用户手动调整时间的操作次数。例如用户设置了 09:00~10:00,然后将开始时间改为 10:00,结束时间会自动变为 11:00。


📌 十五、视觉与配色设计

15.1 主色调

复制代码
主色:  #0078D4  (蓝色)  ─ 按钮、选中态、高亮
强调:  #E81224  (红色)  ─ 周末文字、删除按钮
背景:  #F5F5F5          ─ 页面背景
卡片:  #FFFFFF           ─ 日历网格、日程面板、表单
分割:  #E0E0E0 / #F0F0F0
文字:  #1A1A1A (主) / #666 (次要) / #999 (辅助) / #C0C0C0 (禁用)

15.2 间距规范

复制代码
页面 padding:  16px
元素间距:     8px / 10px / 12px / 14px / 16px
按钮高度:     32px / 34px / 36px / 40px
圆角:         6px / 8px / 12px / 15px / 16px / 17px / 18px / 20px

所有尺寸都遵循 2 的倍数递增原则,视觉节奏统一。

15.3 日期格子的状态样式

复制代码
┌────────────┐
│  isToday  │  →  字号 18, 粗体, 蓝色 #0078D4
├────────────┤
│ isSelected│  →  白色文字, 蓝色圆形背景 #0078D4
├────────────┤
│ isWeekend │  →  红色文字 #E81224
├────────────┤
│ 非本月日期 │  →  灰色文字 #C0C0C0
└────────────┘
组合优先级: isSelected > isToday > isWeekend > isCurrentMonth

📌 十六、性能优化策略

16.1 预计算缓存

typescript 复制代码
private rebuild(): void {
  this.buildGrid();         // 计算月视图网格
  this.wkDates = this.getWkDates();  // 计算周视图日期
}

rebuild() 一次性计算两种视图的数据。当用户在月/周视图间切换时,由于 gridwkDates 已经是最新状态,不需要重新计算,视图切换是零等待的。

16.2 日程查询优化

typescript 复制代码
private getScheds(ds: string): Schedule[] {
  const r: Schedule[] = [];
  for (const s of this.list) {
    if (s.date === ds) r.push(s);
  }
  r.sort((a, b) => toMin(a.startTime) - toMin(b.startTime));
  return r;
}

虽然每次查询都是 O(n) 的遍历,但对于个人日程管理场景(日均日程量 < 20),线性遍历的开销可以忽略不计。如需支持企业级大规模日程(数千条),可引入 Map<date, Schedule[]> 索引结构。

16.3 小圆点截断

typescript 复制代码
private getDots(scheds: Schedule[]): Schedule[] {
  return scheds.length > 3 ? scheds.slice(0, 3) : scheds;
}

月视图每个日期格子的空间有限(约 40×80px),最多显示 3 个日程圆点,超出部分用 +N 文字提示。避免圆点过多导致视觉杂乱。

16.4 列表懒加载

使用 List + ListItem 组件渲染日程列表,而非 Column + ForEachList 组件只渲染可视区域的 ListItem,在日程数量较多时显著减少节点数。


📌 十七、运行与集成指南

17.1 页面注册

main_pages.json 中注册:

json 复制代码
{
  "src": [
    "pages/CalendarTool",
    ...
  ]
}

17.2 EntryAbility 加载

typescript 复制代码
// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/CalendarTool', (err) => {
    if (err.code) {
      hilog.error(0x0000, 'testTag', 'Failed: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(0x0000, 'testTag', 'CalendarTool loaded');
  });
}

17.3 命令行构建

bash 复制代码
cd D:\codeProject\Demo0607
hvigorw assembleHap --parallel

17.4 使用流程

复制代码
① 启动 → 默认显示当月月视图,当天日期蓝色高亮
② ◀/▶ 按钮切换月份 / 周
③ 点击「今天」按钮回到当前日期
④ 点击「月视图」/「周视图」切换浏览模式
⑤ 点击日期格子 → 底部日程面板显示该日详情
⑥ 点击「+ 添加日程」→ 填写标题/时间/提醒/颜色/备注 → 保存
⑦ 点击日程卡片上的 ✏️ → 编辑日程信息
⑧ 点击日程卡片上的 🗑️ → 确认弹窗 → 删除日程
⑨ 设置提醒后的日程会在系统通知中心推送

📌 十八、项目总结与展望

18.1 技术成果

  • 613 行 ArkTS 代码 完整实现了一个功能完备的日历日程管理工具
  • 纯声明式 UI,不依赖任何第三方库或 UI 框架
  • 完整 CRUD 支持,含表单验证、防误删确认、操作反馈
  • 双视图模式(月视图 + 周视图)满足不同用户的浏览习惯
  • 本地持久化,应用重启后数据完整恢复
  • 系统通知集成,日程提醒直达系统通知中心
  • PC + Mobile 双端适配,百分比布局 + 固定弹窗尺寸

18.2 适用场景

  • HarmonyOS NEXT 应用开发者的学习参考
  • 工具类 App 中的日历模块实现
  • 企业内部办公套件中的日程管理组件
  • ArkTS 声明式 UI + @Builder 组件复用的最佳实践示例

18.3 未来扩展方向

功能 实现思路
重复日程 增加 repeat 字段(daily/weekly/monthly),展示时按规则展开
日程搜索 使用 Listfilter + 搜索关键字高亮
拖拽调整时间 PanGesture + onDrag 手势识别
日历导出/导入 JSON / iCal 格式的文件导入导出
深色模式适配 使用 @Styles 定义主题 token,根据系统主题切换
国际化 (i18n) 提取字符串到 $string 资源文件,支持多语言
桌面小组件 (Widget) 使用 ArkTS Widget 框架开发 2×2 / 4×2 日历 Widget
日程分类统计 按颜色/标签统计日程数量,生成月度报表
手势翻页 SwipeGesture 左右滑动切换月/周
云端同步 对接华为云或 WebDAV 实现多设备日程同步

18.4 开发心得

通过本项目的开发,可以深刻体会 ArkTS 声明式 UI 的优势:

  1. 状态驱动 UI:告别 findViewById 和 setText 的繁琐,数据变了界面自然更新
  2. 组件化思维:@Builder 让 UI 复用变得简单自然
  3. 编译期优化:ArkTS 在编译期分析响应式依赖,运行时性能优于运行期依赖追踪方案
  4. 类型安全:完整的 TypeScript 类型系统让接口定义和状态管理更加健壮

📌 附录

A. 完整文件结构

复制代码
Demo0607/
├── entry/src/main/ets/
│   ├── entryability/
│   │   └── EntryAbility.ets          # Ability 入口,加载 CalendarTool
│   ├── pages/
│   │   ├── CalendarTool.ets          # 📌 日历日程管理工具 (613 行)
│   │   ├── BatchRenamer.ets          # 批量重命名工具
│   │   ├── ScreenshotTool.ets        # 截图工具
│   │   ├── Index.ets                 # 首页导航
│   │   ├── ClipboardEnhancer.ets     # 剪贴板增强
│   │   └── SystemMonitor.ets         # 系统监控
│   └── resources/base/profile/
│       └── main_pages.json           # 页面路由注册
├── entry/src/main/module.json5       # 模块配置
├── build-profile.json5               # 构建配置
├── CalendarTool-详细介绍.md           # 📌 本文档
└── hvigorw / hvigorw.bat            # 构建脚本

B. 关键 API 索引

API 来源包 用途
dataPreferences.getPreferences() @kit.ArkUI 获取 KV 存储实例
preferences.put() / .get() / .flush() @kit.ArkUI 读写持久化数据
notificationManager.publish() @kit.NotificationKit 发布系统通知
promptAction.showToast() @kit.ArkUI 短暂提示
AlertDialog.show() @kit.ArkUI 确认对话框
getContext(this) @kit.AbilityKit 获取上下文
window.WindowStage.loadContent() @kit.ArkUI 加载页面

C. 兼容性

  • 最低 API: API 23 (HarmonyOS NEXT)
  • 推荐 API: API 23+
  • 运行设备: Phone / 2in1 (PC 平板二合一)
  • 屏幕适配: 360px ~ 1920px+ 宽度

撰写日期 :2025 年 6 月

运行环境 :HarmonyOS NEXT (API 23+) / OpenHarmony 5.0+

项目地址D:\codeProject\Demo0607

源码文件entry/src/main/ets/pages/CalendarTool.ets (613 行)

相关推荐
金启攻1 小时前
鸿蒙原生应用实战(三):搜索与详情页 —— 多维度筛选与动态路由
华为·harmonyos
祭曦念2 小时前
鸿蒙原生Gauge仪表盘组件深度实践
华为·harmonyos
风华圆舞2 小时前
DevEco Studio 和 Flutter 工具链如何协同工作
flutter·华为·架构·harmonyos
祭曦念2 小时前
鸿蒙Next实战:从零构建每日打卡应用
华为·harmonyos
yuegu7772 小时前
HarmonyOS应用<节气通>开发第20篇:ArticleCard组件封装
华为·harmonyos
金启攻2 小时前
鸿蒙原生应用实战(二):首页开发 —— 周历导航与@Builder组件化实践
华为·harmonyos
hahjee3 小时前
【鸿蒙PC】kcp 移植:AtomCode Skills 4 步速通单文件 C 库适配
c语言·华为·harmonyos
风满城334 小时前
鸿蒙原生应用实战(四):歌单管理 —— 创建歌单与歌曲编排
华为·harmonyos
木咺吟4 小时前
鸿蒙原生应用开发实战(三):电影列表与搜索筛选 — 电影清单App
harmonyos