侧边栏插件

侧边栏插件为 DayFlow 日历添加了一个日历管理侧边栏。它包括一个用于日期导航的迷你日历、带可见性开关的日历列表,以及完整的日历 CRUD 操作(创建、重命名、更改颜色、合并、删除、导入/导出)。

安装

安装插件包:

npm install @dayflow/plugin-sidebar
pnpm add @dayflow/plugin-sidebar
yarn add @dayflow/plugin-sidebar
bun add @dayflow/plugin-sidebar

使用

import { useCalendarApp, DayFlowCalendar } from '@dayflow/react';
import { createSidebarPlugin } from '@dayflow/plugin-sidebar';

function MyCalendar() {
  const sidebarPlugin = createSidebarPlugin({
    width: 280,
    createCalendarMode: 'modal',
  });

  const calendar = useCalendarApp({
    views: [
      /* 视图 */
    ],
    plugins: [sidebarPlugin],
  });

  return <DayFlowCalendar calendar={calendar} />;
}
<template>
  <DayFlowCalendar :calendar="calendar" />
</template>

<script setup>
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { createSidebarPlugin } from '@dayflow/plugin-sidebar';

const sidebarPlugin = createSidebarPlugin({
  width: 280,
  createCalendarMode: 'modal',
});

const calendar = useCalendarApp({
  views: [
    /* 视图 */
  ],
  plugins: [sidebarPlugin],
});
</script>
import { Component } from '@angular/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import { createSidebarPlugin } from '@dayflow/plugin-sidebar';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DayFlowCalendarModule],
  template: `<dayflow-calendar [calendar]="calendar"></dayflow-calendar>`
})
export class AppComponent {
  calendar = {
    views: [
      /* 视图 */
    ],
    plugins: [
      createSidebarPlugin({
        width: 280,
        createCalendarMode: 'modal',
      })
    ]
  };
}
<script>
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
  import { createSidebarPlugin } from '@dayflow/plugin-sidebar';

  const sidebarPlugin = createSidebarPlugin({
    width: 280,
    createCalendarMode: 'modal',
  });

  const calendar = useCalendarApp({
    views: [
      /* 视图 */
    ],
    plugins: [sidebarPlugin],
  });
</script>

<DayFlowCalendar {calendar} />

属性参数

属性类型默认值描述
widthnumber | string'240px'侧边栏宽度(如 280'20rem'
miniWidthstring'50px'折叠状态下的侧边栏宽度
initialCollapsedbooleanfalse是否以折叠状态启动侧边栏
createCalendarMode'inline' | 'modal''inline'创建日历表单的显示方式
colorPickerMode'blossom' | 'default''default'使用的颜色选择器组件
onSubscribeCalendar(calendar, events) => Promise<void>undefined成功订阅新日历后的回调
onLoadSubscription(calendar) => Promise<void>undefined针对现有订阅的自定义加载逻辑
onReorder(calendars: CalendarType[]) => void | Promise<void>undefined日历排序变更后的回调
componentsOrder('calendarList' | 'miniCalendar')[]['calendarList', 'miniCalendar']侧边栏组件显示顺序
render(props: CalendarSidebarRenderProps) => TNodeundefined完全自定义侧边栏 UI
renderSidebarHeader(args: SidebarHeaderSlotArgs) => TNodeundefined自定义侧边栏头部渲染器
renderCalendarContextMenu(calendar, onClose) => TNodeundefined日历项的自定义右键菜单渲染器
renderCreateCalendarDialog(props) => TNodeundefined自定义创建日历对话框渲染器

编程式 API

日历渲染完成后,通过 app.getPlugin 获取 SidebarService

import { type SidebarService } from '@dayflow/plugin-sidebar';

const sidebar = app.getPlugin<SidebarService>('sidebar');

sidebar?.collapse(); // 折叠侧边栏
sidebar?.expand(); // 展开侧边栏
sidebar?.setCollapsed(true); // 显式设置状态
console.log(sidebar?.isCollapsed()); // 读取当前状态

SidebarService

方法返回值说明
collapse()void折叠侧边栏
expand()void展开侧边栏
setCollapsed(collapsed: boolean)void显式设置折叠状态
isCollapsed()boolean返回当前的折叠状态

绑定到键盘快捷键

window.addEventListener('keydown', e => {
  if (e.metaKey && e.key === '[') {
    const sidebar = app.getPlugin<SidebarService>('sidebar');
    sidebar?.setCollapsed(!sidebar.isCollapsed());
  }
});

功能

侧边栏插件提供包含颜色点、可见性开关和折叠按钮的侧栏布局。它会自动与日历状态同步,无需额外代码。同时内置了日历管理与右键菜单功能。

内置功能包括:

  • 迷你日历 - 用于快速日期导航的紧凑月视图
  • 日历列表 - 通过彩色复选框切换各个日历的可见性
  • 创建日历 - 使用自定义名称和颜色添加新日历
  • 重命名 / 更改颜色 - 右键点击日历以重命名或更改颜色
  • 日历排序 - 通过在列表中拖拽日历来调整排序。使用 onReorder 回调将新顺序持久化到后端。
  • 合并日历 - 将一个日历的所有事件移动到另一个日历
  • 删除日历 - 带确认步骤的日历删除(也可先合并)
  • 导入 / 导出 - 导入 .ics 文件或将日历导出为 .ics
  • 订阅日历 - 通过 ICS URL 订阅远程日历,已订阅的日历在侧边栏列表中会显示标识图标

在线示例

这一页用两个示例展示侧边栏的不同形态:一个是开箱即用的默认布局,另一个是使用框架(如 React)完全定制的版本。

可以尝试

  • 可见性切换:在两个示例中切换日历可见性,观察 UI 如何瞬时同步
  • 拖拽同步:拖拽或创建事件,侧栏的"即将到来"卡片会实时更新
  • 收起侧栏:折叠侧栏,看看在周视图等高密度布局里如何回收更多空间

订阅日历

侧边栏内置了订阅远程 ICS 日历源的功能。在侧边栏的日历列表或空白区域右键点击打开菜单,选择订阅日历,然后输入任意公开的 .ics 地址即可。

DayFlow 会自动获取该文件、解析事件并创建一个新日历。已订阅的日历在侧边栏列表中会显示一个小图标,以便与本地创建的日历区分。

自动加载与去重

如果你在初始配置中提供了带有 subscription.url 的日历,侧边栏插件会在挂载时自动获取最新事件。

为了防止将本地缓存的事件与新鲜的订阅数据结合时出现视觉重复,DayFlow 实现了基于 ID 的去重机制

  • 如果来自订阅的事件与核心存储中已有的事件具有相同的 id,订阅版本的事件将优先显示。
  • 这确保了你的 UI 始终显示最新信息,而不会在页面刷新后出现“双重”事件。

持久化订阅日历

DayFlow 通过 CalendarType 对象上是否存在 subscription 字段来识别订阅日历。

订阅日历的默认行为:

  • 只读:UI 修改操作(编辑标题、更换日历、删除)会通过 canMutateFromUI() 禁用。
  • 不可拖拽:禁用拖放操作(移动、调整大小)。
  • 隐藏备注:如果事件没有描述,详情面板中的“备注”字段将被隐藏,以保持界面整洁。

如果您希望允许用户修改订阅事件或移动它们,可以通过在日历对象上设置 readOnly: false 来显式覆盖这些默认保护:

const calendar = useCalendarApp({
  calendars: [
    {
      id: 'team-ics',
      name: '团队日历',
      subscription: {
        url: 'https://example.com/calendar.ics',
        status: 'ready',
      },
      // 覆盖默认保护:
      readOnly: false, // 同时启用 UI 修改和拖放操作
      colors: {
        /* ... */
      },
    },
  ],
});

将日历状态保存到后端时,请保留 subscription 字段,并在下次加载时恢复。

注意: DayFlow 不会自动刷新已订阅 of 日历源。如需保持事件最新,请在应用中自行实现定期拉取逻辑,并通过 app.addEvent() / app.removeEvent() 更新事件。

完全自定义侧边栏

你可以通过传入 render 函数到 createSidebarPlugin (React) 或者使用组件插槽/模板 (Vue, Angular, Svelte) 来完全替换侧边栏。

import { useCalendarApp, DayFlowCalendar } from '@dayflow/react';
import {
  createSidebarPlugin,
  type CalendarSidebarRenderProps,
} from '@dayflow/plugin-sidebar';

const CustomSidebar = ({
  app,
  calendars,
  toggleCalendarVisibility,
  isCollapsed,
  setCollapsed,
}: CalendarSidebarRenderProps) => {
  if (isCollapsed) {
    return (
      <div className='p-2'>
        <button onClick={() => setCollapsed(false)}>展开 →</button>
      </div>
    );
  }

  return (
    <aside className='flex h-full flex-col gap-4 p-4 bg-slate-50 border-r'>
      <header className='flex items-center justify-between'>
        <h3 className='font-semibold'>我的工作区</h3>
        <button onClick={() => setCollapsed(true)}>收起 ←</button>
      </header>

      <nav className='space-y-1'>
        {calendars.map(calendar => (
          <label
            key={calendar.id}
            className='flex items-center gap-2 cursor-pointer'
          >
            <input
              type='checkbox'
              checked={calendar.isVisible}
              onChange={e =>
                toggleCalendarVisibility(calendar.id, e.target.checked)
              }
            />
            <span
              className='w-3 h-3 rounded-full'
              style={{ backgroundColor: calendar.colors.lineColor }}
            />
            {calendar.name}
          </label>
        ))}
      </nav>

      <div className='mt-auto pt-4 border-t text-xs text-slate-500'>
        总事件数:{app.getEvents().length}
      </div>
    </aside>
  );
};

function App() {
  const calendar = useCalendarApp({
    views: [/* 视图 */],
    plugins: [
      createSidebarPlugin({
        render: props => <CustomSidebar {...props} />,
      }),
    ],
  });

  return <DayFlowCalendar calendar={calendar} />;
}
<template>
  <DayFlowCalendar :calendar="calendar">
    <!-- 使用作用域插槽 'sidebar' 覆盖 -->
    <template #sidebar="args">
      <aside v-if="!args.isCollapsed" class="flex h-full flex-col gap-4 p-4 bg-slate-50 border-r">
        <header class="flex items-center justify-between">
          <h3 class="font-semibold">我的工作区</h3>
          <button @click="args.setCollapsed(true)">收起 ←</button>
        </header>

        <nav class="space-y-1">
          <label
            v-for="cal in args.calendars"
            :key="cal.id"
            class="flex items-center gap-2 cursor-pointer"
          >
            <input
              type="checkbox"
              :checked="cal.isVisible"
              @change="args.toggleCalendarVisibility(cal.id, !cal.isVisible)"
            />
            <span
              class="w-3 h-3 rounded-full"
              :style="{ backgroundColor: cal.colors.lineColor }"
            />
            {{ cal.name }}
          </label>
        </nav>

        <div class="mt-auto pt-4 border-t text-xs text-slate-500">
          总事件数:{{ args.app.getEvents().length }}
        </div>
      </aside>
      <div v-else class="p-2">
        <button @click="args.setCollapsed(false)">展开 →</button>
      </div>
    </template>
  </DayFlowCalendar>
</template>

<script setup>
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { createSidebarPlugin } from '@dayflow/plugin-sidebar';

const calendar = useCalendarApp({
  views: [/* 视图 */],
  plugins: [createSidebarPlugin()]
});
</script>
<!-- app.component.html -->
<dayflow-calendar
  [calendar]="calendar"
  [sidebar]="customSidebar"
>
</dayflow-calendar>

<ng-template #customSidebar let-args>
  <aside *ngIf="!args.isCollapsed; else collapsedView" class="flex h-full flex-col gap-4 p-4 bg-slate-50 border-r">
    <header class="flex items-center justify-between">
      <h3 class="font-semibold">我的工作区</h3>
      <button (click)="args.setCollapsed(true)">收起 ←</button>
    </header>

    <nav class="space-y-1">
      <label
        *ngFor="let cal of args.calendars"
        class="flex items-center gap-2 cursor-pointer"
      >
        <input
          type="checkbox"
          [checked]="cal.isVisible"
          (change)="args.toggleCalendarVisibility(cal.id, !cal.isVisible)"
        />
        <span
          class="w-3 h-3 rounded-full"
          [style.background-color]="cal.colors.lineColor"
        ></span>
        {{ cal.name }}
      </label>
    </nav>

    <div class="mt-auto pt-4 border-t text-xs text-slate-500">
      总事件数:{{ args.app.getEvents().length }}
    </div>
  </aside>

  <ng-template #collapsedView>
    <div class="p-2">
      <button (click)="args.setCollapsed(false)">展开 →</button>
    </div>
  </ng-template>
</ng-template>
<!-- App.svelte -->
<script lang="ts">
  import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
  import { createSidebarPlugin } from '@dayflow/plugin-sidebar';
  import CustomSidebar from './CustomSidebar.svelte';

  const calendar = useCalendarApp({
    views: [/* 视图 */],
    plugins: [createSidebarPlugin()]
  });
</script>

<DayFlowCalendar {calendar} sidebar={CustomSidebar} />

<!-- CustomSidebar.svelte -->
<script lang="ts">
  import type { CalendarSidebarRenderProps } from '@dayflow/core';

  let {
    app,
    calendars,
    isCollapsed,
    setCollapsed,
    toggleCalendarVisibility,
  } = $props<CalendarSidebarRenderProps>();
</script>

{#if isCollapsed}
  <div class="p-2">
    <button onclick={() => setCollapsed(false)}>展开 →</button>
  </div>
{:else}
  <aside class="flex h-full flex-col gap-4 p-4 bg-slate-50 border-r">
    <header class="flex items-center justify-between">
      <h3 class="font-semibold">我的工作区</h3>
      <button onclick={() => setCollapsed(true)}>收起 ←</button>
    </header>

    <nav class="space-y-1">
      {#each calendars as cal (cal.id)}
        <label class="flex items-center gap-2 cursor-pointer">
          <input
            type="checkbox"
            checked={cal.isVisible}
            onchange={(e) => toggleCalendarVisibility(cal.id, e.currentTarget.checked)}
          />
          <span
            class="w-3 h-3 rounded-full"
            style:background-color={cal.colors.lineColor}
          ></span>
          {cal.name}
        </label>
      {/each}
    </nav>

    <div class="mt-auto pt-4 border-t text-xs text-slate-500">
      总事件数:{app.getEvents().length}
    </div>
  </aside>
{/if}

自定义右键菜单

可以替换日历项的默认右键菜单:

createSidebarPlugin({
  renderCalendarContextMenu: (calendar, onClose) => (
    <div className='bg-white shadow-lg border rounded p-2'>
      <button
        onClick={() => {
          console.log('Custom action');
          onClose();
        }}
      >
        Custom Action for {calendar.name}
      </button>
    </div>
  ),
});
<template>
  <DayFlowCalendar :calendar="calendar">
    <template #calendarContextMenu="{ calendar, onClose }">
      <div class="bg-white shadow-lg border rounded p-2">
        <button @click="onClose()">
          针对 {{ calendar.name }} 的自定义操作
        </button>
      </div>
    </template>
  </DayFlowCalendar>
</template>
<dayflow-calendar
  [calendar]="calendar"
  [calendarContextMenu]="contextMenu"
>
</dayflow-calendar>

<ng-template #contextMenu let-args>
  <div class="bg-white shadow-lg border rounded p-2">
    <button (click)="args.onClose()">
      针对 {{ args.calendar.name }} 的自定义操作
    </button>
  </div>
</ng-template>
<DayFlowCalendar {calendar} calendarContextMenu={CustomContextMenu} />

<!-- CustomContextMenu.svelte -->
<script lang="ts">
  import type { CalendarContextMenuSlotArgs } from '@dayflow/core';
  let { calendar, onClose } = $props<CalendarContextMenuSlotArgs>();
</script>

<div class="bg-white shadow-lg border rounded p-2">
  <button onclick={() => { console.log('Custom action'); onClose(); }}>
    Custom Action for {calendar.name}
  </button>
</div>

自定义创建日历对话框

可以替换默认的创建日历对话框:

createSidebarPlugin({
  renderCreateCalendarDialog: ({ onCreate, onClose }) => (
    <MyCustomDialog onSave={onCreate} onCancel={onClose} />
  ),
});
<template>
  <DayFlowCalendar :calendar="calendar">
    <template #createCalendarDialog="{ onCreate, onClose }">
      <MyCustomDialog @save="onCreate" @cancel="onClose" />
    </template>
  </DayFlowCalendar>
</template>
<dayflow-calendar
  [calendar]="calendar"
  [createCalendarDialog]="customDialog"
>
</dayflow-calendar>

<ng-template #customDialog let-args>
  <my-custom-dialog
    (save)="args.onCreate($event)"
    (cancel)="args.onClose()"
  >
  </my-custom-dialog>
</ng-template>
<DayFlowCalendar {calendar} createCalendarDialog={CustomDialog} />

<!-- CustomDialog.svelte -->
<script lang="ts">
  import type { CreateCalendarDialogProps } from '@dayflow/core';
  let { onCreate, onClose } = $props<CreateCalendarDialogProps>();
</script>

<div class="dialog">
  <!-- 自定义对话框标记 -->
  <button onclick={() => onCreate({ id: 'new', name: 'New' })}>创建</button>
  <button onclick={onClose}>取消</button>
</div>

自定义侧边栏头部

可以替换默认的侧边栏头部(包含“日历”标题和折叠按钮的区域):

createSidebarPlugin({
  renderSidebarHeader: ({ isCollapsed, onCollapseToggle }) => (
    <div className='flex items-center justify-between p-4 border-b'>
      {!isCollapsed && <span className='font-bold text-lg'>My Calendars</span>}
      <button
        onClick={onCollapseToggle}
        className='p-1 hover:bg-slate-100 rounded'
      >
        {isCollapsed ? '→' : '←'}
      </button>
    </div>
  ),
});
<template>
  <DayFlowCalendar :calendar="calendar">
    <template #sidebarHeader="{ isCollapsed, onCollapseToggle }">
      <div class="flex items-center justify-between p-4 border-b">
        <span v-if="!isCollapsed" class="font-bold text-lg">我的日历</span>
        <button
          @click="onCollapseToggle"
          class="p-1 hover:bg-slate-100 rounded"
        >
          {{ isCollapsed ? '→' : '←' }}
        </button>
      </div>
    </template>
  </DayFlowCalendar>
</template>
<dayflow-calendar
  [calendar]="calendar"
  [sidebarHeader]="headerTemplate"
>
</dayflow-calendar>

<ng-template #headerTemplate let-args>
  <div class="flex items-center justify-between p-4 border-b">
    <span *ngIf="!args.isCollapsed" class="font-bold text-lg">我的日历</span>
    <button
      (click)="args.onCollapseToggle()"
      class="p-1 hover:bg-slate-100 rounded"
    >
      {{ args.isCollapsed ? '→' : '←' }}
    </button>
  </div>
</ng-template>
<DayFlowCalendar {calendar} sidebarHeader={CustomHeader} />

<!-- CustomHeader.svelte -->
<script lang="ts">
  import type { SidebarHeaderSlotArgs } from '@dayflow/core';
  let { isCollapsed, onCollapseToggle } = $props<SidebarHeaderSlotArgs>();
</script>

<div class="custom-header">
  {#if !isCollapsed}
    <span>我的日历</span>
  {/if}
  <button onclick={onCollapseToggle}>
    {isCollapsed ? '→' : '←'}
  </button>
</div>

On this page