@c2-markdown-ui/react 0.0.0-stage → 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 +93 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9453 -0
- 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 +44 -4
- package/src/index.ts +1 -0
- package/src/lib/MarkdownUI.tsx +73 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/widgets.css +1373 -0
- package/src/widgets/Button.tsx +39 -0
- package/src/widgets/ButtonGroup.tsx +47 -0
- package/src/widgets/Cascader.tsx +399 -0
- package/src/widgets/Chart.tsx +238 -0
- package/src/widgets/DatePicker.tsx +434 -0
- package/src/widgets/Form.tsx +372 -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 +146 -0
- package/src/widgets/Select.tsx +142 -0
- package/src/widgets/SelectMulti.tsx +72 -0
- package/src/widgets/ShortAnswerQuestion.tsx +101 -0
- package/src/widgets/Slider.tsx +57 -0
- package/src/widgets/Switch.tsx +56 -0
- package/src/widgets/TextInput.tsx +38 -0
- package/src/widgets/Widget.tsx +83 -0
- package/src/widgets/chartRemoteData.ts +84 -0
- package/src/widgets/choiceUtils.ts +45 -0
- package/src/widgets/index.ts +17 -0
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
};
|
|
@@ -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: #f5f5f5;
|
|
19
|
+
border: 1px solid #ddd;
|
|
20
|
+
border-radius: 4px;
|
|
21
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
22
|
+
font-size: 14px;
|
|
23
|
+
color: #666;
|
|
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
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface NumberInputProps {
|
|
4
|
+
label?: string;
|
|
5
|
+
/** 最小值 */
|
|
6
|
+
min?: number;
|
|
7
|
+
/** 最大值 */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** 步长(点击上下按钮/方向键的增减量),默认 1 */
|
|
10
|
+
step?: number;
|
|
11
|
+
/** 数值精度(小数位数) */
|
|
12
|
+
precision?: number;
|
|
13
|
+
default?: number;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
/** 是否必填:空值提交校验不通过 */
|
|
17
|
+
required?: boolean;
|
|
18
|
+
/** 校验失败提示文案 */
|
|
19
|
+
message?: string;
|
|
20
|
+
/** 外部(form 提交时)传入的错误信息,优先于组件内部即时校验 */
|
|
21
|
+
error?: string;
|
|
22
|
+
/** 是否禁用 */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
onchange: (value: number | null) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 文本 → 数字:空串或输入中途态返回 null,非法文本返回 null */
|
|
28
|
+
function parseNum(v: string): number | null {
|
|
29
|
+
const s = v.trim();
|
|
30
|
+
if (!s || s === '-' || s === '.' || s === '-.' || s === '+' || s === '+.') return null;
|
|
31
|
+
const n = Number(s);
|
|
32
|
+
return isNaN(n) ? null : n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const NumberInput: React.FC<NumberInputProps> = ({
|
|
36
|
+
label = '',
|
|
37
|
+
min,
|
|
38
|
+
max,
|
|
39
|
+
step = 1,
|
|
40
|
+
precision,
|
|
41
|
+
default: initial,
|
|
42
|
+
placeholder = '',
|
|
43
|
+
hint = '',
|
|
44
|
+
required = false,
|
|
45
|
+
message = '',
|
|
46
|
+
error = '',
|
|
47
|
+
disabled = false,
|
|
48
|
+
onchange
|
|
49
|
+
}) => {
|
|
50
|
+
// 输入框显示的原始文本:允许输入中途的临时态(如 "-" / "1."),失焦/回车时规范化
|
|
51
|
+
const [text, setText] = useState(initial !== undefined && initial !== null ? formatNum(initial, precision) : '');
|
|
52
|
+
const [localError, setLocalError] = useState('');
|
|
53
|
+
|
|
54
|
+
/** 显示格式化:设置精度时固定位数小数(如 18 → "18.00",antd 行为) */
|
|
55
|
+
function formatNum(v: number, p?: number): string {
|
|
56
|
+
if (p !== undefined && p >= 0) return v.toFixed(p);
|
|
57
|
+
return String(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 应用精度四舍五入 + min/max 钳制 */
|
|
61
|
+
const normalize = (n: number): number => {
|
|
62
|
+
if (precision !== undefined && precision >= 0) n = Number(n.toFixed(precision));
|
|
63
|
+
if (min !== undefined && n < min) n = min;
|
|
64
|
+
if (max !== undefined && n > max) n = max;
|
|
65
|
+
return n;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 校验当前值:返回错误文案或空串 */
|
|
69
|
+
const validate = (v: number | null): string => {
|
|
70
|
+
if (required && v === null) return message || (label ? `请填写${label}` : '该项为必填项');
|
|
71
|
+
return '';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** 提交:规范化显示文本并对外发值(空/非法 → null) */
|
|
75
|
+
const commit = () => {
|
|
76
|
+
const n = parseNum(text);
|
|
77
|
+
if (n === null) {
|
|
78
|
+
setText('');
|
|
79
|
+
setLocalError(validate(null));
|
|
80
|
+
onchange(null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const v = normalize(n);
|
|
84
|
+
setText(formatNum(v, precision));
|
|
85
|
+
setLocalError('');
|
|
86
|
+
onchange(v);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** 步进:direction 为 1(加)/ -1(减);空值从 0(或 min/max)起步 */
|
|
90
|
+
const stepBy = (direction: 1 | -1) => {
|
|
91
|
+
const base = parseNum(text);
|
|
92
|
+
const start = base ?? 0;
|
|
93
|
+
const v = normalize(start + step * direction);
|
|
94
|
+
setText(formatNum(v, precision));
|
|
95
|
+
setLocalError('');
|
|
96
|
+
onchange(v);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
100
|
+
if (e.key === 'ArrowUp') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
stepBy(1);
|
|
103
|
+
} else if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
stepBy(-1);
|
|
106
|
+
} else if (e.key === 'Enter') {
|
|
107
|
+
commit();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const current = parseNum(text);
|
|
112
|
+
const atMax = max !== undefined && current !== null && current >= max;
|
|
113
|
+
const atMin = min !== undefined && current !== null && current <= min;
|
|
114
|
+
const shownError = error || localError;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className={`widget-number-input${shownError ? ' widget-field-error' : ''}`}>
|
|
118
|
+
{label || hint || required ? (
|
|
119
|
+
<div className="widget-label-row">
|
|
120
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
121
|
+
{label && <label>{label}</label>}
|
|
122
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
125
|
+
<div className="widget-field-content">
|
|
126
|
+
<div className="ni-wrap">
|
|
127
|
+
<input
|
|
128
|
+
type="text"
|
|
129
|
+
inputMode="decimal"
|
|
130
|
+
autoComplete="off"
|
|
131
|
+
className={shownError ? 'widget-input-error' : undefined}
|
|
132
|
+
value={text}
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
onKeyDown={handleKeydown}
|
|
135
|
+
onBlur={commit}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
onChange={(e) => {
|
|
138
|
+
setText(e.target.value);
|
|
139
|
+
if (localError) setLocalError(validate(parseNum(e.target.value)));
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
<span className="ni-steps" aria-hidden="true">
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="ni-step-up"
|
|
146
|
+
onClick={() => stepBy(1)}
|
|
147
|
+
disabled={disabled || atMax}
|
|
148
|
+
aria-label="增加"
|
|
149
|
+
tabIndex={-1}
|
|
150
|
+
>
|
|
151
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 0 L10 6 H0 Z" fill="currentColor" /></svg>
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="ni-step-down"
|
|
156
|
+
onClick={() => stepBy(-1)}
|
|
157
|
+
disabled={disabled || atMin}
|
|
158
|
+
aria-label="减少"
|
|
159
|
+
tabIndex={-1}
|
|
160
|
+
>
|
|
161
|
+
<svg viewBox="0 0 10 6" width="10" height="6"><path d="M5 6 L0 0 H10 Z" fill="currentColor" /></svg>
|
|
162
|
+
</button>
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
{shownError ? <div className="widget-error-text" role="alert">{shownError}</div> : null}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
};
|