HarmonyOS开发:日程管理——日程协同

举报
Jack20 发表于 2026/06/26 16:52:18 2026/06/26
【摘要】 HarmonyOS开发:日程管理——日程协同📌 核心要点:日程管理不是画个日历就完事——日历组件要丝滑、日程CRUD要可靠、多人共享要防冲突、日程与会议要联动,四个能力缺一不可。 背景与动机你打开日历App,想看看明天有什么安排。结果日历加载了3秒,日程列表一片空白——同步失败了你不知道。你新建了一个日程,设了提醒,结果到了时间没提醒——你迟到了。更麻烦的是多人协同。你约了张三明天下午3...

HarmonyOS开发:日程管理——日程协同

📌 核心要点:日程管理不是画个日历就完事——日历组件要丝滑、日程CRUD要可靠、多人共享要防冲突、日程与会议要联动,四个能力缺一不可。

背景与动机

你打开日历App,想看看明天有什么安排。结果日历加载了3秒,日程列表一片空白——同步失败了你不知道。你新建了一个日程,设了提醒,结果到了时间没提醒——你迟到了。

更麻烦的是多人协同。你约了张三明天下午3点开会,但张三那个时间段已经有会了。你不知道,张三也不知道,到了明天下午两个人都以为对方会来,结果会议室空了一个小时。

日程管理的四个核心问题:

  1. 日历组件:月视图、周视图、日视图,滑动切换要丝滑,日程标记要清晰
  2. 日程CRUD:创建、查看、修改、删除日程,离线也能操作,联网自动同步
  3. 多人共享:查看同事日程、检测冲突、协调空闲时间
  4. 日程与会议联动:会议邀请自动创建日程,日程变更同步到会议

鸿蒙做日程管理有个天然优势——提醒能力。@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:0023: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对日程管理相关能力的增强:

  1. 系统日历集成:新增@kit.CalendarKit(假设),可以直接读写系统日历数据,不需要自建日历数据库。

  2. 智能日程建议:AI根据邮件内容、聊天记录自动提取日程信息,一键创建日程。

  3. 分布式日程:日程可以在设备间流转。手机上创建的日程,平板和PC上自动同步显示。

  4. 语音创建日程:支持语音输入创建日程,"明天下午3点和张三开会"自动解析为日程。

  5. 日程与导航联动:日程有地点信息时,自动计算出行时间,提前提醒出发。

总结

日程管理的四个核心能力:日历渲染要丝滑、日程CRUD要可靠、多人共享要防冲突、提醒服务要准时。渲染靠预加载和缓存,CRUD靠本地数据库+云端同步,冲突靠时间区间重叠检测,提醒靠系统ReminderAgent。

核心记住三点:

  • 时区统一用UTC,存储UTC时间戳,显示时转本地时间,跨时区不出错
  • 重复日程存规则不存多条,查询时动态展开,修改时改一条就行
  • 提醒只注册近期的,远期提醒等时间到了再注册,避免超出系统限制
评估维度 说明
学习难度 ⭐⭐⭐ 日历渲染和CRUD不难,冲突检测和重复日程需要仔细设计
使用频率 ⭐⭐⭐⭐⭐ 企业办公必备,每天都要看日程
重要程度 ⭐⭐⭐⭐ 日程管理做不好,会议就约不上,协同就做不了

日程管理是协同办公的"时间基础设施"。没有它,你连什么时候开会都不知道。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。