在这里插入图片描述
在这里插入图片描述

鸿蒙 ArkUI 日历预约时间表:基于 Table 布局的时段预约系统设计与实现

一、引言

1.1 背景与意义

在当今数字化服务体系中,预约系统无处不在——从医疗挂号、美容美发、健身房预约,到会议室预订、课程排课、车辆年检,几乎每个面向C端的服务场景都离不开高效、直观的预约时间表。一个优秀的预约时间表界面,不仅需要清晰地展示「什么时间可以预约」,还要一眼传达「哪些时段已被约满」「哪些时段不可用」等关键状态信息。

HarmonyOS(鸿蒙操作系统)作为面向全场景的分布式操作系统,其原生 UI 框架 ArkUI(方舟UI)提供了丰富的声明式组件和布局能力。本文将深入剖析一个基于 ArkUI ArkTS 实现的日历预约时间表组件,从设计理念、技术架构、核心代码、交互逻辑到扩展方案,全方位解读如何在鸿蒙生态中构建一个生产级可用的预约时间表。

1.2 功能概述

本文实现的预约时间表组件具备以下核心功能:

  • 表格化布局:以「时间段」为列、「日期」为行,构建直观的预约网格
  • 三态颜色编码:每个时段格子通过 Stack 容器的背景色区分「可预约」「已约满」「维护中」三种状态
  • 周视图导航:支持「上一周 / 下一周」切换,自动计算日期范围
  • 交互反馈:点击可预约格子立即标记为已预约,底部信息栏实时显示操作状态
  • 动态数据:基于日期种子生成模拟数据,确保每天不同时段呈现多样化的状态分布
  • 自适应布局:支持不同屏幕尺寸,表格可滚动

二、鸿蒙 ArkUI 布局技术基础

2.1 ArkUI 声明式 UI 概览

ArkUI 是 HarmonyOS 原生的声明式 UI 开发框架,采用 ArkTS 作为开发语言。ArkTS 是 TypeScript 的超集,在保留 TypeScript 语法灵活性的基础上,增加了强类型约束和编译期优化,专为鸿蒙应用开发设计。

ArkUI 的核心编程模型包含以下关键概念:

  • @Component:装饰一个结构体,声明它是一个可复用的 UI 组件
  • @Entry:标记页面的入口组件
  • @State:装饰一个变量,当其值变化时自动触发 UI 重新渲染
  • @Builder:装饰一个方法,声明它是一个可复用的 UI 构建函数
  • build():每个组件必须实现的方法,描述组件的 UI 结构

这种声明式模型与 React/Vue 的理念一脉相承:UI 是状态的函数。开发者只需描述「数据长什么样」「UI 长什么样」「数据变化时 UI 该如何响应」,框架自动处理渲染差异。

2.2 布局容器体系

ArkUI 提供了丰富的布局容器来实现不同的界面结构:

容器组件布局特性适用场景
Column垂直排列子组件纵向列表、表单
Row水平排列子组件导航栏、行式布局
Stack层叠排列子组件居中内容、覆盖效果
Flex弹性布局复杂自适应排列
Grid网格布局规整的二维排列
RelativeContainer相对定位精确锚点对齐
Scroll可滚动容器内容溢出场景

在预约时间表组件中,我们主要使用了 Column(垂直布局)、Row(水平布局)、Stack(居中容器)和 Scroll(滚动容器)四种容器,通过嵌套组合实现了类 Table 的网格效果。

2.3 颜色与资源管理

ArkUI 支持多种颜色赋值方式:

// 十六进制字符串
backgroundColor('#4CAF50')

// Color 枚举值
backgroundColor(Color.Green)

// 资源引用
backgroundColor($r('app.color.available_green'))

其中 ResourceColor 是 ArkUI 内置的类型别名,定义为 type ResourceColor = string | number | Color | Resource,接受字符串色值、数字色值、Color 枚举或 Resource 资源引用。

三、预约时间表的系统设计

3.1 需求分析

在设计预约时间表之前,我们梳理了以下核心需求:

功能需求:

  1. 以周为单位展示可预约时段
  2. 每个时段格子清晰标识状态(可预约/已约满/维护中)
  3. 用户可点击切换预约状态
  4. 支持周切换导航
  5. 提供图例说明

非功能需求:

  1. 界面直观,状态一目了然
  2. 响应式布局,适配不同屏幕
  3. 代码结构清晰,易于扩展
  4. 数据与视图分离

3.2 数据模型设计

预约时间表的核心数据模型包含三个层次:

预约数据 (DaySchedule[])
├── 日期信息 (dateLabel, date)
└── 时段列表 (slots: TimeSlot[])
    ├── 时段标签 (label: '9:00', '10:00', ...)
    └── 状态 (status: SlotStatus)
        ├── AVAILABLE  (可预约)
        ├── BOOKED     (已约满)
        └── MAINTENANCE (维护中)

对应的 ArkTS 类型定义如下:

enum SlotStatus {
  AVAILABLE,    // 可预约
  BOOKED,       // 已约满
  MAINTENANCE   // 维护中
}

interface TimeSlot {
  status: SlotStatus;
  label: string;
}

interface DaySchedule {
  dateLabel: string;   // 显示标签,如 "周一\n6/29"
  date: Date;          // 日期对象
  slots: TimeSlot[];   // 该天的时段列表
}

这种三层结构将「周-日-时段」的层次关系清晰地映射为数据结构,便于遍历渲染和状态管理。

3.3 颜色编码方案

视觉设计的核心原则是:颜色即信息。用户无需阅读文字,仅凭颜色就能判断每个时段的状态。

状态颜色色值语义用户操作
可预约🟢 绿色#4CAF50空闲可用可点击预约
已约满🔴 红色#F44336已被他人预约不可操作
维护中⚪ 灰色#9E9E9E系统维护/不可用不可操作

绿色传达「安全、可行」的心理暗示,红色传递「停止、冲突」的警示信息,灰色则表明「不可用」的中性状态。这三种颜色的语义在全球范围内的预约系统中被广泛认可。

3.4 表格布局方案:为何不用 Grid?

对于「日期 × 时段」的二维网格,ArkUI 提供了 Grid 网格布局组件。但本文选择使用 Column + Row 组合来模拟 Table 布局,原因如下:

  1. 列宽控制更精确:使用 layoutWeight(1) 确保所有时段列等宽,而 Grid 的列宽控制需要显式指定 columnsTemplate
  2. 行高灵活:每行可以独立控制高度,便于容纳多行日期文本
  3. 表头与表体分离:表头(时段标签行)固定在顶部,表体在 Scroll 中滚动,这在 Grid 中实现更复杂
  4. 内嵌交互:每个单元格可以直接绑定 onClick 事件,代码更直观

当然,Grid 方案也有其优势(如更简洁的代码量),但本文选择 Column+Row 方案以追求更高的布局控制力。

四、核心代码深度解析

4.1 组件整体架构

整个预约时间表组件 Index 的结构如下:

Index (@Entry @Component)
├── @State schedule: DaySchedule[]      // 预约数据
├── @State weekOffset: number           // 周偏移
├── @State selectedSlotInfo: string     // 选中提示
│
├── aboutToAppear()                     // 初始化加载
├── loadWeekSchedule()                  // 加载周数据
├── generateDaySlots()                  // 生成时段数据
├── onSlotTap()                         // 点击处理
│
└── build()
    ├── buildHeader()          @Builder  // 标题栏
    ├── buildWeekNavigator()   @Builder  // 周导航
    ├── buildTableHeader()     @Builder  // 表头
    ├── buildTableBody()       @Builder  // 表体(可滚动)
    │   └── buildDayRow()      @Builder  // 单行
    │       └── buildSlotCell() @Builder  // 单格
    ├── buildFooter()          @Builder  // 底部提示
    └── buildLegend()          @Builder  // 图例
        └── legendItem()       @Builder  // 图例项

这种分层的 @Builder 设计模式,将复杂的 UI 拆解为多个可复用的构建函数,每个函数职责单一,代码可读性强。

4.2 生命周期与数据初始化

aboutToAppear(): void {
  this.loadWeekSchedule(this.weekOffset);
}

aboutToAppear 是 ArkUI 组件生命周期方法,在组件即将出现在屏幕上时调用。这里我们用它加载本(weekOffset=0)的数据。

4.3 周数据生成算法

loadWeekSchedule(offset: number): void {
  const today: Date = new Date();
  today.setHours(0, 0, 0, 0);
  // 找到本周一的日期
  const dayOfWeek: number = today.getDay();
  const mondayOffset: number = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
  const monday: Date = new Date(today);
  monday.setDate(today.getDate() + mondayOffset + offset * 7);
  
  const weekData: DaySchedule[] = [];
  for (let d: number = 0; d < 7; d++) {
    const day: Date = new Date(monday);
    day.setDate(monday.getDate() + d);
    weekData.push({
      dateLabel: `${WEEKDAY_LABELS[day.getDay()]}\n${day.getMonth() + 1}/${day.getDate()}`,
      date: day,
      slots: this.generateDaySlots(day)
    });
  }
  this.schedule = weekData;
}

这段代码的核心逻辑是:

  1. 获取当天日期,并将时间归零(避免时间部分干扰日期计算)
  2. 计算本周一:利用 getDay() 获取星期几(周日=0),通过公式 1 - dayOfWeek 计算到周一的天数偏移,再乘以 offset * 7 支持周切换
  3. 遍历7天:从周一开始,每天生成一个 DaySchedule 对象
  4. 日期格式化:使用星期标签 + 月/日 的组合显示
  5. 赋值 @State:将新数组赋值给 this.schedule,触发 UI 更新

这里有一个关键的设计细节:monday.setDate(today.getDate() + mondayOffset + offset * 7) 中,mondayOffset 可能是负数(如周三时 1 - 3 = -2),setDate 会自动处理跨月情况,无需手动判断月份边界。

4.4 模拟数据生成:基于确定性种子的伪随机

generateDaySlots(day: Date): TimeSlot[] {
  const daySeed: number = day.getFullYear() * 10000 
    + (day.getMonth() + 1) * 100 + day.getDate();
  const slots: TimeSlot[] = [];
  for (let i: number = 0; i < TIME_LABELS.length; i++) {
    const seed: number = (daySeed + i * 7) % 11;
    let status: SlotStatus;
    if (seed < 3) {
      status = SlotStatus.MAINTENANCE;      // 3/11 ≈ 27%
    } else if (seed < 7) {
      status = SlotStatus.AVAILABLE;         // 4/11 ≈ 36%
    } else {
      status = SlotStatus.BOOKED;            // 4/11 ≈ 36%
    }
    slots.push({ status, label: TIME_LABELS[i] });
  }
  return slots;
}

这个生成算法的巧妙之处在于:

  • 确定性伪随机:给定相同的日期和时段索引,始终生成相同的状态分布
  • 日期编码为整数种子:如 2026年6月29日 → 20260629
  • 混入时段索引(daySeed + i * 7) % 11,乘7避免相邻时段状态雷同
  • 取模11:产生0~10的11种余数,分布为 3:4:4 ≈ 27%:36%:36%

这样,每次进入页面看到的数据是固定的、可预期的,但不同日期、不同时段之间的状态又有足够的随机变化,模拟了真实预约系统中「某些时段热门已满、某些时段维护不可用」的典型场景。

4.5 表格表头渲染

@Builder
buildTableHeader() {
  Row() {
    // 日期列头
    Text('日期')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .backgroundColor('#607D8B')
      .width(60)
      .height(44)
      .textAlign(TextAlign.Center)
      .border({ width: 0.5, color: COLOR_CELL_BORDER })

    // 每个时段列头
    ForEach(TIME_LABELS, (label: string) => {
      Text(label)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .backgroundColor('#607D8B')
        .layoutWeight(1)
        .height(44)
        .textAlign(TextAlign.Center)
        .border({ width: 0.5, color: COLOR_CELL_BORDER })
    })
  }
  .width('100%')
  .padding({ left: 16, right: 16 })
}

表头设计的要点:

  • 固定宽度 + 弹性宽度:日期列使用 width(60) 固定宽度,时段列使用 layoutWeight(1) 均分剩余空间
  • 蓝灰色背景#607D8B 提供沉稳的视觉基调,与下方白色/浅色表格形成层次对比
  • 白色粗体文字:确保表头清晰可读
  • 细边框:0.5px 的 #E0E0E0 边框定义单元格边界,不过分抢眼

4.6 日期行渲染

@Builder
buildDayRow(day: DaySchedule, dayIndex: number) {
  Row() {
    // 日期列
    Column() {
      Text(day.dateLabel.split('\n')[0])    // 星期几
        .fontSize(12)
        .fontColor('#333333')
        .fontWeight(FontWeight.Medium)
      Text(day.dateLabel.split('\n')[1])    // 月/日
        .fontSize(11)
        .fontColor('#999999')
    }
    .width(60)
    .height(56)
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 0.5, color: COLOR_CELL_BORDER })
    .backgroundColor(dayIndex === 0 ? '#FFF8E1' : '#FFFFFF')

    // 各时段格子
    ForEach(day.slots, (slot: TimeSlot, slotIndex: number) => {
      this.buildSlotCell(slot, dayIndex, slotIndex!)
    }, (slot: TimeSlot, slotIndex: number) => 
        slot.label + slotIndex.toString())
  }
  .width('100%')
  .height(56)
}

日期行的设计亮点:

  • 双行日期显示:星期名和月/日分两行显示,通过 split('\n') 拆分存储的复合标签
  • 当天高亮:周一(dayIndex===0)使用浅黄色 #FFF8E1 背景,强化视觉锚点
  • 统一行高:56px 行高配合内部的 44px 格子,留出上下边距
  • ForEach 键生成器slot.label + slotIndex 确保每个格子有唯一标识

4.7 时段格子渲染 —— 核心视觉单元

@Builder
buildSlotCell(slot: TimeSlot, dayIndex: number, slotIndex: number) {
  Column() {
    Stack() {
      Text(this.getStatusText(slot.status))
        .fontSize(12)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .height(44)
    .alignContent(Alignment.Center)
    .backgroundColor(this.getStatusColor(slot.status))
    .borderRadius(6)
    .margin({ left: 2, right: 2 })
  }
  .layoutWeight(1)
  .height(56)
  .justifyContent(FlexAlign.Center)
  .border({ width: 0.5, color: COLOR_CELL_BORDER })
  .backgroundColor('#FAFAFA')
  .onClick(() => {
    this.onSlotTap(dayIndex, slotIndex);
  })
}

这是整个组件最核心的 UI 单元。架构上采用两层嵌套:

外层 Column

  • layoutWeight(1):在父 Row 中占据等宽的弹性空间
  • 提供半透明边框和浅灰背景 #FAFAFA
  • 绑定 onClick 事件

内层 Stack

  • 100% 宽度、44px 高度,占据 Column 内部空间
  • alignContent(Alignment.Center) 将 Text 居中
  • 背景色由状态决定:绿/红/灰
  • borderRadius(6) 圆角,柔和现代
  • 左右 2px margin,在格子间制造视觉间隙

这种双层设计的好处是:点击整个 Column 区域(包括边框间隙)都能触发,但视觉主体是圆角的 Stack 色块,既有足够的点击热区,又保持美观。

4.8 状态颜色与文字映射

getStatusColor(status: SlotStatus): ResourceColor {
  switch (status) {
    case SlotStatus.AVAILABLE:   return COLOR_AVAILABLE;   // '#4CAF50'
    case SlotStatus.BOOKED:      return COLOR_BOOKED;      // '#F44336'
    case SlotStatus.MAINTENANCE: return COLOR_MAINTENANCE; // '#9E9E9E'
  }
}

getStatusText(status: SlotStatus): string {
  switch (status) {
    case SlotStatus.AVAILABLE:   return '可约';
    case SlotStatus.BOOKED:      return '已满';
    case SlotStatus.MAINTENANCE: return '维护';
  }
}

这两个映射函数将 SlotStatus 枚举转化为可视属性(颜色和文字),是状态驱动 UI 的典型实践。如果未来需要增加新状态(如「待确认」),只需在枚举中添加一项,并在这两个映射函数中补充对应的颜色和文字即可,无需修改渲染代码。

4.9 交互逻辑

onSlotTap(dayIndex: number, slotIndex: number): void {
  const slot: TimeSlot = this.schedule[dayIndex].slots[slotIndex];
  const dayInfo: string = this.schedule[dayIndex].dateLabel.replace('\n', ' ');
  const timeInfo: string = TIME_LABELS[slotIndex];

  switch (slot.status) {
    case SlotStatus.AVAILABLE:
      // 可预约 → 点击后变为已约满
      this.schedule[dayIndex].slots[slotIndex].status = SlotStatus.BOOKED;
      this.selectedSlotInfo = `✅ 已预约:${dayInfo} ${timeInfo}`;
      break;
    case SlotStatus.BOOKED:
      this.selectedSlotInfo = `❌ 已约满:${dayInfo} ${timeInfo},请选择其他时段`;
      break;
    case SlotStatus.MAINTENANCE:
      this.selectedSlotInfo = `🔧 维护中:${dayInfo} ${timeInfo},暂不可约`;
      break;
  }
}

交互逻辑遵循「所见即所得」原则:

  • 点击绿色「可约」:将状态改为「已约满」,UI 立即变为红色,底部显示成功提示
  • 点击红色「已满」:不改变状态,底部提示已约满,建议选择其他时段
  • 点击灰色「维护」:不改变状态,底部提示维护中

这里有一个值得注意的设计决策:this.schedule[dayIndex].slots[slotIndex].status = SlotStatus.BOOKED 直接修改了 @State 数组中嵌套对象的属性。ArkUI 的 @State 装饰器对数组和嵌套对象进行深度观测,因此这种直接赋值能够触发 UI 重新渲染,而无需创建新的数组副本。

4.10 周导航

@Builder
buildWeekNavigator() {
  Row() {
    Button('‹ 上一周')
      .fontSize(14)
      .fontColor('#666666')
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .height(36)
      .onClick(() => {
        this.loadWeekOffset(this.weekOffset - 1);
      })

    Text(this.getWeekTitle())
      .fontSize(16)
      .fontWeight(FontWeight.Medium)
      .fontColor('#333333')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)

    Button('下一周 ›')
      .fontSize(14)
      .fontColor('#666666')
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .height(36)
      .onClick(() => {
        this.loadWeekOffset(this.weekOffset + 1);
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 8, bottom: 8 })
}

导航栏采用经典的「左箭头 + 标题 + 右箭头」三段式布局:

  • 两个 Button 使用圆角 + 白色背景,风格轻量
  • 中间的 Text 使用 layoutWeight(1) 撑满剩余空间,配合 textAlign(TextAlign.Center) 居中显示
  • 周标题显示周一到周日的日期范围,自动处理跨月情况
getWeekTitle(): string {
  if (this.schedule.length === 0) return '';
  const first: Date = this.schedule[0].date;
  const last: Date = this.schedule[6].date;
  const formatMonthDay = (d: Date): string => 
    `${d.getMonth() + 1}/${d.getDate()}`;
  if (first.getMonth() === last.getMonth()) {
    return `${first.getFullYear()}${first.getMonth() + 1}${first.getDate()}-${last.getDate()}`;
  }
  return `${formatMonthDay(first)} - ${formatMonthDay(last)}`;
}

4.11 图例设计

@Builder
buildLegend() {
  Row() {
    this.legendItem('可预约', COLOR_AVAILABLE)
    this.legendItem('已约满', COLOR_BOOKED)
    this.legendItem('维护中', COLOR_MAINTENANCE)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 8, bottom: 16 })
  .justifyContent(FlexAlign.Center)
}

@Builder
legendItem(label: string, color: ResourceColor) {
  Row() {
    Stack()
      .width(14).height(14)
      .backgroundColor(color)
      .borderRadius(4)
      .margin({ right: 4 })
    Text(label).fontSize(13).fontColor('#666666')
  }
  .margin({ left: 8, right: 8 })
}

图例是用户体验的关键辅助元素。每个图例项由「色块 + 文字标签」组成,色块使用 Stack 组件绘制,14×14px 大小、4px 圆角,与表格中的主色块风格一致。

图例布局居中排列,与表格底部留出充足间距,确保界面呼吸感。

五、ArkTS 编码要点与最佳实践

5.1 @Builder 的使用规则

在 ArkTS 中,@Builder 装饰的方法需要遵循以下规则:

  1. 必须定义在 @Component 内部,作为 struct 的方法
  2. 在 build() 中调用时,可以使用 this.methodName()methodName()
  3. 在另一个 @Builder 中调用时,必须使用 this.methodName()
  4. 支持参数传递,参数类型可以是基础类型或自定义接口
  5. 不可有返回值(void),它构建的是 UI 树而非数据

5.2 @State 的深度观测

ArkUI 的 @State 装饰器对以下情况执行深度观测:

  • 基本类型numberstringboolean
  • 对象:对象属性的直接修改
  • 数组:数组元素的修改、增删操作
  • 嵌套:多层嵌套的对象和数组

这意味着 this.schedule[dayIndex].slots[slotIndex].status 的修改能够被自动检测到,触发从该组件开始的局部更新,无需手动调用刷新方法。

5.3 ForEach 的键生成器

ForEach(
  array, 
  itemGenerator, 
  keyGenerator  // 可选,但强烈推荐
)

keyGenerator 为每个列表项生成唯一标识,帮助框架高效地追踪项的新增、删除和更新。如果不提供,框架使用默认的索引键,这在列表顺序变化时可能导致渲染异常。

在本文的代码中,我们为每个 ForEach 都提供了键生成器:

// 时段列头:不指定键(静态列表)
ForEach(TIME_LABELS, (label: string) => { ... })

// 日期行:使用 dateLabel + dayIndex
(day, dayIndex) => day.dateLabel + dayIndex.toString()

// 时段格:使用 label + slotIndex
(slot, slotIndex) => slot.label + slotIndex.toString()

5.4 枚举与接口的最佳实践

// 使用枚举定义有限状态集
enum SlotStatus {
  AVAILABLE,
  BOOKED,
  MAINTENANCE
}

// 使用接口定义数据结构
interface TimeSlot {
  status: SlotStatus;
  label: string;
}

interface DaySchedule {
  dateLabel: string;
  date: Date;
  slots: TimeSlot[];
}

在 ArkTS 中,枚举和接口的命名遵循 PascalCase 规范。枚举值使用全大写命名(如 AVAILABLE),接口属性使用 camelCase(如 dateLabel)。

值得注意的是,ArkTS 对接口的支持比 TypeScript 更严格——接口中的每个属性都必须被赋值,不能有可选属性(除非显式标记为可选)。

六、界面视觉设计解析

6.1 色彩体系

整个界面的色彩设计遵循「信息层级清晰」的原则:

元素色值作用
页面背景#F5F5F5柔和底色,减少视觉疲劳
表头背景#607D8B蓝灰色,沉稳专业
表体背景#FFFFFF / #FAFAFA白色区格行,浅灰区格格子
当天高亮#FFF8E1浅黄色,温和提醒
可预约#4CAF50Material Design 绿色
已约满#F44336Material Design 红色
维护中#9E9E9EMaterial Design 灰色

6.2 字体与排版

标题:    22px Bold    #333333
周导航:  16px Medium  #333333
表头:    13px Bold    #FFFFFF
日期行:  12px Medium  #333333 + 11px #999999
格内文字:12px Bold    #FFFFFF
底部提示:14px         #555555
图例:    13px         #666666

字体大小形成清晰的层级序列,从标题(22px)→ 导航(16px)→ 正文(12-14px),引导用户视觉流。

6.3 间距与边框

页面留白:        16px (左右)
表头行高:        44px
表格行高:        56px
格子圆角:        6px
单元格边框:      0.5px solid #E0E0E0
组件间间距:      8-16px

适中的行高(56px)确保触碰目标符合无障碍设计规范(最小 44px),圆角和细边框在保持清晰边界的同时,避免硬边线带来的生硬感。

七、应用场景与扩展方案

7.1 典型应用场景

本组件可以直接应用于以下场景:

  1. 医疗预约:科室 × 时段,绿色显示可挂号时段
  2. 美容美发:发型师 × 时段,显示各发型师的空闲时段
  3. 健身房:私教课 × 时段,预约一对一指导
  4. 会议室预订:会议室 × 时段,查看各会议室使用情况
  5. 在线教育:课程 × 时段,选择合适的上课时间
  6. 车辆年检:检测站 × 时段,预约验车

7.2 功能扩展方向

1. 真实数据源对接

当前使用模拟数据生成,替换为真实 API 数据源只需:

async loadWeekSchedule(offset: number): Promise<void> {
  const startDate = this.getMondayDate(offset);
  const endDate = new Date(startDate);
  endDate.setDate(startDate.getDate() + 6);
  
  // 从 API 获取数据
  const response = await fetch(
    `/api/slots?start=${startDate.toISOString()}
     &end=${endDate.toISOString()}`
  );
  const data = await response.json();
  this.schedule = this.transformApiData(data);
}

2. 月视图支持

新增一个 loadMonthSchedule() 方法,按日展示整月数据:

loadMonthSchedule(year: number, month: number): void {
  const daysInMonth = new Date(year, month, 0).getDate();
  const monthData: DaySchedule[] = [];
  for (let d = 1; d <= daysInMonth; d++) {
    const day = new Date(year, month - 1, d);
    monthData.push({ ... });
  }
  this.schedule = monthData;
}

3. 多时段粒度

当前是 6 个时段(9:00, 10:00, 11:00, 14:00, 15:00, 16:00),可扩展为半小时粒度:

const TIME_LABELS: string[] = [
  '9:00', '9:30', '10:00', '10:30', '11:00', '11:30',
  '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'
];

4. 预约详情弹窗

点击「可预约」格子后,弹出确认对话框而非直接预约:

onSlotTap(dayIndex: number, slotIndex: number): void {
  if (slot.status === SlotStatus.AVAILABLE) {
    AlertDialog.show({
      title: '确认预约',
      message: `确定预约 ${dayInfo} ${timeInfo} 吗?`,
      primaryButton: {
        value: '取消',
        action: () => {}
      },
      secondaryButton: {
        value: '确认',
        action: () => {
          this.schedule[dayIndex].slots[slotIndex].status = SlotStatus.BOOKED;
        }
      }
    });
  }
}

5. 多资源视图

将行从「日期」改为「资源」(如多个医生/会议室):

interface ResourceSchedule {
  resourceName: string;
  resourceId: string;
  slots: TimeSlot[];
}

@State resources: ResourceSchedule[] = [];

6. 数据持久化

使用 HarmonyOS 的 Preferences 或关系型数据库存储预约状态:

import { preferences } from '@kit.ArkData';

async saveBooking(dayKey: string, slotIndex: number): Promise<void> {
  const pref = await preferences.getPreferences(this.context, 'booking_db');
  await pref.put(`booking_${dayKey}_${slotIndex}`, SlotStatus.BOOKED);
  await pref.flush();
}

7.3 性能优化建议

  1. LazyForEach 替代 ForEach:当数据量较大时(如月视图 31天×24时段=744个格子),使用 LazyForEach 实现虚拟列表渲染
  2. 避免不必要的重建:将不变的部分(表头、图例)抽取为独立组件,利用 ArkUI 的组件缓存机制
  3. 使用 Resource 引用颜色:将颜色值定义在 element/color.json 中,通过 $r('app.color.xxx') 引用,便于主题切换

八、总结

本文详细阐述了如何基于 HarmonyOS ArkUI 框架,使用 ArkTS 声明式编程语言,构建一个功能完整的「日历预约时间表」组件。

核心设计决策回顾:

  1. 布局方案:采用 Column + Row 组合模拟 Table 布局,灵活控制列宽和行高
  2. 颜色编码:通过 Stack 容器的背景色(绿色/红色/灰色)直观传达三种预约状态
  3. 状态管理:使用 @State 装饰器实现数据驱动的 UI 自动更新
  4. 代码组织:将 UI 拆解为多个 @Builder 构建函数,职责分明、易于维护
  5. 数据模型:采用三层嵌套结构(周→日→时段),清晰映射业务逻辑

本组件代码结构清晰、扩展性强,既可作为预约系统的直接参考实现,也可作为学习 ArkUI 布局、状态管理和组件化开发的入门案例。

从更宏观的角度看,预约系统的本质是「资源 × 时间」的二维映射。本文实现的日历预约时间表正是在这种二维映射的可视化表达上做文章——通过表格化布局将抽象的时间资源组织为直观的视觉网格,通过颜色编码将复杂的预约状态压缩为即时的视觉理解,通过交互反馈将用户的预约操作转化为即时的系统响应。

这种「数据→视图→交互」的闭环设计理念,不仅适用于预约系统,也可推广到票务选座、课表排布、排班管理、仓储可视化等广泛的二维资源管理场景中。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐