预约排期

@dayflow-pro/appointment-schedule 只做两件事:

  1. 让组织者在 DayFlow 周视图中直接定义什么时候可以被预约
  2. 把这份定义转换成参与者可以直接选择的时间段,且不依赖 CalendarApp

它不是预约平台:没有后端、没有预约生命周期、没有通知、没有支付。选择时间段只会触发回调,后续流程完全交给你的应用。

安装

npm install @dayflow-pro/appointment-schedule
pnpm add @dayflow-pro/appointment-schedule
yarn add @dayflow-pro/appointment-schedule
bun add @dayflow-pro/appointment-schedule

安装步骤请参考 Pro 安装指南

temporal-polyfill 是必需依赖。只有组织者插件和 week-overlay 布局需要 @dayflow/core。React、Vue、Svelte、Angular 都是可选 peer dependency,只需安装当前 adapter 对应的框架。

import '@dayflow-pro/appointment-schedule/styles.css';
// 或者,Tailwind 项目若已加载核心主题:
import '@dayflow-pro/appointment-schedule/styles.components.css';

组织者插件

appointmentPlugin.ts
import { createAppointmentSchedulePlugin } from '@dayflow-pro/appointment-schedule/plugin';
import type { AppointmentSchedule } from '@dayflow-pro/appointment-schedule/engine';
import '@dayflow-pro/appointment-schedule/styles.css';

let schedules: AppointmentSchedule[] = [];

const upsertSchedule = (schedule: AppointmentSchedule) => {
  schedules = [
    ...schedules.filter(current => current.id !== schedule.id),
    schedule,
  ];
  appointmentPlugin.updateConfig?.({ schedules });
};

export const appointmentPlugin = createAppointmentSchedulePlugin({
  schedules,
  drawerPlacement: 'calendar',
  drawerWidth: 420,
  onCreateSchedule: upsertSchedule,
  onUpdateSchedule: upsertSchedule,
});

在对应框架的周视图中加入同一个 plugin 实例:

import { createWeekView } from '@dayflow/core';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/react';
import { appointmentPlugin } from './appointmentPlugin';

export function App() {
  const calendar = useCalendarApp({
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  });

  return (
    <>
      <button onClick={() => appointmentPlugin.api.openCreate()}>
        新建预约排期
      </button>
      <DayFlowCalendar calendar={calendar} />
    </>
  );
}
<script setup lang="ts">
import { createWeekView } from '@dayflow/core';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { appointmentPlugin } from './appointmentPlugin';

const calendar = useCalendarApp({
  views: [createWeekView()],
  plugins: [appointmentPlugin],
});
</script>

<template>
  <button @click="appointmentPlugin.api.openCreate()">新建预约排期</button>
  <DayFlowCalendar :calendar="calendar" />
</template>
import { Component } from '@angular/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import { createWeekView } from '@dayflow/core';
import { appointmentPlugin } from './appointmentPlugin';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DayFlowCalendarModule],
  template: `
    <button (click)="appointmentPlugin.api.openCreate()">新建预约排期</button>
    <dayflow-calendar [calendar]="calendar"></dayflow-calendar>
  `,
})
export class AppComponent {
  readonly appointmentPlugin = appointmentPlugin;
  readonly calendar = {
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  };
}
<script lang="ts">
  import { createWeekView } from '@dayflow/core';
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
  import { appointmentPlugin } from './appointmentPlugin';

  const calendar = useCalendarApp({
    views: [createWeekView()],
    plugins: [appointmentPlugin],
  });
</script>

<button onclick={() => appointmentPlugin.api.openCreate()}>
  新建预约排期
</button>
<DayFlowCalendar {calendar} />

schedules 是受控数据。插件不持久化任何内容。可用时间以背景层绘制在事件下方,因此不会进入 getEvents()、搜索、打印或远程同步。

Schedule 数据模型

AppointmentSchedule 是组织者、预约组件和 headless 引擎共用的数据契约。

参数类型作用
idstring稳定的排期 ID。
titlestring组织者和预约界面显示的名称。
durationMinutesnumber每次预约的时长。
slotIntervalMinutes?number相邻 slot 开始时间的间隔,默认等于预约时长。
beforeBufferMinutes?number预约前的忙碌缓冲,默认 0
afterBufferMinutes?number预约后的忙碌缓冲,默认 0
timeZonestring定义可用时间所用的 IANA 时区。
calendarId?string用于颜色和新建事件的宿主日历关联。
recurrence?AppointmentRecurrence每周、单次或每 N 周重复规则。
availabilityWeeklyAvailability[]按星期分组的循环可预约范围,必填。
unavailableIntervals?WeeklyAvailability[]可见但不会生成 slot 的循环休息时间。
dateOverrides?DateAvailabilityOverride[]覆盖每周规则的指定日期范围。
location?AppointmentLocationConfig固定链接、会议服务、地址或电话。
meta?Record<string, unknown>应用自行保存的可序列化元数据。

WeeklyAvailability 包含 dayOfWeek0 为周日,6 为周六)和 intervals。每个 AvailabilityInterval 包含 idstartTimeendTime,时间格式为 HH:mmDateAvailabilityOverride 包含 ISO date 和替代用的 intervals;空数组表示该日期全天关闭。

从你自己的 UI 打开编辑器:

appointmentPlugin.api.openCreate();
appointmentPlugin.api.openEdit('product-demo');

插件还会向日历的快速创建弹层贡献一个入口。未安装该插件的日历保持原有的纯快速创建布局。

常用配置

这些参数构成 AppointmentSchedulePluginConfig

配置项默认值用途
schedules必填由应用控制的预约排期列表。
activeScheduleId打开编辑器时选中指定排期。
availabilitySnapMinutes15编辑可用时间时采用的分钟间隔。
drawerPlacement'viewport'将编辑器挂载到视口或日历。
drawerWidth420抽屉宽度,可传像素值或 CSS 长度。
drawerTarget第一个日历calendar 模式使用的元素或 selector。
drawerRenderer内置 drawer替换完整的组织者编辑器。
timeFormat当前视图的格式使用 12 小时制或 24 小时制。
conferenceProviders[]在地点选择器中加入具名会议服务。
onCreateSchedule在你的应用中持久化新建的排期。
onUpdateSchedule在你的应用中持久化已有排期的修改。
onDeleteScheduleRequest请求宿主确认并删除排期。
onExternalUpdateConflict编辑期间收到外部受控更新时报告冲突。

Plugin API

appointmentPlugin.api 提供 AppointmentScheduleApi

方法作用
openCreate(initial?)使用可选初始字段打开新 draft。
openEdit(scheduleId)打开已有的受控排期。
closeEditor() / cancelDraft()丢弃当前 draft 并关闭 drawer。
saveDraft()调用新建或更新 callback,成功后关闭。
setActiveSchedule(scheduleId)不打开编辑器,仅切换当前排期。
getActiveSchedule()返回当前受控排期。
getDraft()编辑时返回当前可变 draft。
draftManager提供字段和可用时间编辑操作。
subscribeDraft(listener)订阅 draft 更新并返回取消订阅函数。

自定义组织者编辑器

使用 drawerRenderer 可以替换完整的组织者 drawer。插件仍然负责定位、当前 draft、日历中的可用时间编辑、保存和取消;应用则在指定的 host 中渲染正常的框架组件。

Renderer 会收到 AppointmentScheduleDrawerRenderArgs

参数作用
draft当前 AppointmentSchedule draft。
isCreating区分新建和编辑。
draftManager更新字段,并提供 toggleDayaddIntervalupdateIntervalremoveInterval 与复制操作。
calendars可选的 { id, name, color? } 日历。
conferenceProviders已注册的 { id, name, icon? } 会议服务。
placement最终使用的 'calendar''viewport' 位置。
drawerWidth解析后的 CSS 宽度字符串。
timeFormat / locale来自配置和日历的显示偏好。
translate使用 fallback 查询包内翻译。
save()调用宿主的新建或更新 callback,成功后关闭。
cancel()丢弃 draft 并关闭编辑器。

这个 callback 是框架的挂载边界,并不表示需要用原生 DOM API 编写表单。下面分别用各框架的组件实现相同的标题、保存和取消控件:

organiserDrawer.tsx
import { createRoot } from 'react-dom/client';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';

function OrganiserDrawer({
  args,
}: {
  args: AppointmentScheduleDrawerRenderArgs;
}) {
  return (
    <form onSubmit={event => { event.preventDefault(); void args.save(); }}>
      <input
        value={args.draft.title}
        onChange={event =>
          args.draftManager.updateDraft({ title: event.target.value })
        }
      />
      <button type="submit">保存</button>
      <button type="button" onClick={args.cancel}>取消</button>
    </form>
  );
}

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const root = createRoot(host);
    const render = (args: AppointmentScheduleDrawerRenderArgs) =>
      root.render(<OrganiserDrawer args={args} />);

    render(initial);
    // 在 React effect 中关闭 drawer(例如切换 `drawerRenderer`)会在 React
    // 仍在渲染时调用 `destroy`,此时卸载 root 会与该次渲染产生竞态。
    // 用一个微任务把它推迟到渲染结束之后。
    return {
      update: render,
      destroy: () => queueMicrotask(() => root.unmount()),
    };
  };
OrganiserDrawer.vue
<script setup lang="ts">
import type { AppointmentScheduleDrawerRenderArgs } from '@dayflow-pro/appointment-schedule/plugin';

const props = defineProps<{ args: AppointmentScheduleDrawerRenderArgs }>();
const updateTitle = (event: Event) => {
  props.args.draftManager.updateDraft({
    title: (event.target as HTMLInputElement).value,
  });
};
</script>

<template>
  <form @submit.prevent="args.save()">
    <input :value="args.draft.title" @input="updateTitle" />
    <button type="submit">保存</button>
    <button type="button" @click="args.cancel()">取消</button>
  </form>
</template>
organiserDrawerRenderer.ts
import { createApp, h, reactive } from 'vue';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';
import OrganiserDrawer from './OrganiserDrawer.vue';

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const state = reactive({ args: initial });
    const app = createApp({
      render: () => h(OrganiserDrawer, { args: state.args }),
    });
    app.mount(host);

    return {
      update: (args: AppointmentScheduleDrawerRenderArgs) => {
        state.args = args;
      },
      destroy: () => app.unmount(),
    };
  };
organiser-drawer.ts
import {
  ApplicationRef,
  Component,
  EnvironmentInjector,
  Input,
  createComponent,
} from '@angular/core';
import type {
  AppointmentScheduleDrawerRenderArgs,
  AppointmentScheduleDrawerRenderer,
} from '@dayflow-pro/appointment-schedule/plugin';

@Component({
  selector: 'app-organiser-drawer',
  standalone: true,
  template: `
    <input [value]="args.draft.title" (input)="updateTitle($event)" />
    <button type="button" (click)="args.save()">保存</button>
    <button type="button" (click)="args.cancel()">取消</button>
  `,
})
export class OrganiserDrawerComponent {
  @Input({ required: true }) args!: AppointmentScheduleDrawerRenderArgs;

  updateTitle(event: Event) {
    this.args.draftManager.updateDraft({
      title: (event.target as HTMLInputElement).value,
    });
  }
}

export const createDrawerRenderer = (
  appRef: ApplicationRef,
  environmentInjector: EnvironmentInjector
): AppointmentScheduleDrawerRenderer => (initial, host) => {
  const component = createComponent(OrganiserDrawerComponent, {
    hostElement: host,
    environmentInjector,
  });
  appRef.attachView(component.hostView);
  const update = (args: AppointmentScheduleDrawerRenderArgs) => {
    component.setInput('args', args);
    component.changeDetectorRef.detectChanges();
  };
  update(initial);

  return {
    update,
    destroy: () => {
      appRef.detachView(component.hostView);
      component.destroy();
    },
  };
};
OrganiserDrawer.svelte
<script lang="ts">
  import type { Readable } from 'svelte/store';
  import type { AppointmentScheduleDrawerRenderArgs } from '@dayflow-pro/appointment-schedule/plugin';

  let { state }: { state: Readable<AppointmentScheduleDrawerRenderArgs> } = $props();
  const updateTitle = (event: Event) => {
    $state.draftManager.updateDraft({
      title: (event.target as HTMLInputElement).value,
    });
  };
</script>

<form onsubmit={(event) => { event.preventDefault(); void $state.save(); }}>
  <input value={$state.draft.title} oninput={updateTitle} />
  <button type="submit">保存</button>
  <button type="button" onclick={$state.cancel}>取消</button>
</form>
organiserDrawerRenderer.ts
import { mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import type { AppointmentScheduleDrawerRenderer } from '@dayflow-pro/appointment-schedule/plugin';
import OrganiserDrawer from './OrganiserDrawer.svelte';

export const drawerRenderer: AppointmentScheduleDrawerRenderer =
  (initial, host) => {
    const state = writable(initial);
    const component = mount(OrganiserDrawer, {
      target: host,
      props: { state },
    });

    return {
      update: next => state.set(next),
      destroy: () => void unmount(component),
    };
  };

把得到的 renderer 传给 createAppointmentSchedulePlugin({ drawerRenderer })。Draft 变化时会调用 update;drawer 关闭或 renderer 被替换时会调用 destroy。参数中还包含日历及会议服务元数据、locale、timeFormattranslateisCreatingsave()cancel()

Drawer host、动画与层级

传给 renderer 的 host 元素由插件管理。它带有 df-appointment-custom-drawer-host 以及 --calendar--viewport 修饰类,定位、宽度和层级由插件以 inline style 设置。请通过这些 class 添加样式,不要移动它在 DOM 中的位置或改写 position —— 插件在每次渲染时都会重新写入这两项。

替换后的 drawer 同样有进出场动画。关闭时插件会给 host 加上 data-df-drawer-exiting,等它的 keyframe 动画播放完,才调用 destroy 并移除 host —— 因此 drawer 是带着内容滑出,而不是直接消失:

/* 包内默认样式;覆盖它即可改变动画。 */
.df-appointment-custom-drawer-host {
  animation: df-slide-in-left 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

.df-appointment-custom-drawer-host[data-df-drawer-exiting] {
  animation: df-slide-out-left 180ms cubic-bezier(0.7, 0, 0.84, 0) forwards;
}

有三点需要留意。插件只等待 keyframe 动画,框架挂在 hover、focus 上的 CSS transition 不会拖慢关闭。无限循环的动画会被跳过,并且等待还有一个以动画自身时长为上限的定时器兜底 —— 这样 drawer 里的 loading 动画,或者标签页切到后台(动画帧会停止)时,都不会让 host 永远留在文档里。如果移除动画,或用户的系统设置匹配 prefers-reduced-motion: reduce,host 会在关闭的同一帧移除;后者正是包内已有的行为。

Drawer 的 z-index900,位于日历 quick-create 弹层与对话框(1000)之下,因此打开「新建」菜单时不会被展开的编辑器盖住。把 drawer 提到 1000 以上会反过来盖住它们。如果确实需要 drawer 盖住这些浮层,请把它们一起提高,而不是只提高 drawer。

重复规则

可用时间默认按周重复。抽屉中还提供「不重复」和「每 N 周」的自定义规则:

type AppointmentRecurrence = {
  frequency: 'weekly' | 'none' | 'custom';
  startDate?: string; // YYYY-MM-DD anchor
  intervalWeeks?: number; // custom: every N weeks
  endsOnDate?: string;
  endsAfterOccurrences?: number;
};

frequency: 'none' 表示 Weekly hours 仅在锚点日期所在的周一至周日生效,并不是只让锚点当天可用。

按月、第几个周几或 RRULE 等复杂规则属于业务逻辑。请通过 dateOverrides 表达,它的优先级始终高于重复规则。

会议地点与会议服务

Schedule 可以描述在哪里开会。这里保存的是配置,而不是某一次已预订的会议。同一个 Schedule 会被预约很多次,所以它不会保存单次预约的入会链接:

type AppointmentLocationConfig =
  | { type: 'custom-link'; url: string; label?: string }
  | { type: 'conference'; providerId: string }
  | { type: 'in-person'; address: string }
  | { type: 'phone'; phone?: string };

抽屉里的 Location 选择器始终提供「Custom meeting link」「In person」「Phone call」。只有在你注册之后,具名的会议服务才会出现:

createAppointmentSchedulePlugin({
  schedules,
  conferenceProviders: [googleMeet, zoom], // ← 选择器会把它们排在最前面
});

Conference Provider

DayFlow 从不直接调用 Google、Zoom 或 Microsoft。它只定义一个单方法接口并调用它;OAuth Token、API Secret 和厂商 SDK 全部留在你的后端:

import type { ConferenceProvider } from '@dayflow-pro/appointment-schedule/engine';

const googleMeet: ConferenceProvider = {
  id: 'google-meet',
  name: 'Google Meet',
  icon: '/icons/meet.svg',
  createConference: input =>
    fetch('/api/dayflow/google-meet', {
      method: 'POST',
      body: JSON.stringify({ ...input, start: input.start.toString() }),
    }).then(response => response.json()),
};
// → { provider, joinUrl, meetingId?, hostUrl?, password?, meta? }

createConference 接收 CreateConferenceInputscheduleIdtitle、Temporal startendtimeZone,以及可选的 hostattendees。它返回 Conference,其中 providerjoinUrl 必填,meetingIdhostUrlpasswordmeta 可选。

把同一批 provider 传给预约组件,就能在会议信息中显示服务名称和图标:

<AppointmentBooking schedule={schedule} conferenceProviders={[googleMeet]} />

创建会议

请在参与者确认时创建会议,而不是在他选中某个时间段时。点了 10:00 又离开的人不该留下一个会议。

import {
  createBookingEvent,
  createConferenceForBooking,
} from '@dayflow-pro/appointment-schedule/engine';

const conference = await createConferenceForBooking({
  slot,
  schedule,
  providers: [googleMeet],
  attendees: [{ name: 'Ada Lovelace', email: 'ada@example.com' }],
});

const draft = createBookingEvent({ slot, schedule, conference });
// draft.location  → 'https://meet.google.com/abc-defg-hij'
// draft.conference → { provider: 'google-meet', joinUrl, meetingId }

custom-link 会直接返回主办方的固定会议室,不发起任何网络请求;in-personphone 返回 undefined。如果 providerId 没有对应的已注册 provider,函数会抛错,避免预约悄悄丢失会议链接。

为什么不用一个固定链接?

Google Meet、Zoom 和 Teams 建议使用 conference 而不是 custom-link。Google 明确建议每个 Event 创建独立的 conference,因为跨事件复用 conference 会带来访问和隐私问题。

要在别处(邮件、确认页)渲染地点,可以复用会议信息行使用的同一个纯函数:

import { resolveLocation } from '@dayflow-pro/appointment-schedule/engine';

resolveLocation(schedule.location, { providers: [googleMeet] });
// → { kind: 'video', label: 'Google Meet', icon: '/icons/meet.svg', config }

配置了地点时,resolver 返回 ResolvedLocation

参与者预约组件

import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/booking';

<AppointmentBooking
  schedule={schedule}
  presentation={{ organiserName: 'Alex Morgan', locationLabel: 'Zoom meeting' }}
  busyIntervals={busyIntervals}
  layout='calendar-day-slots'
  onSelectSlot={slot => console.log(slot.start.toString())}
/>;

presentation 只包含会议信息区域使用的可选展示内容。可用时间和预约行为仍然由 schedule 决定。

参数类型作用
organiserNamestring显示组织者姓名;未提供头像时也用它生成首字母头像。
organiserAvatarstring组织者头像的图片 URL。
organiserUrlstring点击组织者头像时打开的个人资料 URL。
titlestring显示在时长、地点和时区上方的标题;传入 schedule.title 可以复用排期标题。
descriptionstring显示在会议信息下方的补充说明。
locationLabelstring覆盖从 schedule.location 解析出的地点文本;通常应优先配置 schedule 中的地点字段。

预约组件参数

所有框架 adapter 最终都接受 AppointmentBookingProps;Vue、Angular 和 Svelte 会在各自入口中提供等价的 mount 类型。

参数组作用
schedule用来生成 slot 的必填 AppointmentSchedule
calendarApp把 DayFlow 事件读取并订阅为组织者忙碌时间。
presentation上表所述的可选 AppointmentPresentation 内容。
conferenceProviders解析会议地点的服务名称和图标。
busyIntervals / attendeeBusyIntervals移除组织者 slot 或叠加参与者冲突的 Temporal 范围。
layoutBookingLayoutcalendar-day-slotsmulti-day-slotsweek-overlay
renderWeekOverlay / weekOverlayOptions使用 WeekOverlayRenderArgsWeekOverlayOptions 启用和调整周布局。
availableLayouts / onLayoutChange控制内置布局切换器提供的布局。
displayTimeZone / timeZoneOptions / onDisplayTimeZoneChange控制参与者显示时区和时区选择器。
timeFormat / onTimeFormatChange控制 TimeFormat,可选 12h24h
theme / locale / startOfWeek设置周主题、locale 和 StartOfWeek016)。
multiDayCount / skipEmptyDays调整多日布局。
now / rangeStart / rangeEnd覆盖当前时间和 slot 生成范围。
selectedDate / onSelectDate受控的当前日期。
selectedSlotId / onSelectSlot受控的 slot 选择与选择 callback。
loading / disabled显示加载状态或禁用交互。
onError / onRetry接入错误上报和重试行为。
className / style添加根元素样式。
labels覆盖任意一部分用户可见文本。
slots替换或扩展下方列出的区域。
onAnalyticsEvent接收 AppointmentBookingAnalyticsEvent 及其非个人信息 payload。

根据要展示的可预约时间范围选择布局:

布局适用场景
calendar-day-slots月历配合当前选中日期的可预约时间列表。
multi-day-slots以多列方式比较连续几天的可预约时间。
week-overlay在周时间线上显示可预约时间,需要使用下方的 renderer。

周视图需要显式启用,因为它是唯一依赖 @dayflow/core 的布局:

import { renderWeekOverlay } from '@dayflow-pro/appointment-schedule/booking/week-overlay';

<AppointmentBooking
  layout='week-overlay'
  renderWeekOverlay={renderWeekOverlay}
/>;

框架 Adapter

请选择预约页面所属框架对应的 adapter。每个 adapter 都接收同一套预约 options,并负责挂载、响应式更新和清理。导入某一个 adapter 不会把其他框架运行时带进 bundle。

先创建一份可由任何 adapter 共用、可以直接序列化的 schedule:

schedule.ts
import type { AppointmentSchedule } from '@dayflow-pro/appointment-schedule/engine';

export const schedule: AppointmentSchedule = {
  id: 'product-demo',
  title: 'Product demo',
  durationMinutes: 30,
  slotIntervalMinutes: 30,
  timeZone: 'Europe/London',
  recurrence: { frequency: 'weekly' },
  availability: [
    {
      dayOfWeek: 1,
      intervals: [{ id: 'monday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 2,
      intervals: [{ id: 'tuesday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 3,
      intervals: [{ id: 'wednesday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 4,
      intervals: [{ id: 'thursday', startTime: '09:00', endTime: '17:00' }],
    },
    {
      dayOfWeek: 5,
      intervals: [{ id: 'friday', startTime: '09:00', endTime: '17:00' }],
    },
  ],
  location: {
    type: 'custom-link',
    url: 'https://zoom.us/j/1234567890',
    label: 'Zoom meeting',
  },
};

在应用入口中导入一次 styles.components.css,然后使用对应框架的 adapter:

import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/react';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

export function BookingPage() {
  return (
    <AppointmentBooking
      schedule={schedule}
      presentation={{
        organiserName: 'Alex Morgan',
        locationLabel: 'Zoom meeting',
      }}
      layout='calendar-day-slots'
      onSelectSlot={slot => console.log(slot.start.toString())}
    />
  );
}
<script setup lang="ts">
import { AppointmentBooking } from '@dayflow-pro/appointment-schedule/vue';
import type { MountAppointmentBookingProps } from '@dayflow-pro/appointment-schedule/vue';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

const bookingOptions: MountAppointmentBookingProps = {
  schedule,
  presentation: {
    organiserName: 'Alex Morgan',
    locationLabel: 'Zoom meeting',
  },
  layout: 'calendar-day-slots',
  onSelectSlot: slot => console.log(slot.start.toString()),
};
</script>

<template>
  <AppointmentBooking :options="bookingOptions" />
</template>
import { Component } from '@angular/core';
import {
  AppointmentBookingDirective,
  type MountAppointmentBookingProps,
} from '@dayflow-pro/appointment-schedule/angular';
import '@dayflow-pro/appointment-schedule/styles.components.css';
import { schedule } from './schedule';

@Component({
  standalone: true,
  imports: [AppointmentBookingDirective],
  template: '<div [dfAppointmentBooking]="bookingOptions"></div>',
})
export class BookingPage {
  readonly bookingOptions: MountAppointmentBookingProps = {
    schedule,
    presentation: {
      organiserName: 'Alex Morgan',
      locationLabel: 'Zoom meeting',
    },
    layout: 'calendar-day-slots',
    onSelectSlot: slot => console.log(slot.start.toString()),
  };
}
<script lang="ts">
  import { appointmentBooking } from '@dayflow-pro/appointment-schedule/svelte';
  import type { SvelteAppointmentBookingOptions } from '@dayflow-pro/appointment-schedule/svelte';
  import '@dayflow-pro/appointment-schedule/styles.components.css';
  import { schedule } from './schedule';

  const options: SvelteAppointmentBookingOptions = {
    schedule,
    presentation: {
      organiserName: 'Alex Morgan',
      locationLabel: 'Zoom meeting',
    },
    layout: 'calendar-day-slots',
    onSelectSlot: slot => console.log(slot.start.toString()),
  };
</script>

<div use:appointmentBooking={options}></div>

接收 options 对象的 adapter 通过 options.slots 提供自定义区域;组件 props 则直接使用同一套 slots 契约。替换 options 对象会更新预约组件。slot renderer 集合变化时会安全地重新挂载,宿主卸载时则会销毁预约实例。

集成 API

Plain mount API

如果需要自行挂载预约 UI,可以使用 createAppointmentBooking。它既适用于其他框架,也适用于无框架页面:

import { createAppointmentBooking } from '@dayflow-pro/appointment-schedule/booking';

const booking = createAppointmentBooking('#booking', {
  schedule,
  onSelectSlot: slot => console.log(slot.start.toString()),
});

booking.update({ disabled: true });
booking.destroy();

目标元素原有的子节点不会被改动。Mount API 会创建并持有一个子元素,并在 destroy() 时将其移除。

DOM slot 会收到当前参数和一个由 renderer 管理的容器:

createAppointmentBooking('#booking', {
  schedule,
  slots: {
    slotButton: (args, host) => {
      host.textContent = `${args.formattedTime} · ${price(args.slot)}`;
    },
  },
});

Renderer 可以不返回内容,也可以返回 cleanup 函数或 { update, destroy }。Handle 形式适合只会追加而不会 patch 的框架 mount API。

无头预约控制器

如果希望保留内置预约行为,但完全使用自己的 markup,可以使用 createBookingController。它管理选中日期与 slot、时区、时间格式、参与者忙碌状态和 slot 分组,但从不访问 DOM:

import { createBookingController } from '@dayflow-pro/appointment-schedule/controller';

const booking = createBookingController({ schedule });
const unsubscribe = booking.subscribe(() => render(booking.getState()));

booking.getState().selectSlot(slot);
booking.setOptions({ schedule, disabled: true });

unsubscribe();
booking.destroy();

自定义

设计变量

.df-appointment-booking 是参与者预约组件的主题作用域,并不是唯一的设计变量。package 支持下列 CSS 自定义属性。请在 package stylesheet 之后加载覆盖样式,并把变量设置在 .df-appointment-booking 上;也可以传入自定义 className,再同时匹配两个 class。

.df-appointment-booking.my-booking-theme {
  --df-ap-accent: #2563eb;
  --df-ap-radius: 6px;
  --df-ap-sidebar-width: 320px;
}
变量默认值控制内容
--df-ap-surfaceCore background主表面
--df-ap-surface-sunkenCore muted下沉表面
--df-ap-fill#e8eaef中性色块
--df-ap-borderCore border边框和分隔线
--df-ap-fgCore foreground主要文字
--df-ap-fg-mutedCore muted foreground次要文字
--df-ap-fg-subtle#9ca3af弱化文字
--df-ap-accentCore primary选中的控件
--df-ap-accent-fgCore primary foreground强调色表面上的文字
--df-ap-accent-soft根据强调色派生柔和的 hover 背景
--df-ap-accent-ring根据强调色派生聚焦和选中环
--df-ap-available#22c55e可预约状态指示器
--df-ap-radius14px卡片圆角
--df-ap-radius-md9px控件圆角
--df-ap-radius-sm7px紧凑内容项圆角
--df-ap-max-width1440px预约组件最大宽度
--df-ap-sidebar-width296px侧栏宽度
--df-ap-slots-width320px当日时间列宽度
--df-ap-week-height620px周时间线高度
--df-ap-slot-scroll-heightnone当日时间列表高度
--df-ap-pad1.5rem主要内边距
--df-ap-font系统字体栈预约组件字体

Hover、选中和聚焦颜色由 --df-ap-accent 自动派生。高级主题也可以直接覆盖 --df-ap-accent-soft--df-ap-accent-ring

组织者 drawer 属于 DayFlow 日历,使用 core 主题变量,而不是参与者预约组件的变量:

:root {
  --df-color-background: #ffffff;
  --df-color-card: #ffffff;
  --df-color-foreground: #172033;
  --df-color-muted: #f4f6f8;
  --df-color-muted-foreground: #667085;
  --df-color-border: #d0d5dd;
  --df-color-primary: #7c3aed;
  --df-color-primary-foreground: #ffffff;
  --df-color-destructive: #dc2626;
  --df-color-ring: #7c3aed;
}

drawerPlacementviewport 时请把变量设置在 :root,因为 drawer 挂载在日历元素之外。使用 calendar 时,也可以把变量设置在日历容器上。

插槽

所有 adapter 都支持插槽,但 renderer 签名并不相同:

  • React slot 返回 React 内容。slotButtondayCell 还会收到 defaultContent,可以在内置内容外继续扩展。
  • Vue、Angular 和 Svelte 使用 options.slots。每个 renderer 接收 (args, host),并把内容写入指定 DOM 元素。如果通过框架 API 挂载组件,可以返回 cleanup 函数或 { update, destroy } handle。

下面展示如何在每个框架中正确自定义同一个 slotButton 区域:

<AppointmentBooking
  schedule={schedule}
  slots={{
    slotButton: ({ defaultContent }) => (
      <>
        {defaultContent}
        <span>$120</span>
      </>
    ),
  }}
/>
import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/vue';

const slots: DomBookingSlots = {
  slotButton: ({ formattedTime }, host) => {
    host.textContent = `${formattedTime} · $120`;
  },
};

const bookingOptions = { schedule, slots };
import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/angular';

const slots: DomBookingSlots = {
  slotButton: ({ formattedTime }, host) => {
    host.textContent = `${formattedTime} · $120`;
  },
};

export class BookingPage {
  readonly bookingOptions = { schedule, slots };
}
<script lang="ts">
  import type { DomBookingSlots } from '@dayflow-pro/appointment-schedule/svelte';

  const slots: DomBookingSlots = {
    slotButton: ({ formattedTime }, host) => {
      host.textContent = `${formattedTime} · $120`;
    },
  };

  const options = { schedule, slots };
</script>

<div use:appointmentBooking={options}></div>

AppointmentBookingSlots 定义下表中的区域。导出的参数类型包括 SidebarSlotArgsMeetingInfoSlotArgsMonthPickerSlotArgsSlotListSlotArgsToolbarSlotArgsSlotButtonSlotArgsDayCellSlotArgsSelectedSummarySlotArgs

插槽用途主要参数
sidebar替换包含会议信息和迷你月历的完整侧栏。schedulepresentationlocationlayoutdefaultContent
meetingInfo替换会议信息面板。schedulepresentationlocationtimeZonedefaultContent
meetingInfoFooter在会议信息面板下方添加内容。schedulepresentationlocationtimeZone
monthPicker替换主区域和侧栏中的月份选择器。selectedDateavailableDatesonSelectDatecompactdefaultContent
slotList替换 calendar-day-slots 中当前日期的时间列表。dateslotsselectedSlotIdonSelectSlotdefaultContent
toolbar替换 multi-day-slotsweek-overlay 中的工具栏。layouttimeZonetimeFormatrangeLabeldefaultContent
toolbarExtra在工具栏控件右侧添加内容。layouttimeZonetimeFormatrangeLabel
slotButton替换每个可预约时间按钮内部的内容。slotformattedTimeisSelecteddisableddefaultContent
dayCell替换月份选择器中每个日期单元格的内容。date、月份/可用/选中状态、defaultContent
emptyDay当前日期没有可预约时间时显示。date
emptyRange当前日期范围没有可预约时间时显示。
loading替换加载状态。
error替换错误状态,并接收错误对象和可选的重试操作。errorretry
selectedSummary选择时间后,在预约内容下方添加摘要。slotformattedDateformattedRangetimeZone

Headless:自建 UI

如果三种布局都不合适,可以完全跳过组件。引擎是一个接收 schedule 并返回 slots 的纯函数。包里的其他代码不会进入你的打包产物。

import { generateSlots } from '@dayflow-pro/appointment-schedule/engine';
import { Temporal } from 'temporal-polyfill';

const slots = generateSlots({
  schedule,
  rangeStart: Temporal.PlainDate.from('2026-08-03'),
  rangeEnd: Temporal.PlainDate.from('2026-08-09'),
  busyIntervals,
  displayTimeZone: 'Europe/London',
  now: Temporal.Now.zonedDateTimeISO('Australia/Sydney'),
});
// slots: { id, scheduleId, start, end, displayTimeZone }[]

输入类型是 SlotQueryrangeStartrangeEnd 是包含边界的 Temporal.PlainDatebusyIntervalsattendeeBusyIntervals 接收带 Temporal startendBusyInterval。返回值是 AppointmentSlot[]

引擎不会访问 windowdocument 或系统时区,可以安全地在服务端导入。它还导出了 expandAvailabilityrecurrenceAppliesOnsortBusyIntervalsmergeBusyIntervalshasConflicteventsToBusyIntervals 等组成部分,你可以据此组合自己的流水线。

把预约转换成事件

模块不会写入事件,而是提供一个纯映射函数:

import { createBookingEvent } from '@dayflow-pro/appointment-schedule/engine';

const draft = createBookingEvent({
  slot,
  schedule, // schedule.calendarId 决定归属日历(以及颜色)
  attendee: { name: 'Ada Lovelace' }, // 由你收集,模块从不存储
  conference, // 可选,见「会议地点与会议服务」
  // titleTemplate: ({ attendee }) => `1:1 · ${attendee?.name}`,
});
// → { id, title: 'Meeting with Ada Lovelace', start, end, calendarId, location?, conference?, meta }

calendar.addEvent(draft);

meta 中带有 appointmentScheduleIdappointmentSlotId,可以由事件反查它来自哪个时间段;当 Schedule 配置了地点时,还会带上 appointmentLocationappointmentConference

可访问性

目标为 WCAG 2.2 AA。月历是真正的 role="grid",支持方向键、Home、End、PageUp 和 PageDown 导航;时间段按钮提供 aria-pressed 以及包含日期、时间和时区的无障碍名称;可用、不可用和选中状态从不只依赖颜色传达。

On this page