@c2-markdown-ui/react 0.4.4 → 0.4.5

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.
Files changed (73) hide show
  1. package/README.md +24 -6
  2. package/dist/index.js +4365 -3733
  3. package/package.json +2 -2
  4. package/src/lib/MarkdownUI.tsx +47 -9
  5. package/src/lib/widgets.css +2188 -1372
  6. package/src/widgets/Button.tsx +39 -39
  7. package/src/widgets/ButtonGroup.tsx +47 -47
  8. package/src/widgets/Cascader.tsx +3 -2
  9. package/src/widgets/Chart.tsx +237 -237
  10. package/src/widgets/DatePicker.tsx +451 -434
  11. package/src/widgets/Form.tsx +512 -372
  12. package/src/widgets/Incomplete.tsx +3 -3
  13. package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
  14. package/src/widgets/Quiz.tsx +320 -320
  15. package/src/widgets/Radio.tsx +57 -26
  16. package/src/widgets/Select.tsx +178 -142
  17. package/src/widgets/SelectMulti.tsx +105 -72
  18. package/src/widgets/ShortAnswerQuestion.tsx +101 -101
  19. package/src/widgets/Slider.tsx +70 -56
  20. package/src/widgets/Switch.tsx +14 -2
  21. package/src/widgets/TextInput.tsx +76 -38
  22. package/src/widgets/TreeSelect.tsx +649 -0
  23. package/src/widgets/Widget.tsx +130 -82
  24. package/src/widgets/choiceUtils.ts +90 -45
  25. package/src/widgets/index.ts +17 -16
  26. package/dist/index.d.ts +0 -2
  27. package/dist/index.d.ts.map +0 -1
  28. package/dist/lib/MarkdownUI.d.ts +0 -10
  29. package/dist/lib/MarkdownUI.d.ts.map +0 -1
  30. package/dist/lib/index.d.ts +0 -4
  31. package/dist/lib/index.d.ts.map +0 -1
  32. package/dist/widgets/Button.d.ts +0 -15
  33. package/dist/widgets/Button.d.ts.map +0 -1
  34. package/dist/widgets/ButtonGroup.d.ts +0 -11
  35. package/dist/widgets/ButtonGroup.d.ts.map +0 -1
  36. package/dist/widgets/Cascader.d.ts +0 -34
  37. package/dist/widgets/Cascader.d.ts.map +0 -1
  38. package/dist/widgets/Chart.d.ts +0 -20
  39. package/dist/widgets/Chart.d.ts.map +0 -1
  40. package/dist/widgets/DatePicker.d.ts +0 -28
  41. package/dist/widgets/DatePicker.d.ts.map +0 -1
  42. package/dist/widgets/Form.d.ts +0 -47
  43. package/dist/widgets/Form.d.ts.map +0 -1
  44. package/dist/widgets/Incomplete.d.ts +0 -3
  45. package/dist/widgets/Incomplete.d.ts.map +0 -1
  46. package/dist/widgets/MultipleChoiceQuestion.d.ts +0 -15
  47. package/dist/widgets/MultipleChoiceQuestion.d.ts.map +0 -1
  48. package/dist/widgets/NumberInput.d.ts +0 -26
  49. package/dist/widgets/NumberInput.d.ts.map +0 -1
  50. package/dist/widgets/Quiz.d.ts +0 -37
  51. package/dist/widgets/Quiz.d.ts.map +0 -1
  52. package/dist/widgets/Radio.d.ts +0 -24
  53. package/dist/widgets/Radio.d.ts.map +0 -1
  54. package/dist/widgets/Select.d.ts +0 -15
  55. package/dist/widgets/Select.d.ts.map +0 -1
  56. package/dist/widgets/SelectMulti.d.ts +0 -20
  57. package/dist/widgets/SelectMulti.d.ts.map +0 -1
  58. package/dist/widgets/ShortAnswerQuestion.d.ts +0 -16
  59. package/dist/widgets/ShortAnswerQuestion.d.ts.map +0 -1
  60. package/dist/widgets/Slider.d.ts +0 -13
  61. package/dist/widgets/Slider.d.ts.map +0 -1
  62. package/dist/widgets/Switch.d.ts +0 -17
  63. package/dist/widgets/Switch.d.ts.map +0 -1
  64. package/dist/widgets/TextInput.d.ts +0 -11
  65. package/dist/widgets/TextInput.d.ts.map +0 -1
  66. package/dist/widgets/Widget.d.ts +0 -7
  67. package/dist/widgets/Widget.d.ts.map +0 -1
  68. package/dist/widgets/chartRemoteData.d.ts +0 -18
  69. package/dist/widgets/chartRemoteData.d.ts.map +0 -1
  70. package/dist/widgets/choiceUtils.d.ts +0 -24
  71. package/dist/widgets/choiceUtils.d.ts.map +0 -1
  72. package/dist/widgets/index.d.ts +0 -18
  73. package/dist/widgets/index.d.ts.map +0 -1
@@ -1,434 +1,451 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react';
2
-
3
- export interface DatePickerProps {
4
- label?: string;
5
- /** 选择模式:date 仅日期(默认)/ datetime 日期+时间 */
6
- picker?: 'date' | 'datetime';
7
- /** 范围选择(antd RangePicker 效果:双月历面板) */
8
- range?: boolean;
9
- /** 默认值:时间戳(毫秒)或格式化时间字符串;range 模式为 [start, end] */
10
- default?: number | string | (number | string)[];
11
- /** 显示格式:默认按模式推导——date 为 YYYY-MM-DD,datetime 为 YYYY-MM-DD HH:mm:ss */
12
- format?: string;
13
- placeholder?: string;
14
- /** 禁用当前日期之前的日期 */
15
- disableBeforeNow?: boolean;
16
- /** 禁用当前日期之后的日期 */
17
- disableAfterNow?: boolean;
18
- hint?: string;
19
- required?: boolean;
20
- /** 校验失败提示文案 */
21
- message?: string;
22
- /** 外部(form 提交时)传入的错误信息 */
23
- error?: string;
24
- /** 是否禁用(整组件不可交互,区别于 disableBeforeNow/disableAfterNow 的部分日期禁用) */
25
- disabled?: boolean;
26
- onchange: (value: number | number[] | null) => void;
27
- }
28
-
29
- type Cell = { ts: number; day: number; inMonth: boolean; disabled: boolean };
30
- type TimeState = { h: number; m: number; s: number };
31
-
32
- const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
33
-
34
- /** 时间戳/格式化字符串(2026-08-20 18:20:06、2026/05/11 等)→ 毫秒时间戳 */
35
- function parseDateLike(v: number | string | undefined | null): number | null {
36
- if (v === undefined || v === null || v === '') return null;
37
- if (typeof v === 'number') return v;
38
- const s = String(v).trim();
39
- if (/^-?\d+$/.test(s)) return Number(s);
40
- const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
41
- if (m) {
42
- return new Date(
43
- Number(m[1]), Number(m[2]) - 1, Number(m[3]),
44
- Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)
45
- ).getTime();
46
- }
47
- const t = Date.parse(s);
48
- return isNaN(t) ? null : t;
49
- }
50
-
51
- function pad(n: number): string {
52
- return String(n).padStart(2, '0');
53
- }
54
-
55
- function startOfDay(ts: number): number {
56
- const d = new Date(ts);
57
- return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
58
- }
59
-
60
- export const DatePicker: React.FC<DatePickerProps> = ({
61
- label = '',
62
- picker = 'date',
63
- range = false,
64
- default: initial,
65
- format,
66
- placeholder = '',
67
- disableBeforeNow = false,
68
- disableAfterNow = false,
69
- hint = '',
70
- required = false,
71
- message = '',
72
- error = '',
73
- disabled = false,
74
- onchange
75
- }) => {
76
- const showTime = picker === 'datetime';
77
- // 实际生效的格式:默认值按模式推导——datetime 为 YYYY-MM-DD HH:mm:ss,date 为 YYYY-MM-DD
78
- const effFormat = format ?? (showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
79
-
80
- // ---------- 状态 ----------
81
- const [selected, setSelected] = useState<number | null>(() => {
82
- if (initial === undefined || initial === null || range) return null;
83
- return parseDateLike(Array.isArray(initial) ? initial[0] : initial);
84
- });
85
- const [rangeStart, setRangeStart] = useState<number | null>(() => {
86
- if (initial === undefined || initial === null || !range) return null;
87
- if (Array.isArray(initial)) return parseDateLike(initial[0]);
88
- return parseDateLike(initial);
89
- });
90
- const [rangeEnd, setRangeEnd] = useState<number | null>(() => {
91
- if (range && Array.isArray(initial) && initial.length >= 2) return parseDateLike(initial[1]);
92
- return null;
93
- });
94
-
95
- const [open, setOpen] = useState(false);
96
- const [viewYear, setViewYear] = useState(() => {
97
- const ref = range ? (rangeStart ?? rangeEnd) : selected;
98
- return ref !== null ? new Date(ref).getFullYear() : new Date().getFullYear();
99
- });
100
- const [viewMonth, setViewMonth] = useState(() => {
101
- const ref = range ? (rangeStart ?? rangeEnd) : selected;
102
- return ref !== null ? new Date(ref).getMonth() : new Date().getMonth();
103
- });
104
- // datetime 模式下正在编辑的时间(单选一组;范围选择开始/结束各一组)
105
- const [tSingle, setTSingle] = useState<TimeState>({ h: 0, m: 0, s: 0 });
106
- const [tStart, setTStart] = useState<TimeState>({ h: 0, m: 0, s: 0 });
107
- const [tEnd, setTEnd] = useState<TimeState>({ h: 0, m: 0, s: 0 });
108
- // range 模式:第二次点击为结束日期;hover 预览
109
- const [pickingEnd, setPickingEnd] = useState(false);
110
- const [hoverTs, setHoverTs] = useState<number | null>(null);
111
-
112
- const rootRef = useRef<HTMLDivElement>(null);
113
-
114
- // 外部点击关闭面板
115
- useEffect(() => {
116
- function onDocClick(e: MouseEvent) {
117
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
118
- }
119
- document.addEventListener('mousedown', onDocClick);
120
- return () => document.removeEventListener('mousedown', onDocClick);
121
- }, []);
122
-
123
- // 打开面板时同步视图到已有值
124
- function syncView() {
125
- const ref = range ? (rangeStart ?? rangeEnd) : selected;
126
- const d = ref !== null ? new Date(ref) : new Date();
127
- setViewYear(d.getFullYear());
128
- setViewMonth(d.getMonth());
129
- if (range) {
130
- if (rangeStart !== null) setTStart(fromDate(rangeStart));
131
- if (rangeEnd !== null) setTEnd(fromDate(rangeEnd));
132
- } else if (selected !== null) {
133
- setTSingle(fromDate(selected));
134
- }
135
- }
136
-
137
- function fromDate(ts: number): TimeState {
138
- const d = new Date(ts);
139
- return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
140
- }
141
-
142
- function toggleOpen() {
143
- if (disabled) return;
144
- setOpen(prev => {
145
- const next = !prev;
146
- if (next) { setPickingEnd(false); syncView(); }
147
- return next;
148
- });
149
- }
150
-
151
- // ---------- 日历格子 ----------
152
- function makeCells(year: number, month: number): Cell[] {
153
- const first = new Date(year, month, 1);
154
- // 周一为一周起点
155
- const startOffset = (first.getDay() + 6) % 7;
156
- const gridStart = new Date(year, month, 1 - startOffset);
157
- const list: Cell[] = [];
158
- const now = new Date();
159
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
160
- for (let i = 0; i < 42; i++) {
161
- const d = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i);
162
- const ts = d.getTime();
163
- let disabled = false;
164
- if (disableBeforeNow && ts < todayStart) disabled = true;
165
- if (disableAfterNow && ts > todayStart) disabled = true;
166
- list.push({ ts, day: d.getDate(), inMonth: d.getMonth() === month, disabled });
167
- }
168
- return list;
169
- }
170
-
171
- // 单选 / range 左侧面板
172
- const leftCells = useMemo(() => makeCells(viewYear, viewMonth), [viewYear, viewMonth, disableBeforeNow, disableAfterNow]);
173
- const leftTitle = `${viewYear}年 ${viewMonth + 1}月`;
174
- // range 右侧面板(右侧月份 = 左侧 + 1)
175
- const rightYear = viewMonth === 11 ? viewYear + 1 : viewYear;
176
- const rightMonth = viewMonth === 11 ? 0 : viewMonth + 1;
177
- const rightCells = useMemo(() => makeCells(rightYear, rightMonth), [rightYear, rightMonth, disableBeforeNow, disableAfterNow]);
178
- const rightTitle = `${rightYear}年 ${rightMonth + 1}月`;
179
-
180
- function prevMonth() {
181
- if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1);
182
- }
183
- function nextMonth() {
184
- if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1);
185
- }
186
-
187
- // ---------- 选中逻辑 ----------
188
- function applyTime(dayTs: number, t: TimeState): number {
189
- if (!showTime) return new Date(dayTs).setHours(0, 0, 0, 0);
190
- return new Date(dayTs).setHours(t.h, t.m, t.s, 0);
191
- }
192
-
193
- function emit(nextSelected: number | null, nextStart: number | null, nextEnd: number | null) {
194
- if (range) {
195
- onchange(nextStart !== null && nextEnd !== null ? [nextStart, nextEnd] : []);
196
- } else {
197
- onchange(nextSelected);
198
- }
199
- }
200
-
201
- function pickCell(cell: Cell) {
202
- if (cell.disabled) return;
203
- if (range) {
204
- if (!pickingEnd || rangeStart === null || cell.ts < startOfDay(rangeStart)) {
205
- // 第一次点击,或点击了早于当前开始日期的格子 → (重新)选择开始
206
- const s = applyTime(cell.ts, tStart);
207
- setRangeStart(s);
208
- setRangeEnd(null);
209
- setPickingEnd(true);
210
- } else {
211
- const e = applyTime(cell.ts, tEnd);
212
- setRangeEnd(e);
213
- setPickingEnd(false);
214
- // datetime 模式保持面板打开,等时间输入 + 确定;仅日期模式直接完成
215
- if (!showTime) setOpen(false);
216
- emit(selected, rangeStart, e);
217
- }
218
- } else {
219
- const v = applyTime(cell.ts, tSingle);
220
- setSelected(v);
221
- if (!showTime) setOpen(false);
222
- emit(v, rangeStart, rangeEnd);
223
- }
224
- }
225
-
226
- function setTime(side: 'single' | 'start' | 'end', part: 'h' | 'm' | 's', ev: React.ChangeEvent<HTMLInputElement>) {
227
- const n = parseInt(ev.target.value, 10);
228
- if (isNaN(n)) return;
229
- const max = part === 'h' ? 23 : 59;
230
- const v = Math.min(max, Math.max(0, n));
231
- const t = side === 'single' ? tSingle : side === 'start' ? tStart : tEnd;
232
- const next = { ...t, [part]: v } as TimeState;
233
- if (side === 'single') setTSingle(next);
234
- if (side === 'start') setTStart(next);
235
- if (side === 'end') setTEnd(next);
236
- // 已有选中值时实时更新
237
- if (side === 'single' && selected !== null) { const s = applyTime(selected, next); setSelected(s); emit(s, rangeStart, rangeEnd); }
238
- if (side === 'start' && rangeStart !== null) { const s = applyTime(rangeStart, next); setRangeStart(s); if (rangeEnd !== null) emit(selected, s, rangeEnd); }
239
- if (side === 'end' && rangeEnd !== null) { const e = applyTime(rangeEnd, next); setRangeEnd(e); if (rangeStart !== null) emit(selected, rangeStart, e); }
240
- }
241
-
242
- function clearValue() {
243
- setSelected(null);
244
- setRangeStart(null);
245
- setRangeEnd(null);
246
- setPickingEnd(false);
247
- onchange(range ? [] : null);
248
- }
249
-
250
- // datetime 模式:应用时间输入并确认
251
- function confirmTime() {
252
- if (range) {
253
- const s = rangeStart !== null ? applyTime(rangeStart, tStart) : null;
254
- const e = rangeEnd !== null ? applyTime(rangeEnd, tEnd) : null;
255
- if (s !== null) setRangeStart(s);
256
- if (e !== null) setRangeEnd(e);
257
- emit(selected, s, e);
258
- } else if (selected !== null) {
259
- const v = applyTime(selected, tSingle);
260
- setSelected(v);
261
- emit(v, rangeStart, rangeEnd);
262
- }
263
- setOpen(false);
264
- }
265
-
266
- function formatDate(ts: number): string {
267
- const d = new Date(ts);
268
- return effFormat
269
- .replace(/YYYY/g, String(d.getFullYear()))
270
- .replace(/MM/g, pad(d.getMonth() + 1))
271
- .replace(/DD/g, pad(d.getDate()))
272
- .replace(/HH/g, pad(d.getHours()))
273
- .replace(/mm/g, pad(d.getMinutes()))
274
- .replace(/ss/g, pad(d.getSeconds()));
275
- }
276
-
277
- // 展示文案
278
- const startText = rangeStart !== null ? formatDate(rangeStart) : '';
279
- const endText = rangeEnd !== null ? formatDate(rangeEnd) : '';
280
-
281
- // 格子状态 class
282
- function cellCls(c: Cell): string {
283
- const day = startOfDay(c.ts);
284
- let cls = 'dp-cell';
285
- if (!c.inMonth) cls += ' dp-cell-out';
286
- if (c.disabled) {
287
- cls += ' dp-cell-disabled';
288
- return cls;
289
- }
290
- const todayTs = startOfDay(Date.now());
291
- if (day === todayTs) cls += ' dp-cell-today';
292
- if (range && rangeStart !== null) {
293
- const s = startOfDay(rangeStart);
294
- // 已有结束日期用真实值;正在选结束时用 hover 位置预览(支持反向 hover)
295
- const e = rangeEnd !== null ? startOfDay(rangeEnd) : (pickingEnd && hoverTs !== null ? startOfDay(hoverTs) : null);
296
- if (e !== null) {
297
- const lo = Math.min(s, e);
298
- const hi = Math.max(s, e);
299
- if (day === lo) cls += ' dp-cell-range-start';
300
- if (day === hi) cls += ' dp-cell-range-end';
301
- if (day > lo && day < hi) cls += ' dp-cell-in-range';
302
- } else if (day === s) {
303
- cls += ' dp-cell-range-start';
304
- }
305
- } else if (selected !== null && day === startOfDay(selected)) {
306
- cls += ' dp-cell-selected';
307
- }
308
- return cls;
309
- }
310
-
311
- // 单个日历面板
312
- const calPanel = (cellsList: Cell[], title: string, nav: 'prev' | 'next' | 'both') => (
313
- <div className="dp-calendar">
314
- <div className="dp-header">
315
- {(nav === 'prev' || nav === 'both')
316
- ? <button type="button" className="dp-nav" onClick={prevMonth} aria-label="上个月">‹</button>
317
- : <span className="dp-nav-spacer"></span>}
318
- <span className="dp-title">{title}</span>
319
- {(nav === 'next' || nav === 'both')
320
- ? <button type="button" className="dp-nav" onClick={nextMonth} aria-label="下个月">›</button>
321
- : <span className="dp-nav-spacer"></span>}
322
- </div>
323
- <div className="dp-week-header">
324
- {WEEK_LABELS.map(w => <span className="dp-week" key={w}>{w}</span>)}
325
- </div>
326
- <div className="dp-grid" onMouseLeave={() => setHoverTs(null)}>
327
- {cellsList.map(c => (
328
- <button
329
- type="button"
330
- key={c.ts}
331
- className={cellCls(c)}
332
- onClick={() => pickCell(c)}
333
- onMouseOver={() => setHoverTs(c.ts)}
334
- disabled={c.disabled}
335
- >{c.day}</button>
336
- ))}
337
- </div>
338
- </div>
339
- );
340
-
341
- const timeInput = (side: 'single' | 'start' | 'end', label: string, t: TimeState) => (
342
- <>
343
- {label && <span className="dp-time-label">{label}</span>}
344
- <input className="dp-time-input" type="number" min={0} max={23} value={t.h} onChange={(e) => setTime(side, 'h', e)} aria-label={`${label} 时`} />
345
- <span className="dp-time-sep">:</span>
346
- <input className="dp-time-input" type="number" min={0} max={59} value={t.m} onChange={(e) => setTime(side, 'm', e)} aria-label={`${label} 分`} />
347
- <span className="dp-time-sep">:</span>
348
- <input className="dp-time-input" type="number" min={0} max={59} value={t.s} onChange={(e) => setTime(side, 's', e)} aria-label={`${label} 秒`} />
349
- </>
350
- );
351
-
352
- return (
353
- <div className="selector widget-date-picker" ref={rootRef}>
354
- {(label || hint || required) && (
355
- <div className="widget-label-row">
356
- {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
357
- {label && <label>{label}</label>}
358
- {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
359
- </div>
360
- )}
361
- <div className="widget-field-content">
362
- <button
363
- type="button"
364
- className={`dp-input${error ? ' widget-input-error' : ''}`}
365
- onClick={toggleOpen}
366
- aria-expanded={open}
367
- disabled={disabled}
368
- >
369
- <span className="dp-input-icon" aria-hidden="true">📅</span>
370
- {range ? (
371
- <>
372
- <span className={`dp-input-text dp-seg${startText ? '' : ' dp-placeholder'}`}>
373
- {startText || placeholder || (showTime ? '开始时间' : '开始日期')}
374
- </span>
375
- <span className="dp-sep" aria-hidden="true">→</span>
376
- <span className={`dp-input-text dp-seg${endText ? '' : ' dp-placeholder'}`}>
377
- {endText || (showTime ? '结束时间' : '结束日期')}
378
- </span>
379
- </>
380
- ) : (
381
- <span className={`dp-input-text${selected !== null ? '' : ' dp-placeholder'}`}>
382
- {selected !== null ? formatDate(selected) : placeholder || '请选择日期'}
383
- </span>
384
- )}
385
- {!disabled && (range ? (startText || endText) : selected !== null) && (
386
- <span
387
- className="dp-clear"
388
- role="button"
389
- tabIndex={-1}
390
- aria-label="清空"
391
- onClick={(e) => { e.stopPropagation(); clearValue(); setOpen(false); }}
392
- >×</span>
393
- )}
394
- </button>
395
- {error && <div className="widget-error-text" role="alert">{error}</div>}
396
-
397
- {open && (
398
- <div className={`dp-panel${range ? ' dp-range' : ''}`}>
399
- {range ? (
400
- <div className="dp-panels">
401
- {calPanel(leftCells, leftTitle, 'prev')}
402
- {calPanel(rightCells, rightTitle, 'next')}
403
- </div>
404
- ) : (
405
- calPanel(leftCells, leftTitle, 'both')
406
- )}
407
-
408
- {showTime && (
409
- <div className="dp-time">
410
- {range ? (
411
- <>
412
- <div className="dp-time-groups">
413
- <div className="dp-time-group">{timeInput('start', '开始', tStart)}</div>
414
- <span className="dp-time-arrow" aria-hidden="true">~</span>
415
- <div className="dp-time-group">{timeInput('end', '结束', tEnd)}</div>
416
- </div>
417
- <div className="dp-footer">
418
- <button type="button" className="dp-ok" onClick={confirmTime} disabled={rangeStart === null}>确定</button>
419
- </div>
420
- </>
421
- ) : (
422
- <div className="dp-time-group">
423
- {timeInput('single', '', tSingle)}
424
- <button type="button" className="dp-ok" onClick={confirmTime} disabled={selected === null}>确定</button>
425
- </div>
426
- )}
427
- </div>
428
- )}
429
- </div>
430
- )}
431
- </div>
432
- </div>
433
- );
434
- };
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+
3
+ export interface DatePickerProps {
4
+ label?: string;
5
+ /** 选择模式:date 仅日期(默认)/ datetime 日期+时间 */
6
+ picker?: 'date' | 'datetime';
7
+ /** 范围选择(antd RangePicker 效果:双月历面板) */
8
+ range?: boolean;
9
+ /** 默认值:时间戳(毫秒)或格式化时间字符串;range 模式为 [start, end] */
10
+ default?: number | string | (number | string)[];
11
+ /** 显示格式:默认按模式推导——date 为 YYYY-MM-DD,datetime 为 YYYY-MM-DD HH:mm:ss */
12
+ format?: string;
13
+ placeholder?: string;
14
+ /** 禁用当前日期之前的日期 */
15
+ disableBeforeNow?: boolean;
16
+ /** 禁用当前日期之后的日期 */
17
+ disableAfterNow?: boolean;
18
+ hint?: string;
19
+ required?: boolean;
20
+ /** 校验失败提示文案 */
21
+ message?: string;
22
+ /** 外部(form 提交时)传入的错误信息 */
23
+ error?: string;
24
+ /** 是否禁用(整组件不可交互,区别于 disableBeforeNow/disableAfterNow 的部分日期禁用) */
25
+ disabled?: boolean;
26
+ onchange: (value: number | number[] | null) => void;
27
+ }
28
+
29
+ type Cell = { ts: number; day: number; inMonth: boolean; disabled: boolean };
30
+ type TimeState = { h: number; m: number; s: number };
31
+
32
+ const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
33
+
34
+ /** 时间戳/格式化字符串(2026-08-20 18:20:06、2026/05/11 等)→ 毫秒时间戳 */
35
+ function parseDateLike(v: number | string | undefined | null): number | null {
36
+ if (v === undefined || v === null || v === '') return null;
37
+ if (typeof v === 'number') return v;
38
+ const s = String(v).trim();
39
+ if (/^-?\d+$/.test(s)) return Number(s);
40
+ const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
41
+ if (m) {
42
+ return new Date(
43
+ Number(m[1]), Number(m[2]) - 1, Number(m[3]),
44
+ Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)
45
+ ).getTime();
46
+ }
47
+ const t = Date.parse(s);
48
+ return isNaN(t) ? null : t;
49
+ }
50
+
51
+ function pad(n: number): string {
52
+ return String(n).padStart(2, '0');
53
+ }
54
+
55
+ function startOfDay(ts: number): number {
56
+ const d = new Date(ts);
57
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
58
+ }
59
+
60
+ export const DatePicker: React.FC<DatePickerProps> = ({
61
+ label = '',
62
+ picker = 'date',
63
+ range = false,
64
+ default: initial,
65
+ format,
66
+ placeholder = '',
67
+ disableBeforeNow = false,
68
+ disableAfterNow = false,
69
+ hint = '',
70
+ required = false,
71
+ message = '',
72
+ error = '',
73
+ disabled = false,
74
+ onchange
75
+ }) => {
76
+ const showTime = picker === 'datetime';
77
+ // 实际生效的格式:默认值按模式推导——datetime 为 YYYY-MM-DD HH:mm:ss,date 为 YYYY-MM-DD
78
+ const effFormat = format ?? (showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
79
+
80
+ // ---------- 状态 ----------
81
+ const [selected, setSelected] = useState<number | null>(() => {
82
+ if (initial === undefined || initial === null || range) return null;
83
+ return parseDateLike(Array.isArray(initial) ? initial[0] : initial);
84
+ });
85
+ const [rangeStart, setRangeStart] = useState<number | null>(() => {
86
+ if (initial === undefined || initial === null || !range) return null;
87
+ if (Array.isArray(initial)) return parseDateLike(initial[0]);
88
+ return parseDateLike(initial);
89
+ });
90
+ const [rangeEnd, setRangeEnd] = useState<number | null>(() => {
91
+ if (range && Array.isArray(initial) && initial.length >= 2) return parseDateLike(initial[1]);
92
+ return null;
93
+ });
94
+
95
+ const [open, setOpen] = useState(false);
96
+ const [viewYear, setViewYear] = useState(() => {
97
+ const ref = range ? (rangeStart ?? rangeEnd) : selected;
98
+ return ref !== null ? new Date(ref).getFullYear() : new Date().getFullYear();
99
+ });
100
+ const [viewMonth, setViewMonth] = useState(() => {
101
+ const ref = range ? (rangeStart ?? rangeEnd) : selected;
102
+ return ref !== null ? new Date(ref).getMonth() : new Date().getMonth();
103
+ });
104
+ // datetime 模式下正在编辑的时间(单选一组;范围选择开始/结束各一组)
105
+ const [tSingle, setTSingle] = useState<TimeState>({ h: 0, m: 0, s: 0 });
106
+ const [tStart, setTStart] = useState<TimeState>({ h: 0, m: 0, s: 0 });
107
+ const [tEnd, setTEnd] = useState<TimeState>({ h: 0, m: 0, s: 0 });
108
+ // range 模式:第二次点击为结束日期;hover 预览
109
+ const [pickingEnd, setPickingEnd] = useState(false);
110
+ const [hoverTs, setHoverTs] = useState<number | null>(null);
111
+
112
+ const rootRef = useRef<HTMLDivElement>(null);
113
+
114
+ // 外部点击关闭面板
115
+ useEffect(() => {
116
+ function onDocClick(e: MouseEvent) {
117
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
118
+ }
119
+ document.addEventListener('mousedown', onDocClick);
120
+ return () => document.removeEventListener('mousedown', onDocClick);
121
+ }, []);
122
+
123
+ // 打开面板时同步视图到已有值
124
+ function syncView() {
125
+ const ref = range ? (rangeStart ?? rangeEnd) : selected;
126
+ const d = ref !== null ? new Date(ref) : new Date();
127
+ setViewYear(d.getFullYear());
128
+ setViewMonth(d.getMonth());
129
+ if (range) {
130
+ if (rangeStart !== null) setTStart(fromDate(rangeStart));
131
+ if (rangeEnd !== null) setTEnd(fromDate(rangeEnd));
132
+ } else if (selected !== null) {
133
+ setTSingle(fromDate(selected));
134
+ }
135
+ }
136
+
137
+ function fromDate(ts: number): TimeState {
138
+ const d = new Date(ts);
139
+ return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
140
+ }
141
+
142
+ function toggleOpen() {
143
+ if (disabled) return;
144
+ setOpen(prev => {
145
+ const next = !prev;
146
+ if (next) { setPickingEnd(false); syncView(); }
147
+ return next;
148
+ });
149
+ }
150
+
151
+ // ---------- 日历格子 ----------
152
+ function makeCells(year: number, month: number): Cell[] {
153
+ const first = new Date(year, month, 1);
154
+ // 周一为一周起点
155
+ const startOffset = (first.getDay() + 6) % 7;
156
+ const gridStart = new Date(year, month, 1 - startOffset);
157
+ const list: Cell[] = [];
158
+ const now = new Date();
159
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
160
+ for (let i = 0; i < 42; i++) {
161
+ const d = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i);
162
+ const ts = d.getTime();
163
+ let disabled = false;
164
+ if (disableBeforeNow && ts < todayStart) disabled = true;
165
+ if (disableAfterNow && ts > todayStart) disabled = true;
166
+ list.push({ ts, day: d.getDate(), inMonth: d.getMonth() === month, disabled });
167
+ }
168
+ return list;
169
+ }
170
+
171
+ // 单选 / range 左侧面板
172
+ const leftCells = useMemo(() => makeCells(viewYear, viewMonth), [viewYear, viewMonth, disableBeforeNow, disableAfterNow]);
173
+ const leftTitle = `${viewMonth + 1}月`;
174
+ const leftYearTitle = `${viewYear}年`;
175
+ // range 右侧面板(右侧月份 = 左侧 + 1)
176
+ const rightYear = viewMonth === 11 ? viewYear + 1 : viewYear;
177
+ const rightMonth = viewMonth === 11 ? 0 : viewMonth + 1;
178
+ const rightCells = useMemo(() => makeCells(rightYear, rightMonth), [rightYear, rightMonth, disableBeforeNow, disableAfterNow]);
179
+ const rightTitle = `${rightMonth + 1}月`;
180
+ const rightYearTitle = `${rightYear}年`;
181
+
182
+ function prevMonth() {
183
+ if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1);
184
+ }
185
+ function nextMonth() {
186
+ if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1);
187
+ }
188
+ function prevYear() { setViewYear(y => y - 1); }
189
+ function nextYear() { setViewYear(y => y + 1); }
190
+
191
+ // ---------- 选中逻辑 ----------
192
+ function applyTime(dayTs: number, t: TimeState): number {
193
+ if (!showTime) return new Date(dayTs).setHours(0, 0, 0, 0);
194
+ return new Date(dayTs).setHours(t.h, t.m, t.s, 0);
195
+ }
196
+
197
+ function emit(nextSelected: number | null, nextStart: number | null, nextEnd: number | null) {
198
+ if (range) {
199
+ onchange(nextStart !== null && nextEnd !== null ? [nextStart, nextEnd] : []);
200
+ } else {
201
+ onchange(nextSelected);
202
+ }
203
+ }
204
+
205
+ function pickCell(cell: Cell) {
206
+ if (cell.disabled) return;
207
+ if (range) {
208
+ if (!pickingEnd || rangeStart === null || cell.ts < startOfDay(rangeStart)) {
209
+ // 第一次点击,或点击了早于当前开始日期的格子 → (重新)选择开始
210
+ const s = applyTime(cell.ts, tStart);
211
+ setRangeStart(s);
212
+ setRangeEnd(null);
213
+ setPickingEnd(true);
214
+ } else {
215
+ const e = applyTime(cell.ts, tEnd);
216
+ setRangeEnd(e);
217
+ setPickingEnd(false);
218
+ // datetime 模式保持面板打开,等时间输入 + 确定;仅日期模式直接完成
219
+ if (!showTime) setOpen(false);
220
+ emit(selected, rangeStart, e);
221
+ }
222
+ } else {
223
+ const v = applyTime(cell.ts, tSingle);
224
+ setSelected(v);
225
+ if (!showTime) setOpen(false);
226
+ emit(v, rangeStart, rangeEnd);
227
+ }
228
+ }
229
+
230
+ function setTime(side: 'single' | 'start' | 'end', part: 'h' | 'm' | 's', ev: React.ChangeEvent<HTMLInputElement>) {
231
+ const n = parseInt(ev.target.value, 10);
232
+ if (isNaN(n)) return;
233
+ const max = part === 'h' ? 23 : 59;
234
+ const v = Math.min(max, Math.max(0, n));
235
+ const t = side === 'single' ? tSingle : side === 'start' ? tStart : tEnd;
236
+ const next = { ...t, [part]: v } as TimeState;
237
+ if (side === 'single') setTSingle(next);
238
+ if (side === 'start') setTStart(next);
239
+ if (side === 'end') setTEnd(next);
240
+ // 已有选中值时实时更新
241
+ if (side === 'single' && selected !== null) { const s = applyTime(selected, next); setSelected(s); emit(s, rangeStart, rangeEnd); }
242
+ if (side === 'start' && rangeStart !== null) { const s = applyTime(rangeStart, next); setRangeStart(s); if (rangeEnd !== null) emit(selected, s, rangeEnd); }
243
+ if (side === 'end' && rangeEnd !== null) { const e = applyTime(rangeEnd, next); setRangeEnd(e); if (rangeStart !== null) emit(selected, rangeStart, e); }
244
+ }
245
+
246
+ function clearValue() {
247
+ setSelected(null);
248
+ setRangeStart(null);
249
+ setRangeEnd(null);
250
+ setPickingEnd(false);
251
+ onchange(range ? [] : null);
252
+ }
253
+
254
+ // datetime 模式:应用时间输入并确认
255
+ function confirmTime() {
256
+ if (range) {
257
+ const s = rangeStart !== null ? applyTime(rangeStart, tStart) : null;
258
+ const e = rangeEnd !== null ? applyTime(rangeEnd, tEnd) : null;
259
+ if (s !== null) setRangeStart(s);
260
+ if (e !== null) setRangeEnd(e);
261
+ emit(selected, s, e);
262
+ } else if (selected !== null) {
263
+ const v = applyTime(selected, tSingle);
264
+ setSelected(v);
265
+ emit(v, rangeStart, rangeEnd);
266
+ }
267
+ setOpen(false);
268
+ }
269
+
270
+ function formatDate(ts: number): string {
271
+ const d = new Date(ts);
272
+ return effFormat
273
+ .replace(/YYYY/g, String(d.getFullYear()))
274
+ .replace(/MM/g, pad(d.getMonth() + 1))
275
+ .replace(/DD/g, pad(d.getDate()))
276
+ .replace(/HH/g, pad(d.getHours()))
277
+ .replace(/mm/g, pad(d.getMinutes()))
278
+ .replace(/ss/g, pad(d.getSeconds()));
279
+ }
280
+
281
+ // 展示文案
282
+ const startText = rangeStart !== null ? formatDate(rangeStart) : '';
283
+ const endText = rangeEnd !== null ? formatDate(rangeEnd) : '';
284
+
285
+ // 格子状态 class
286
+ function cellCls(c: Cell): string {
287
+ const day = startOfDay(c.ts);
288
+ let cls = 'dp-cell';
289
+ if (!c.inMonth) cls += ' dp-cell-out';
290
+ if (c.disabled) {
291
+ cls += ' dp-cell-disabled';
292
+ return cls;
293
+ }
294
+ const todayTs = startOfDay(Date.now());
295
+ if (day === todayTs) cls += ' dp-cell-today';
296
+ if (range && rangeStart !== null) {
297
+ const s = startOfDay(rangeStart);
298
+ // 已有结束日期用真实值;正在选结束时用 hover 位置预览(支持反向 hover)
299
+ const e = rangeEnd !== null ? startOfDay(rangeEnd) : (pickingEnd && hoverTs !== null ? startOfDay(hoverTs) : null);
300
+ if (e !== null) {
301
+ const lo = Math.min(s, e);
302
+ const hi = Math.max(s, e);
303
+ if (day === lo) cls += ' dp-cell-range-start';
304
+ if (day === hi) cls += ' dp-cell-range-end';
305
+ if (day > lo && day < hi) cls += ' dp-cell-in-range';
306
+ } else if (day === s) {
307
+ cls += ' dp-cell-range-start';
308
+ }
309
+ } else if (selected !== null && day === startOfDay(selected)) {
310
+ cls += ' dp-cell-selected';
311
+ }
312
+ return cls;
313
+ }
314
+
315
+ // 单个日历面板
316
+ const calPanel = (cellsList: Cell[], title: string, yearTitle: string, nav: 'prev' | 'next' | 'both') => (
317
+ <div className="dp-calendar">
318
+ <div className="dp-header">
319
+ <div className="dp-header-nav">
320
+ {(nav === 'prev' || nav === 'both') && (
321
+ <>
322
+ <button type="button" className="dp-nav" onClick={prevYear} aria-label="上一年">«</button>
323
+ <button type="button" className="dp-nav" onClick={prevMonth} aria-label="上个月">‹</button>
324
+ </>
325
+ )}
326
+ </div>
327
+ <span className="dp-title">
328
+ <span className="dp-title-year">{yearTitle}</span>
329
+ {title}
330
+ </span>
331
+ <div className="dp-header-nav">
332
+ {(nav === 'next' || nav === 'both') && (
333
+ <>
334
+ <button type="button" className="dp-nav" onClick={nextMonth} aria-label="下个月">›</button>
335
+ <button type="button" className="dp-nav" onClick={nextYear} aria-label="下一年">»</button>
336
+ </>
337
+ )}
338
+ </div>
339
+ </div>
340
+ <div className="dp-week-header">
341
+ {WEEK_LABELS.map(w => <span className="dp-week" key={w}>{w}</span>)}
342
+ </div>
343
+ <div className="dp-grid" onMouseLeave={() => setHoverTs(null)}>
344
+ {cellsList.map(c => (
345
+ <button
346
+ type="button"
347
+ key={c.ts}
348
+ className={cellCls(c)}
349
+ onClick={() => pickCell(c)}
350
+ onMouseOver={() => setHoverTs(c.ts)}
351
+ disabled={c.disabled}
352
+ >{c.day}</button>
353
+ ))}
354
+ </div>
355
+ </div>
356
+ );
357
+
358
+ const timeInput = (side: 'single' | 'start' | 'end', label: string, t: TimeState) => (
359
+ <>
360
+ {label && <span className="dp-time-label">{label}</span>}
361
+ <input className="dp-time-input" type="number" min={0} max={23} value={t.h} onChange={(e) => setTime(side, 'h', e)} aria-label={`${label} 时`} />
362
+ <span className="dp-time-sep">:</span>
363
+ <input className="dp-time-input" type="number" min={0} max={59} value={t.m} onChange={(e) => setTime(side, 'm', e)} aria-label={`${label} 分`} />
364
+ <span className="dp-time-sep">:</span>
365
+ <input className="dp-time-input" type="number" min={0} max={59} value={t.s} onChange={(e) => setTime(side, 's', e)} aria-label={`${label} 秒`} />
366
+ </>
367
+ );
368
+
369
+ return (
370
+ <div className="selector widget-date-picker" ref={rootRef}>
371
+ {(label || hint || required) && (
372
+ <div className="widget-label-row">
373
+ {required && <span className="widget-required-mark" aria-hidden="true">*</span>}
374
+ {label && <label>{label}</label>}
375
+ {hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
376
+ </div>
377
+ )}
378
+ <div className="widget-field-content">
379
+ <button
380
+ type="button"
381
+ className={`dp-input${error ? ' widget-input-error' : ''}`}
382
+ onClick={toggleOpen}
383
+ aria-expanded={open}
384
+ disabled={disabled}
385
+ >
386
+ <span className="dp-input-icon" aria-hidden="true">📅</span>
387
+ {range ? (
388
+ <>
389
+ <span className={`dp-input-text dp-seg${startText ? '' : ' dp-placeholder'}`}>
390
+ {startText || placeholder || (showTime ? '开始时间' : '开始日期')}
391
+ </span>
392
+ <span className="dp-sep" aria-hidden="true">→</span>
393
+ <span className={`dp-input-text dp-seg${endText ? '' : ' dp-placeholder'}`}>
394
+ {endText || (showTime ? '结束时间' : '结束日期')}
395
+ </span>
396
+ </>
397
+ ) : (
398
+ <span className={`dp-input-text${selected !== null ? '' : ' dp-placeholder'}`}>
399
+ {selected !== null ? formatDate(selected) : placeholder || '请选择日期'}
400
+ </span>
401
+ )}
402
+ {!disabled && (range ? (startText || endText) : selected !== null) && (
403
+ <span
404
+ className="dp-clear"
405
+ role="button"
406
+ tabIndex={-1}
407
+ aria-label="清空"
408
+ onClick={(e) => { e.stopPropagation(); clearValue(); setOpen(false); }}
409
+ >×</span>
410
+ )}
411
+ </button>
412
+ {error && <div className="widget-error-text" role="alert">{error}</div>}
413
+
414
+ {open && (
415
+ <div className={`dp-panel${range ? ' dp-range' : ''}`}>
416
+ {range ? (
417
+ <div className="dp-panels">
418
+ {calPanel(leftCells, leftTitle, leftYearTitle, 'prev')}
419
+ {calPanel(rightCells, rightTitle, rightYearTitle, 'next')}
420
+ </div>
421
+ ) : (
422
+ calPanel(leftCells, leftTitle, leftYearTitle, 'both')
423
+ )}
424
+
425
+ {showTime && (
426
+ <div className="dp-time">
427
+ {range ? (
428
+ <>
429
+ <div className="dp-time-groups">
430
+ <div className="dp-time-group">{timeInput('start', '开始', tStart)}</div>
431
+ <span className="dp-time-arrow" aria-hidden="true">~</span>
432
+ <div className="dp-time-group">{timeInput('end', '结束', tEnd)}</div>
433
+ </div>
434
+ <div className="dp-footer">
435
+ <button type="button" className="dp-ok" onClick={confirmTime} disabled={rangeStart === null}>确定</button>
436
+ </div>
437
+ </>
438
+ ) : (
439
+ <div className="dp-time-group">
440
+ {timeInput('single', '', tSingle)}
441
+ <button type="button" className="dp-ok" onClick={confirmTime} disabled={selected === null}>确定</button>
442
+ </div>
443
+ )}
444
+ </div>
445
+ )}
446
+ </div>
447
+ )}
448
+ </div>
449
+ </div>
450
+ );
451
+ };