@c2-markdown-ui/react 0.4.3 → 0.4.4
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 +6 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3733 -4365
- package/dist/lib/MarkdownUI.d.ts +10 -0
- package/dist/lib/MarkdownUI.d.ts.map +1 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/widgets/Button.d.ts +15 -0
- package/dist/widgets/Button.d.ts.map +1 -0
- package/dist/widgets/ButtonGroup.d.ts +11 -0
- package/dist/widgets/ButtonGroup.d.ts.map +1 -0
- package/dist/widgets/Cascader.d.ts +34 -0
- package/dist/widgets/Cascader.d.ts.map +1 -0
- package/dist/widgets/Chart.d.ts +20 -0
- package/dist/widgets/Chart.d.ts.map +1 -0
- package/dist/widgets/DatePicker.d.ts +28 -0
- package/dist/widgets/DatePicker.d.ts.map +1 -0
- package/dist/widgets/Form.d.ts +47 -0
- package/dist/widgets/Form.d.ts.map +1 -0
- package/dist/widgets/Incomplete.d.ts +3 -0
- package/dist/widgets/Incomplete.d.ts.map +1 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts +15 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts.map +1 -0
- package/dist/widgets/NumberInput.d.ts +26 -0
- package/dist/widgets/NumberInput.d.ts.map +1 -0
- package/dist/widgets/Quiz.d.ts +37 -0
- package/dist/widgets/Quiz.d.ts.map +1 -0
- package/dist/widgets/Radio.d.ts +24 -0
- package/dist/widgets/Radio.d.ts.map +1 -0
- package/dist/widgets/Select.d.ts +15 -0
- package/dist/widgets/Select.d.ts.map +1 -0
- package/dist/widgets/SelectMulti.d.ts +20 -0
- package/dist/widgets/SelectMulti.d.ts.map +1 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts +16 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts.map +1 -0
- package/dist/widgets/Slider.d.ts +13 -0
- package/dist/widgets/Slider.d.ts.map +1 -0
- package/dist/widgets/Switch.d.ts +17 -0
- package/dist/widgets/Switch.d.ts.map +1 -0
- package/dist/widgets/TextInput.d.ts +11 -0
- package/dist/widgets/TextInput.d.ts.map +1 -0
- package/dist/widgets/Widget.d.ts +7 -0
- package/dist/widgets/Widget.d.ts.map +1 -0
- package/dist/widgets/chartRemoteData.d.ts +18 -0
- package/dist/widgets/chartRemoteData.d.ts.map +1 -0
- package/dist/widgets/choiceUtils.d.ts +24 -0
- package/dist/widgets/choiceUtils.d.ts.map +1 -0
- package/dist/widgets/index.d.ts +18 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/lib/MarkdownUI.tsx +9 -47
- package/src/lib/widgets.css +1372 -2188
- package/src/widgets/Button.tsx +39 -39
- package/src/widgets/ButtonGroup.tsx +47 -47
- package/src/widgets/Cascader.tsx +2 -3
- package/src/widgets/Chart.tsx +237 -237
- package/src/widgets/DatePicker.tsx +434 -451
- package/src/widgets/Form.tsx +372 -512
- package/src/widgets/Incomplete.tsx +3 -3
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
- package/src/widgets/Quiz.tsx +320 -320
- package/src/widgets/Radio.tsx +26 -57
- package/src/widgets/Select.tsx +142 -178
- package/src/widgets/SelectMulti.tsx +72 -105
- package/src/widgets/ShortAnswerQuestion.tsx +101 -101
- package/src/widgets/Slider.tsx +56 -70
- package/src/widgets/Switch.tsx +2 -14
- package/src/widgets/TextInput.tsx +38 -76
- package/src/widgets/Widget.tsx +82 -130
- package/src/widgets/choiceUtils.ts +45 -90
- package/src/widgets/index.ts +16 -17
- package/src/widgets/TreeSelect.tsx +0 -649
package/src/widgets/Form.tsx
CHANGED
|
@@ -1,512 +1,372 @@
|
|
|
1
|
-
import React, { useEffect, useMemo,
|
|
2
|
-
import { Button } from './Button';
|
|
3
|
-
import { ButtonGroup } from './ButtonGroup';
|
|
4
|
-
import { Cascader } from './Cascader';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
} else if (f.type === '
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return <DatePicker {...commonProps} error={errors[field.id] || ''} />;
|
|
374
|
-
case 'number-input':
|
|
375
|
-
return <NumberInput {...commonProps} error={errors[field.id] || ''} />;
|
|
376
|
-
case 'chart-line':
|
|
377
|
-
case 'chart-bar':
|
|
378
|
-
case 'chart-pie':
|
|
379
|
-
case 'chart-scatter':
|
|
380
|
-
return <Chart {...field} type={field.type} labels={field.labels ?? []} datasets={field.datasets ?? []} onchange={(value: any) => setValue(field.id, value)} />;
|
|
381
|
-
default:
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
const containerClass = inline ? 'widget-form widget-form-inline' : 'widget-form';
|
|
387
|
-
|
|
388
|
-
// 左右布局:容器类 + CSS 变量(label 宽度 / 内容宽度)
|
|
389
|
-
const isHorizontal = layout === 'horizontal';
|
|
390
|
-
const layoutClass = isHorizontal ? 'widget-form-layout-horizontal' : '';
|
|
391
|
-
const layoutStyleVars: Record<string, string> | undefined = isHorizontal
|
|
392
|
-
? ({
|
|
393
|
-
'--form-label-width': `${labelWidth ?? 80}px`,
|
|
394
|
-
...(contentWidth ? { '--form-content-width': `${contentWidth}px` } : {})
|
|
395
|
-
})
|
|
396
|
-
: undefined;
|
|
397
|
-
|
|
398
|
-
const containerStyle: React.CSSProperties | undefined = columns
|
|
399
|
-
? ({ '--form-columns': columns, ...layoutStyleVars } as React.CSSProperties)
|
|
400
|
-
: (layoutStyleVars as React.CSSProperties | undefined);
|
|
401
|
-
|
|
402
|
-
// 字段模式(无 row 分组):任一字段带显式 span 时切换为 24 栅格布局,让 span 生效
|
|
403
|
-
// (无 span 的字段保持整行显示,不配 span 的表单仍走原布局,完全向后兼容)
|
|
404
|
-
const fieldsGridMode = !inline && (!rows || rows.length === 0) &&
|
|
405
|
-
fields.some(f => typeof f.span === 'number' && f.span >= 1 && f.span <= 24);
|
|
406
|
-
|
|
407
|
-
/** 字段模式栅格样式:显式 span 占 N 列;无 span 占满整行(1/-1,保持传统一行一个) */
|
|
408
|
-
const fieldsSpanStyle = (field: Field): React.CSSProperties | undefined => {
|
|
409
|
-
if (!fieldsGridMode && !inline) return undefined;
|
|
410
|
-
const s = typeof field.span === 'number' && field.span >= 1 && field.span <= 24 ? Math.round(field.span) : 0;
|
|
411
|
-
if (inline) {
|
|
412
|
-
// inline 模式:显式 span 覆盖 flex-basis(占行宽 N/24,flex-shrink 保证与相邻字段同行收放)
|
|
413
|
-
return s >= 1 ? { flex: `0 1 calc(${s} / 24 * 100%)` } : undefined;
|
|
414
|
-
}
|
|
415
|
-
return { gridColumn: s >= 1 ? `span ${s}` : '1 / -1' };
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
// 规范化 row:兼容旧数组形态 Field[][]
|
|
419
|
-
const normalizeRow = (row: FormRow | Field[]): FormRow =>
|
|
420
|
-
Array.isArray(row) ? { fields: row } : row;
|
|
421
|
-
|
|
422
|
-
// ---------- 重复 id 防护:与 svelte 版一致,只保留每个 id 的首次出现 ----------
|
|
423
|
-
const dedupeFields = (list: Field[]): Field[] => {
|
|
424
|
-
const seen = new Set<string>();
|
|
425
|
-
return (list ?? []).filter(f => {
|
|
426
|
-
if (!f?.id || seen.has(f.id)) return false;
|
|
427
|
-
seen.add(f.id);
|
|
428
|
-
return true;
|
|
429
|
-
});
|
|
430
|
-
};
|
|
431
|
-
const uniqueFields = useMemo(() => dedupeFields(fields), [fields]);
|
|
432
|
-
const normRows = useMemo(
|
|
433
|
-
() => (Array.isArray(rows) ? rows.map(normalizeRow).map(r => ({ ...r, fields: dedupeFields(r.fields) })).filter(r => r.fields.length > 0) : []),
|
|
434
|
-
[rows]
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
// 计算行内每个字段的 grid span(24 栅格,类似 antd)
|
|
438
|
-
// 显式 span 优先;无 span 时若 form 设置了 columns,则每字段占 24/columns(跨行对齐),
|
|
439
|
-
// 否则均分剩余栅格;总 span 超 24 时自动换行(由 grid auto-flow 处理)
|
|
440
|
-
const computeSpans = (fields: Field[]): number[] => {
|
|
441
|
-
const spans = fields.map(f => (typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : undefined));
|
|
442
|
-
const fixedTotal = spans.reduce((acc, s) => acc + (s ?? 0), 0);
|
|
443
|
-
const autoCount = spans.filter(s => s === undefined).length;
|
|
444
|
-
let autoSpan = 0;
|
|
445
|
-
if (autoCount > 0) {
|
|
446
|
-
if (columns && columns >= 1) {
|
|
447
|
-
autoSpan = Math.max(Math.floor(24 / columns), 1);
|
|
448
|
-
} else {
|
|
449
|
-
const remaining = Math.max(24 - fixedTotal, 0);
|
|
450
|
-
autoSpan = Math.max(Math.floor(remaining / autoCount), 1);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
return spans.map(s => s ?? autoSpan);
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
// Rows 分组模式(24 栅格)
|
|
457
|
-
if (rows && rows.length > 0) {
|
|
458
|
-
// 设置 columns 时:所有 row 的字段合并流动,每行自动 columns 列(覆盖 row 手动分组)
|
|
459
|
-
if (columns && columns >= 1) {
|
|
460
|
-
const allFields = normRows.flatMap(r => r.fields).filter(f => isVisible(f));
|
|
461
|
-
const spans = computeSpans(allFields);
|
|
462
|
-
const gutter = normRows.find(r => r.gutter !== undefined)?.gutter;
|
|
463
|
-
return (
|
|
464
|
-
<div className={`widget-form ${layoutClass}`} style={layoutStyleVars as React.CSSProperties | undefined}>
|
|
465
|
-
<div
|
|
466
|
-
className="widget-form-row"
|
|
467
|
-
style={gutter !== undefined ? ({ '--row-gutter': `${gutter}px` } as React.CSSProperties) : undefined}
|
|
468
|
-
>
|
|
469
|
-
{allFields.map((field, fi) => (
|
|
470
|
-
<div className="widget-form-field" key={`${field.id}:${version}`} style={{ gridColumn: `span ${spans[fi]}` }}>
|
|
471
|
-
{renderField(field)}
|
|
472
|
-
</div>
|
|
473
|
-
))}
|
|
474
|
-
</div>
|
|
475
|
-
{renderActions()}
|
|
476
|
-
</div>
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return (
|
|
481
|
-
<div className={`widget-form ${layoutClass}`} style={layoutStyleVars as React.CSSProperties | undefined}>
|
|
482
|
-
{normRows.map((row, ri) => {
|
|
483
|
-
const visibleFields = row.fields.filter(f => isVisible(f));
|
|
484
|
-
const spans = computeSpans(visibleFields);
|
|
485
|
-
return (
|
|
486
|
-
<div
|
|
487
|
-
className="widget-form-row"
|
|
488
|
-
key={ri}
|
|
489
|
-
style={row.gutter !== undefined ? ({ '--row-gutter': `${row.gutter}px` } as React.CSSProperties) : undefined}
|
|
490
|
-
>
|
|
491
|
-
{visibleFields.map((field, fi) => (
|
|
492
|
-
<div className="widget-form-field" key={`${field.id}:${version}`} style={{ gridColumn: `span ${spans[fi]}` }}>
|
|
493
|
-
{renderField(field)}
|
|
494
|
-
</div>
|
|
495
|
-
))}
|
|
496
|
-
</div>
|
|
497
|
-
);
|
|
498
|
-
})}
|
|
499
|
-
{renderActions()}
|
|
500
|
-
</div>
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
return (
|
|
505
|
-
<div className={fieldsGridMode ? `${containerClass} ${layoutClass} widget-form-fields-grid` : `${containerClass} ${layoutClass}`} style={containerStyle}>
|
|
506
|
-
{uniqueFields.filter(f => isVisible(f)).map((field) => (
|
|
507
|
-
<div className="widget-form-field" key={`${field.id}:${version}`} data-field-type={field.type} style={fieldsSpanStyle(field)}>{renderField(field)}</div>
|
|
508
|
-
))}
|
|
509
|
-
{renderActions()}
|
|
510
|
-
</div>
|
|
511
|
-
);
|
|
512
|
-
};
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Button } from './Button';
|
|
3
|
+
import { ButtonGroup } from './ButtonGroup';
|
|
4
|
+
import { Cascader } from './Cascader';
|
|
5
|
+
import { Select } from './Select';
|
|
6
|
+
import { SelectMulti } from './SelectMulti';
|
|
7
|
+
import { RadioGroup } from './Radio';
|
|
8
|
+
import { Slider } from './Slider';
|
|
9
|
+
import { Switch } from './Switch';
|
|
10
|
+
import { TextInput } from './TextInput';
|
|
11
|
+
import { DatePicker } from './DatePicker';
|
|
12
|
+
import { NumberInput } from './NumberInput';
|
|
13
|
+
import { Chart } from './Chart';
|
|
14
|
+
import { parseChoice } from './choiceUtils';
|
|
15
|
+
|
|
16
|
+
export interface FieldWhen {
|
|
17
|
+
/** 依赖的字段 id */
|
|
18
|
+
field: string;
|
|
19
|
+
/** 依赖字段命中这些值之一时才展示 */
|
|
20
|
+
values: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 条件组合:all 且组(全部命中)、any 或组(任一命中),两组同时存在取交集 */
|
|
24
|
+
export interface FieldWhenGroup {
|
|
25
|
+
all?: FieldWhen[];
|
|
26
|
+
any?: FieldWhen[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Field {
|
|
30
|
+
type: string;
|
|
31
|
+
id: string;
|
|
32
|
+
/** 在 row 内占据的栅格数(1-24,类似 antd Col 的 span) */
|
|
33
|
+
span?: number;
|
|
34
|
+
/** 条件显示:依赖字段的取值命中时才展示;
|
|
35
|
+
* 多条件时为数组(AND 组合:全部命中才显示);
|
|
36
|
+
* 分组形态 { all, any }:且组全部命中 且 或组任一命中 */
|
|
37
|
+
when?: FieldWhen | FieldWhen[] | FieldWhenGroup;
|
|
38
|
+
[key: string]: any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FormRow {
|
|
42
|
+
/** 列间距(px),类似 antd Row 的 gutter */
|
|
43
|
+
gutter?: number;
|
|
44
|
+
fields: Field[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FormProps {
|
|
48
|
+
fields: Field[];
|
|
49
|
+
/** 行分组(对象形态 { gutter, fields };兼容旧数组形态 Field[][]) */
|
|
50
|
+
rows?: (FormRow | Field[])[];
|
|
51
|
+
inline?: boolean;
|
|
52
|
+
columns?: number;
|
|
53
|
+
submitLabel?: string;
|
|
54
|
+
/** 取消按钮文案,未设置则不显示 */
|
|
55
|
+
cancelLabel?: string;
|
|
56
|
+
/** 清空/重置按钮文案,未设置则不显示 */
|
|
57
|
+
resetLabel?: string;
|
|
58
|
+
/** 按钮组对齐:left / center / right */
|
|
59
|
+
buttonsAlign?: 'left' | 'center' | 'right';
|
|
60
|
+
/** 是否禁用:级联禁用所有字段与按钮 */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
onchange: (values: Record<string, unknown>) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const Form: React.FC<FormProps> = ({
|
|
66
|
+
fields,
|
|
67
|
+
rows,
|
|
68
|
+
inline,
|
|
69
|
+
columns,
|
|
70
|
+
submitLabel,
|
|
71
|
+
cancelLabel,
|
|
72
|
+
resetLabel,
|
|
73
|
+
buttonsAlign,
|
|
74
|
+
disabled = false,
|
|
75
|
+
onchange
|
|
76
|
+
}) => {
|
|
77
|
+
const initialData = useMemo(() => {
|
|
78
|
+
const acc: Record<string, unknown> = {};
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
for (const f of fields) {
|
|
81
|
+
if (!f?.id || seen.has(f.id)) continue; // 重复 id 防护:默认值只取首次出现
|
|
82
|
+
seen.add(f.id);
|
|
83
|
+
if (f.type === 'button-group' || f.type === 'select') {
|
|
84
|
+
// choices[0] 可能是 key:label 格式,默认值取 key(表单值体系)
|
|
85
|
+
if (f.default !== undefined && f.default !== null && f.default !== '') {
|
|
86
|
+
acc[f.id] = parseChoice(String(f.default)).value;
|
|
87
|
+
} else {
|
|
88
|
+
acc[f.id] = Array.isArray(f.choices) && f.choices.length ? parseChoice(String(f.choices[0])).value : undefined;
|
|
89
|
+
}
|
|
90
|
+
} else if (f.type === 'select-multi') {
|
|
91
|
+
const d = f.default;
|
|
92
|
+
// choices 可能是对象数组({value,key,label}):default 写 label 时映射回 value 取值体系
|
|
93
|
+
const objChoices = (Array.isArray(f.choices) ? f.choices : [])
|
|
94
|
+
.filter((c: any) => c && typeof c === 'object') as Array<{ value?: string; key?: string; label?: string }>;
|
|
95
|
+
const mapDefault = (v: any): string => {
|
|
96
|
+
const s = String(v);
|
|
97
|
+
const hit = objChoices.find(c => String(c.value ?? c.key) === s || c.label === s);
|
|
98
|
+
return hit ? String(hit.value ?? hit.key) : parseChoice(s).value;
|
|
99
|
+
};
|
|
100
|
+
acc[f.id] = Array.isArray(d)
|
|
101
|
+
? d.map(mapDefault)
|
|
102
|
+
: (d ? [mapDefault(d)] : []);
|
|
103
|
+
} else if (f.type === 'radio-group') {
|
|
104
|
+
// 单选:choices 可能是对象数组({value,key,label}),default 写 label 时映射回 value
|
|
105
|
+
const d = f.default;
|
|
106
|
+
const objChoices = (Array.isArray(f.choices) ? f.choices : [])
|
|
107
|
+
.filter((c: any) => c && typeof c === 'object') as Array<{ value?: string; key?: string; label?: string }>;
|
|
108
|
+
const mapDefault = (v: any): string => {
|
|
109
|
+
const s = String(v);
|
|
110
|
+
const hit = objChoices.find(c => String(c.value ?? c.key) === s || c.label === s);
|
|
111
|
+
return hit ? String(hit.value ?? hit.key) : parseChoice(s).value;
|
|
112
|
+
};
|
|
113
|
+
if (d !== undefined && d !== null && d !== '') {
|
|
114
|
+
acc[f.id] = mapDefault(Array.isArray(d) ? d[0] : d);
|
|
115
|
+
} else {
|
|
116
|
+
acc[f.id] = Array.isArray(f.choices) && f.choices.length ? mapDefault(f.choices[0]) : '';
|
|
117
|
+
}
|
|
118
|
+
} else if (f.type === 'cascade') {
|
|
119
|
+
acc[f.id] = f.default ?? '';
|
|
120
|
+
} else if (f.type === 'slider') {
|
|
121
|
+
acc[f.id] = f.default ?? (typeof f.min === 'number' ? f.min : 0);
|
|
122
|
+
} else if (f.type === 'switch') {
|
|
123
|
+
acc[f.id] = f.default ?? false;
|
|
124
|
+
} else if (f.type === 'date-picker') {
|
|
125
|
+
// 范围模式默认 [](空数组),单选默认 null;default 已是时间戳/格式化字符串
|
|
126
|
+
acc[f.id] = f.range
|
|
127
|
+
? (Array.isArray(f.default) ? f.default : (f.default !== undefined ? [f.default] : []))
|
|
128
|
+
: (f.default ?? null);
|
|
129
|
+
} else if (f.type === 'number-input') {
|
|
130
|
+
// 无 default 时为 null(空值),由组件内部处理显示
|
|
131
|
+
acc[f.id] = f.default ?? null;
|
|
132
|
+
} else if (f.type === 'text-input') {
|
|
133
|
+
acc[f.id] = f.default ?? '';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return acc;
|
|
137
|
+
}, [fields]);
|
|
138
|
+
|
|
139
|
+
const [data, setData] = useState<Record<string, unknown>>(initialData);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setData(initialData);
|
|
143
|
+
}, [initialData]);
|
|
144
|
+
|
|
145
|
+
const setValue = (id: string, value: unknown) => {
|
|
146
|
+
setData(prev => ({ ...prev, [id]: value }));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** 字段是否可见:无 when 时恒可见;
|
|
150
|
+
* - 单条件:依赖字段值命中 values 任一
|
|
151
|
+
* - 数组(FieldWhen[]):AND 组合,全部命中才显示
|
|
152
|
+
* - 分组(FieldWhenGroup):且组 all 全部命中 且 或组 any 任一命中 */
|
|
153
|
+
const isVisible = (f: Field): boolean => {
|
|
154
|
+
if (!f.when) return true;
|
|
155
|
+
const matchCond = (c: any): boolean => {
|
|
156
|
+
if (!c?.field || !Array.isArray(c.values) || c.values.length === 0) return true; // 非法条件忽略
|
|
157
|
+
const dep = data[c.field];
|
|
158
|
+
const op = c.op || 'eq';
|
|
159
|
+
if (op === 'eq') return c.values.includes(String(dep ?? ''));
|
|
160
|
+
const left = Number(dep);
|
|
161
|
+
if (dep === undefined || dep === null || dep === '' || isNaN(left)) return false;
|
|
162
|
+
return c.values.some((v: string) => {
|
|
163
|
+
const right = Number(v);
|
|
164
|
+
if (isNaN(right)) return false;
|
|
165
|
+
if (op === 'gt') return left > right;
|
|
166
|
+
if (op === 'gte') return left >= right;
|
|
167
|
+
if (op === 'lt') return left < right;
|
|
168
|
+
if (op === 'lte') return left <= right;
|
|
169
|
+
return left !== right; // ne
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
const when = f.when as any;
|
|
173
|
+
if (!Array.isArray(when) && typeof when === 'object' && ('all' in when || 'any' in when)) {
|
|
174
|
+
const allOk = (when.all || []).every(matchCond);
|
|
175
|
+
const anyList = when.any || [];
|
|
176
|
+
const anyOk = anyList.length === 0 ? true : anyList.some(matchCond);
|
|
177
|
+
return allOk && anyOk;
|
|
178
|
+
}
|
|
179
|
+
const conds = Array.isArray(when) ? when : [when];
|
|
180
|
+
if (conds.length === 0) return true;
|
|
181
|
+
return conds.every(matchCond);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const handleSubmit = () => {
|
|
185
|
+
if (disabled) return;
|
|
186
|
+
onchange({ ...data });
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleReset = () => {
|
|
190
|
+
if (disabled) return;
|
|
191
|
+
setData(initialData);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const handleCancel = () => {
|
|
195
|
+
if (disabled) return;
|
|
196
|
+
onchange({ ...initialData, _action: 'cancel' });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** 按钮组:提交 + 可选的清空/取消,data-align 控制对齐;全部未设置则不渲染 */
|
|
200
|
+
const renderActions = () => {
|
|
201
|
+
if (!cancelLabel && !resetLabel && !submitLabel) return null;
|
|
202
|
+
return (
|
|
203
|
+
<div className="widget-form-actions" data-align={buttonsAlign || 'left'}>
|
|
204
|
+
{cancelLabel && <button type="button" className="widget-form-cancel" onClick={handleCancel} disabled={disabled}>{cancelLabel}</button>}
|
|
205
|
+
{resetLabel && <button type="button" className="widget-form-reset" onClick={handleReset} disabled={disabled}>{resetLabel}</button>}
|
|
206
|
+
{submitLabel && <button type="button" className="widget-form-submit" onClick={handleSubmit} disabled={disabled}>{submitLabel}</button>}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const renderField = (field: Field) => {
|
|
212
|
+
const commonProps = {
|
|
213
|
+
...field,
|
|
214
|
+
disabled: disabled || !!field.disabled,
|
|
215
|
+
onchange: (value: any) => setValue(field.id, value)
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
switch (field.type) {
|
|
219
|
+
case 'button':
|
|
220
|
+
return <Button {...commonProps} />;
|
|
221
|
+
case 'button-group':
|
|
222
|
+
return <ButtonGroup {...commonProps} choices={field.choices ?? []} />;
|
|
223
|
+
case 'select':
|
|
224
|
+
return <Select {...commonProps} choices={field.choices ?? []} />;
|
|
225
|
+
case 'select-multi':
|
|
226
|
+
return <SelectMulti {...commonProps} choices={field.choices ?? []} />;
|
|
227
|
+
case 'radio-group':
|
|
228
|
+
return <RadioGroup {...commonProps} choices={field.choices ?? []} />;
|
|
229
|
+
case 'cascade':
|
|
230
|
+
return <Cascader {...commonProps} choices={field.choices ?? []} />;
|
|
231
|
+
case 'slider':
|
|
232
|
+
return (
|
|
233
|
+
<Slider
|
|
234
|
+
{...commonProps}
|
|
235
|
+
min={field.min ?? 0}
|
|
236
|
+
max={field.max ?? 100}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
case 'switch':
|
|
240
|
+
return <Switch {...commonProps} />;
|
|
241
|
+
case 'text-input':
|
|
242
|
+
return <TextInput {...commonProps} />;
|
|
243
|
+
case 'date-picker':
|
|
244
|
+
return <DatePicker {...commonProps} />;
|
|
245
|
+
case 'number-input':
|
|
246
|
+
return <NumberInput {...commonProps} />;
|
|
247
|
+
case 'chart-line':
|
|
248
|
+
case 'chart-bar':
|
|
249
|
+
case 'chart-pie':
|
|
250
|
+
case 'chart-scatter':
|
|
251
|
+
return <Chart {...field} type={field.type} labels={field.labels ?? []} datasets={field.datasets ?? []} onchange={(value: any) => setValue(field.id, value)} />;
|
|
252
|
+
default:
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const containerClass = inline ? 'widget-form widget-form-inline' : 'widget-form';
|
|
258
|
+
const containerStyle: React.CSSProperties | undefined = columns
|
|
259
|
+
? ({ '--form-columns': columns } as React.CSSProperties)
|
|
260
|
+
: undefined;
|
|
261
|
+
|
|
262
|
+
// 字段模式(无 row 分组):任一字段带显式 span 时切换为 24 栅格布局,让 span 生效
|
|
263
|
+
// (无 span 的字段保持整行显示,不配 span 的表单仍走原布局,完全向后兼容)
|
|
264
|
+
const fieldsGridMode = !inline && (!rows || rows.length === 0) &&
|
|
265
|
+
fields.some(f => typeof f.span === 'number' && f.span >= 1 && f.span <= 24);
|
|
266
|
+
|
|
267
|
+
/** 字段模式栅格样式:显式 span 占 N 列;无 span 占满整行(1/-1,保持传统一行一个) */
|
|
268
|
+
const fieldsSpanStyle = (field: Field): React.CSSProperties | undefined => {
|
|
269
|
+
if (!fieldsGridMode && !inline) return undefined;
|
|
270
|
+
const s = typeof field.span === 'number' && field.span >= 1 && field.span <= 24 ? Math.round(field.span) : 0;
|
|
271
|
+
if (inline) {
|
|
272
|
+
// inline 模式:显式 span 覆盖 flex-basis(占行宽 N/24,flex-shrink 保证与相邻字段同行收放)
|
|
273
|
+
return s >= 1 ? { flex: `0 1 calc(${s} / 24 * 100%)` } : undefined;
|
|
274
|
+
}
|
|
275
|
+
return { gridColumn: s >= 1 ? `span ${s}` : '1 / -1' };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// 规范化 row:兼容旧数组形态 Field[][]
|
|
279
|
+
const normalizeRow = (row: FormRow | Field[]): FormRow =>
|
|
280
|
+
Array.isArray(row) ? { fields: row } : row;
|
|
281
|
+
|
|
282
|
+
// ---------- 重复 id 防护:与 svelte 版一致,只保留每个 id 的首次出现 ----------
|
|
283
|
+
const dedupeFields = (list: Field[]): Field[] => {
|
|
284
|
+
const seen = new Set<string>();
|
|
285
|
+
return (list ?? []).filter(f => {
|
|
286
|
+
if (!f?.id || seen.has(f.id)) return false;
|
|
287
|
+
seen.add(f.id);
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
const uniqueFields = useMemo(() => dedupeFields(fields), [fields]);
|
|
292
|
+
const normRows = useMemo(
|
|
293
|
+
() => (Array.isArray(rows) ? rows.map(normalizeRow).map(r => ({ ...r, fields: dedupeFields(r.fields) })).filter(r => r.fields.length > 0) : []),
|
|
294
|
+
[rows]
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// 计算行内每个字段的 grid span(24 栅格,类似 antd)
|
|
298
|
+
// 显式 span 优先;无 span 时若 form 设置了 columns,则每字段占 24/columns(跨行对齐),
|
|
299
|
+
// 否则均分剩余栅格;总 span 超 24 时自动换行(由 grid auto-flow 处理)
|
|
300
|
+
const computeSpans = (fields: Field[]): number[] => {
|
|
301
|
+
const spans = fields.map(f => (typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : undefined));
|
|
302
|
+
const fixedTotal = spans.reduce((acc, s) => acc + (s ?? 0), 0);
|
|
303
|
+
const autoCount = spans.filter(s => s === undefined).length;
|
|
304
|
+
let autoSpan = 0;
|
|
305
|
+
if (autoCount > 0) {
|
|
306
|
+
if (columns && columns >= 1) {
|
|
307
|
+
autoSpan = Math.max(Math.floor(24 / columns), 1);
|
|
308
|
+
} else {
|
|
309
|
+
const remaining = Math.max(24 - fixedTotal, 0);
|
|
310
|
+
autoSpan = Math.max(Math.floor(remaining / autoCount), 1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return spans.map(s => s ?? autoSpan);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Rows 分组模式(24 栅格)
|
|
317
|
+
if (rows && rows.length > 0) {
|
|
318
|
+
// 设置 columns 时:所有 row 的字段合并流动,每行自动 columns 列(覆盖 row 手动分组)
|
|
319
|
+
if (columns && columns >= 1) {
|
|
320
|
+
const allFields = normRows.flatMap(r => r.fields).filter(isVisible);
|
|
321
|
+
const spans = computeSpans(allFields);
|
|
322
|
+
const gutter = normRows.find(r => r.gutter !== undefined)?.gutter;
|
|
323
|
+
return (
|
|
324
|
+
<div className="widget-form">
|
|
325
|
+
<div
|
|
326
|
+
className="widget-form-row"
|
|
327
|
+
style={gutter !== undefined ? ({ '--row-gutter': `${gutter}px` } as React.CSSProperties) : undefined}
|
|
328
|
+
>
|
|
329
|
+
{allFields.map((field, fi) => (
|
|
330
|
+
<div className="widget-form-field" key={field.id} style={{ gridColumn: `span ${spans[fi]}` }}>
|
|
331
|
+
{renderField(field)}
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
{renderActions()}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className="widget-form">
|
|
342
|
+
{normRows.map((row, ri) => {
|
|
343
|
+
const visibleFields = row.fields.filter(isVisible);
|
|
344
|
+
const spans = computeSpans(visibleFields);
|
|
345
|
+
return (
|
|
346
|
+
<div
|
|
347
|
+
className="widget-form-row"
|
|
348
|
+
key={ri}
|
|
349
|
+
style={row.gutter !== undefined ? ({ '--row-gutter': `${row.gutter}px` } as React.CSSProperties) : undefined}
|
|
350
|
+
>
|
|
351
|
+
{visibleFields.map((field, fi) => (
|
|
352
|
+
<div className="widget-form-field" key={field.id} style={{ gridColumn: `span ${spans[fi]}` }}>
|
|
353
|
+
{renderField(field)}
|
|
354
|
+
</div>
|
|
355
|
+
))}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
{renderActions()}
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className={fieldsGridMode ? `${containerClass} widget-form-fields-grid` : containerClass} style={containerStyle}>
|
|
366
|
+
{uniqueFields.filter(isVisible).map((field) => (
|
|
367
|
+
<div className="widget-form-field" key={field.id} data-field-type={field.type} style={fieldsSpanStyle(field)}>{renderField(field)}</div>
|
|
368
|
+
))}
|
|
369
|
+
{renderActions()}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
};
|