HarmonyOS开发:日程管理——日程协同
HarmonyOS开发:日程管理——日程协同
📌 核心要点:日程管理不是画个日历就完事——日历组件要丝滑、日程CRUD要可靠、多人共享要防冲突、日程与会议要联动,四个能力缺一不可。
背景与动机
你打开日历App,想看看明天有什么安排。结果日历加载了3秒,日程列表一片空白——同步失败了你不知道。你新建了一个日程,设了提醒,结果到了时间没提醒——你迟到了。
更麻烦的是多人协同。你约了张三明天下午3点开会,但张三那个时间段已经有会了。你不知道,张三也不知道,到了明天下午两个人都以为对方会来,结果会议室空了一个小时。
日程管理的四个核心问题:
- 日历组件:月视图、周视图、日视图,滑动切换要丝滑,日程标记要清晰
- 日程CRUD:创建、查看、修改、删除日程,离线也能操作,联网自动同步
- 多人共享:查看同事日程、检测冲突、协调空闲时间
- 日程与会议联动:会议邀请自动创建日程,日程变更同步到会议
鸿蒙做日程管理有个天然优势——提醒能力。@kit.ReminderAgentKit可以在应用未启动时发送提醒通知,比Web端可靠得多。
核心原理
日程管理的架构核心:日历渲染 + 数据同步 + 冲突检测 + 提醒服务。
flowchart TD
subgraph 日历渲染层
A[月视图<br/>日期网格]
B[周视图<br/>时间轴]
C[日视图<br/>时间线]
end
subgraph 数据层
D[日程RDB<br/>本地存储]
E[云端同步<br/>增量拉取]
F[共享日程<br/>权限过滤]
end
subgraph 冲突检测层
G[时间重叠检测]
H[会议室冲突]
I[人员冲突]
end
subgraph 提醒服务层
J[ReminderAgent<br/>系统提醒]
K[提前提醒<br/>5/15/30分钟]
L[全天日程<br/>当日提醒]
end
A --> D
B --> D
C --> D
D --> E
E --> F
D --> G
F --> H
F --> I
D --> J
J --> K
J --> L
classDef render fill:#1565C0,color:#fff,stroke:#0D47A1
classDef data fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef conflict fill:#E65100,color:#fff,stroke:#BF360C
classDef remind fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,B,C render
class D,E,F data
class G,H,I conflict
class J,K,L remind
日历渲染策略
日历渲染的核心挑战:性能。一个月视图42个格子,每个格子可能有多个日程,滑动切换月份时不能卡顿。
关键优化:
- 虚拟滚动:只渲染可见区域的日程,不可见的不渲染
- 数据预加载:滑动到当前月份时,预加载前后各一个月的数据
- 差异更新:月份切换时只更新变化的日期格子,不全量刷新
冲突检测算法
两个日程是否冲突,判断标准很简单:时间区间是否重叠。
日程A: [startA, endA)
日程B: [startB, endB)
冲突条件: startA < endB && startB < endA
注意是左闭右开区间。两个日程一个3点结束一个3点开始,不算冲突。
提醒服务
鸿蒙的reminderAgentManager支持三种提醒类型:
- TIMER:定时提醒,精确到秒
- CALENDAR:日历提醒,精确到分钟
- ALARM:闹钟提醒,系统级
日程提醒用CALENDAR类型,可以设置提前N分钟提醒。
代码实战
基础用法:日历组件与日程CRUD
先实现日历组件和日程的增删改查。
// ScheduleManager.ets - 日程管理核心
import { relationalStore } from '@kit.ArkData';
import { reminderAgentManager } from '@kit.ReminderAgentKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 日程类型
export enum ScheduleType {
MEETING = 'meeting', // 会议
TASK = 'task', // 任务
REMINDER = 'reminder', // 提醒
LEAVE = 'leave', // 请假
TRAVEL = 'travel', // 出差
PERSONAL = 'personal', // 个人
}
// 日程重复规则
export enum RecurrenceRule {
NONE = 'none', // 不重复
DAILY = 'daily', // 每天
WEEKLY = 'weekly', // 每周
MONTHLY = 'monthly', // 每月
YEARLY = 'yearly', // 每年
}
// 日程数据
export interface ScheduleItem {
scheduleId: string; // 日程ID
title: string; // 标题
description: string; // 描述
type: ScheduleType; // 类型
startTime: number; // 开始时间(毫秒时间戳)
endTime: number; // 结束时间
isAllDay: boolean; // 是否全天
location: string; // 地点
recurrence: RecurrenceRule; // 重复规则
recurrenceEnd?: number; // 重复结束时间
reminderMinutes: number[]; // 提前提醒(分钟)
participants: string[]; // 参与者ID
creatorId: string; // 创建者ID
color: string; // 日程颜色
isShared: boolean; // 是否共享日程
syncStatus: 'synced' | 'pending' | 'conflict'; // 同步状态
}
// 日程冲突
export interface ScheduleConflict {
scheduleA: ScheduleItem;
scheduleB: ScheduleItem;
conflictType: 'time' | 'room' | 'person';
overlapStart: number;
overlapEnd: number;
}
export class ScheduleManager {
private static instance: ScheduleManager;
private rdbStore: relationalStore.RdbStore | null = null;
private reminderIds: Map<string, number> = new Map(); // 日程ID -> 提醒ID
private constructor() {}
static getInstance(): ScheduleManager {
if (!ScheduleManager.instance) {
ScheduleManager.instance = new ScheduleManager();
}
return ScheduleManager.instance;
}
// 初始化数据库
async init(context: Context): Promise<void> {
const config: relationalStore.StoreConfig = {
name: 'schedule_db.db',
securityLevel: relationalStore.SecurityLevel.S2,
};
this.rdbStore = await relationalStore.getRdbStore(context, config);
await this.createTables();
console.info('[Schedule] 初始化完成');
}
// 创建数据表
private async createTables(): Promise<void> {
const sql = `
CREATE TABLE IF NOT EXISTS schedule (
schedule_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
is_all_day INTEGER DEFAULT 0,
location TEXT,
recurrence TEXT DEFAULT 'none',
recurrence_end INTEGER,
reminder_minutes TEXT,
participants TEXT,
creator_id TEXT NOT NULL,
color TEXT DEFAULT '#1565C0',
is_shared INTEGER DEFAULT 0,
sync_status TEXT DEFAULT 'pending'
)`;
await this.rdbStore!.executeSql(sql);
}
// 创建日程
async createSchedule(schedule: ScheduleItem): Promise<boolean> {
if (!this.rdbStore) return false;
try {
const valueBucket: relationalStore.ValuesBucket = {
schedule_id: schedule.scheduleId,
title: schedule.title,
description: schedule.description,
type: schedule.type,
start_time: schedule.startTime,
end_time: schedule.endTime,
is_all_day: schedule.isAllDay ? 1 : 0,
location: schedule.location,
recurrence: schedule.recurrence,
recurrence_end: schedule.recurrenceEnd,
reminder_minutes: JSON.stringify(schedule.reminderMinutes),
participants: JSON.stringify(schedule.participants),
creator_id: schedule.creatorId,
color: schedule.color,
is_shared: schedule.isShared ? 1 : 0,
sync_status: 'pending',
};
await this.rdbStore.insert('schedule', valueBucket);
// 注册提醒
await this.registerReminder(schedule);
console.info(`[Schedule] 日程已创建: ${schedule.title}`);
return true;
} catch (error) {
console.error(`[Schedule] 创建失败: ${JSON.stringify(error)}`);
return false;
}
}
// 查询指定日期范围的日程
async getSchedulesByRange(startTime: number, endTime: number): Promise<ScheduleItem[]> {
if (!this.rdbStore) return [];
try {
const predicates = new relationalStore.RdbPredicates('schedule');
// 查询与指定时间范围有重叠的日程
predicates.equalTo('is_all_day', 0);
predicates.lessThan('start_time', endTime);
predicates.greaterThan('end_time', startTime);
predicates.orderByAsc('start_time');
const resultSet = await this.rdbStore.query(predicates);
const schedules: ScheduleItem[] = [];
while (resultSet.goToNextRow()) {
schedules.push(this.parseSchedule(resultSet));
}
resultSet.close();
// 同时查询全天日程
const allDayPredicates = new relationalStore.RdbPredicates('schedule');
allDayPredicates.equalTo('is_all_day', 1);
allDayPredicates.greaterThanOrEqualTo('start_time', startTime);
allDayPredicates.lessThan('start_time', endTime);
const allDayResultSet = await this.rdbStore.query(allDayPredicates);
while (allDayResultSet.goToNextRow()) {
schedules.push(this.parseSchedule(allDayResultSet));
}
allDayResultSet.close();
return schedules;
} catch (error) {
console.error(`[Schedule] 查询失败: ${JSON.stringify(error)}`);
return [];
}
}
// 更新日程
async updateSchedule(schedule: ScheduleItem): Promise<boolean> {
if (!this.rdbStore) return false;
try {
const valueBucket: relationalStore.ValuesBucket = {
title: schedule.title,
description: schedule.description,
type: schedule.type,
start_time: schedule.startTime,
end_time: schedule.endTime,
is_all_day: schedule.isAllDay ? 1 : 0,
location: schedule.location,
recurrence: schedule.recurrence,
reminder_minutes: JSON.stringify(schedule.reminderMinutes),
participants: JSON.stringify(schedule.participants),
color: schedule.color,
sync_status: 'pending',
};
const predicates = new relationalStore.RdbPredicates('schedule');
predicates.equalTo('schedule_id', schedule.scheduleId);
await this.rdbStore.update(valueBucket, predicates);
// 更新提醒
await this.cancelReminder(schedule.scheduleId);
await this.registerReminder(schedule);
console.info(`[Schedule] 日程已更新: ${schedule.title}`);
return true;
} catch (error) {
console.error(`[Schedule] 更新失败: ${JSON.stringify(error)}`);
return false;
}
}
// 删除日程
async deleteSchedule(scheduleId: string): Promise<boolean> {
if (!this.rdbStore) return false;
try {
const predicates = new relationalStore.RdbPredicates('schedule');
predicates.equalTo('schedule_id', scheduleId);
await this.rdbStore.delete(predicates);
// 取消提醒
await this.cancelReminder(scheduleId);
console.info(`[Schedule] 日程已删除: ${scheduleId}`);
return true;
} catch (error) {
console.error(`[Schedule] 删除失败: ${JSON.stringify(error)}`);
return false;
}
}
// 注册系统提醒
private async registerReminder(schedule: ScheduleItem): Promise<void> {
if (schedule.reminderMinutes.length === 0) return;
try {
for (const minutes of schedule.reminderMinutes) {
const reminderTime = schedule.startTime - minutes * 60 * 1000;
if (reminderTime <= Date.now()) continue; // 已过时的提醒不注册
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: new Date(reminderTime).getFullYear(),
month: new Date(reminderTime).getMonth() + 1,
day: new Date(reminderTime).getDate(),
hour: new Date(reminderTime).getHours(),
minute: new Date(reminderTime).getMinutes(),
},
title: '日程提醒',
content: `${schedule.title} - ${minutes}分钟后开始`,
actionButton: [
{ title: '查看', type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CUSTOM },
],
wantAgent: undefined, // 实际项目中配置点击提醒后的跳转
maxScreen: true,
};
const reminderId = await reminderAgentManager.publishReminder(request);
this.reminderIds.set(`${schedule.scheduleId}_${minutes}`, reminderId);
console.info(`[Schedule] 提醒已注册: ${schedule.title}, 提前${minutes}分钟`);
}
} catch (error) {
console.error(`[Schedule] 注册提醒失败: ${JSON.stringify(error)}`);
}
}
// 取消提醒
private async cancelReminder(scheduleId: string): Promise<void> {
const keysToDelete: string[] = [];
for (const [key, reminderId] of this.reminderIds) {
if (key.startsWith(scheduleId)) {
try {
await reminderAgentManager.cancelReminder(reminderId);
keysToDelete.push(key);
} catch (error) {
console.warn(`[Schedule] 取消提醒失败: ${reminderId}`);
}
}
}
keysToDelete.forEach(key => this.reminderIds.delete(key));
}
// 解析日程数据
private parseSchedule(resultSet: relationalStore.ResultSet): ScheduleItem {
return {
scheduleId: resultSet.getString(resultSet.getColumnIndex('schedule_id')),
title: resultSet.getString(resultSet.getColumnIndex('title')),
description: resultSet.getString(resultSet.getColumnIndex('description')),
type: resultSet.getString(resultSet.getColumnIndex('type')) as ScheduleType,
startTime: resultSet.getLong(resultSet.getColumnIndex('start_time')),
endTime: resultSet.getLong(resultSet.getColumnIndex('end_time')),
isAllDay: resultSet.getLong(resultSet.getColumnIndex('is_all_day')) === 1,
location: resultSet.getString(resultSet.getColumnIndex('location')),
recurrence: resultSet.getString(resultSet.getColumnIndex('recurrence')) as RecurrenceRule,
recurrenceEnd: resultSet.getLong(resultSet.getColumnIndex('recurrence_end')),
reminderMinutes: JSON.parse(resultSet.getString(resultSet.getColumnIndex('reminder_minutes')) || '[]'),
participants: JSON.parse(resultSet.getString(resultSet.getColumnIndex('participants')) || '[]'),
creatorId: resultSet.getString(resultSet.getColumnIndex('creator_id')),
color: resultSet.getString(resultSet.getColumnIndex('color')),
isShared: resultSet.getLong(resultSet.getColumnIndex('is_shared')) === 1,
syncStatus: resultSet.getString(resultSet.getColumnIndex('sync_status')) as 'synced' | 'pending' | 'conflict',
};
}
}
进阶用法:多人日程共享与冲突检测
查看同事日程、检测时间冲突、协调空闲时间。
// ScheduleCollaboration.ets - 日程协同
import { ScheduleItem, ScheduleConflict, ScheduleManager } from './ScheduleManager';
// 空闲时间段
export interface FreeSlot {
startTime: number;
endTime: number;
duration: number; // 可用时长(分钟)
}
// 协同查询结果
export interface CollaborationResult {
conflicts: ScheduleConflict[];
freeSlots: FreeSlot[];
suggestedTimes: FreeSlot[];
}
export class ScheduleCollaboration {
private scheduleManager: ScheduleManager = ScheduleManager.getInstance();
// 检测日程冲突
detectConflicts(schedules: ScheduleItem[]): ScheduleConflict[] {
const conflicts: ScheduleConflict[] = [];
// 按开始时间排序
const sorted = [...schedules].sort((a, b) => a.startTime - b.startTime);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i];
const b = sorted[j];
// b的开始时间已经超过a的结束时间,后面的更不可能冲突
if (b.startTime >= a.endTime) break;
// 检测时间重叠
if (this.isTimeOverlap(a, b)) {
const overlapStart = Math.max(a.startTime, b.startTime);
const overlapEnd = Math.min(a.endTime, b.endTime);
// 判断冲突类型
let conflictType: 'time' | 'room' | 'person' = 'time';
if (a.location && a.location === b.location) {
conflictType = 'room';
}
conflicts.push({
scheduleA: a,
scheduleB: b,
conflictType,
overlapStart,
overlapEnd,
});
}
}
}
return conflicts;
}
// 检测新日程与已有日程是否冲突
async checkNewScheduleConflict(
newSchedule: ScheduleItem,
userId: string
): Promise<ScheduleConflict[]> {
// 查询同一时间段内的已有日程
const existingSchedules = await this.scheduleManager.getSchedulesByRange(
newSchedule.startTime,
newSchedule.endTime
);
const conflicts: ScheduleConflict[] = [];
for (const existing of existingSchedules) {
if (this.isTimeOverlap(newSchedule, existing)) {
conflicts.push({
scheduleA: newSchedule,
scheduleB: existing,
conflictType: 'time',
overlapStart: Math.max(newSchedule.startTime, existing.startTime),
overlapEnd: Math.min(newSchedule.endTime, existing.endTime),
});
}
}
return conflicts;
}
// 查找多人的共同空闲时间
findCommonFreeSlots(
allSchedules: Map<string, ScheduleItem[]>, // userId -> 日程列表
dateRange: { start: number; end: number },
minDurationMinutes: number = 30
): FreeSlot[] {
// 收集所有人的忙碌时间段
const busySlots: { start: number; end: number }[] = [];
for (const schedules of allSchedules.values()) {
for (const schedule of schedules) {
busySlots.push({ start: schedule.startTime, end: schedule.endTime });
}
}
// 按开始时间排序并合并重叠的忙碌时间段
busySlots.sort((a, b) => a.start - b.start);
const mergedBusy = this.mergeOverlappingSlots(busySlots);
// 从忙碌时间段中反推空闲时间段
const freeSlots: FreeSlot[] = [];
let currentStart = dateRange.start;
for (const busy of mergedBusy) {
if (currentStart < busy.start) {
const duration = (busy.start - currentStart) / (60 * 1000);
if (duration >= minDurationMinutes) {
freeSlots.push({
startTime: currentStart,
endTime: busy.start,
duration,
});
}
}
currentStart = Math.max(currentStart, busy.end);
}
// 最后一段空闲时间
if (currentStart < dateRange.end) {
const duration = (dateRange.end - currentStart) / (60 * 1000);
if (duration >= minDurationMinutes) {
freeSlots.push({
startTime: currentStart,
endTime: dateRange.end,
duration,
});
}
}
return freeSlots;
}
// 推荐会议时间
suggestMeetingTime(
allSchedules: Map<string, ScheduleItem[]>,
dateRange: { start: number; end: number },
meetingDurationMinutes: number,
preferWorkHours: boolean = true
): FreeSlot[] {
let freeSlots = this.findCommonFreeSlots(
allSchedules, dateRange, meetingDurationMinutes
);
// 过滤出足够长的空闲时间
freeSlots = freeSlots.filter(slot => slot.duration >= meetingDurationMinutes);
// 优先工作时间(9:00-18:00)
if (preferWorkHours) {
const workHourSlots = freeSlots.filter(slot => {
const hour = new Date(slot.startTime).getHours();
return hour >= 9 && hour < 18;
});
if (workHourSlots.length > 0) {
freeSlots = workHourSlots;
}
}
// 按可用时长排序,优先推荐最紧凑的时间段
freeSlots.sort((a, b) => a.duration - b.duration);
return freeSlots;
}
// 判断两个日程时间是否重叠
private isTimeOverlap(a: ScheduleItem, b: ScheduleItem): boolean {
// 全天日程特殊处理
if (a.isAllDay || b.isAllDay) {
const aDate = new Date(a.startTime).toDateString();
const bDate = new Date(b.startTime).toDateString();
return aDate === bDate;
}
return a.startTime < b.endTime && b.startTime < a.endTime;
}
// 合并重叠的时间段
private mergeOverlappingSlots(
slots: { start: number; end: number }[]
): { start: number; end: number }[] {
if (slots.length === 0) return [];
const merged: { start: number; end: number }[] = [slots[0]];
for (let i = 1; i < slots.length; i++) {
const last = merged[merged.length - 1];
if (slots[i].start <= last.end) {
// 重叠,合并
last.end = Math.max(last.end, slots[i].end);
} else {
merged.push(slots[i]);
}
}
return merged;
}
}
完整示例:日程管理页面
把日历组件、日程CRUD、冲突检测串起来,做一个完整的日程管理页面。
// SchedulePage.ets - 日程管理页面
import { ScheduleManager, ScheduleItem, ScheduleType, RecurrenceRule } from '../schedule/ScheduleManager';
import { ScheduleCollaboration, ScheduleConflict } from '../schedule/ScheduleCollaboration';
@Entry
@Component
struct SchedulePage {
@State currentMonth: number = new Date().getMonth();
@State currentYear: number = new Date().getFullYear();
@State selectedDate: number = Date.now();
@State schedules: ScheduleItem[] = [];
@State viewMode: 'month' | 'week' | 'day' = 'month';
@State showCreateDialog: boolean = false;
@State conflicts: ScheduleConflict[] = [];
// 新建日程表单
@State newTitle: string = '';
@State newStartTime: number = Date.now();
@State newEndTime: number = Date.now() + 3600000;
@State newLocation: string = '';
@State newType: ScheduleType = ScheduleType.MEETING;
@State newReminder: number = 15;
private scheduleManager: ScheduleManager = ScheduleManager.getInstance();
private collaboration: ScheduleCollaboration = new ScheduleCollaboration();
aboutToAppear(): void {
this.loadSchedules();
}
build() {
Column() {
// 顶部导航
this.TopBar()
// 日历区域
if (this.viewMode === 'month') {
this.MonthView()
} else {
this.DayView()
}
// 日程列表
this.ScheduleList()
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// 顶部导航
@Builder
TopBar() {
Row() {
// 月份切换
Row() {
Image($r('app.media.ic_chevron_left'))
.width(24).height(24)
.fillColor('#666666')
.onClick(() => this.prevMonth())
Text(`${this.currentYear}年${this.currentMonth + 1}月`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ left: 8, right: 8 })
Image($r('app.media.ic_chevron_right'))
.width(24).height(24)
.fillColor('#666666')
.onClick(() => this.nextMonth())
}
Blank()
// 今天按钮
Text('今天')
.fontSize(14)
.fontColor('#1565C0')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor('#E3F2FD')
.onClick(() => {
this.selectedDate = Date.now();
this.currentMonth = new Date().getMonth();
this.currentYear = new Date().getFullYear();
this.loadSchedules();
})
// 视图切换
Text(this.viewMode === 'month' ? '日视图' : '月视图')
.fontSize(14)
.fontColor('#1565C0')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor('#E3F2FD')
.margin({ left: 8 })
.onClick(() => {
this.viewMode = this.viewMode === 'month' ? 'day' : 'month';
})
// 新建日程
Image($r('app.media.ic_add'))
.width(28).height(28)
.fillColor('#1565C0')
.margin({ left: 12 })
.onClick(() => { this.showCreateDialog = true; })
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
// 月视图
@Builder
MonthView() {
Column() {
// 星期标题
Row() {
ForEach(['日', '一', '二', '三', '四', '五', '六'], (day: string) => {
Text(day)
.fontSize(13)
.fontColor('#999999')
.width('14.28%')
.textAlign(TextAlign.Center)
})
}
.width('100%')
.padding({ top: 8, bottom: 8 })
// 日期网格
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.getMonthDates(), (dateInfo: DateCell) => {
this.DateCell(dateInfo)
})
}
.width('100%')
}
.width('100%')
.padding({ left: 8, right: 8 })
.backgroundColor(Color.White)
}
// 日期格子
@Builder
DateCell(dateInfo: DateCell) {
Column() {
Text(dateInfo.day.toString())
.fontSize(15)
.fontColor(dateInfo.isCurrentMonth ?
(dateInfo.isToday ? '#FFFFFF' : '#333333') :
'#CCCCCC')
.fontWeight(dateInfo.isToday ? FontWeight.Bold : FontWeight.Normal)
.textAlign(TextAlign.Center)
// 日程指示点
if (dateInfo.hasSchedule) {
Row() {
ForEach(dateInfo.scheduleColors.slice(0, 3), (color: string) => {
Circle().width(5).height(5).fill(color).margin({ left: 2, right: 2 })
})
}
.margin({ top: 2 })
}
}
.width('14.28%')
.height(48)
.justifyContent(FlexAlign.Center)
.borderRadius(dateInfo.isToday ? 20 : 0)
.backgroundColor(dateInfo.isToday ? '#1565C0' : (dateInfo.isSelected ? '#E3F2FD' : Color.Transparent))
.onClick(() => {
this.selectedDate = dateInfo.timestamp;
this.loadSchedules();
})
}
// 日视图
@Builder
DayView() {
Scroll() {
Column() {
// 24小时时间轴
ForEach(Array.from({ length: 24 }, (_, i) => i), (hour: number) => {
Row() {
Text(`${hour.toString().padStart(2, '0')}:00`)
.fontSize(12)
.fontColor('#999999')
.width(50)
Row()
.width('1px')
.height(60)
.backgroundColor('#E0E0E0')
// 该小时的日程
Column() {
ForEach(this.getSchedulesForHour(hour), (schedule: ScheduleItem) => {
Row() {
Column()
.width(3)
.height(40)
.backgroundColor(schedule.color)
.borderRadius(2)
Column() {
Text(schedule.title)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(schedule.location || '')
.fontSize(11)
.fontColor('#999999')
.maxLines(1)
.margin({ top: 2 })
}
.layoutWeight(1)
.margin({ left: 8 })
}
.width('100%')
.height(48)
.padding({ left: 8, right: 8 })
.borderRadius(6)
.backgroundColor('#F5F5F5')
.margin({ bottom: 4 })
})
}
.layoutWeight(1)
.padding({ left: 8 })
}
.width('100%')
})
}
}
.height(400)
.padding(8)
.backgroundColor(Color.White)
}
// 日程列表
@Builder
ScheduleList() {
Column() {
Text(this.formatDate(this.selectedDate))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.padding({ left: 16, top: 16, bottom: 8 })
if (this.schedules.length === 0) {
Column() {
Text('暂无日程')
.fontSize(14)
.fontColor('#999999')
Text('点击右上角 + 创建新日程')
.fontSize(12)
.fontColor('#CCCCCC')
.margin({ top: 4 })
}
.width('100%')
.padding(32)
.alignItems(HorizontalAlign.Center)
} else {
List({ space: 8 }) {
ForEach(this.schedules, (schedule: ScheduleItem) => {
ListItem() {
this.ScheduleCard(schedule)
}
})
}
.padding({ left: 16, right: 16 })
}
}
.layoutWeight(1)
}
// 日程卡片
@Builder
ScheduleCard(schedule: ScheduleItem) {
Row() {
// 时间条
Column()
.width(4)
.height(60)
.borderRadius(2)
.backgroundColor(schedule.color)
// 日程信息
Column() {
Text(schedule.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(this.formatTime(schedule.startTime) + ' - ' + this.formatTime(schedule.endTime))
.fontSize(13)
.fontColor('#666666')
if (schedule.location) {
Text(' · ' + schedule.location)
.fontSize(13)
.fontColor('#666666')
}
}
.margin({ top: 4 })
if (schedule.participants.length > 0) {
Text(`${schedule.participants.length}人参与`)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 2 })
}
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
// 更多操作
Image($r('app.media.ic_more'))
.width(20).height(20)
.fillColor('#999999')
}
.width('100%')
.padding(12)
.borderRadius(10)
.backgroundColor(Color.White)
.shadow({ radius: 2, color: '#0A000000', offsetY: 1 })
}
// ========== 业务方法 ==========
// 加载日程
private loadSchedules(): void {
const dayStart = new Date(this.selectedDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(this.selectedDate);
dayEnd.setHours(23, 59, 59, 999);
this.scheduleManager.getSchedulesByRange(dayStart.getTime(), dayEnd.getTime()).then(list => {
this.schedules = list;
// 检测冲突
this.conflicts = this.collaboration.detectConflicts(list);
});
}
// 获取月视图日期数据
private getMonthDates(): DateCell[] {
const dates: DateCell[] = [];
const firstDay = new Date(this.currentYear, this.currentMonth, 1);
const startDayOfWeek = firstDay.getDay(); // 第一天是星期几
const daysInMonth = new Date(this.currentYear, this.currentMonth + 1, 0).getDate();
// 上月填充
const prevMonthDays = new Date(this.currentYear, this.currentMonth, 0).getDate();
for (let i = startDayOfWeek - 1; i >= 0; i--) {
const day = prevMonthDays - i;
dates.push({
day, isCurrentMonth: false, isToday: false, isSelected: false,
hasSchedule: false, scheduleColors: [], timestamp: 0,
});
}
// 当月
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(this.currentYear, this.currentMonth, day);
const isToday = date.toDateString() === today.toDateString();
const isSelected = date.toDateString() === new Date(this.selectedDate).toDateString();
dates.push({
day, isCurrentMonth: true, isToday, isSelected,
hasSchedule: Math.random() > 0.6, // 模拟
scheduleColors: ['#1565C0', '#2E7D32', '#E65100'].slice(0, Math.floor(Math.random() * 3) + 1),
timestamp: date.getTime(),
});
}
// 下月填充
const remaining = 42 - dates.length;
for (let day = 1; day <= remaining; day++) {
dates.push({
day, isCurrentMonth: false, isToday: false, isSelected: false,
hasSchedule: false, scheduleColors: [], timestamp: 0,
});
}
return dates;
}
// 获取指定小时的日程
private getSchedulesForHour(hour: number): ScheduleItem[] {
return this.schedules.filter(s => {
const startHour = new Date(s.startTime).getHours();
return startHour === hour;
});
}
private prevMonth(): void {
this.currentMonth--;
if (this.currentMonth < 0) {
this.currentMonth = 11;
this.currentYear--;
}
}
private nextMonth(): void {
this.currentMonth++;
if (this.currentMonth > 11) {
this.currentMonth = 0;
this.currentYear++;
}
}
private formatDate(timestamp: number): string {
const d = new Date(timestamp);
return `${d.getMonth() + 1}月${d.getDate()}日`;
}
private formatTime(timestamp: number): string {
const d = new Date(timestamp);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
}
// 日期格子数据
interface DateCell {
day: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
hasSchedule: boolean;
scheduleColors: string[];
timestamp: number;
}
踩坑与注意事项
坑1:时区问题
日程的时间戳是UTC还是本地时间?不同设备时区不一样,同一个时间戳显示的时间不同。
建议:存储用UTC时间戳,显示时转换为本地时间。跨时区出差时日程不会错乱。
坑2:全天日程的起止时间
全天日程的起止时间怎么设?00:00:00到23:59:59?还是00:00:00到次日00:00:00?
建议:全天日程的起止时间设为当天的00:00:00到次日00:00:00,用isAllDay标记区分。查询时全天日程按日期匹配,不按时间戳范围匹配。
坑3:重复日程的展开
每周一上午10点开会,这个日程要存多少条?如果存52条(一年52周),修改时得改52条。如果存1条+重复规则,查询时得展开计算。
建议:存1条+重复规则,查询时动态展开。展开范围限制在查询时间窗口前后各1个月,避免无限展开。
坑4:提醒服务有数量限制
reminderAgentManager最多注册多少个提醒?系统有限制(通常30个)。如果用户有100个日程,每个3个提醒,就300个了。
建议:只注册未来7天内的提醒,过期的自动取消,远期的等时间到了再注册。
坑5:多人日程的隐私问题
查看同事日程时,能看到多少信息?只看到"忙碌"还是能看到具体日程标题?
建议:默认只显示"忙碌/空闲"状态,不显示日程详情。需要查看详情时向对方发送请求,对方同意后才能看到。
坑6:日历滑动的性能
月视图左右滑动切换月份,如果每次都重新计算42个格子的数据,低端设备会卡。
建议:预计算3个月的数据(当前+前后各1),缓存起来。滑动时直接用缓存数据,不重新计算。
HarmonyOS 6适配说明
HarmonyOS 6对日程管理相关能力的增强:
-
系统日历集成:新增
@kit.CalendarKit(假设),可以直接读写系统日历数据,不需要自建日历数据库。 -
智能日程建议:AI根据邮件内容、聊天记录自动提取日程信息,一键创建日程。
-
分布式日程:日程可以在设备间流转。手机上创建的日程,平板和PC上自动同步显示。
-
语音创建日程:支持语音输入创建日程,"明天下午3点和张三开会"自动解析为日程。
-
日程与导航联动:日程有地点信息时,自动计算出行时间,提前提醒出发。
总结
日程管理的四个核心能力:日历渲染要丝滑、日程CRUD要可靠、多人共享要防冲突、提醒服务要准时。渲染靠预加载和缓存,CRUD靠本地数据库+云端同步,冲突靠时间区间重叠检测,提醒靠系统ReminderAgent。
核心记住三点:
- 时区统一用UTC,存储UTC时间戳,显示时转本地时间,跨时区不出错
- 重复日程存规则不存多条,查询时动态展开,修改时改一条就行
- 提醒只注册近期的,远期提醒等时间到了再注册,避免超出系统限制
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐ 日历渲染和CRUD不难,冲突检测和重复日程需要仔细设计 |
| 使用频率 | ⭐⭐⭐⭐⭐ 企业办公必备,每天都要看日程 |
| 重要程度 | ⭐⭐⭐⭐ 日程管理做不好,会议就约不上,协同就做不了 |
日程管理是协同办公的"时间基础设施"。没有它,你连什么时候开会都不知道。
- 点赞
- 收藏
- 关注作者
评论(0)