@kne/react-calendar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,949 @@
1
+ # react-calendar
2
+
3
+ ### 描述
4
+
5
+ 实现一个日程的组件
6
+
7
+ ### 安装
8
+
9
+ ```shell
10
+ npm i --save @kne/react-calendar
11
+ ```
12
+
13
+ ### 概述
14
+
15
+ ### 项目概述
16
+
17
+ `@kne/react-calendar` 提供一个左右布局的日程组件,支持日程查看和日程安排两种状态。
18
+
19
+ 左侧日历按日期聚合日程并展示 badge,右侧面板在查看状态展示当天日程列表,在安排状态按时间单位展示空闲、占用和禁用时间段。组件支持受控和非受控模式,内部使用 `@kne/use-control-value` 管理当前选中日期,并使用 `dayjs` 处理时间计算。
20
+
21
+ 组件还支持通过 `disabledDateTime` 和 `availableDateTime` 配置安排规则,用于禁用部分日期时间或仅开放符合条件的日期时间。
22
+
23
+ ### 主要特性
24
+
25
+ - 左右布局:左侧月日历,右侧动态面板。
26
+ - 查看状态:日期下方展示日程数量 badge,右侧展示当天日程列表。
27
+ - 安排状态:按固定时间单位生成当天时间段,区分空闲、占用和禁用状态。
28
+ - 规则控制:支持按规则禁用日期时间,也支持仅开放符合条件的日期时间。
29
+ - 表单扩展:默认提供标题和详情字段,也可以通过 `formInner` 传入 `FormInfo` 自定义表单内容。
30
+ - 渲染扩展:支持自定义日期格、日程 item、时间段、占用时间段和右侧面板头部。
31
+ - 状态模式:通过 `@kne/use-control-value` 同时支持受控和非受控用法。
32
+
33
+ ### 使用场景
34
+
35
+ - 会议室、面试、服务预约等需要按时间段安排资源的场景。
36
+ - 项目排期、客户回访、值班表等需要按日期查看日程的场景。
37
+ - 需要在业务系统中嵌入可定制日程组件,并由外部接口提供日程数据的场景。
38
+
39
+
40
+ ### 示例
41
+
42
+ #### 示例代码
43
+
44
+ - 基础用法
45
+ - view 模式最简接入:events + defaultValue
46
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
47
+
48
+ ```jsx
49
+ /* global _ReactCalendar, antd, React, dayjs, render */
50
+
51
+ const { default: ScheduleCalendar } = _ReactCalendar;
52
+ const { Flex, Select, Typography, message } = antd;
53
+ const { useState } = React;
54
+ const { Text } = Typography;
55
+
56
+ const localeOptions = [
57
+ { value: 'zh-CN', label: '中文' },
58
+ { value: 'en-US', label: 'English' }
59
+ ];
60
+
61
+ const interviewEvents = [
62
+ {
63
+ id: 'iv-1001',
64
+ title: '前端工程师 - 张三',
65
+ detail: '电话初筛 · 面试官:李敏',
66
+ start: '2026-06-11 10:00',
67
+ end: '2026-06-11 10:30',
68
+ color: 'blue',
69
+ raw: { type: '电话' },
70
+ onClick: event => message.info(`查看面试:${event.title}`)
71
+ },
72
+ {
73
+ id: 'iv-1002',
74
+ title: '产品经理 - 王芳',
75
+ detail: '视频复试 · 面试官:赵强',
76
+ start: '2026-06-11 14:00',
77
+ end: '2026-06-11 15:00',
78
+ color: 'purple',
79
+ raw: { type: '视频' },
80
+ onClick: event => message.info(`查看面试:${event.title}`)
81
+ },
82
+ {
83
+ id: 'iv-1003',
84
+ title: 'Java 工程师 - 陈浩',
85
+ detail: '现场面试 · 会议室 A-301',
86
+ start: '2026-06-12 09:30',
87
+ end: '2026-06-12 11:00',
88
+ color: 'green',
89
+ raw: { type: '现场' },
90
+ onClick: event => message.info(`查看面试:${event.title}`)
91
+ }
92
+ ];
93
+
94
+ const BaseExample = () => {
95
+ const [locale, setLocale] = useState('zh-CN');
96
+
97
+ return (
98
+ <Flex vertical gap={8}>
99
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
100
+ <Text type="secondary">最基础用法:&#96;mode="view"&#96; + &#96;events&#96; + &#96;defaultValue&#96;。日程项可配置 &#96;onClick&#96; 响应点击。</Text>
101
+ <ScheduleCalendar locale={locale} mode="view" defaultValue="2026-06-11" defaultCurrent="2026-06-01" events={interviewEvents} />
102
+ </Flex>
103
+ );
104
+ };
105
+
106
+ render(<BaseExample />);
107
+
108
+ ```
109
+
110
+ - 查看模式
111
+ - view 模式默认样式、event.onClick / onEventClick、loading、onDateChange 等 API
112
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
113
+
114
+ ```jsx
115
+ /* global _ReactCalendar, antd, React, dayjs, render */
116
+
117
+ const { default: ScheduleCalendar } = _ReactCalendar;
118
+ const { Flex, Select, Space, Switch, Typography, message } = antd;
119
+ const { useState } = React;
120
+ const { Text } = Typography;
121
+
122
+ const localeOptions = [
123
+ { value: 'zh-CN', label: '中文' },
124
+ { value: 'en-US', label: 'English' }
125
+ ];
126
+
127
+ const interviewEvents = [
128
+ {
129
+ id: 'iv-1001',
130
+ title: '前端工程师 - 张三',
131
+ detail: '电话初筛 · 面试官:李敏',
132
+ start: '2026-06-11 10:00',
133
+ end: '2026-06-11 10:30',
134
+ color: 'blue',
135
+ raw: { type: '电话', label: '电话' },
136
+ onClick: event => message.info(&#96;打开详情:${event.title}&#96;)
137
+ },
138
+ {
139
+ id: 'iv-1002',
140
+ title: '产品经理 - 王芳',
141
+ detail: '视频复试 · 面试官:赵强、周琳',
142
+ start: '2026-06-11 14:00',
143
+ end: '2026-06-11 15:00',
144
+ color: 'purple',
145
+ raw: { type: '视频', label: '视频' },
146
+ onClick: event => message.info(&#96;打开详情:${event.title}&#96;)
147
+ },
148
+ {
149
+ id: 'iv-1003',
150
+ title: 'Java 工程师 - 陈浩',
151
+ detail: '现场面试 · 会议室 A-301',
152
+ start: '2026-06-12 09:30',
153
+ end: '2026-06-12 11:00',
154
+ color: 'green',
155
+ raw: { type: '现场', label: '现场' },
156
+ onClick: event => message.info(&#96;打开详情:${event.title}&#96;)
157
+ },
158
+ {
159
+ id: 'iv-1004',
160
+ title: 'UI 设计师 - 孙悦',
161
+ detail: '作品集评审',
162
+ start: '2026-06-12 15:00',
163
+ end: '2026-06-12 16:00',
164
+ color: 'purple',
165
+ raw: { type: '视频', label: '视频' }
166
+ },
167
+ {
168
+ id: 'iv-1005',
169
+ title: '测试工程师 - 吴磊',
170
+ detail: '技术面二面',
171
+ start: '2026-06-16 10:00',
172
+ end: '2026-06-16 11:30',
173
+ color: 'green',
174
+ raw: { type: '现场', label: '现场' }
175
+ }
176
+ ];
177
+
178
+ const ViewExample = () => {
179
+ const [locale, setLocale] = useState('zh-CN');
180
+ const [loading, setLoading] = useState(false);
181
+ const [lastChange, setLastChange] = useState('-');
182
+
183
+ return (
184
+ <Flex vertical gap={12}>
185
+ <Text type="secondary">
186
+ 单条日程可通过 &#96;event.onClick&#96; 响应点击;未配置时由 &#96;onEventClick&#96; 统一处理(见 iv-1004、iv-1005)。
187
+ </Text>
188
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
189
+ <Space wrap>
190
+ <Switch checked={loading} onChange={setLoading} checkedChildren="loading" unCheckedChildren="idle" />
191
+ <Text type="secondary" style={{ fontSize: 12 }}>
192
+ onDateChange:{lastChange}
193
+ </Text>
194
+ </Space>
195
+ <ScheduleCalendar
196
+ locale={locale}
197
+ mode="view"
198
+ defaultValue="2026-06-11"
199
+ defaultCurrent="2026-06-01"
200
+ events={interviewEvents}
201
+ loading={loading}
202
+ badgeMaxCount={9}
203
+ onDateChange={(date, info) => setLastChange(&#96;${dayjs(date).format('YYYY-MM-DD')} (${info.source})&#96;)}
204
+ onEventClick={(event, ctx) => message.info(&#96;onEventClick:${event.title}(${ctx.mode})&#96;)}
205
+ panelProps={{ 'data-demo': 'view-panel' }}
206
+ calendarProps={{ 'data-demo': 'view-calendar' }}
207
+ />
208
+ </Flex>
209
+ );
210
+ };
211
+
212
+ render(<ViewExample />);
213
+
214
+ ```
215
+
216
+ - 安排模式
217
+ - 时间段安排、自定义表单、拖选多段、onCreate / onFreeSlotClick / onSlotRangeChange
218
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),_FormInfo(@kne/form-info)[import * as _FormInfo from "@kne/form-info"],(@kne/form-info/dist/index.css),antd(antd),dayjs(dayjs)
219
+
220
+ ```jsx
221
+ /* global _ReactCalendar, _FormInfo, antd, React, dayjs, render */
222
+
223
+ const { default: ScheduleCalendar } = _ReactCalendar;
224
+ const { default: FormInfo, Input, TextArea, Select: FormSelect } = _FormInfo;
225
+ const { Flex, Select, Space, Typography, message } = antd;
226
+ const { useState } = React;
227
+ const { Text } = Typography;
228
+
229
+ const localeOptions = [
230
+ { value: 'zh-CN', label: '中文' },
231
+ { value: 'en-US', label: 'English' }
232
+ ];
233
+
234
+ const interviewEvents = [
235
+ {
236
+ id: 'iv-1001',
237
+ title: '前端工程师 - 张三',
238
+ detail: '电话初筛 · 面试官:李敏',
239
+ start: '2026-06-11 10:00',
240
+ end: '2026-06-11 10:30'
241
+ },
242
+ {
243
+ id: 'iv-1002',
244
+ title: '产品经理 - 王芳',
245
+ detail: '视频复试 · 面试官:赵强',
246
+ start: '2026-06-11 14:00',
247
+ end: '2026-06-11 15:00'
248
+ },
249
+ {
250
+ id: 'iv-1003',
251
+ title: 'Java 工程师 - 陈浩',
252
+ detail: '现场面试 · 会议室 A-301',
253
+ start: '2026-06-12 09:30',
254
+ end: '2026-06-12 11:00'
255
+ }
256
+ ];
257
+
258
+ const formInner = (
259
+ <FormInfo
260
+ column={1}
261
+ list={[
262
+ <Input name="title" label="面试主题" rule="REQ" placeholder="如:前端工程师 - 候选人姓名" block />,
263
+ <FormSelect
264
+ name="interviewType"
265
+ label="面试形式"
266
+ rule="REQ"
267
+ options={[
268
+ { label: '电话初筛', value: 'phone' },
269
+ { label: '视频复试', value: 'video' },
270
+ { label: '现场面试', value: 'onsite' }
271
+ ]}
272
+ block
273
+ />,
274
+ <Input name="interviewer" label="面试官" rule="REQ" block />,
275
+ <TextArea name="detail" label="备注" placeholder="会议室、候选人背景等" block />
276
+ ]}
277
+ />
278
+ );
279
+
280
+ const ScheduleExample = () => {
281
+ const [locale, setLocale] = useState('zh-CN');
282
+ const [events, setEvents] = useState(interviewEvents);
283
+ const [selectedDate, setSelectedDate] = useState('2026-06-11');
284
+ const [actionLog, setActionLog] = useState([]);
285
+
286
+ const appendLog = (label, payload) => {
287
+ setActionLog(log => [&#96;${label}: ${JSON.stringify(payload)}&#96;, ...log].slice(0, 4));
288
+ };
289
+
290
+ const handleCreate = async (values, context) => {
291
+ const firstSlot = context.slots[0];
292
+ const lastSlot = context.slots[context.slots.length - 1];
293
+ setEvents(list =>
294
+ list.concat({
295
+ id: &#96;iv-${Date.now()}&#96;,
296
+ title: values.title,
297
+ detail: &#96;${values.detail || ''}${values.interviewer ? &#96; · 面试官:${values.interviewer}&#96; : ''}&#96;.trim(),
298
+ start: firstSlot.start,
299
+ end: lastSlot.end
300
+ })
301
+ );
302
+ message.success(&#96;已创建:${values.title}&#96;);
303
+ };
304
+
305
+ return (
306
+ <Flex vertical gap={12}>
307
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
308
+ <Text type="secondary">
309
+ 安排模式 API:&#96;timeUnit&#96;、&#96;dayStart&#96;、&#96;dayEnd&#96;、&#96;allowMultiSelect&#96;、&#96;formInner&#96;、&#96;formProps&#96;、&#96;defaultFormValues&#96;、&#96;onCreate&#96;、&#96;onFreeSlotClick&#96;、&#96;onSlotRangeChange&#96;。
310
+ </Text>
311
+ <ScheduleCalendar
312
+ locale={locale}
313
+ mode="schedule"
314
+ value={selectedDate}
315
+ onChange={(date, info) => {
316
+ setSelectedDate(dayjs(date).format('YYYY-MM-DD'));
317
+ appendLog('onChange', { date: dayjs(date).format('YYYY-MM-DD'), source: info.source });
318
+ }}
319
+ events={events}
320
+ timeUnit={30}
321
+ dayStart="09:00"
322
+ dayEnd="18:00"
323
+ allowMultiSelect
324
+ formInner={formInner}
325
+ formProps={{ size: 'small' }}
326
+ defaultFormValues={({ date, slots }) => ({
327
+ title: '',
328
+ interviewType: 'video',
329
+ interviewer: '',
330
+ detail: slots.length > 1 ? &#96;连续选择 ${slots.length} 个时段&#96; : &#96;日期 ${dayjs(date).format('YYYY-MM-DD')}&#96;
331
+ })}
332
+ onFreeSlotClick={slot =>
333
+ appendLog('onFreeSlotClick', {
334
+ range: &#96;${dayjs(slot.start).format('HH:mm')}-${dayjs(slot.end).format('HH:mm')}&#96;
335
+ })
336
+ }
337
+ onSlotRangeChange={(slots, info) =>
338
+ appendLog('onSlotRangeChange', {
339
+ action: info.action,
340
+ count: slots.length
341
+ })
342
+ }
343
+ onCreate={handleCreate}
344
+ renderPanelHeader={({ date }) => (
345
+ <Flex vertical gap={2}>
346
+ <Text strong>{dayjs(date).format('M月D日')} 可预约时段</Text>
347
+ <Text type="secondary" style={{ fontSize: 12 }}>
348
+ 点击或拖选空闲段后填写表单创建面试
349
+ </Text>
350
+ </Flex>
351
+ )}
352
+ />
353
+ {actionLog.length ? (
354
+ <Flex vertical gap={4}>
355
+ <Text type="secondary" style={{ fontSize: 12 }}>
356
+ 最近回调:
357
+ </Text>
358
+ {actionLog.map((item, index) => (
359
+ <Text key={index} code style={{ fontSize: 12 }}>
360
+ {item}
361
+ </Text>
362
+ ))}
363
+ </Flex>
364
+ ) : null}
365
+ </Flex>
366
+ );
367
+ };
368
+
369
+ render(<ScheduleExample />);
370
+
371
+ ```
372
+
373
+ - 开放与禁用规则
374
+ - disabledDate、availableDateTime、disabledDateTime 组合配置
375
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
376
+
377
+ ```jsx
378
+ /* global _ReactCalendar, antd, React, dayjs, render */
379
+
380
+ const { default: ScheduleCalendar } = _ReactCalendar;
381
+ const { Flex, Select, Typography } = antd;
382
+ const { useState } = React;
383
+ const { Text } = Typography;
384
+
385
+ const localeOptions = [
386
+ { value: 'zh-CN', label: '中文' },
387
+ { value: 'en-US', label: 'English' }
388
+ ];
389
+
390
+ const interviewEvents = [
391
+ {
392
+ id: 'iv-1001',
393
+ title: '前端工程师 - 张三',
394
+ start: '2026-06-11 10:00',
395
+ end: '2026-06-11 10:30'
396
+ },
397
+ {
398
+ id: 'iv-1002',
399
+ title: '产品经理 - 王芳',
400
+ start: '2026-06-11 14:00',
401
+ end: '2026-06-11 15:00'
402
+ },
403
+ {
404
+ id: 'iv-1003',
405
+ title: 'Java 工程师 - 陈浩',
406
+ start: '2026-06-12 09:30',
407
+ end: '2026-06-12 11:00'
408
+ }
409
+ ];
410
+
411
+ const isWeekend = date => [0, 6].includes(date.getDay());
412
+
413
+ const workdayAvailable = ({ date, slot }) => {
414
+ if (isWeekend(date)) {
415
+ return false;
416
+ }
417
+ if (!slot) {
418
+ return true;
419
+ }
420
+ const hour = dayjs(slot.start).hour();
421
+ return hour >= 9 && hour < 18;
422
+ };
423
+
424
+ const lunchDisabled = ({ slot }) => {
425
+ if (!slot) {
426
+ return false;
427
+ }
428
+ if (dayjs(slot.start).hour() === 12) {
429
+ return { disabled: true, reason: '12:00-13:00 午休不可预约' };
430
+ }
431
+ return false;
432
+ };
433
+
434
+ const RulesExample = () => {
435
+ const [locale, setLocale] = useState('zh-CN');
436
+
437
+ return (
438
+ <Flex vertical gap={12}>
439
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
440
+ <Text type="secondary">
441
+ 规则 API:&#96;disabledDate&#96;(禁用 6/10 前日期)、&#96;availableDateTime&#96;(仅工作日 9:00-18:00)、&#96;disabledDateTime&#96;(12:00 午休禁用并展示 reason)。
442
+ </Text>
443
+ <ScheduleCalendar
444
+ locale={locale}
445
+ mode="schedule"
446
+ defaultValue="2026-06-11"
447
+ events={interviewEvents}
448
+ timeUnit={30}
449
+ dayStart="08:00"
450
+ dayEnd="20:00"
451
+ disabledDate={date => dayjs(date).isBefore(dayjs('2026-06-10'), 'day')}
452
+ availableDateTime={workdayAvailable}
453
+ disabledDateTime={lunchDisabled}
454
+ renderPanelHeader={({ date }) => (
455
+ <Flex vertical gap={2}>
456
+ <Text strong>{dayjs(date).format('YYYY-MM-DD')} 开放规则</Text>
457
+ <Text type="secondary" style={{ fontSize: 12 }}>
458
+ 周末关闭 · 工作日 9-18 点 · 12 点午休禁用
459
+ </Text>
460
+ </Flex>
461
+ )}
462
+ />
463
+ </Flex>
464
+ );
465
+ };
466
+
467
+ render(<RulesExample />);
468
+
469
+ ```
470
+
471
+ - 受控模式
472
+ - value / onChange 与 current / onCurrentChange 受控用法
473
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
474
+
475
+ ```jsx
476
+ /* global _ReactCalendar, antd, React, dayjs, render */
477
+
478
+ const { default: ScheduleCalendar } = _ReactCalendar;
479
+ const { Flex, Select, Space, Typography } = antd;
480
+ const { useState } = React;
481
+ const { Text } = Typography;
482
+
483
+ const localeOptions = [
484
+ { value: 'zh-CN', label: '中文' },
485
+ { value: 'en-US', label: 'English' }
486
+ ];
487
+
488
+ const interviewEvents = [
489
+ {
490
+ id: 'iv-1001',
491
+ title: '前端工程师 - 张三',
492
+ detail: '电话初筛',
493
+ start: '2026-06-11 10:00',
494
+ end: '2026-06-11 10:30'
495
+ },
496
+ {
497
+ id: 'iv-1002',
498
+ title: '产品经理 - 王芳',
499
+ detail: '视频复试',
500
+ start: '2026-06-16 14:00',
501
+ end: '2026-06-16 15:00'
502
+ }
503
+ ];
504
+
505
+ const ControlledExample = () => {
506
+ const [locale, setLocale] = useState('zh-CN');
507
+ const [selectedDate, setSelectedDate] = useState('2026-06-11');
508
+ const [currentMonth, setCurrentMonth] = useState('2026-06-01');
509
+ const [logs, setLogs] = useState([]);
510
+
511
+ const appendLog = (label, payload) => {
512
+ setLogs(list => [&#96;${label}: ${JSON.stringify(payload)}&#96;, ...list].slice(0, 6));
513
+ };
514
+
515
+ return (
516
+ <Flex vertical gap={12}>
517
+ <Text type="secondary">受控 API:&#96;value&#96; / &#96;onChange&#96; 控制选中日期,&#96;current&#96; / &#96;onCurrentChange&#96; 控制浏览月份(与 &#96;defaultValue&#96; / &#96;defaultCurrent&#96; 相对)。</Text>
518
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
519
+ <Space wrap>
520
+ <Text>选中日期:{selectedDate}</Text>
521
+ <Text>浏览月份:{dayjs(currentMonth).format('YYYY-MM')}</Text>
522
+ </Space>
523
+ <ScheduleCalendar
524
+ locale={locale}
525
+ mode="view"
526
+ value={selectedDate}
527
+ current={currentMonth}
528
+ events={interviewEvents}
529
+ onChange={(date, info) => {
530
+ setSelectedDate(dayjs(date).format('YYYY-MM-DD'));
531
+ appendLog('onChange', { date: dayjs(date).format('YYYY-MM-DD'), source: info.source });
532
+ }}
533
+ onDateChange={(date, info) => {
534
+ appendLog('onDateChange', { date: dayjs(date).format('YYYY-MM-DD'), source: info.source });
535
+ }}
536
+ onCurrentChange={(date, info) => {
537
+ setCurrentMonth(dayjs(date).startOf('month').format('YYYY-MM-DD'));
538
+ appendLog('onCurrentChange', { month: dayjs(date).format('YYYY-MM'), source: info.source });
539
+ }}
540
+ />
541
+ {logs.length ? (
542
+ <Flex vertical gap={4}>
543
+ <Text type="secondary" style={{ fontSize: 12 }}>
544
+ 回调日志:
545
+ </Text>
546
+ {logs.map((item, index) => (
547
+ <Text key={index} code style={{ fontSize: 12 }}>
548
+ {item}
549
+ </Text>
550
+ ))}
551
+ </Flex>
552
+ ) : null}
553
+ </Flex>
554
+ );
555
+ };
556
+
557
+ render(<ControlledExample />);
558
+
559
+ ```
560
+
561
+ - 自定义渲染
562
+ - renderDateCell、renderTimeSlot、renderOccupiedSlot、className、style
563
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
564
+
565
+ ```jsx
566
+ /* global _ReactCalendar, antd, React, dayjs, render */
567
+
568
+ const { default: ScheduleCalendar } = _ReactCalendar;
569
+ const { Flex, Select, Space, Tag, Typography, message } = antd;
570
+ const { useState } = React;
571
+ const { Text } = Typography;
572
+
573
+ const localeOptions = [
574
+ { value: 'zh-CN', label: '中文' },
575
+ { value: 'en-US', label: 'English' }
576
+ ];
577
+
578
+ const interviewEvents = [
579
+ {
580
+ id: 'iv-1001',
581
+ title: '前端工程师 - 张三',
582
+ start: '2026-06-11 10:00',
583
+ end: '2026-06-11 10:30'
584
+ },
585
+ {
586
+ id: 'iv-1002',
587
+ title: '产品经理 - 王芳',
588
+ start: '2026-06-11 14:00',
589
+ end: '2026-06-11 15:00'
590
+ },
591
+ {
592
+ id: 'iv-1003',
593
+ title: 'Java 工程师 - 陈浩',
594
+ start: '2026-06-12 09:30',
595
+ end: '2026-06-12 11:00'
596
+ }
597
+ ];
598
+
599
+ const CustomRenderExample = () => {
600
+ const [locale, setLocale] = useState('zh-CN');
601
+ const [events, setEvents] = useState(interviewEvents);
602
+
603
+ return (
604
+ <Flex vertical gap={12}>
605
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
606
+ <Text type="secondary">
607
+ 自定义渲染 API:&#96;renderDateCell&#96;、&#96;renderTimeSlot&#96;、&#96;renderOccupiedSlot&#96;、&#96;renderPanelHeader&#96;,以及根节点 &#96;className&#96; / &#96;style&#96;。
608
+ </Text>
609
+ <ScheduleCalendar
610
+ locale={locale}
611
+ mode="schedule"
612
+ defaultValue="2026-06-12"
613
+ events={events}
614
+ timeUnit={60}
615
+ dayStart="09:00"
616
+ dayEnd="18:00"
617
+ allowMultiSelect={false}
618
+ className="demo-schedule-calendar-custom"
619
+ style={{ boxShadow: '0 8px 24px rgba(15, 23, 42, 0.08)' }}
620
+ onCreate={(values, { slots }) => {
621
+ const firstSlot = slots[0];
622
+ const lastSlot = slots[slots.length - 1];
623
+ setEvents(list =>
624
+ list.concat({
625
+ id: &#96;iv-${Date.now()}&#96;,
626
+ title: values.title,
627
+ detail: values.detail,
628
+ start: firstSlot.start,
629
+ end: lastSlot.end
630
+ })
631
+ );
632
+ message.success(&#96;已创建:${values.title}&#96;);
633
+ }}
634
+ renderDateCell={(date, { events: dateEvents, selected, currentMonth }) => (
635
+ <div
636
+ style={{
637
+ width: '100%',
638
+ aspectRatio: 1,
639
+ minHeight: 44,
640
+ padding: '8%',
641
+ borderRadius: '12%',
642
+ boxSizing: 'border-box',
643
+ display: 'flex',
644
+ flexDirection: 'column',
645
+ alignItems: 'center',
646
+ justifyContent: 'center',
647
+ gap: 4,
648
+ background: selected ? '#1677ff' : dateEvents.length ? '#e6f4ff' : 'transparent',
649
+ color: selected ? '#fff' : currentMonth ? '#202124' : 'rgba(0,0,0,0.35)'
650
+ }}
651
+ >
652
+ <strong>{dayjs(date).date()}</strong>
653
+ <span style={{ fontSize: 11 }}>{dateEvents.length ? &#96;${dateEvents.length} 场&#96; : '—'}</span>
654
+ </div>
655
+ )}
656
+ renderTimeSlot={(slot, { selected }) => (
657
+ <div
658
+ style={{
659
+ width: '100%',
660
+ padding: '8px 10px',
661
+ borderRadius: 10,
662
+ textAlign: 'center',
663
+ background: selected ? '#1677ff' : slot.status === 'free' ? '#fff' : slot.status === 'occupied' ? '#fff1f0' : '#f0f0f0',
664
+ color: selected ? '#fff' : undefined,
665
+ opacity: slot.status === 'disabled' ? 0.55 : 1
666
+ }}
667
+ >
668
+ {dayjs(slot.start).format('HH:mm')} - {dayjs(slot.end).format('HH:mm')}
669
+ </div>
670
+ )}
671
+ renderOccupiedSlot={(slot, { events: slotEvents }) => (
672
+ <Space size={4} wrap>
673
+ <Tag color="red">冲突</Tag>
674
+ <span style={{ fontSize: 12 }}>{slotEvents.map(item => item.title).join('、')}</span>
675
+ </Space>
676
+ )}
677
+ renderPanelHeader={({ date, mode, events: dayEvents }) => (
678
+ <Text strong>
679
+ {dayjs(date).format('YYYY-MM-DD')} · {mode} · {dayEvents.length} 条
680
+ </Text>
681
+ )}
682
+ />
683
+ </Flex>
684
+ );
685
+ };
686
+
687
+ render(<CustomRenderExample />);
688
+
689
+ ```
690
+
691
+ - 工具函数
692
+ - groupEventsByDate 与 generateTimeSlots 独立使用示例
693
+ - _ReactCalendar(@kne/current-lib_react-calendar)[import * as _ReactCalendar from "@kne/react-calendar"],(@kne/current-lib_react-calendar/dist/index.css),antd(antd),dayjs(dayjs)
694
+
695
+ ```jsx
696
+ /* global _ReactCalendar, antd, React, dayjs, render */
697
+
698
+ const { default: ScheduleCalendar, groupEventsByDate, generateTimeSlots } = _ReactCalendar;
699
+ const { Flex, Select, Typography } = antd;
700
+ const { useMemo, useState } = React;
701
+ const { Text } = Typography;
702
+
703
+ const localeOptions = [
704
+ { value: 'zh-CN', label: '中文' },
705
+ { value: 'en-US', label: 'English' }
706
+ ];
707
+
708
+ const interviewEvents = [
709
+ {
710
+ id: 'iv-1001',
711
+ title: '前端工程师 - 张三',
712
+ start: '2026-06-11 10:00',
713
+ end: '2026-06-11 10:30'
714
+ },
715
+ {
716
+ id: 'iv-1002',
717
+ title: '产品经理 - 王芳',
718
+ start: '2026-06-11 14:00',
719
+ end: '2026-06-11 15:30'
720
+ },
721
+ {
722
+ id: 'iv-1003',
723
+ title: 'Java 工程师 - 陈浩',
724
+ start: '2026-06-12 09:30',
725
+ end: '2026-06-12 11:00'
726
+ }
727
+ ];
728
+
729
+ const UtilsExample = () => {
730
+ const [locale, setLocale] = useState('zh-CN');
731
+ const [selectedDate, setSelectedDate] = useState('2026-06-11');
732
+
733
+ const grouped = useMemo(() => groupEventsByDate(interviewEvents), []);
734
+ const slots = useMemo(
735
+ () =>
736
+ generateTimeSlots({
737
+ date: selectedDate,
738
+ events: grouped[selectedDate] || [],
739
+ timeUnit: 30,
740
+ dayStart: '09:00',
741
+ dayEnd: '18:00',
742
+ mode: 'schedule'
743
+ }),
744
+ [grouped, selectedDate]
745
+ );
746
+
747
+ const freeCount = slots.filter(item => item.status === 'free').length;
748
+ const occupiedCount = slots.filter(item => item.status === 'occupied').length;
749
+
750
+ return (
751
+ <Flex vertical gap={12}>
752
+ <Select value={locale} onChange={setLocale} style={{ width: 120 }} options={localeOptions} />
753
+ <Text type="secondary">
754
+ 工具函数:&#96;groupEventsByDate&#96; 按天聚合日程,&#96;generateTimeSlots&#96; 生成时间段及 &#96;free/occupied/disabled&#96; 状态。下方组件切换日期时同步更新统计。
755
+ </Text>
756
+ <ScheduleCalendar
757
+ locale={locale}
758
+ mode="view"
759
+ value={selectedDate}
760
+ onChange={date => setSelectedDate(dayjs(date).format('YYYY-MM-DD'))}
761
+ events={interviewEvents}
762
+ />
763
+ <Flex vertical gap={4} style={{ paddingTop: 8, borderTop: '1px solid #eceef3' }}>
764
+ <Text code style={{ fontSize: 12 }}>
765
+ groupEventsByDate → {Object.keys(grouped).length} 天有日程
766
+ </Text>
767
+ <Text code style={{ fontSize: 12 }}>
768
+ generateTimeSlots({selectedDate}) → 共 {slots.length} 段,空闲 {freeCount},占用 {occupiedCount}
769
+ </Text>
770
+ <Text type="secondary" style={{ fontSize: 12 }}>
771
+ 首段:{slots[0] ? &#96;${dayjs(slots[0].start).format('HH:mm')}-${dayjs(slots[0].end).format('HH:mm')} (${slots[0].status})&#96; : '-'}
772
+ </Text>
773
+ </Flex>
774
+ </Flex>
775
+ );
776
+ };
777
+
778
+ render(<UtilsExample />);
779
+
780
+ ```
781
+
782
+ ### API
783
+
784
+ ### ScheduleCalendar API
785
+
786
+ `ScheduleCalendar` 是一个左右布局的日程查看和日程安排组件。左侧显示月日历,右侧根据 `mode` 展示日程列表或当天时间段安排面板。
787
+
788
+ #### 基础类型
789
+
790
+ ```ts
791
+ type DateLike = string | Date;
792
+
793
+ type CalendarEvent = {
794
+ id: string | number;
795
+ title: string;
796
+ detail?: React.ReactNode;
797
+ date?: DateLike;
798
+ start: DateLike;
799
+ end: DateLike;
800
+ disabled?: boolean;
801
+ color?: string;
802
+ raw?: unknown;
803
+ onClick?: (event: CalendarEvent, ctx: { date: Date; mode: 'view' | 'schedule' }) => void;
804
+ };
805
+
806
+ type TimeSlot = {
807
+ key: string;
808
+ date: string;
809
+ start: Date;
810
+ end: Date;
811
+ status: 'free' | 'occupied' | 'disabled';
812
+ disabledReason?: React.ReactNode;
813
+ events: CalendarEvent[];
814
+ };
815
+
816
+ type ScheduleDateTimeRuleContext = {
817
+ date: Date;
818
+ slot?: {
819
+ start: Date;
820
+ end: Date;
821
+ };
822
+ mode: 'view' | 'schedule';
823
+ };
824
+ ```
825
+
826
+ #### Props
827
+
828
+ | 属性 | 类型 | 默认值 | 说明 |
829
+ | ------------------ | ---------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------ |
830
+ | mode | `'view' \| 'schedule'` | `'view'` | 组件模式,查看状态展示日程列表,安排状态展示时间段和创建表单 |
831
+ | locale | `'zh-CN' \| 'en-US' \| string` | `'zh-CN'` | 组件内置文案语言,传入后切换中英文界面 |
832
+ | value | `DateLike` | - | 当前选中日期,传入后为受控模式 |
833
+ | defaultValue | `DateLike` | - | 当前选中日期默认值,不传 `value` 时为非受控模式 |
834
+ | onChange | `(date: Date, info) => void` | - | 当前选中日期变化回调,配合 `@kne/use-control-value` |
835
+ | onDateChange | `(date: Date, info) => void` | - | 当前选中日期变化的语义化回调,会和 `onChange` 同步触发 |
836
+ | onEventClick | `(event, ctx) => void` | - | 日程项点击回调;单条 `event.onClick` 存在时优先执行 |
837
+ | current | `DateLike` | - | 当前浏览年月,传入后浏览年月为受控模式 |
838
+ | defaultCurrent | `DateLike` | - | 当前浏览年月默认值 |
839
+ | onCurrentChange | `(date: Date, info) => void` | - | 浏览年月变化回调 |
840
+ | events | `CalendarEvent[]` | `[]` | 日程数据,左侧 badge 和右侧面板都由它驱动 |
841
+ | loading | `boolean` | `false` | 右侧面板加载状态 |
842
+ | disabledDate | `(date: Date) => boolean` | - | 禁用某些日期 |
843
+ | disabledDateTime | `(ctx) => boolean \| { disabled?: boolean; reason?: React.ReactNode }` | - | 按规则禁用某些日期或时间段 |
844
+ | availableDateTime | `(ctx) => boolean` | - | 白名单规则,仅开放返回 `true` 的日期或时间段 |
845
+ | badgeMaxCount | `number` | `9` | 日期 badge 最大展示数量,超过后显示 `9+` |
846
+ | timeUnit | `number` | `30` | 安排状态下的时间段粒度,单位分钟 |
847
+ | dayStart | `string` | `'09:00'` | 安排状态下当天开始时间 |
848
+ | dayEnd | `string` | `'18:00'` | 安排状态下当天结束时间 |
849
+ | allowMultiSelect | `boolean` | `mode === 'schedule'` | 是否允许拖动选择多个空闲时间段 |
850
+ | renderEventItem | `(event, ctx) => React.ReactNode` | - | 自定义查看状态下的日程列表 item |
851
+ | renderDateCell | `(date, ctx) => React.ReactNode` | - | 自定义左侧日期单元格 |
852
+ | renderTimeSlot | `(slot, ctx) => React.ReactNode` | - | 自定义安排状态下的时间段 |
853
+ | renderOccupiedSlot | `(slot, ctx) => React.ReactNode` | - | 自定义占用时间段内容 |
854
+ | renderPanelHeader | `(ctx) => React.ReactNode` | - | 自定义右侧面板头部 |
855
+ | formInner | `React.ReactNode` | 默认标题和详情 | 自定义创建日程表单内部 JSX 内容 |
856
+ | formProps | `object` | - | 传给 `@kne/form-info` 的 `Form` |
857
+ | defaultFormValues | `object \| (ctx) => object` | - | 创建表单默认值 |
858
+ | onFreeSlotClick | `(slot: TimeSlot) => void` | - | 点击空闲时间段回调 |
859
+ | onSlotRangeChange | `(slots: TimeSlot[], info) => void` | - | 拖动选择时间段变化回调 |
860
+ | onCreate | `(values, ctx) => void \| Promise<void>` | - | 创建日程提交回调;Promise reject 时保留表单与选中时段 |
861
+ | className | `string` | - | 根节点类名 |
862
+ | style | `React.CSSProperties` | - | 根节点样式 |
863
+ | stackAt | `number` | `560` | 容器宽度 **小于** 该值(px)时左右面板改为上下排列;通过 `ResizeObserver` 监听组件根节点实际宽度 |
864
+ | calendarProps | `object` | - | 透传给 `antd` 的 `Calendar` |
865
+ | panelProps | `object` | - | 透传给右侧面板容器 |
866
+
867
+ #### 回调信息
868
+
869
+ | 回调 | info/context 结构 | 说明 |
870
+ | ----------------- | ---------------------------------------------------- | ---------------------------------------------------- |
871
+ | onChange | `{ source: 'date' \| 'today' }` | 当前选中日期变化,内部由 `useControlValue` 触发 |
872
+ | onDateChange | `{ source: 'date' \| 'today' }` | 与 `onChange` 同步触发,用于更语义化地监听日期变化 |
873
+ | onEventClick | `{ date: Date; mode: 'view' \| 'schedule' }` | 点击日程项时触发;也可在单条 event 上配置 `onClick` |
874
+ | onCurrentChange | `{ source: 'prevMonth' \| 'nextMonth' \| 'picker' \| 'today' }` | 浏览年月变化,来源分别为上一月、下一月、年月下拉选择、回到今天 |
875
+ | onSlotRangeChange | `{ action: 'select' \| 'clear' }` | 拖动或取消选择空闲时间段 |
876
+ | onCreate | `{ date: Date; slots: TimeSlot[] }` | 创建表单提交时返回当前日期和选中的时间段 |
877
+
878
+ #### 开放与禁用规则
879
+
880
+ 规则优先级为:
881
+
882
+ 1. `disabledDate` / `disabledDateTime`
883
+ 2. `availableDateTime`
884
+ 3. `events` 占用判断
885
+
886
+ 也就是说,被禁用或不在开放范围内的时间段会直接变成 `disabled`,不会再被视为空闲时间段,也不会参与拖动多选。
887
+
888
+ ```jsx
889
+ <ScheduleCalendar
890
+ mode="schedule"
891
+ availableDateTime={({ date, slot }) => {
892
+ if ([0, 6].includes(date.getDay())) return false;
893
+ if (!slot) return true;
894
+ const hour = dayjs(slot.start).hour();
895
+ return hour >= 9 && hour < 18;
896
+ }}
897
+ disabledDateTime={({ slot }) => {
898
+ if (slot && dayjs(slot.start).hour() === 12) {
899
+ return { disabled: true, reason: '午休时间' };
900
+ }
901
+ return false;
902
+ }}
903
+ />
904
+ ```
905
+
906
+ #### 默认表单
907
+
908
+ 安排状态下,点击空闲时间段会展示默认表单。默认表单包含 `title` 和 `detail` 两个字段。如果传入 `formInner`,请使用 `@kne/form-info` 的 `FormInfo` 构建表单字段,组件外层仍然使用 `Form` 处理提交。
909
+
910
+ ```jsx
911
+ import FormInfo, { Input, TextArea } from '@kne/form-info';
912
+
913
+ const formInner = (
914
+ <FormInfo
915
+ column={1}
916
+ list={[
917
+ <Input name="title" label="日程标题" rule="REQ" block />,
918
+ <TextArea name="detail" label="日程说明" block />
919
+ ]}
920
+ />
921
+ );
922
+
923
+ <ScheduleCalendar mode="schedule" formInner={formInner} />;
924
+ ```
925
+
926
+ #### 工具函数
927
+
928
+ | 名称 | 参数 | 返回值 | 说明 |
929
+ | ----------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------- |
930
+ | groupEventsByDate | `CalendarEvent[]` | `Record<string, CalendarEvent[]>` | 按 `YYYY-MM-DD` 聚合日程,返回项含规范化后的 `start`/`end` |
931
+ | generateTimeSlots | `{ date, events, timeUnit, dayStart, dayEnd, disabledDate, disabledDateTime, availableDateTime, mode }` | `TimeSlot[]` | 按天生成时间段,并计算 `free`、`occupied`、`disabled` 状态 |
932
+ | normalizeEvent | `CalendarEvent` | `CalendarEvent` | 规范化日程起止时间为 dayjs 对象 |
933
+ | sortEventsByStart | `CalendarEvent[]` | `CalendarEvent[]` | 按开始时间升序排列 |
934
+ | dedupeEventsById | `CalendarEvent[]` | `CalendarEvent[]` | 按 `id` 去重,保留最后一次出现 |
935
+
936
+ #### 导出
937
+
938
+ ```js
939
+ import ScheduleCalendar, {
940
+ ReactCalendar,
941
+ groupEventsByDate,
942
+ generateTimeSlots,
943
+ normalizeEvent,
944
+ sortEventsByStart,
945
+ dedupeEventsById
946
+ } from '@kne/react-calendar';
947
+ ```
948
+
949
+ `ReactCalendar` 为 `ScheduleCalendar` 的别名导出。