🗓️ 日历日程管理工具 --- 基于 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.ArkUI的dataPreferencesAPI 实现本地键值对存储,数据随应用生命周期自动管理 - 系统集成 :对接
notificationManager通知服务,实现日程提醒推送至系统通知中心 - 多端适配 :同时支持手机竖屏和 PC 2in1 横屏操作,布局使用
layoutWeight+百分比弹性适配
📌 二、技术栈与整体架构
2.1 技术选型明细
| 层级 | 技术选型 |
|---|---|
| 语言 | ArkTS(基于 TypeScript 的超集语言,支持静态类型 + 声明式 UI) |
| UI 框架 | @kit.ArkUI --- Column / Row / List / Scroll / Stack / Flex |
| 状态管理 | @State 装饰器驱动的单向数据流 |
| 组件复用 | @Builder 自定义构建函数(参数化组件) |
| 数据持久化 | @kit.ArkUI 的 dataPreferences 键值对存储 API |
| 通知服务 | @kit.NotificationKit 的 notificationManager |
| 弹窗交互 | 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 对象逐日递增?」原因有二:
- 性能 :
buildGrid单次创建 35~42 个DayInfo对象,使用纯算术运算比反复构造Date对象快一个数量级 - 可控性 :手算可以精确控制跨年/跨月的边界条件,避免
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 固定宽度的弹窗
表单卡片和通知弹窗分别使用固定宽度 420px 和 380px,在手机竖屏(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() 一次性计算两种视图的数据。当用户在月/周视图间切换时,由于 grid 和 wkDates 已经是最新状态,不需要重新计算,视图切换是零等待的。
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 + ForEach。List 组件只渲染可视区域的 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),展示时按规则展开 |
| 日程搜索 | 使用 List 的 filter + 搜索关键字高亮 |
| 拖拽调整时间 | PanGesture + onDrag 手势识别 |
| 日历导出/导入 | JSON / iCal 格式的文件导入导出 |
| 深色模式适配 | 使用 @Styles 定义主题 token,根据系统主题切换 |
| 国际化 (i18n) | 提取字符串到 $string 资源文件,支持多语言 |
| 桌面小组件 (Widget) | 使用 ArkTS Widget 框架开发 2×2 / 4×2 日历 Widget |
| 日程分类统计 | 按颜色/标签统计日程数量,生成月度报表 |
| 手势翻页 | SwipeGesture 左右滑动切换月/周 |
| 云端同步 | 对接华为云或 WebDAV 实现多设备日程同步 |
18.4 开发心得
通过本项目的开发,可以深刻体会 ArkTS 声明式 UI 的优势:
- 状态驱动 UI:告别 findViewById 和 setText 的繁琐,数据变了界面自然更新
- 组件化思维:@Builder 让 UI 复用变得简单自然
- 编译期优化:ArkTS 在编译期分析响应式依赖,运行时性能优于运行期依赖追踪方案
- 类型安全:完整的 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 行)