@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,512 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Button } from './Button';
|
|
3
|
+
import { ButtonGroup } from './ButtonGroup';
|
|
4
|
+
import { Cascader } from './Cascader';
|
|
5
|
+
import { TreeSelect } from './TreeSelect';
|
|
6
|
+
import { Select } from './Select';
|
|
7
|
+
import { SelectMulti } from './SelectMulti';
|
|
8
|
+
import { RadioGroup } from './Radio';
|
|
9
|
+
import { Slider } from './Slider';
|
|
10
|
+
import { Switch } from './Switch';
|
|
11
|
+
import { TextInput } from './TextInput';
|
|
12
|
+
import { DatePicker } from './DatePicker';
|
|
13
|
+
import { NumberInput } from './NumberInput';
|
|
14
|
+
import { Chart } from './Chart';
|
|
15
|
+
import { parseChoice } from './choiceUtils';
|
|
16
|
+
|
|
17
|
+
export interface FieldWhen {
|
|
18
|
+
/** 依赖的字段 id */
|
|
19
|
+
field: string;
|
|
20
|
+
/** 依赖字段命中这些值之一时才展示 */
|
|
21
|
+
values: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 条件组合:all 且组(全部命中)、any 或组(任一命中),两组同时存在取交集 */
|
|
25
|
+
export interface FieldWhenGroup {
|
|
26
|
+
all?: FieldWhen[];
|
|
27
|
+
any?: FieldWhen[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Field {
|
|
31
|
+
type: string;
|
|
32
|
+
id: string;
|
|
33
|
+
/** 在 row 内占据的栅格数(1-24,类似 antd Col 的 span) */
|
|
34
|
+
span?: number;
|
|
35
|
+
/** 条件显示:依赖字段的取值命中时才展示;
|
|
36
|
+
* 多条件时为数组(AND 组合:全部命中才显示);
|
|
37
|
+
* 分组形态 { all, any }:且组全部命中 且 或组任一命中 */
|
|
38
|
+
when?: FieldWhen | FieldWhen[] | FieldWhenGroup;
|
|
39
|
+
[key: string]: any;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FormRow {
|
|
43
|
+
/** 列间距(px),类似 antd Row 的 gutter */
|
|
44
|
+
gutter?: number;
|
|
45
|
+
fields: Field[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FormProps {
|
|
49
|
+
/** 表单 id(字段级统计事件携带,用于区分多个表单) */
|
|
50
|
+
id?: string;
|
|
51
|
+
fields: Field[];
|
|
52
|
+
/** 行分组(对象形态 { gutter, fields };兼容旧数组形态 Field[][]) */
|
|
53
|
+
rows?: (FormRow | Field[])[];
|
|
54
|
+
inline?: boolean;
|
|
55
|
+
columns?: number;
|
|
56
|
+
submitLabel?: string;
|
|
57
|
+
/** 取消按钮文案,未设置则不显示 */
|
|
58
|
+
cancelLabel?: string;
|
|
59
|
+
/** 清空/重置按钮文案,未设置则不显示 */
|
|
60
|
+
resetLabel?: string;
|
|
61
|
+
/** 按钮组对齐:left / center / right */
|
|
62
|
+
buttonsAlign?: 'left' | 'center' | 'right';
|
|
63
|
+
/** 字段 label 与内容的布局:vertical 上下(默认)/ horizontal 左右 */
|
|
64
|
+
layout?: 'vertical' | 'horizontal';
|
|
65
|
+
/** 左右布局时 label 的宽度(px),默认 80 */
|
|
66
|
+
labelWidth?: number;
|
|
67
|
+
/** 左右布局时内容区域的宽度(px 或省略自适应填满) */
|
|
68
|
+
contentWidth?: number;
|
|
69
|
+
/** 是否禁用:级联禁用所有字段与按钮 */
|
|
70
|
+
disabled?: boolean;
|
|
71
|
+
/** 外部注入的初始值 { 字段id: 值 },优先级高于字段 default;仅覆盖表单中已声明的字段 id */
|
|
72
|
+
initialData?: Record<string, unknown>;
|
|
73
|
+
onchange: (values: Record<string, unknown>) => void;
|
|
74
|
+
/** 提交成功回调:校验通过后触发,独立于 onchange 的提交事件 */
|
|
75
|
+
onsubmit?: (values: Record<string, unknown>) => void;
|
|
76
|
+
/** 字段级变化回调:任一表单项被填写时触发,携带统计信息与最新表单数据 */
|
|
77
|
+
onfieldchange?: (stats: FormStats) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 表单统计信息:字段级变化事件携带 */
|
|
81
|
+
export interface FormStats {
|
|
82
|
+
/** 表单 id */
|
|
83
|
+
id: string;
|
|
84
|
+
/** 当前展示(可见)的字段数 */
|
|
85
|
+
visibleCount: number;
|
|
86
|
+
/** 已填写(非空)的字段数 */
|
|
87
|
+
filledCount: number;
|
|
88
|
+
/** 当前表单数据(含隐藏字段值) */
|
|
89
|
+
values: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const Form: React.FC<FormProps> = ({
|
|
93
|
+
fields,
|
|
94
|
+
rows,
|
|
95
|
+
inline,
|
|
96
|
+
columns,
|
|
97
|
+
submitLabel,
|
|
98
|
+
cancelLabel,
|
|
99
|
+
resetLabel,
|
|
100
|
+
buttonsAlign,
|
|
101
|
+
layout,
|
|
102
|
+
labelWidth,
|
|
103
|
+
contentWidth,
|
|
104
|
+
disabled = false,
|
|
105
|
+
initialData: externalData,
|
|
106
|
+
onchange,
|
|
107
|
+
onsubmit,
|
|
108
|
+
onfieldchange,
|
|
109
|
+
id: formId
|
|
110
|
+
}) => {
|
|
111
|
+
const initialData = useMemo(() => {
|
|
112
|
+
const acc: Record<string, unknown> = {};
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
for (const f of fields) {
|
|
115
|
+
if (!f?.id || seen.has(f.id)) continue; // 重复 id 防护:默认值只取首次出现
|
|
116
|
+
seen.add(f.id);
|
|
117
|
+
if (f.type === 'button-group' || f.type === 'select') {
|
|
118
|
+
// choices[0] 可能是 key:label 格式,默认值取 key(表单值体系)
|
|
119
|
+
if (f.default !== undefined && f.default !== null && f.default !== '') {
|
|
120
|
+
acc[f.id] = parseChoice(String(f.default)).value;
|
|
121
|
+
} else {
|
|
122
|
+
acc[f.id] = Array.isArray(f.choices) && f.choices.length ? parseChoice(String(f.choices[0])).value : undefined;
|
|
123
|
+
}
|
|
124
|
+
} else if (f.type === 'select-multi') {
|
|
125
|
+
const d = f.default;
|
|
126
|
+
// choices 可能是对象数组({value,key,label}):default 写 label 时映射回 value 取值体系
|
|
127
|
+
const objChoices = (Array.isArray(f.choices) ? f.choices : [])
|
|
128
|
+
.filter((c: any) => c && typeof c === 'object') as Array<{ value?: string; key?: string; label?: string }>;
|
|
129
|
+
const mapDefault = (v: any): string => {
|
|
130
|
+
const s = String(v);
|
|
131
|
+
const hit = objChoices.find(c => String(c.value ?? c.key) === s || c.label === s);
|
|
132
|
+
return hit ? String(hit.value ?? hit.key) : parseChoice(s).value;
|
|
133
|
+
};
|
|
134
|
+
acc[f.id] = Array.isArray(d)
|
|
135
|
+
? d.map(mapDefault)
|
|
136
|
+
: (d ? [mapDefault(d)] : []);
|
|
137
|
+
} else if (f.type === 'radio-group') {
|
|
138
|
+
// 单选:choices 可能是对象数组({value,key,label}),default 写 label 时映射回 value
|
|
139
|
+
const d = f.default;
|
|
140
|
+
const objChoices = (Array.isArray(f.choices) ? f.choices : [])
|
|
141
|
+
.filter((c: any) => c && typeof c === 'object') as Array<{ value?: string; key?: string; label?: string }>;
|
|
142
|
+
const mapDefault = (v: any): string => {
|
|
143
|
+
const s = String(v);
|
|
144
|
+
const hit = objChoices.find(c => String(c.value ?? c.key) === s || c.label === s);
|
|
145
|
+
return hit ? String(hit.value ?? hit.key) : parseChoice(s).value;
|
|
146
|
+
};
|
|
147
|
+
if (d !== undefined && d !== null && d !== '') {
|
|
148
|
+
acc[f.id] = mapDefault(Array.isArray(d) ? d[0] : d);
|
|
149
|
+
} else {
|
|
150
|
+
acc[f.id] = Array.isArray(f.choices) && f.choices.length ? mapDefault(f.choices[0]) : '';
|
|
151
|
+
}
|
|
152
|
+
} else if (f.type === 'cascade') {
|
|
153
|
+
acc[f.id] = f.default ?? '';
|
|
154
|
+
} else if (f.type === 'slider') {
|
|
155
|
+
acc[f.id] = f.default ?? (typeof f.min === 'number' ? f.min : 0);
|
|
156
|
+
} else if (f.type === 'switch') {
|
|
157
|
+
acc[f.id] = f.default ?? false;
|
|
158
|
+
} else if (f.type === 'date-picker') {
|
|
159
|
+
// 范围模式默认 [](空数组),单选默认 null;default 已是时间戳/格式化字符串
|
|
160
|
+
acc[f.id] = f.range
|
|
161
|
+
? (Array.isArray(f.default) ? f.default : (f.default !== undefined ? [f.default] : []))
|
|
162
|
+
: (f.default ?? null);
|
|
163
|
+
} else if (f.type === 'number-input') {
|
|
164
|
+
// 无 default 时为 null(空值),由组件内部处理显示
|
|
165
|
+
acc[f.id] = f.default ?? null;
|
|
166
|
+
} else if (f.type === 'text-input') {
|
|
167
|
+
acc[f.id] = f.default ?? '';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 外部注入的初始值覆盖(仅覆盖表单已声明的字段 id:fields 与 rows 的并集)
|
|
172
|
+
if (externalData) {
|
|
173
|
+
const declaredIds = new Set<string>();
|
|
174
|
+
for (const f of fields ?? []) { if (f?.id) declaredIds.add(f.id); }
|
|
175
|
+
for (const r of rows ?? []) {
|
|
176
|
+
const rowFields = Array.isArray(r) ? r : (r?.fields ?? []);
|
|
177
|
+
for (const f of rowFields) { if (f?.id) declaredIds.add(f.id); }
|
|
178
|
+
}
|
|
179
|
+
for (const [k, v] of Object.entries(externalData)) {
|
|
180
|
+
if (declaredIds.has(k)) acc[k] = v;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return acc;
|
|
185
|
+
}, [fields, rows, externalData]);
|
|
186
|
+
|
|
187
|
+
const [data, setData] = useState<Record<string, unknown>>(initialData);
|
|
188
|
+
|
|
189
|
+
// 子组件以 default 初始化内部状态且仅挂载时读取一次;
|
|
190
|
+
// version 用于 initialData 变化(异步回填)/重置时 remount 子组件,让新值真正回显
|
|
191
|
+
const [version, setVersion] = useState(0);
|
|
192
|
+
const isFirstEffect = useRef(true);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (isFirstEffect.current) { isFirstEffect.current = false; return; }
|
|
196
|
+
setData(initialData);
|
|
197
|
+
setVersion(v => v + 1);
|
|
198
|
+
}, [initialData]);
|
|
199
|
+
|
|
200
|
+
// 字段校验错误:提交时统一校验填充,key 为字段 id
|
|
201
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
202
|
+
|
|
203
|
+
/** 空值口径:undefined / null / 空串 / 空数组 */
|
|
204
|
+
const isEmptyVal = (v: unknown): boolean =>
|
|
205
|
+
v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
206
|
+
|
|
207
|
+
/** 基于给定表单数据计算统计信息(可见性与取值统一基于传入的 values,避免闭包旧值) */
|
|
208
|
+
const computeStats = (values: Record<string, unknown>): FormStats => {
|
|
209
|
+
const visibleFields = uniqueFields.filter(f => isVisible(f, values));
|
|
210
|
+
return {
|
|
211
|
+
id: formId ?? '',
|
|
212
|
+
visibleCount: visibleFields.length,
|
|
213
|
+
filledCount: visibleFields.filter(f => !isEmptyVal(values[f.id])).length,
|
|
214
|
+
values
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const setValue = (id: string, value: unknown) => {
|
|
219
|
+
setErrors(prev => (prev[id] ? { ...prev, [id]: '' } : prev));
|
|
220
|
+
setData(prev => {
|
|
221
|
+
const next = { ...prev, [id]: value };
|
|
222
|
+
// 字段级统计事件:在函数式更新内基于合并后的新值计算,
|
|
223
|
+
// 避免 isVisible 读取变更前的旧依赖值(stale closure 导致统计滞后一步)
|
|
224
|
+
if (onfieldchange) onfieldchange(computeStats(next));
|
|
225
|
+
return next;
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** 字段是否可见:无 when 时恒可见;
|
|
230
|
+
* - 单条件:依赖字段值命中 values 任一
|
|
231
|
+
* - 数组(FieldWhen[]):AND 组合,全部命中才显示
|
|
232
|
+
* - 分组(FieldWhenGroup):且组 all 全部命中 且 或组 any 任一命中
|
|
233
|
+
* values 显式传入依赖值(渲染时传 state data;事件统计时传合并后的新值) */
|
|
234
|
+
const isVisible = (f: Field, values: Record<string, unknown> = data): boolean => {
|
|
235
|
+
if (!f.when) return true;
|
|
236
|
+
const matchCond = (c: any): boolean => {
|
|
237
|
+
if (!c?.field || !Array.isArray(c.values) || c.values.length === 0) return true; // 非法条件忽略
|
|
238
|
+
const dep = values[c.field];
|
|
239
|
+
const op = c.op || 'eq';
|
|
240
|
+
if (op === 'eq') {
|
|
241
|
+
// 依赖值为数组时任一元素命中即可(支持 select-multi 等多选字段作为依赖)
|
|
242
|
+
if (Array.isArray(dep)) return dep.some(v => c.values.includes(String(v ?? '')));
|
|
243
|
+
return c.values.includes(String(dep ?? ''));
|
|
244
|
+
}
|
|
245
|
+
const left = Number(dep);
|
|
246
|
+
if (dep === undefined || dep === null || dep === '' || isNaN(left)) return false;
|
|
247
|
+
return c.values.some((v: string) => {
|
|
248
|
+
const right = Number(v);
|
|
249
|
+
if (isNaN(right)) return false;
|
|
250
|
+
if (op === 'gt') return left > right;
|
|
251
|
+
if (op === 'gte') return left >= right;
|
|
252
|
+
if (op === 'lt') return left < right;
|
|
253
|
+
if (op === 'lte') return left <= right;
|
|
254
|
+
return left !== right; // ne
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
const when = f.when as any;
|
|
258
|
+
if (!Array.isArray(when) && typeof when === 'object' && ('all' in when || 'any' in when)) {
|
|
259
|
+
const allOk = (when.all || []).every(matchCond);
|
|
260
|
+
const anyList = when.any || [];
|
|
261
|
+
const anyOk = anyList.length === 0 ? true : anyList.some(matchCond);
|
|
262
|
+
return allOk && anyOk;
|
|
263
|
+
}
|
|
264
|
+
const conds = Array.isArray(when) ? when : [when];
|
|
265
|
+
if (conds.length === 0) return true;
|
|
266
|
+
return conds.every(matchCond);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** 单字段校验:返回错误文案或空串 */
|
|
270
|
+
const validateField = (f: Field): string => {
|
|
271
|
+
const v = data[f.id];
|
|
272
|
+
if (f.required) {
|
|
273
|
+
const empty = v === undefined || v === null || v === '' ||
|
|
274
|
+
(Array.isArray(v) && v.length === 0);
|
|
275
|
+
if (empty) return f.message || (f.label ? `请填写${f.label}` : '该项为必填项');
|
|
276
|
+
}
|
|
277
|
+
if (f.pattern && typeof v === 'string' && v && !new RegExp(f.pattern).test(v)) {
|
|
278
|
+
return f.message || (f.label ? `${f.label}格式不正确` : '格式不正确');
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** 提交前校验所有可见字段,全部通过返回 true(隐藏字段不校验) */
|
|
284
|
+
const validateAll = (): boolean => {
|
|
285
|
+
const next: Record<string, string> = {};
|
|
286
|
+
for (const f of uniqueFields) {
|
|
287
|
+
if (!isVisible(f)) continue;
|
|
288
|
+
const err = validateField(f);
|
|
289
|
+
if (err) next[f.id] = err;
|
|
290
|
+
}
|
|
291
|
+
setErrors(next);
|
|
292
|
+
return Object.keys(next).length === 0;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleSubmit = () => {
|
|
296
|
+
if (disabled) return;
|
|
297
|
+
if (!validateAll()) return;
|
|
298
|
+
// 提交数据中剔除隐藏字段的值(避免提交不可见的中间值)
|
|
299
|
+
const visible: Record<string, unknown> = {};
|
|
300
|
+
for (const key of Object.keys(data)) {
|
|
301
|
+
const f = uniqueFields.find(x => x.id === key);
|
|
302
|
+
if (f && !isVisible(f)) continue;
|
|
303
|
+
visible[key] = data[key];
|
|
304
|
+
}
|
|
305
|
+
onchange(visible);
|
|
306
|
+
onsubmit?.(visible);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handleReset = () => {
|
|
310
|
+
if (disabled) return;
|
|
311
|
+
setData(initialData);
|
|
312
|
+
setErrors({});
|
|
313
|
+
setVersion(v => v + 1); // remount 子组件,界面回到初始值
|
|
314
|
+
// 重置同样派发统计事件:让面板即时回到初始统计口径
|
|
315
|
+
if (onfieldchange) onfieldchange(computeStats(initialData));
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const handleCancel = () => {
|
|
319
|
+
if (disabled) return;
|
|
320
|
+
onchange({ ...initialData, _action: 'cancel' });
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** 按钮组:提交 + 可选的清空/取消,data-align 控制对齐;全部未设置则不渲染 */
|
|
324
|
+
const renderActions = () => {
|
|
325
|
+
if (!cancelLabel && !resetLabel && !submitLabel) return null;
|
|
326
|
+
return (
|
|
327
|
+
<div className="widget-form-actions" data-align={buttonsAlign || 'left'}>
|
|
328
|
+
{cancelLabel && <button type="button" className="widget-form-cancel" onClick={handleCancel} disabled={disabled}>{cancelLabel}</button>}
|
|
329
|
+
{resetLabel && <button type="button" className="widget-form-reset" onClick={handleReset} disabled={disabled}>{resetLabel}</button>}
|
|
330
|
+
{submitLabel && <button type="button" className="widget-form-submit" onClick={handleSubmit} disabled={disabled}>{submitLabel}</button>}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const renderField = (field: Field) => {
|
|
336
|
+
const commonProps = {
|
|
337
|
+
...field,
|
|
338
|
+
// 用当前表单值覆盖字段 default:子组件以 default 初始化内部状态,
|
|
339
|
+
// 这样外部 data / 表单默认值才能回显到界面
|
|
340
|
+
default: data[field.id] !== undefined ? data[field.id] : field.default,
|
|
341
|
+
disabled: disabled || !!field.disabled,
|
|
342
|
+
onchange: (value: any) => setValue(field.id, value)
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
switch (field.type) {
|
|
346
|
+
case 'button':
|
|
347
|
+
return <Button {...commonProps} />;
|
|
348
|
+
case 'button-group':
|
|
349
|
+
return <ButtonGroup {...commonProps} choices={field.choices ?? []} />;
|
|
350
|
+
case 'select':
|
|
351
|
+
return <Select {...commonProps} choices={field.choices ?? []} />;
|
|
352
|
+
case 'select-multi':
|
|
353
|
+
return <SelectMulti {...commonProps} choices={field.choices ?? []} error={errors[field.id] || ''} />;
|
|
354
|
+
case 'radio-group':
|
|
355
|
+
return <RadioGroup {...commonProps} choices={field.choices ?? []} error={errors[field.id] || ''} />;
|
|
356
|
+
case 'cascade':
|
|
357
|
+
return <Cascader {...commonProps} choices={field.choices ?? []} error={errors[field.id] || ''} />;
|
|
358
|
+
case 'tree-select':
|
|
359
|
+
return <TreeSelect {...commonProps} choices={field.choices ?? []} error={errors[field.id] || ''} />;
|
|
360
|
+
case 'slider':
|
|
361
|
+
return (
|
|
362
|
+
<Slider
|
|
363
|
+
{...commonProps}
|
|
364
|
+
min={field.min ?? 0}
|
|
365
|
+
max={field.max ?? 100}
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
case 'switch':
|
|
369
|
+
return <Switch {...commonProps} />;
|
|
370
|
+
case 'text-input':
|
|
371
|
+
return <TextInput {...commonProps} error={errors[field.id] || ''} />;
|
|
372
|
+
case 'date-picker':
|
|
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
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export const Incomplete: React.FC = () => {
|
|
4
|
+
return (
|
|
5
|
+
<div className="incomplete-widget">
|
|
6
|
+
<div className="loading-indicator">
|
|
7
|
+
<span className="loading-text">Loading...</span>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const styles = `
|
|
14
|
+
.incomplete-widget {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
padding: 8px 12px;
|
|
18
|
+
background-color: rgba(0, 0, 0, 0.04);
|
|
19
|
+
border: 1px solid #f0f0f0;
|
|
20
|
+
border-radius: 4px;
|
|
21
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
22
|
+
font-size: 14px;
|
|
23
|
+
color: rgba(0, 0, 0, 0.65);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.loading-indicator {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 8px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.loading-text {
|
|
33
|
+
opacity: 0.7;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.loading-text::after {
|
|
37
|
+
content: '';
|
|
38
|
+
animation: dots 1.5s infinite;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@keyframes dots {
|
|
42
|
+
0%, 20% { content: ''; }
|
|
43
|
+
40% { content: '.'; }
|
|
44
|
+
60% { content: '..'; }
|
|
45
|
+
80%, 100% { content: '...'; }
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
// Inject styles if not already present
|
|
50
|
+
if (typeof document !== 'undefined' && !document.getElementById('incomplete-widget-styles')) {
|
|
51
|
+
const styleSheet = document.createElement('style');
|
|
52
|
+
styleSheet.id = 'incomplete-widget-styles';
|
|
53
|
+
styleSheet.textContent = styles;
|
|
54
|
+
document.head.appendChild(styleSheet);
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { parseChoices } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface MultipleChoiceQuestionProps {
|
|
5
|
+
question: string;
|
|
6
|
+
choices: string[];
|
|
7
|
+
correctAnswer?: string;
|
|
8
|
+
showFeedback?: boolean;
|
|
9
|
+
/** 是否禁用:禁用后选项不可点击 */
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
onchange: (value: { answer: string; isCorrect?: boolean }) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const MultipleChoiceQuestion: React.FC<MultipleChoiceQuestionProps> = ({
|
|
15
|
+
question,
|
|
16
|
+
choices,
|
|
17
|
+
correctAnswer,
|
|
18
|
+
showFeedback = false,
|
|
19
|
+
disabled = false,
|
|
20
|
+
onchange
|
|
21
|
+
}) => {
|
|
22
|
+
// choices 支持 key:label 格式:value(key) 是取值,label 是展示文案
|
|
23
|
+
const items = parseChoices(choices);
|
|
24
|
+
const [selectedAnswer, setSelectedAnswer] = useState<string>('');
|
|
25
|
+
const [hasAnswered, setHasAnswered] = useState(false);
|
|
26
|
+
|
|
27
|
+
const handleAnswerSelect = (answer: string) => {
|
|
28
|
+
if (disabled) return;
|
|
29
|
+
setSelectedAnswer(answer);
|
|
30
|
+
setHasAnswered(true);
|
|
31
|
+
|
|
32
|
+
const isCorrect = correctAnswer ? answer === correctAnswer : undefined;
|
|
33
|
+
onchange({ answer, isCorrect });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getChoiceClassName = (value: string) => {
|
|
37
|
+
let className = 'mcq-choice';
|
|
38
|
+
|
|
39
|
+
if (hasAnswered && showFeedback && correctAnswer) {
|
|
40
|
+
if (value === correctAnswer) {
|
|
41
|
+
className += ' mcq-choice-correct';
|
|
42
|
+
} else if (value === selectedAnswer && value !== correctAnswer) {
|
|
43
|
+
className += ' mcq-choice-incorrect';
|
|
44
|
+
}
|
|
45
|
+
} else if (value === selectedAnswer) {
|
|
46
|
+
className += ' mcq-choice-selected';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return className;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 反馈文案:correctAnswer 是 key,展示时映射回 label
|
|
53
|
+
const correctLabel = correctAnswer
|
|
54
|
+
? (items.find(i => i.value === correctAnswer)?.label ?? correctAnswer)
|
|
55
|
+
: '';
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="widget-mcq">
|
|
59
|
+
<div className="mcq-question">{question}</div>
|
|
60
|
+
<div className="mcq-choices">
|
|
61
|
+
{items.map((item, index) => (
|
|
62
|
+
<button
|
|
63
|
+
key={index}
|
|
64
|
+
className={getChoiceClassName(item.value)}
|
|
65
|
+
onClick={() => handleAnswerSelect(item.value)}
|
|
66
|
+
disabled={disabled || (hasAnswered && showFeedback)}
|
|
67
|
+
>
|
|
68
|
+
{item.label}
|
|
69
|
+
</button>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
{hasAnswered && showFeedback && selectedAnswer && (
|
|
73
|
+
<div className={`mcq-feedback ${selectedAnswer === correctAnswer ? 'correct' : 'incorrect'}`}>
|
|
74
|
+
{correctAnswer ?
|
|
75
|
+
(selectedAnswer === correctAnswer ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${correctLabel}`) :
|
|
76
|
+
`You selected: ${selectedAnswer}`
|
|
77
|
+
}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|