本指南将构建一个用于预订会议室的日历:时间轴上每个会议室各占一行,预订可以拖到合适的位置,有一条规则防止同一个会议室被预订两次,还有一个文件能把预订添加到任何日历中。它用的是 @needless-ui/angular 中的日程表,与 Needless UI 的其他部分一样采用 MIT 许可证,因此资源时间轴在任何应用中都能免费使用。
会议室与预订
会议室就是资源:有 id 和 title,其他信息都放在 data 中。预订就是日程,带有普通的 ISO 时间(例如 2026-10-23T09:00),并在 resource 中存放所属会议室的 id。一个预订也可以用列表同时包含多个会议室或多名人员。
每个会议室一行
把两者连同 view="timeline" 一起传给 nui-scheduler。dayStart 和 dayEnd 把一天限定在会议室的开放时间内,snapMinutes 让预订以 15 分钟为单位移动,businessHours 则用灰底标出会议室关闭的时间。nuiSchedulerResource 负责绘制每一行的表头。
<nui-scheduler
[events]="bookings()"
[resources]="rooms"
view="timeline"
dayStart="8"
dayEnd="19"
snapMinutes="15"
[businessHours]="hours"
editable
selectable
[allow]="free"
(slotSelect)="book($event)"
(eventChange)="move($event)"
(rangeChange)="range.set($event)"
>
<ng-template nuiSchedulerResource let-room>{{ room.title }}</ng-template>
</nui-scheduler>
<button nuiButton type="button" (click)="download()">Add to calendar</button>预订与移动
日程表从不自行修改你的数据,只会报告用户做了什么。在会议室的行上拖动会选择一段时间,slotSelect 会连同会议室一起报告这段时间。拖动预订或其末端时,eventChange 会报告它的新时间;如果移到了另一个会议室,还会报告新的会议室。更新 signal,时间轴就会随之变化。
import { Component, signal } from '@angular/core';
import { NuiButton } from '@needless-ui/angular/button';
import {
NuiScheduler,
type NuiSchedulerChange,
type NuiSchedulerEvent,
NuiSchedulerResourceTemplate,
type NuiSchedulerSlot,
} from '@needless-ui/angular/scheduler';
import { downloadIcs } from './booking-ics';
@Component({
selector: 'app-bookings',
imports: [NuiButton, NuiScheduler, NuiSchedulerResourceTemplate],
templateUrl: './booking.html',
})
export class Bookings {
/** Rooms are resources; bookings are events, with their room in `resource`. */
protected readonly rooms = [
{ id: 'atlas', title: 'Atlas' },
{ id: 'boreal', title: 'Boreal' },
];
protected readonly bookings = signal<NuiSchedulerEvent[]>([]);
/** Open on weekdays from 8:00 to 19:00: other time is shaded. */
protected readonly hours = [{ days: [1, 2, 3, 4, 5], start: '08:00', end: '19:00' }];
/** The days on screen: load their bookings when it changes. */
protected readonly range = signal<{ start: string; end: string } | null>(null);
protected book({ start, end, resource }: NuiSchedulerSlot): void {
const booking = { id: crypto.randomUUID(), title: 'New booking', start, end, resource };
this.bookings.update((all) => [...all, booking]);
}
protected move({ event, start, end, resource }: NuiSchedulerChange): void {
this.bookings.update((all) =>
all.map((b) =>
b.id === event.id ? { ...b, start, end, resource: resource ?? b.resource } : b,
),
);
}
/** A room holds one booking at a time. */
protected readonly free = (time: NuiSchedulerChange | NuiSchedulerSlot): boolean => {
const id = 'event' in time ? time.event.id : null;
const room = 'event' in time ? (time.resource ?? time.event.resource) : time.resource;
return !this.bookings().some(
(b) => b.id !== id && b.resource === room && b.start < time.end && b.end > time.start,
);
};
protected download(): void {
downloadIcs(this.bookings(), 'bookings');
}
}防止重复预订
创建或移动预订之前,日程表会先询问 allow。它返回 false 时,拖动会显示为红色,松开后也不会有任何变化;用键盘操作时,日程表会播报该时间不可用。本示例会检查同一会议室的其他预订;请在服务器上再检查一次,最终以服务器为准。
加载屏幕上的日期
rangeChange 会报告显示的第一天和最后一天:开始时报告一次,之后用户每次翻页时再报告一次。这样你就可以只获取这些日期的预订,用 httpResource 或任何你喜欢的请求都可以。
添加到日历
nuiSchedulerToIcs() 把预订写成 iCalendar 文件,Google Calendar、Outlook 和 Apple Calendar 都能导入。nuiSchedulerFromIcs() 则把这种文件读回来,并把其他时区的时间换算成用户所在时区的时间。
import { type NuiSchedulerEvent, nuiSchedulerToIcs } from '@needless-ui/angular/scheduler';
/** Saves events as an .ics file, which Google Calendar, Outlook and Apple Calendar import. */
export function downloadIcs(events: readonly NuiSchedulerEvent[], name: string): void {
const file = new Blob([nuiSchedulerToIcs(events, { name })], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = `${name}.ics`;
link.click();
URL.revokeObjectURL(link.href);
}键盘与屏幕阅读器
这一切不用鼠标也能完成。时间轴在 Tab 键顺序中只占一个位置:方向键在时间和会议室之间移动,Shift 加方向键选择时间,按 Enter 键即可预订。在预订上按 Alt 加方向键,可以把它移到另一个时间或会议室。每个预订都是一个按钮,以其标题、会议室、日期和时间命名。