REAL SHOWCASE SOURCEscheduler-interaction-events

3 个文件 · 1145 行 · TypeScript

// ===== showcase/src/examples/scheduler/interaction_events.ts ===== import {  RenderButton,  RenderText,  RenderWrapPanel,  type SchedulerView,} from 'ds-ui'import {  createSchedulerDateNavigation,  createSchedulerEditingSnapshot,  createSchedulerStage,  createShowcaseScheduler,  schedulerChangeText,  schedulerSelectionText,  schedulerShowcaseDomain,  schedulerWeekStartEpochMs,  type SchedulerDateNavigation,} from './shared' export function createSchedulerInteractionEventsExample() {  const initialDateEpochMs =    schedulerWeekStartEpochMs + 10 * 60 * 60 * 1_000  let currentView: SchedulerView = 'resourceTimelineWeek'  let navigation!: SchedulerDateNavigation  let blockNextViewChange = false  let lastViewChangeIntercepted = false  let viewButton!: RenderButton  let blockNextDelete = true  const status = new RenderText(    '事件日志:等待选择、激活、视图切换、导航或本地数据提交。',    { role: 'secondary' },  )  const scheduler = createShowcaseScheduler({    data: createSchedulerEditingSnapshot(),    domain: schedulerShowcaseDomain,    queryKey: 'showcase-scheduler-interaction-events',    title: '资源',    view: 'resourceTimelineWeek',    zoomPreset: 'standard',    initialDateEpochMs,    editable: true,    onBeforeSelectionChange: event => {      if (event.current?.disabled) {        event.preventDefault({          code: 'disabled-demo-item',          message: '禁用 Event 不参与业务选择。',        })      }    },    onSelectionChange: event => {      status.text =        `after selection (${event.source}/${event.reason}):` +        schedulerSelectionText(event.current)    },    onBeforeEventActivate: event => {      if (event.item.title.includes('审计')) {        event.preventDefault({          code: 'audit-review-only',          message: '审计排期只允许选择查看,不执行默认激活动作。',        })        status.text = 'before activation 已拦截:审计排期只允许选择查看。'      }    },    onEventActivate: event => {      status.text =        `after activation:${event.item.title} · ${event.source}/${event.surface}`    },    onBeforeViewChange: event => {      if (!blockNextViewChange) return      blockNextViewChange = false      lastViewChangeIntercepted = true      event.preventDefault({        code: 'demo-view-policy',        message: '宿主同步规则拦截了这一次视图切换。',      })      status.text =        `before view 已拦截:${event.previous} → ${event.current}`    },    onViewChange: event => {      currentView = event.current      navigation?.setView(currentView)      viewButton.label = viewToggleLabel(currentView)      viewButton.markNeedsLayout()      status.text =        `after view:${event.previous} → ${event.current}`    },    onBeforeNavigate: event => {      status.text =        `before navigate:${event.request.kind} · ${event.source}`    },    onNavigate: event => {      status.text =        `after navigate:${event.request.kind} · 可见资源 ${event.viewport.visibleResourceIds.length}`    },    onBeforeDataChange: event => {      if (event.change.action !== 'remove' || !blockNextDelete) return      blockNextDelete = false      event.preventDefault({        code: 'demo-delete-policy',        message: '演示规则拦截第一次删除,第二次允许提交。',      })      status.text = 'before data change 已拦截:第一次删除保持零提交。'    },    onDataChange: change => {      status.text = `after data change:${schedulerChangeText(change)}`    },  })  navigation = createSchedulerDateNavigation({    scheduler,    domain: schedulerShowcaseDomain,    initialDateEpochMs,    initialView: currentView,  })   const controls = new RenderWrapPanel({ spacing: 8, runSpacing: 8 })  controls.addChild(new RenderButton({    label: '拦截下一次视图切换',    variant: 'primary',    onClick: () => {      blockNextViewChange = true      status.text =        `已设置一次性 before view 拦截,请点击“${viewToggleLabel(currentView)}”。`    },  }))  viewButton = new RenderButton({    label: viewToggleLabel(currentView),    onClick: () => {      lastViewChangeIntercepted = false      const targetView = currentView === 'agenda'        ? 'resourceTimelineWeek'        : 'agenda'      const changed = scheduler.setView(targetView)      if (!changed && !lastViewChangeIntercepted) {        blockNextViewChange = false        status.text = '视图切换未发生,当前状态保持不变。'      }    },  })  controls.addChild(viewButton)  controls.addChild(new RenderButton({    label: '定位会议室 08',    onClick: () => scheduler.scrollToResource('room-08', 'center'),  }))  controls.addChild(new RenderButton({    label: 'API 删除客户评审',    variant: 'danger',    onClick: () => {      const result = scheduler.removeEvent('editable-customer-review')      if (!result.ok) {        status.text =          `删除未提交:${result.reason}` +          (result.rejection?.message ? ` · ${result.rejection.message}` : '')      }    },  }))   return createSchedulerStage({    title: 'Scheduler 业务事件与同步拦截',    description:      'before/after 事件让宿主观察或取消选择、激活、视图、导航和本地数据提交,不暴露内部布局对象。',    scheduler,    navigation: navigation.controls,    controls,    status,  })} function viewToggleLabel(view: SchedulerView): string {  return view === 'agenda'    ? '切换到 Week'    : '切换到 Agenda'} // ===== showcase/src/examples/scheduler/date_navigation.ts ===== import {  RenderButton,  RenderDatePicker,  RenderScheduler,  RenderText,  RenderWrapPanel,  type SchedulerInstantRange,  type SchedulerViewportChangeEvent,  type SchedulerView,  type SchedulerViewport,} from 'ds-ui' const schedulerDayMs = 24 * 60 * 60 * 1_000const viewportListeners = new WeakMap<  RenderScheduler,  Set<(event: SchedulerViewportChangeEvent) => void>>() export interface SchedulerDateNavigation {  readonly controls: RenderWrapPanel  readonly datePicker: RenderDatePicker  readonly dateEpochMs: number  readonly view: SchedulerView  navigateToDate(epochMs: number): boolean  reset(): boolean  setView(view: SchedulerView): void} export function createSchedulerDateNavigation(options: {  scheduler: RenderScheduler  domain: SchedulerInstantRange  initialDateEpochMs: number  initialView: SchedulerView}): SchedulerDateNavigation {  const controls = new RenderWrapPanel({ spacing: 8, runSpacing: 8 })  let view = options.initialView  let dateEpochMs = clampDate(options.initialDateEpochMs, options.domain)  let viewport: SchedulerViewport | null = null   const previousButton = new RenderButton({    label: '',    onClick: () => navigateToDate(shiftDate(dateEpochMs, view, -1)),  })  const datePicker = new RenderDatePicker({    value: formatIsoDate(dateEpochMs),    prefixText: '日期',    onChange: value => {      const selected = parseIsoDate(value, dateEpochMs)      if (selected === null || navigateToDate(selected)) return      datePicker.value = formatIsoDate(dateEpochMs)    },  })  datePicker.width = 168  const resetButton = new RenderButton({    label: '回到演示基准日',    onClick: () => navigateToDate(options.initialDateEpochMs),  })  const nextButton = new RenderButton({    label: '',    onClick: () => navigateToDate(shiftDate(dateEpochMs, view, 1)),  })  const rangeText = new RenderText('', { role: 'secondary' })   controls.addChild(previousButton)  controls.addChild(datePicker)  controls.addChild(resetButton)  controls.addChild(nextButton)  controls.addChild(rangeText)   function navigateToDate(epochMs: number): boolean {    const next = clampDate(epochMs, options.domain)    const changed = options.scheduler.scrollToDate(next, 'center')    if (!changed && next !== dateEpochMs) {      if (canAdoptStationaryBoundaryDate(        dateEpochMs,        next,        viewport,        options.domain,      )) {        dateEpochMs = next        refresh()        return true      }      refresh()      return false    }    dateEpochMs = next    refresh()    return changed  }   function refresh(): void {    datePicker.value = formatIsoDate(dateEpochMs)    const labels = navigationLabels(view)    updateButtonLabel(previousButton, labels.previous)    updateButtonLabel(nextButton, labels.next)    previousButton.disabled =      viewportTouchesDomainStart(viewport, options.domain) ||      clampDate(shiftDate(dateEpochMs, view, -1), options.domain) ===        dateEpochMs    nextButton.disabled =      viewportTouchesDomainEnd(viewport, options.domain) ||      clampDate(shiftDate(dateEpochMs, view, 1), options.domain) ===        dateEpochMs    resetButton.disabled =      dateEpochMs === clampDate(options.initialDateEpochMs, options.domain)    rangeText.text = navigationRangeText(dateEpochMs, view, options.domain)  }   const navigation: SchedulerDateNavigation = {    controls,    datePicker,    get dateEpochMs() {      return dateEpochMs    },    get view() {      return view    },    navigateToDate,    reset: () => navigateToDate(options.initialDateEpochMs),    setView: nextView => {      if (view === nextView) return      view = nextView      refresh()    },  }  subscribeViewport(options.scheduler, event => {    viewport = event.current    view = viewport.view    const range = viewport.visibleTimeRange    if (      range !== null &&      (event.source === 'pointer' || event.source === 'keyboard')    ) {      dateEpochMs = clampDate(        (range.startEpochMs + range.endEpochMs) / 2,        options.domain,      )    }    refresh()  })  refresh()  return navigation} export function notifySchedulerDateNavigationViewport(  scheduler: RenderScheduler,  event: SchedulerViewportChangeEvent,): void {  viewportListeners.get(scheduler)?.forEach(listener => listener(event))} function subscribeViewport(  scheduler: RenderScheduler,  listener: (event: SchedulerViewportChangeEvent) => void,): void {  let listeners = viewportListeners.get(scheduler)  if (!listeners) {    listeners = new Set()    viewportListeners.set(scheduler, listeners)  }  listeners.add(listener)} function canAdoptStationaryBoundaryDate(  currentEpochMs: number,  nextEpochMs: number,  viewport: SchedulerViewport | null,  domain: SchedulerInstantRange,): boolean {  if (nextEpochMs < currentEpochMs) {    return viewportTouchesDomainStart(viewport, domain)  }  if (nextEpochMs > currentEpochMs) {    return viewportTouchesDomainEnd(viewport, domain)  }  return false} function viewportTouchesDomainStart(  viewport: SchedulerViewport | null,  domain: SchedulerInstantRange,): boolean {  const range = viewport?.visibleTimeRange  return range !== null && range !== undefined &&    range.startEpochMs <= domain.startEpochMs} function viewportTouchesDomainEnd(  viewport: SchedulerViewport | null,  domain: SchedulerInstantRange,): boolean {  const range = viewport?.visibleTimeRange  return range !== null && range !== undefined &&    range.endEpochMs >= domain.endEpochMs} function navigationLabels(view: SchedulerView): {  readonly previous: string  readonly next: string} {  if (view === 'resourceTimelineDay') {    return { previous: '上一天', next: '下一天' }  }  if (view === 'resourceTimelineMonth') {    return { previous: '上一月', next: '下一月' }  }  return { previous: '上一周', next: '下一周' }} function shiftDate(  epochMs: number,  view: SchedulerView,  direction: -1 | 1,): number {  const value = new Date(epochMs)  if (view === 'resourceTimelineMonth') {    const day = value.getUTCDate()    const month = new Date(Date.UTC(      value.getUTCFullYear(),      value.getUTCMonth() + direction,      1,      value.getUTCHours(),      value.getUTCMinutes(),      value.getUTCSeconds(),      value.getUTCMilliseconds(),    ))    const lastDay = new Date(Date.UTC(      month.getUTCFullYear(),      month.getUTCMonth() + 1,      0,    )).getUTCDate()    month.setUTCDate(Math.min(day, lastDay))    return month.getTime()  }  const days = view === 'resourceTimelineDay' ? 1 : 7  value.setUTCDate(value.getUTCDate() + direction * days)  return value.getTime()} function navigationRangeText(  epochMs: number,  view: SchedulerView,  domain: SchedulerInstantRange,): string {  if (view === 'resourceTimelineDay') {    return `当前日期 ${formatIsoDate(epochMs)}`  }  let startEpochMs = epochMs  let endEpochMs = epochMs  if (view === 'resourceTimelineMonth') {    const value = new Date(epochMs)    startEpochMs = Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1)    endEpochMs = Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0)  } else {    const mondayOffset = (new Date(epochMs).getUTCDay() + 6) % 7    startEpochMs = epochMs - mondayOffset * schedulerDayMs    endEpochMs = startEpochMs + 6 * schedulerDayMs  }  startEpochMs = clampDate(startEpochMs, domain)  endEpochMs = clampDate(endEpochMs, domain)  const prefix = view === 'agenda' ? 'Agenda 范围' : '当前范围'  return `${prefix} ${formatIsoDate(startEpochMs)} — ` +    formatIsoDate(endEpochMs)} function clampDate(  epochMs: number,  domain: SchedulerInstantRange,): number {  const maxEpochMs = Math.max(domain.startEpochMs, domain.endEpochMs - 1)  if (!Number.isFinite(epochMs)) return domain.startEpochMs  if (epochMs >= domain.startEpochMs && epochMs <= maxEpochMs) {    return epochMs  }  const source = new Date(epochMs)  const boundary = new Date(    epochMs < domain.startEpochMs ? domain.startEpochMs : maxEpochMs,  )  const sameTimeOnBoundaryDate = Date.UTC(    boundary.getUTCFullYear(),    boundary.getUTCMonth(),    boundary.getUTCDate(),    source.getUTCHours(),    source.getUTCMinutes(),    source.getUTCSeconds(),    source.getUTCMilliseconds(),  )  return Math.min(    Math.max(sameTimeOnBoundaryDate, domain.startEpochMs),    maxEpochMs,  )} function formatIsoDate(epochMs: number): string {  const value = new Date(epochMs)  return [    String(value.getUTCFullYear()).padStart(4, '0'),    String(value.getUTCMonth() + 1).padStart(2, '0'),    String(value.getUTCDate()).padStart(2, '0'),  ].join('-')} function parseIsoDate(  value: string,  timeSourceEpochMs: number,): number | null {  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)  if (!match) return null  const source = new Date(timeSourceEpochMs)  const result = Date.UTC(    Number(match[1]),    Number(match[2]) - 1,    Number(match[3]),    source.getUTCHours(),    source.getUTCMinutes(),    source.getUTCSeconds(),    source.getUTCMilliseconds(),  )  const parsed = new Date(result)  if (    parsed.getUTCFullYear() !== Number(match[1]) ||    parsed.getUTCMonth() !== Number(match[2]) - 1 ||    parsed.getUTCDate() !== Number(match[3])  ) {    return null  }  return result} function updateButtonLabel(button: RenderButton, label: string): void {  if (button.label === label) return  button.label = label  button.markNeedsLayout()} // ===== showcase/src/examples/scheduler/shared.ts ===== import {  RenderPageHeader,  RenderScheduler,  RenderStackPanel,  RenderText,  type RenderBox,  type RenderSchedulerOptions,  type SchedulerAssignment,  type SchedulerCreateEventContext,  type SchedulerDataChange,  type SchedulerDataSnapshot,  type SchedulerEvent,  type SchedulerEventDraft,  type SchedulerInstantRange,  type SchedulerKey,  type SchedulerReadonlyItem,  type SchedulerResource,  type SchedulerTimeRange,} from 'ds-ui'import {  notifySchedulerDateNavigationViewport,} from './date_navigation' export {  createSchedulerDateNavigation,  type SchedulerDateNavigation,} from './date_navigation' export const schedulerHourMs = 60 * 60 * 1_000export const schedulerDayMs = 24 * schedulerHourMsexport const schedulerWeekStartEpochMs = Date.UTC(2026, 6, 27)export const schedulerNowEpochMs = Date.UTC(2026, 6, 28, 10, 30) export const schedulerShowcaseDomain: SchedulerInstantRange = Object.freeze({  startEpochMs: Date.UTC(2026, 6, 1),  endEpochMs: Date.UTC(2026, 9, 1),}) export const schedulerLargeDomain = schedulerShowcaseDomain const resourceColors = [  '#2563eb',  '#0891b2',  '#059669',  '#65a30d',  '#ca8a04',  '#ea580c',  '#dc2626',  '#db2777',  '#9333ea',  '#4f46e5',  '#0f766e',  '#475569',] as const export interface SchedulerStageOptions {  title: string  description: string  scheduler: RenderScheduler  navigation?: RenderBox  controls?: RenderBox  status?: RenderText} export function createSchedulerStage(options: SchedulerStageOptions): RenderStackPanel {  const panel = new RenderStackPanel({    orientation: 'vertical',    spacing: 10,    crossAxisAlignment: 'stretch',    padding: 16,  })  panel.addChild(new RenderPageHeader({    title: options.title,    description: options.description,  }))  if (options.navigation) panel.addChild(options.navigation)  if (options.controls) panel.addChild(options.controls)  options.scheduler.minHeight = 330  panel.addChild(options.scheduler, 1)  if (options.status) panel.addChild(options.status)  return panel} export function createSchedulerOverviewSnapshot(  options: {    resourceCount?: number    routineEventsPerResource?: number    denseEventCount?: number  } = {},): SchedulerDataSnapshot {  const resourceCount = options.resourceCount ?? 12  const routineEventsPerResource = options.routineEventsPerResource ?? 3  const denseEventCount = options.denseEventCount ?? 8  const resources = createResources(resourceCount, 2)  const events: SchedulerEvent[] = []  const assignments: SchedulerAssignment[] = []  const weekMs = 7 * schedulerDayMs  const firstWeekOffset = Math.floor(    (schedulerShowcaseDomain.startEpochMs - schedulerWeekStartEpochMs) /      weekMs,  )  const lastWeekOffset = Math.ceil(    (schedulerShowcaseDomain.endEpochMs - schedulerWeekStartEpochMs) /      weekMs,  ) - 1   for (let resourceIndex = 0; resourceIndex < resources.length; resourceIndex += 1) {    const resource = resources[resourceIndex]!    for (      let weekOffset = firstWeekOffset;      weekOffset <= lastWeekOffset;      weekOffset += 1    ) {      for (        let occurrence = 0;        occurrence < routineEventsPerResource;        occurrence += 1      ) {        const eventId = weekOffset === 0          ? `plan-${resourceIndex + 1}-${occurrence + 1}`          : `plan-${resourceIndex + 1}-${occurrence + 1}-week-${weekOffset}`        const dayIndex = (resourceIndex + occurrence * 2) % 7        const startHour = 8 + (resourceIndex + occurrence) % 8        const startEpochMs =          schedulerWeekStartEpochMs +          weekOffset * weekMs +          dayIndex * schedulerDayMs +          startHour * schedulerHourMs +          (occurrence % 2) * 30 * 60 * 1_000        if (          startEpochMs < schedulerShowcaseDomain.startEpochMs ||          startEpochMs >= schedulerShowcaseDomain.endEpochMs        ) {          continue        }        events.push({          id: eventId,          title: `业务排期 ${resourceIndex + 1}-${occurrence + 1}`,          time: {            kind: 'timed',            range: {              startEpochMs,              endEpochMs:                startEpochMs +                (60 + ((resourceIndex + occurrence) % 3) * 30) *                  60 *                  1_000,            },          },          version: 1,        })        assignments.push({          id: `assignment-${eventId}`,          eventId,          resourceId: resource.id,          version: 1,        })      }    }  }   const readonlyEvent = createTimedEvent({    id: 'readonly-audit',    title: '只读 · 审计复核',    dayIndex: 0,    startHour: 9,    durationMinutes: 90,    readOnly: true,  })  events.push(readonlyEvent)  assignments.push({    id: 'assignment-readonly-audit',    eventId: readonlyEvent.id,    resourceId: 'room-02',  })   const disabledEvent = createTimedEvent({    id: 'disabled-maintenance',    title: '禁用 · 设备维护',    dayIndex: 2,    startHour: 10,    durationMinutes: 120,    disabled: true,  })  events.push(disabledEvent)  assignments.push({    id: 'assignment-disabled-maintenance',    eventId: disabledEvent.id,    resourceId: 'room-03',  })   events.push({    id: 'all-day-upgrade',    title: '全天 · 系统升级窗口',    time: {      kind: 'all-day',      range: {        start: { year: 2026, month: 7, day: 30 },        endExclusive: { year: 2026, month: 8, day: 1 },      },    },    readOnly: true,  })  assignments.push({    id: 'assignment-all-day-upgrade',    eventId: 'all-day-upgrade',    resourceId: 'room-04',  })   appendDenseEvents(    events,    assignments,    denseEventCount,    'room-01',    schedulerWeekStartEpochMs + schedulerDayMs + 10 * schedulerHourMs,  )   return {    resources,    events,    assignments,    timeRanges: createWeekTimeRanges(resources),  }} export function createSchedulerEditingSnapshot(): SchedulerDataSnapshot {  const snapshot = createSchedulerOverviewSnapshot({    resourceCount: 8,    routineEventsPerResource: 1,    denseEventCount: 0,  })  return {    ...snapshot,    resources: snapshot.resources.map((resource, index) => ({      ...resource,      ...(index < 4 ? { readOnly: false } : { readOnly: true }),      title: index < 4        ? `会议室 ${String(index + 1).padStart(2, '0')}(可写)`        : `会议室 ${String(index + 1).padStart(2, '0')}(只读)`,    })),    events: snapshot.events.map(event => event.id === 'plan-1-1'      ? {          ...event,          id: 'editable-customer-review',          title: '可编辑 · 客户评审',        }      : event),    assignments: snapshot.assignments.map(assignment => (      assignment.eventId === 'plan-1-1'        ? {            ...assignment,            id: 'assignment-editable-customer-review',            eventId: 'editable-customer-review',          }        : assignment    )),  }} export function createSchedulerDenseSnapshot(): SchedulerDataSnapshot {  const resources = createResources(6, 1)  const events: SchedulerEvent[] = []  const assignments: SchedulerAssignment[] = []  const timeRanges: SchedulerTimeRange[] = []  const baselineStartEpochMs =    schedulerWeekStartEpochMs + schedulerDayMs + 9 * schedulerHourMs  const dayCount = Math.ceil(    (schedulerShowcaseDomain.endEpochMs -      schedulerShowcaseDomain.startEpochMs) /      schedulerDayMs,  )   for (let dayIndex = 0; dayIndex < dayCount; dayIndex += 1) {    const dayStartEpochMs =      schedulerShowcaseDomain.startEpochMs + dayIndex * schedulerDayMs    const startEpochMs = dayStartEpochMs + 9 * schedulerHourMs    const isBaseline = startEpochMs === baselineStartEpochMs    const dayToken = `day-${dayIndex + 1}`    appendDenseEvents(      events,      assignments,      28,      'room-01',      startEpochMs,      isBaseline ? 'dense' : `dense-${dayToken}`,    )    for (      let resourceIndex = 1;      resourceIndex < resources.length;      resourceIndex += 1    ) {      for (let index = 0; index < 4; index += 1) {        const eventId = isBaseline          ? `secondary-${resourceIndex}-${index}`          : `secondary-${dayToken}-${resourceIndex}-${index}`        const eventStart =          startEpochMs +          resourceIndex * 20 * 60 * 1_000 +          index * 45 * 60 * 1_000        events.push({          id: eventId,          title: `并发协作 ${resourceIndex}-${index + 1}`,          time: {            kind: 'timed',            range: {              startEpochMs: eventStart,              endEpochMs: eventStart + 100 * 60 * 1_000,            },          },          readOnly: index === 2,          disabled: index === 3,        })        assignments.push({          id: `assignment-${eventId}`,          eventId,          resourceId: resources[resourceIndex]!.id,        })      }    }    timeRanges.push({      id: isBaseline        ? 'dense-blocked-range'        : `dense-blocked-range-${dayToken}`,      resourceId: 'room-05',      kind: 'blocked',      time: {        kind: 'timed',        range: {          startEpochMs: startEpochMs + 2 * schedulerHourMs,          endEpochMs: startEpochMs + 5 * schedulerHourMs,        },      },      readOnly: true,    })  }   return {    resources,    events,    assignments,    timeRanges,  }} export function createSchedulerLargeSnapshot(  resourceCount: number,  eventCount: number,): SchedulerDataSnapshot {  const resources = createResources(resourceCount, 0).map(resource => ({    ...resource,    readOnly: true,  }))  const events: SchedulerEvent[] = []  const assignments: SchedulerAssignment[] = []  const timelineDays = Math.floor(    (schedulerLargeDomain.endEpochMs - schedulerLargeDomain.startEpochMs) /      schedulerDayMs,  )   for (let index = 0; index < eventCount; index += 1) {    const eventId = `large-event-${index + 1}`    const resourceIndex = index % resourceCount    const dayIndex = (index * 17 + Math.floor(index / resourceCount)) % timelineDays    const minuteOfDay = 7 * 60 + (index * 37) % (12 * 60)    const startEpochMs =      schedulerLargeDomain.startEpochMs +      dayIndex * schedulerDayMs +      minuteOfDay * 60 * 1_000    events.push({      id: eventId,      title: `计划任务 ${String(index + 1).padStart(5, '0')}`,      time: {        kind: 'timed',        range: {          startEpochMs,          endEpochMs:            startEpochMs + (30 + (index % 6) * 15) * 60 * 1_000,        },      },      readOnly: true,    })    assignments.push({      id: `large-assignment-${index + 1}`,      eventId,      resourceId: resources[resourceIndex]!.id,    })  }   return {    resources,    events,    assignments,    timeRanges: [],  }} export function createSchedulerDraftFactory(): (  context: SchedulerCreateEventContext,) => SchedulerEventDraft {  let sequence = 0  return context => {    sequence += 1    const eventId = `created-event-${sequence}`    return {      event: {        id: eventId,        title: `新建排期 ${sequence}`,        time: {          kind: 'timed',          range: {            startEpochMs: context.startEpochMs,            endEpochMs: context.endEpochMs,          },        },      },      assignments: [{        id: `created-assignment-${sequence}`,        eventId,        resourceId: context.resourceId,      }],    }  }} export function schedulerSelectionText(item: SchedulerReadonlyItem | null): string {  if (!item) return '未选择日程'  const state = item.disabled    ? '禁用'    : item.readOnly      ? '只读'      : '可写'  return `已选择:${item.title} · ${item.resourceTitle} · ${state}`} export function schedulerChangeText(change: SchedulerDataChange): string {  const title =    change.event.after?.title ??    change.event.before?.title ??    String(change.event.after?.id ?? change.event.before?.id ?? 'Event')  const gesture = change.gesture ? ` / ${change.gesture}` : ''  return `本地提交 #${change.revision}:${change.action}${gesture} · ${title}`} export function createShowcaseScheduler(  options: Omit<RenderSchedulerOptions, 'minHeight'> & {    initialDateEpochMs?: number  },): RenderScheduler {  const {    initialDateEpochMs,    onStateChange,    onViewportChange,    ...schedulerOptions  } = options  let initialPositionApplied = false  let scheduler!: RenderScheduler  scheduler = new RenderScheduler({    minHeight: 330,    resourceHeaderWidth: 210,    timeHeaderHeight: 50,    ...schedulerOptions,    onViewportChange: event => {      onViewportChange?.(event)      notifySchedulerDateNavigationViewport(scheduler, event)    },    onStateChange: state => {      onStateChange?.(state)      if (        initialPositionApplied ||        initialDateEpochMs === undefined ||        state.status !== 'ready' ||        state.bodyWidth <= 0 ||        state.bodyHeight <= 0      ) {        return      }      initialPositionApplied = true      queueMicrotask(() => {        scheduler.scrollToDate(initialDateEpochMs, 'center')      })    },  })  return scheduler} function createResources(  count: number,  writableCount: number,): SchedulerResource[] {  return Array.from({ length: count }, (_, index) => ({    id: `room-${String(index + 1).padStart(2, '0')}`,    title: index < writableCount      ? `会议室 ${String(index + 1).padStart(2, '0')}(可写)`      : `会议室 ${String(index + 1).padStart(2, '0')}(只读)`,    color: resourceColors[index % resourceColors.length],    ...(index < writableCount ? {} : { readOnly: true }),    sortOrder: index,    version: 1,  }))} function createWeekTimeRanges(  resources: readonly SchedulerResource[],): SchedulerTimeRange[] {  const ranges: SchedulerTimeRange[] = Array.from(    { length: 7 },    (_, dayIndex) => ({      id: `working-${dayIndex + 1}`,      kind: 'working',      time: {        kind: 'timed',        range: {          startEpochMs:            schedulerWeekStartEpochMs +            dayIndex * schedulerDayMs +            8 * schedulerHourMs,          endEpochMs:            schedulerWeekStartEpochMs +            dayIndex * schedulerDayMs +            18 * schedulerHourMs,        },      },      readOnly: true,    }),  )   if (resources.length >= 3) {    ranges.push({      id: 'unavailable-room-03',      resourceId: resources[2]!.id,      kind: 'unavailable',      time: {        kind: 'timed',        range: {          startEpochMs:            schedulerWeekStartEpochMs +            schedulerDayMs +            12 * schedulerHourMs,          endEpochMs:            schedulerWeekStartEpochMs +            schedulerDayMs +            15 * schedulerHourMs,        },      },      readOnly: true,    })  }  if (resources.length >= 6) {    ranges.push({      id: 'blocked-room-06',      resourceId: resources[5]!.id,      kind: 'blocked',      time: {        kind: 'timed',        range: {          startEpochMs:            schedulerWeekStartEpochMs +            2 * schedulerDayMs +            12 * schedulerHourMs,          endEpochMs:            schedulerWeekStartEpochMs +            2 * schedulerDayMs +            16 * schedulerHourMs,        },      },      readOnly: true,    })  }  return ranges} function createTimedEvent(options: {  id: SchedulerKey  title: string  dayIndex: number  startHour: number  durationMinutes: number  readOnly?: boolean  disabled?: boolean}): SchedulerEvent {  const startEpochMs =    schedulerWeekStartEpochMs +    options.dayIndex * schedulerDayMs +    options.startHour * schedulerHourMs  return {    id: options.id,    title: options.title,    time: {      kind: 'timed',      range: {        startEpochMs,        endEpochMs: startEpochMs + options.durationMinutes * 60 * 1_000,      },    },    readOnly: options.readOnly,    disabled: options.disabled,    version: 1,  }} function appendDenseEvents(  events: SchedulerEvent[],  assignments: SchedulerAssignment[],  count: number,  resourceId: SchedulerKey,  baseStartEpochMs: number,  idPrefix = 'dense',): void {  for (let index = 0; index < count; index += 1) {    const eventId = `${idPrefix}-${index + 1}`    const startEpochMs = baseStartEpochMs + index * 5 * 60 * 1_000    events.push({      id: eventId,      title: `并发任务 ${index + 1}`,      time: {        kind: 'timed',        range: {          startEpochMs,          endEpochMs: startEpochMs + 100 * 60 * 1_000,        },      },      readOnly: index % 7 === 5,      disabled: index % 11 === 9,    })    assignments.push({      id: `assignment-${eventId}`,      eventId,      resourceId,    })  }}