@c2-markdown-ui/react 0.0.0-stage → 0.4.3
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 +111 -2
- package/dist/index.js +10085 -0
- package/package.json +44 -4
- package/src/index.ts +1 -0
- package/src/lib/MarkdownUI.tsx +111 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/widgets.css +2189 -0
- package/src/widgets/Button.tsx +39 -0
- package/src/widgets/ButtonGroup.tsx +47 -0
- package/src/widgets/Cascader.tsx +400 -0
- package/src/widgets/Chart.tsx +238 -0
- package/src/widgets/DatePicker.tsx +451 -0
- package/src/widgets/Form.tsx +512 -0
- package/src/widgets/Incomplete.tsx +55 -0
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -0
- package/src/widgets/NumberInput.tsx +169 -0
- package/src/widgets/Quiz.tsx +320 -0
- package/src/widgets/Radio.tsx +177 -0
- package/src/widgets/Select.tsx +178 -0
- package/src/widgets/SelectMulti.tsx +105 -0
- package/src/widgets/ShortAnswerQuestion.tsx +101 -0
- package/src/widgets/Slider.tsx +71 -0
- package/src/widgets/Switch.tsx +68 -0
- package/src/widgets/TextInput.tsx +76 -0
- package/src/widgets/TreeSelect.tsx +649 -0
- package/src/widgets/Widget.tsx +131 -0
- package/src/widgets/chartRemoteData.ts +84 -0
- package/src/widgets/choiceUtils.ts +90 -0
- package/src/widgets/index.ts +18 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
CategoryScale,
|
|
5
|
+
LinearScale,
|
|
6
|
+
PointElement,
|
|
7
|
+
LineElement,
|
|
8
|
+
BarElement,
|
|
9
|
+
Title,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Legend,
|
|
12
|
+
ArcElement,
|
|
13
|
+
LineController,
|
|
14
|
+
BarController,
|
|
15
|
+
PieController,
|
|
16
|
+
ScatterController
|
|
17
|
+
} from 'chart.js';
|
|
18
|
+
import { parseChartCsv, normalizeRemoteChartData, RemoteChartData } from './chartRemoteData';
|
|
19
|
+
|
|
20
|
+
// Register Chart.js components
|
|
21
|
+
ChartJS.register(
|
|
22
|
+
CategoryScale,
|
|
23
|
+
LinearScale,
|
|
24
|
+
PointElement,
|
|
25
|
+
LineElement,
|
|
26
|
+
BarElement,
|
|
27
|
+
Title,
|
|
28
|
+
Tooltip,
|
|
29
|
+
Legend,
|
|
30
|
+
ArcElement,
|
|
31
|
+
LineController,
|
|
32
|
+
BarController,
|
|
33
|
+
PieController,
|
|
34
|
+
ScatterController
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
interface Dataset {
|
|
38
|
+
label: string;
|
|
39
|
+
data: number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ChartProps {
|
|
43
|
+
type: "chart-line" | "chart-bar" | "chart-pie" | "chart-scatter";
|
|
44
|
+
title?: string;
|
|
45
|
+
height?: number;
|
|
46
|
+
/** 远端数据源 URL:设置后运行时 fetch 拉取(CSV 文本或 JSON),本地 CSV 作为初始/后备数据 */
|
|
47
|
+
url?: string;
|
|
48
|
+
/** 是否展示图例:未设置(undefined)时自动判断(多数据集或饼图时展示) */
|
|
49
|
+
legend?: boolean;
|
|
50
|
+
labels: string[];
|
|
51
|
+
datasets: Dataset[];
|
|
52
|
+
options?: Record<string, any>;
|
|
53
|
+
onchange: (data: any) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function Chart({ type, title, height, url = '', legend, labels, datasets, options = {}, onchange }: ChartProps) {
|
|
57
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
58
|
+
const chartRef = useRef<ChartJS | null>(null);
|
|
59
|
+
|
|
60
|
+
// 远端数据:拉取成功后覆盖本地 CSV 数据
|
|
61
|
+
// 加载失败静默处理:回落本地数据,均无数据时图表区域显示灰色"暂无数据"
|
|
62
|
+
const [remoteData, setRemoteData] = useState<RemoteChartData | null>(null);
|
|
63
|
+
const [loading, setLoading] = useState(false);
|
|
64
|
+
|
|
65
|
+
// 实际渲染数据:远端优先,本地 CSV 作为初始/后备
|
|
66
|
+
const effectiveLabels = remoteData ? remoteData.labels : labels;
|
|
67
|
+
const effectiveDatasets = remoteData ? remoteData.datasets : datasets;
|
|
68
|
+
/** 无任何可渲染数据(本地与远端均为空) */
|
|
69
|
+
const isEmpty = !loading && effectiveLabels.length === 0 && effectiveDatasets.length === 0;
|
|
70
|
+
|
|
71
|
+
// 远端数据源:fetch 拉取 CSV 文本或 JSON,成功后覆盖本地数据
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!url) return;
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
setLoading(true);
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url);
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
if (cancelled) return;
|
|
82
|
+
let parsed: RemoteChartData | null = null;
|
|
83
|
+
const trimmed = text.trim();
|
|
84
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
85
|
+
try {
|
|
86
|
+
parsed = normalizeRemoteChartData(JSON.parse(trimmed));
|
|
87
|
+
} catch {
|
|
88
|
+
parsed = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!parsed) parsed = parseChartCsv(text);
|
|
92
|
+
if (cancelled) return;
|
|
93
|
+
if (!parsed) throw new Error('响应无法解析为图表数据(CSV 或 JSON)');
|
|
94
|
+
setRemoteData(parsed);
|
|
95
|
+
} catch {
|
|
96
|
+
// 加载失败:静默回落本地数据
|
|
97
|
+
} finally {
|
|
98
|
+
if (!cancelled) setLoading(false);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return () => { cancelled = true; };
|
|
102
|
+
}, [url]);
|
|
103
|
+
|
|
104
|
+
// Clamp height to safe bounds: 200px min, 800px max, 400px default
|
|
105
|
+
const safeHeight = Math.max(200, Math.min(800, height || 400));
|
|
106
|
+
|
|
107
|
+
const getChartType = (widgetType: string) => {
|
|
108
|
+
switch (widgetType) {
|
|
109
|
+
case 'chart-line': return 'line';
|
|
110
|
+
case 'chart-bar': return 'bar';
|
|
111
|
+
case 'chart-pie': return 'pie';
|
|
112
|
+
case 'chart-scatter': return 'scatter';
|
|
113
|
+
default: return 'line';
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const getDefaultColors = () => [
|
|
118
|
+
'#1677ff',
|
|
119
|
+
'#52c41a',
|
|
120
|
+
'#faad14',
|
|
121
|
+
'#ff4d4f',
|
|
122
|
+
'#722ed1',
|
|
123
|
+
'#13c2c2'
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const prepareDatasets = (datasets: Dataset[], chartType: string, chartLabels: string[]) => {
|
|
127
|
+
const colors = getDefaultColors();
|
|
128
|
+
|
|
129
|
+
return datasets.map((dataset, index) => {
|
|
130
|
+
const color = colors[index % colors.length];
|
|
131
|
+
let processedData: any = dataset.data;
|
|
132
|
+
|
|
133
|
+
// For scatter plots, convert data to {x, y} format
|
|
134
|
+
if (chartType === 'scatter') {
|
|
135
|
+
processedData = dataset.data.map((value, dataIndex) => ({
|
|
136
|
+
x: dataIndex,
|
|
137
|
+
y: value
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (chartType === 'pie') {
|
|
142
|
+
return {
|
|
143
|
+
...dataset,
|
|
144
|
+
data: processedData,
|
|
145
|
+
backgroundColor: colors.slice(0, dataset.data.length),
|
|
146
|
+
borderColor: colors.slice(0, dataset.data.length),
|
|
147
|
+
borderWidth: 1
|
|
148
|
+
};
|
|
149
|
+
} else {
|
|
150
|
+
return {
|
|
151
|
+
...dataset,
|
|
152
|
+
data: processedData,
|
|
153
|
+
borderColor: color,
|
|
154
|
+
backgroundColor: chartType === 'line' ? color + '20' : color,
|
|
155
|
+
borderWidth: 2,
|
|
156
|
+
fill: chartType === 'line' ? false : true
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!canvasRef.current) return;
|
|
164
|
+
|
|
165
|
+
// Destroy existing chart
|
|
166
|
+
if (chartRef.current) {
|
|
167
|
+
chartRef.current.destroy();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const chartType = getChartType(type);
|
|
171
|
+
const preparedDatasets = prepareDatasets(effectiveDatasets, chartType, effectiveLabels);
|
|
172
|
+
|
|
173
|
+
const defaultOptions = {
|
|
174
|
+
responsive: true,
|
|
175
|
+
maintainAspectRatio: false,
|
|
176
|
+
plugins: {
|
|
177
|
+
title: {
|
|
178
|
+
display: !!title,
|
|
179
|
+
text: title || ''
|
|
180
|
+
},
|
|
181
|
+
legend: {
|
|
182
|
+
display: legend !== undefined ? legend : (effectiveDatasets.length > 1 || chartType === 'pie')
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onClick: (_event: any, elements: any[]) => {
|
|
186
|
+
if (elements.length > 0) {
|
|
187
|
+
const element = elements[0];
|
|
188
|
+
const dataIndex = element.index;
|
|
189
|
+
const datasetIndex = element.datasetIndex;
|
|
190
|
+
|
|
191
|
+
onchange({
|
|
192
|
+
label: effectiveLabels[dataIndex],
|
|
193
|
+
value: effectiveDatasets[datasetIndex]?.data[dataIndex],
|
|
194
|
+
datasetLabel: effectiveDatasets[datasetIndex]?.label,
|
|
195
|
+
dataIndex,
|
|
196
|
+
datasetIndex
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const mergedOptions = { ...defaultOptions, ...options };
|
|
203
|
+
|
|
204
|
+
const chartData = chartType === 'scatter'
|
|
205
|
+
? { datasets: preparedDatasets }
|
|
206
|
+
: { labels: effectiveLabels, datasets: preparedDatasets };
|
|
207
|
+
|
|
208
|
+
chartRef.current = new ChartJS(canvasRef.current, {
|
|
209
|
+
type: chartType as any,
|
|
210
|
+
data: chartData,
|
|
211
|
+
options: mergedOptions
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Cleanup function
|
|
215
|
+
return () => {
|
|
216
|
+
if (chartRef.current) {
|
|
217
|
+
chartRef.current.destroy();
|
|
218
|
+
chartRef.current = null;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, [type, title, height, url, legend, effectiveLabels, effectiveDatasets, options, onchange]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="widget-chart" style={{ height: `${safeHeight}px`, position: 'relative', width: '100%' }}>
|
|
225
|
+
{isEmpty && (
|
|
226
|
+
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(0, 0, 0, 0.45)', fontSize: 13, background: '#ffffff' }}>
|
|
227
|
+
暂无数据
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{loading && (
|
|
231
|
+
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 1, padding: '2px 8px', borderRadius: 4, background: 'rgba(0, 0, 0, 0.05)', fontSize: 12 }}>
|
|
232
|
+
数据加载中...
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
<canvas ref={canvasRef} style={{ maxWidth: '100%', height: '100%' }}></canvas>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +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 = `${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
|
+
};
|