REAL SHOWCASE SOURCEscheduler-interaction-events

2 个文件 · 732 行 · TypeScript

// ===== showcase/src/examples/scheduler/interaction_events.ts ===== import {  RenderButton,  RenderText,  RenderWrapPanel,  type SchedulerView,} from 'ds-ui'import {  createSchedulerEditingSnapshot,  createSchedulerStage,  createShowcaseScheduler,  schedulerChangeText,  schedulerSelectionText,  schedulerWeekDomain,  schedulerWeekStartEpochMs,} from './shared' export function createSchedulerInteractionEventsExample() {  let currentView: SchedulerView = 'resourceTimelineWeek'  let blockNextViewChange = false  let lastViewChangeIntercepted = false  let viewButton!: RenderButton  let blockNextDelete = true  const status = new RenderText(    '事件日志:等待选择、激活、视图切换、导航或本地数据提交。',    { role: 'secondary' },  )  const scheduler = createShowcaseScheduler({    data: createSchedulerEditingSnapshot(),    domain: schedulerWeekDomain,    queryKey: 'showcase-scheduler-interaction-events',    title: '资源',    view: 'resourceTimelineWeek',    zoomPreset: 'standard',    initialDateEpochMs:      schedulerWeekStartEpochMs + 10 * 60 * 60 * 1_000,    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      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)}`    },  })   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,    controls,    status,  })} function viewToggleLabel(view: SchedulerView): string {  return view === 'agenda'    ? '切换到 Week'    : '切换到 Agenda'} // ===== 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' 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 schedulerWeekDomain: SchedulerInstantRange = Object.freeze({  startEpochMs: schedulerWeekStartEpochMs,  endEpochMs: schedulerWeekStartEpochMs + 7 * schedulerDayMs,}) export const schedulerMonthDomain: SchedulerInstantRange = Object.freeze({  startEpochMs: Date.UTC(2026, 6, 1),  endEpochMs: Date.UTC(2026, 7, 1),}) export const schedulerLargeDomain: SchedulerInstantRange = Object.freeze({  startEpochMs: Date.UTC(2026, 6, 1),  endEpochMs: Date.UTC(2026, 9, 1),}) 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  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.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[] = []   for (let resourceIndex = 0; resourceIndex < resources.length; resourceIndex += 1) {    const resource = resources[resourceIndex]!    for (let occurrence = 0; occurrence < routineEventsPerResource; occurrence += 1) {      const eventId = `plan-${resourceIndex + 1}-${occurrence + 1}`      const dayIndex = (resourceIndex + occurrence * 2) % 7      const startHour = 8 + (resourceIndex + occurrence) % 8      const startEpochMs =        schedulerWeekStartEpochMs +        dayIndex * schedulerDayMs +        startHour * schedulerHourMs +        (occurrence % 2) * 30 * 60 * 1_000      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 startEpochMs =    schedulerWeekStartEpochMs + schedulerDayMs + 9 * schedulerHourMs   appendDenseEvents(events, assignments, 28, 'room-01', startEpochMs)  for (let resourceIndex = 1; resourceIndex < resources.length; resourceIndex += 1) {    for (let index = 0; index < 4; index += 1) {      const eventId = `secondary-${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,      })    }  }   return {    resources,    events,    assignments,    timeRanges: [      {        id: 'dense-blocked-range',        resourceId: 'room-05',        kind: 'blocked',        time: {          kind: 'timed',          range: {            startEpochMs: startEpochMs + 2 * schedulerHourMs,            endEpochMs: startEpochMs + 5 * schedulerHourMs,          },        },        readOnly: true,      },    ],  }} 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,    ...schedulerOptions  } = options  let initialPositionApplied = false  let scheduler!: RenderScheduler  scheduler = new RenderScheduler({    minHeight: 330,    resourceHeaderWidth: 210,    timeHeaderHeight: 50,    ...schedulerOptions,    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,): void {  for (let index = 0; index < count; index += 1) {    const eventId = `dense-${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,    })  }}