@c2-markdown-ui/svelte 0.0.0-stage → 0.4.2
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 +91 -2
- package/package.json +37 -5
- package/src/lib/MarkdownUI.svelte +60 -0
- package/src/lib/Navbar.svelte +144 -0
- package/src/lib/WidgetEvents.svelte +139 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/widgets/Button.svelte +30 -0
- package/src/lib/widgets/ButtonGroup.svelte +37 -0
- package/src/lib/widgets/Cascader.svelte +486 -0
- package/src/lib/widgets/Chart.svelte +278 -0
- package/src/lib/widgets/DatePicker.svelte +609 -0
- package/src/lib/widgets/Form.svelte +571 -0
- package/src/lib/widgets/Incomplete.svelte +72 -0
- package/src/lib/widgets/MultipleChoiceQuestion.svelte +65 -0
- package/src/lib/widgets/NumberInput.svelte +149 -0
- package/src/lib/widgets/Quiz.svelte +301 -0
- package/src/lib/widgets/Radio.svelte +180 -0
- package/src/lib/widgets/Row.svelte +140 -0
- package/src/lib/widgets/Select.svelte +276 -0
- package/src/lib/widgets/SelectMulti.svelte +130 -0
- package/src/lib/widgets/ShortAnswerQuestion.svelte +57 -0
- package/src/lib/widgets/Slider.svelte +46 -0
- package/src/lib/widgets/Switch.svelte +60 -0
- package/src/lib/widgets/Text.svelte +58 -0
- package/src/lib/widgets/TextInput.svelte +58 -0
- package/src/lib/widgets/TreeSelect.svelte +924 -0
- package/src/lib/widgets/Widget.svelte +90 -0
- package/src/lib/widgets/chartRemoteData.ts +84 -0
- package/src/lib/widgets/index.ts +23 -0
- package/src/lib/widgets/remoteChoices.ts +90 -0
- package/src/lib/widgets.css +1754 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from './Button.svelte';
|
|
3
|
+
import ButtonGroup from './ButtonGroup.svelte';
|
|
4
|
+
import Select from './Select.svelte';
|
|
5
|
+
import SelectMulti from './SelectMulti.svelte';
|
|
6
|
+
import RadioGroup from './Radio.svelte';
|
|
7
|
+
import Cascader from './Cascader.svelte';
|
|
8
|
+
import TreeSelect from './TreeSelect.svelte';
|
|
9
|
+
import Slider from './Slider.svelte';
|
|
10
|
+
import Switch from './Switch.svelte';
|
|
11
|
+
import TextInput from './TextInput.svelte';
|
|
12
|
+
import DatePicker from './DatePicker.svelte';
|
|
13
|
+
import NumberInput from './NumberInput.svelte';
|
|
14
|
+
import Text from './Text.svelte';
|
|
15
|
+
import Chart from './Chart.svelte';
|
|
16
|
+
import { parseChoice, parseChoices } from './remoteChoices';
|
|
17
|
+
|
|
18
|
+
interface FieldWhen {
|
|
19
|
+
field: string;
|
|
20
|
+
values: string[];
|
|
21
|
+
}
|
|
22
|
+
interface Field {
|
|
23
|
+
type: string;
|
|
24
|
+
id: string;
|
|
25
|
+
/** 在 row 内占据的栅格数(1-24,类似 antd Col 的 span) */
|
|
26
|
+
span?: number;
|
|
27
|
+
/** 显示条件(form 内联动显隐):
|
|
28
|
+
* - 单条件对象/数组(AND 组合):依赖字段的值命中 values 任一时显示,多条件全部命中才显示
|
|
29
|
+
* - 分组对象 { all, any }:all 且组全部命中 且 any 或组任一命中 */
|
|
30
|
+
when?: FieldWhen | FieldWhen[] | { all?: FieldWhen[]; any?: FieldWhen[] };
|
|
31
|
+
[key: string]: any;
|
|
32
|
+
}
|
|
33
|
+
interface FormRow {
|
|
34
|
+
/** 列间距(px),类似 antd Row 的 gutter */
|
|
35
|
+
gutter?: number;
|
|
36
|
+
fields: Field[];
|
|
37
|
+
}
|
|
38
|
+
interface Props {
|
|
39
|
+
id?: string;
|
|
40
|
+
fields: Field[];
|
|
41
|
+
/** 行分组(对象形态 { gutter, fields };兼容旧数组形态 Field[][]) */
|
|
42
|
+
rows?: (FormRow | Field[])[];
|
|
43
|
+
inline?: boolean;
|
|
44
|
+
columns?: number;
|
|
45
|
+
submitLabel?: string;
|
|
46
|
+
/** 取消按钮文案,未设置则不显示 */
|
|
47
|
+
cancelLabel?: string;
|
|
48
|
+
/** 清空/重置按钮文案,未设置则不显示 */
|
|
49
|
+
resetLabel?: string;
|
|
50
|
+
/** 按钮组对齐:left / center / right */
|
|
51
|
+
buttonsAlign?: 'left' | 'center' | 'right';
|
|
52
|
+
/** 字段 label 与内容的布局:vertical 上下(默认)/ horizontal 左右 */
|
|
53
|
+
layout?: 'vertical' | 'horizontal';
|
|
54
|
+
/** 左右布局时 label 的宽度(px),默认 80 */
|
|
55
|
+
labelWidth?: number;
|
|
56
|
+
/** 左右布局时内容区域的宽度(px,省略自适应填满) */
|
|
57
|
+
contentWidth?: number;
|
|
58
|
+
/** 是否禁用:级联禁用所有字段与按钮 */
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
onchange: (values: Record<string, unknown>) => void;
|
|
61
|
+
}
|
|
62
|
+
let { id, fields, rows, inline, columns, submitLabel, cancelLabel, resetLabel, buttonsAlign, layout, labelWidth, contentWidth, disabled = false, onchange }: Props = $props();
|
|
63
|
+
|
|
64
|
+
// 左右布局:容器类 + CSS 变量(label 宽度 / 内容宽度)
|
|
65
|
+
const isHorizontal = layout === 'horizontal';
|
|
66
|
+
const layoutClass = isHorizontal ? 'widget-form-layout-horizontal' : '';
|
|
67
|
+
const layoutStyle = isHorizontal
|
|
68
|
+
? [
|
|
69
|
+
`--form-label-width: ${labelWidth ?? 80}px`,
|
|
70
|
+
contentWidth ? `--form-content-width: ${contentWidth}px` : ''
|
|
71
|
+
].filter(Boolean).join('; ')
|
|
72
|
+
: '';
|
|
73
|
+
|
|
74
|
+
// 规范化 row:兼容旧数组形态 Field[][]
|
|
75
|
+
function normalizeRow(row: FormRow | Field[]): FormRow {
|
|
76
|
+
return Array.isArray(row) ? { fields: row } : row;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 计算行内每个字段的 grid span(24 栅格,类似 antd)
|
|
80
|
+
// 显式 span 优先;无 span 时若 form 设置了 columns,则每字段占 24/columns(跨行对齐),
|
|
81
|
+
// 否则均分剩余栅格;总 span 超 24 时自动换行(由 grid auto-flow 处理)
|
|
82
|
+
function computeSpans(fields: Field[]): number[] {
|
|
83
|
+
const spans = fields.map(f =>
|
|
84
|
+
typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : undefined
|
|
85
|
+
);
|
|
86
|
+
const fixedTotal = spans.reduce<number>((acc, s) => acc + (s ?? 0), 0);
|
|
87
|
+
const autoCount = spans.filter(s => s === undefined).length;
|
|
88
|
+
let autoSpan = 0;
|
|
89
|
+
if (autoCount > 0) {
|
|
90
|
+
if (columns && columns >= 1) {
|
|
91
|
+
autoSpan = Math.max(Math.floor(24 / columns), 1);
|
|
92
|
+
} else {
|
|
93
|
+
const remaining = 24 - fixedTotal;
|
|
94
|
+
// 剩余栅格不足时占满整行(grid auto-flow 会自动换行),避免被挤进窄列导致文字竖排
|
|
95
|
+
if (remaining <= 0) {
|
|
96
|
+
autoSpan = 24;
|
|
97
|
+
} else {
|
|
98
|
+
autoSpan = Math.max(Math.floor(remaining / autoCount), 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return spans.map(s => s ?? autoSpan);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------- 重复 id 防护 ----------
|
|
106
|
+
// DSL 手写或编辑器插入可能产生重复字段 id;keyed each (f.id) 遇重复 key 会渲染崩溃(整个表单消失)。
|
|
107
|
+
// 这里统一过滤:fields/rows 各处只保留每个 id 的首次出现,重复项静默忽略。
|
|
108
|
+
function dedupe(list: Field[]): Field[] {
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
return (list ?? []).filter(f => {
|
|
111
|
+
if (!f?.id) return false;
|
|
112
|
+
if (seen.has(f.id)) return false;
|
|
113
|
+
seen.add(f.id);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** 去重后的顶层字段(渲染与数据初始化均用它;row 存在时 parser 已合并进 fields) */
|
|
118
|
+
const uniqueFields = $derived(dedupe(fields));
|
|
119
|
+
/** row 分组也去重:row 内重复 id 保留首个,并滤掉空 row */
|
|
120
|
+
const normRows = $derived(
|
|
121
|
+
Array.isArray(rows)
|
|
122
|
+
? rows.map(normalizeRow).map(r => ({ ...r, fields: dedupe(r.fields) })).filter(r => r.fields.length > 0)
|
|
123
|
+
: []
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 字段模式(无 row 分组):任一字段带显式 span 时切换为 24 栅格布局,让 span 生效
|
|
127
|
+
// (无 span 的字段保持整行显示,不配 span 的表单仍走 flex 纵向布局,完全向后兼容)
|
|
128
|
+
const fieldsGridMode = $derived(
|
|
129
|
+
!inline && (!Array.isArray(rows) || rows.length === 0) &&
|
|
130
|
+
uniqueFields.some(f => typeof f?.span === 'number' && f.span >= 1 && f.span <= 24)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
/** 字段模式栅格样式:显式 span 占 N 列;无 span 占满整行(1/-1,保持传统一行一个) */
|
|
134
|
+
function fieldsSpanStyle(f: Field): string | undefined {
|
|
135
|
+
if (!fieldsGridMode) return undefined;
|
|
136
|
+
const s = typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : 0;
|
|
137
|
+
return s >= 1 ? `grid-column: span ${s}` : 'grid-column: 1 / -1';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** inline 模式:显式 span 覆盖 flex-basis(占行宽 N/24,flex-shrink 保证与相邻字段同行收放) */
|
|
141
|
+
function inlineSpanStyle(f: Field): string | undefined {
|
|
142
|
+
const s = typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : 0;
|
|
143
|
+
return s >= 1 ? `flex: 0 1 calc(${s} / 24 * 100%)` : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------- helper ----------
|
|
147
|
+
function buildDefaults(fields: Field[]): Record<string, unknown> {
|
|
148
|
+
const out: Record<string, unknown> = {};
|
|
149
|
+
|
|
150
|
+
for (const f of fields) {
|
|
151
|
+
switch (f.type) {
|
|
152
|
+
case 'button':
|
|
153
|
+
out[f.id] = f.label ?? '';
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case 'button-group':
|
|
157
|
+
case 'select':
|
|
158
|
+
// choices[0] 可能是 key:label 格式,默认值取 key(表单值体系)
|
|
159
|
+
out[f.id] = f.default !== undefined
|
|
160
|
+
? (f.default !== null && f.default !== '' ? parseChoice(String(f.default)).value : f.default)
|
|
161
|
+
: (f.choices?.[0] !== undefined ? parseChoice(String(f.choices[0])).value : undefined);
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'select-multi': {
|
|
165
|
+
// choices 可能是对象数组({value,key,label}):default 写 label 时映射回 value 取值体系
|
|
166
|
+
const items = parseChoices(f.choices as any) ?? [];
|
|
167
|
+
const mapDefault = (v: any): string => {
|
|
168
|
+
const s = String(v);
|
|
169
|
+
const hit = items.find(it => it.value === s || it.label === s);
|
|
170
|
+
return hit ? hit.value : parseChoice(s).value;
|
|
171
|
+
};
|
|
172
|
+
out[f.id] = Array.isArray(f.default)
|
|
173
|
+
? f.default.map(mapDefault)
|
|
174
|
+
: (f.default ? [mapDefault(f.default)] : []);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'radio-group': {
|
|
179
|
+
// 单选:default 写 label 时映射回 value;无 default 时选中第一项(与 button-group 一致)
|
|
180
|
+
const items = parseChoices(f.choices as any) ?? [];
|
|
181
|
+
const mapDefault = (v: any): string => {
|
|
182
|
+
const s = String(v);
|
|
183
|
+
const hit = items.find(it => it.value === s || it.label === s);
|
|
184
|
+
return hit ? hit.value : parseChoice(s).value;
|
|
185
|
+
};
|
|
186
|
+
const d = f.default;
|
|
187
|
+
if (d !== undefined && d !== null && d !== '') {
|
|
188
|
+
out[f.id] = mapDefault(Array.isArray(d) ? d[0] : d);
|
|
189
|
+
} else {
|
|
190
|
+
out[f.id] = items.length ? items[0].value : '';
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'cascade':
|
|
196
|
+
// 默认值为完整路径(value 串,/ 分隔);无 default 时为空串(未选择)
|
|
197
|
+
out[f.id] = f.default ?? '';
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'slider':
|
|
201
|
+
out[f.id] = f.default ?? (typeof f.min === 'number' ? f.min : 0);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'switch':
|
|
205
|
+
out[f.id] = f.default ?? false;
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'date-picker':
|
|
209
|
+
// 范围模式默认 [](空数组),单选默认 null;default 已是时间戳/格式化字符串
|
|
210
|
+
out[f.id] = f.range
|
|
211
|
+
? (Array.isArray(f.default) ? f.default : (f.default !== undefined ? [f.default] : []))
|
|
212
|
+
: (f.default ?? null);
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case 'number-input':
|
|
216
|
+
// 无 default 时为 null(空值),由组件内部处理显示
|
|
217
|
+
out[f.id] = f.default ?? null;
|
|
218
|
+
break;
|
|
219
|
+
|
|
220
|
+
case 'text-input':
|
|
221
|
+
out[f.id] = f.default ?? '';
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- state ----------
|
|
229
|
+
// 注意:不能在顶层直接读取 $derived 的 uniqueFields(只捕获初始值的告警);
|
|
230
|
+
// fields 是 prop 不可变引用,dedupe(fields) 与 uniqueFields 结果一致
|
|
231
|
+
const initialData = buildDefaults(dedupe(fields));
|
|
232
|
+
const data = $state<Record<string, unknown>>({ ...initialData });
|
|
233
|
+
|
|
234
|
+
/** 字段是否可见:无 when 时恒可见;单个 when 依赖字段值命中 values 任一;
|
|
235
|
+
* 多个 when(数组)为 AND 组合,全部命中才显示 */
|
|
236
|
+
function isVisible(f: Field): boolean {
|
|
237
|
+
if (!f.when) return true;
|
|
238
|
+
// 单条件命中:eq 为依赖字段的值在 values 任一中;比较运算符(gt/gte/lt/lte/ne)按数值比较
|
|
239
|
+
// (依赖值或任一匹配值非数字时该值视为不命中)。values 为空或条件非法视为忽略=true
|
|
240
|
+
const matchCond = (c: any): boolean => {
|
|
241
|
+
if (!c?.field || !Array.isArray(c.values) || c.values.length === 0) return true; // 非法条件忽略
|
|
242
|
+
const dep = data[c.field];
|
|
243
|
+
const op = c.op || 'eq';
|
|
244
|
+
if (op === 'eq') return c.values.includes(String(dep ?? ''));
|
|
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
|
+
// 分组形态 { all, any }:且组全部命中 且 或组任一命中
|
|
259
|
+
if (!Array.isArray(when) && typeof when === 'object' && ('all' in when || 'any' in when)) {
|
|
260
|
+
const allOk = (when.all || []).every(matchCond);
|
|
261
|
+
const anyList = when.any || [];
|
|
262
|
+
const anyOk = anyList.length === 0 ? true : anyList.some(matchCond);
|
|
263
|
+
return allOk && anyOk;
|
|
264
|
+
}
|
|
265
|
+
// 数组形态(AND)/单条件对象
|
|
266
|
+
const conds = Array.isArray(when) ? when : [when];
|
|
267
|
+
if (conds.length === 0) return true;
|
|
268
|
+
return conds.every(matchCond);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 清空计数:递增触发 {#key} 重建字段区,让子组件恢复初始值(子组件显示值是各自本地状态,只重置 data 不会刷新 UI)
|
|
272
|
+
let resetCount = $state(0);
|
|
273
|
+
|
|
274
|
+
// 字段校验错误:提交时统一校验填充,key 为字段 id
|
|
275
|
+
let errors = $state<Record<string, string>>({});
|
|
276
|
+
|
|
277
|
+
function set(id: string, value: unknown) {
|
|
278
|
+
data[id] = value;
|
|
279
|
+
// 用户修改后清除该字段的提交错误(组件内部即时校验仍会显示)
|
|
280
|
+
if (errors[id]) errors[id] = '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 单字段校验:返回错误文案或空串 */
|
|
284
|
+
function validateField(f: Field): string {
|
|
285
|
+
const v = data[f.id];
|
|
286
|
+
if (f.required) {
|
|
287
|
+
const empty = v === undefined || v === null || v === '' ||
|
|
288
|
+
(Array.isArray(v) && v.length === 0);
|
|
289
|
+
if (empty) return f.message || (f.label ? `请填写${f.label}` : '该项为必填项');
|
|
290
|
+
}
|
|
291
|
+
if (f.pattern && typeof v === 'string' && v && !new RegExp(f.pattern).test(v)) {
|
|
292
|
+
return f.message || (f.label ? `${f.label}格式不正确` : '格式不正确');
|
|
293
|
+
}
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 提交前校验所有可见字段,全部通过返回 true(隐藏字段不校验) */
|
|
298
|
+
function validateAll(): boolean {
|
|
299
|
+
const next: Record<string, string> = {};
|
|
300
|
+
for (const f of uniqueFields) {
|
|
301
|
+
if (!isVisible(f)) continue;
|
|
302
|
+
const err = validateField(f);
|
|
303
|
+
if (err) next[f.id] = err;
|
|
304
|
+
}
|
|
305
|
+
errors = next;
|
|
306
|
+
return Object.keys(next).length === 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function submit() {
|
|
310
|
+
if (disabled) return;
|
|
311
|
+
if (!validateAll()) return;
|
|
312
|
+
// 提交数据中剔除隐藏字段的值(避免提交不可见的中间值)
|
|
313
|
+
const visible: Record<string, unknown> = {};
|
|
314
|
+
for (const key of Object.keys(data)) {
|
|
315
|
+
const f = uniqueFields.find(x => x.id === key);
|
|
316
|
+
if (f && !isVisible(f)) continue;
|
|
317
|
+
visible[key] = data[key];
|
|
318
|
+
}
|
|
319
|
+
// 控制台打印表单提交信息(字段值 + 全量表单信息)
|
|
320
|
+
console.log('[form submit] 表单数据:', visible);
|
|
321
|
+
console.log('[form submit] 表单信息:', {
|
|
322
|
+
id,
|
|
323
|
+
fields: uniqueFields.map(f => ({ id: f.id, type: f.type, label: f.label, required: !!f.required, visible: isVisible(f), value: visible[f.id] })),
|
|
324
|
+
submitLabel,
|
|
325
|
+
_action: 'submit'
|
|
326
|
+
});
|
|
327
|
+
onchange(visible);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function reset() {
|
|
331
|
+
if (disabled) return;
|
|
332
|
+
Object.assign(data, initialData);
|
|
333
|
+
errors = {};
|
|
334
|
+
resetCount++;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function cancel() {
|
|
338
|
+
if (disabled) return;
|
|
339
|
+
onchange({ ...initialData, _action: 'cancel' });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------- render ----------
|
|
343
|
+
function renderField(f: Field) {
|
|
344
|
+
switch (f.type) {
|
|
345
|
+
case 'button-group':
|
|
346
|
+
return `<ButtonGroup {...f} choices={f.choices ?? []} onchange={(v) => set(f.id, v)} />`;
|
|
347
|
+
case 'select':
|
|
348
|
+
return `<Select {...f} choices={f.choices ?? []} onchange={(v) => set(f.id, v)} />`;
|
|
349
|
+
case 'select-multi':
|
|
350
|
+
return `<SelectMulti {...f} choices={f.choices ?? []} onchange={(v) => set(f.id, v)} />`;
|
|
351
|
+
case 'radio-group':
|
|
352
|
+
return `<RadioGroup {...f} choices={f.choices ?? []} onchange={(v) => set(f.id, v)} />`;
|
|
353
|
+
case 'slider':
|
|
354
|
+
return `<Slider {...f} min={f.min ?? 0} max={f.max ?? 100} onchange={(v) => set(f.id, v)} />`;
|
|
355
|
+
case 'switch':
|
|
356
|
+
return `<Switch {...f} onchange={(v) => set(f.id, v)} />`;
|
|
357
|
+
case 'text-input':
|
|
358
|
+
return `<TextInput {...f} onchange={(v) => set(f.id, v)} />`;
|
|
359
|
+
case 'text':
|
|
360
|
+
return `<Text {...f} />`;
|
|
361
|
+
default:
|
|
362
|
+
return '';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
</script>
|
|
366
|
+
|
|
367
|
+
<!-- 全局 inline 模式(所有字段 flex-wrap 横排) -->
|
|
368
|
+
{#if inline && !rows}
|
|
369
|
+
<div class="widget-form widget-form-inline {layoutClass}" style={[columns ? `--form-columns: ${columns}` : '', layoutStyle].filter(Boolean).join('; ')}>
|
|
370
|
+
{#key resetCount}
|
|
371
|
+
{#each uniqueFields as f (f.id)}
|
|
372
|
+
{#if isVisible(f)}
|
|
373
|
+
<div class="widget-form-field" data-field-id={f.id} data-field-type={f.type} style={inlineSpanStyle(f)}>
|
|
374
|
+
{#if f.type === 'button'}
|
|
375
|
+
<Button {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
376
|
+
{:else if f.type === 'button-group'}
|
|
377
|
+
<ButtonGroup {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
378
|
+
{:else if f.type === 'select'}
|
|
379
|
+
<Select {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
380
|
+
{:else if f.type === 'select-multi'}
|
|
381
|
+
<SelectMulti {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
382
|
+
{:else if f.type === 'radio-group'}
|
|
383
|
+
<RadioGroup {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
384
|
+
{:else if f.type === 'cascade'}
|
|
385
|
+
<Cascader {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
386
|
+
{:else if f.type === 'tree-select'}
|
|
387
|
+
<TreeSelect {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
388
|
+
{:else if f.type === 'slider'}
|
|
389
|
+
<Slider {...f} min={f.min ?? 0} max={f.max ?? 100} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
390
|
+
{:else if f.type === 'switch'}
|
|
391
|
+
<Switch {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
392
|
+
{:else if f.type === 'date-picker'}
|
|
393
|
+
<DatePicker {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
394
|
+
{:else if f.type === 'number-input'}
|
|
395
|
+
<NumberInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
396
|
+
{:else if f.type === 'text-input'}
|
|
397
|
+
<TextInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
398
|
+
{:else if f.type === 'chart-line' || f.type === 'chart-bar' || f.type === 'chart-pie' || f.type === 'chart-scatter'}
|
|
399
|
+
<Chart {...(f as any)} type={f.type as 'chart-line' | 'chart-bar' | 'chart-pie' | 'chart-scatter'} labels={f.labels ?? []} datasets={f.datasets ?? []} onchange={(v) => set(f.id, v)} />
|
|
400
|
+
{:else if f.type === 'text'}
|
|
401
|
+
<Text {...f} />
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
{/if}
|
|
405
|
+
{/each}
|
|
406
|
+
{/key}
|
|
407
|
+
{#if cancelLabel || resetLabel || submitLabel}
|
|
408
|
+
<!-- 按钮组:data-align 控制对齐 -->
|
|
409
|
+
<div class="widget-form-actions" data-align={buttonsAlign || 'left'}>
|
|
410
|
+
{#if cancelLabel}<button type="button" class="widget-form-cancel" onclick={cancel} {disabled}>{cancelLabel}</button>{/if}
|
|
411
|
+
{#if resetLabel}<button type="button" class="widget-form-reset" onclick={reset} {disabled}>{resetLabel}</button>{/if}
|
|
412
|
+
{#if submitLabel}<button type="button" class="widget-form-submit" onclick={submit} {disabled}>{submitLabel}</button>{/if}
|
|
413
|
+
</div>
|
|
414
|
+
{/if}
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<!-- row 分组模式(24 栅格,类似 antd Row/Col) -->
|
|
418
|
+
{:else if rows && rows.length > 0}
|
|
419
|
+
<div class="widget-form {layoutClass}" style={layoutStyle}>
|
|
420
|
+
<!-- 设置 columns 时:所有 row 的字段合并流动,每行自动 columns 列(覆盖 row 手动分组) -->
|
|
421
|
+
{#if columns && columns >= 1}
|
|
422
|
+
{@const allFields = normRows.flatMap(r => r.fields).filter(isVisible)}
|
|
423
|
+
{@const spans = computeSpans(allFields)}
|
|
424
|
+
{@const gutter = normRows.find(r => r.gutter !== undefined)?.gutter}
|
|
425
|
+
{#key resetCount}
|
|
426
|
+
<div class="widget-form-row" style={gutter !== undefined ? `--row-gutter: ${gutter}px` : ''}>
|
|
427
|
+
{#each allFields as f, fi (f.id)}
|
|
428
|
+
<div class="widget-form-field" data-field-id={f.id} data-field-type={f.type} style={`grid-column: span ${spans[fi]}`}>
|
|
429
|
+
{#if f.type === 'button'}
|
|
430
|
+
<Button {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
431
|
+
{:else if f.type === 'button-group'}
|
|
432
|
+
<ButtonGroup {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
433
|
+
{:else if f.type === 'select'}
|
|
434
|
+
<Select {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
435
|
+
{:else if f.type === 'select-multi'}
|
|
436
|
+
<SelectMulti {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
437
|
+
{:else if f.type === 'radio-group'}
|
|
438
|
+
<RadioGroup {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
439
|
+
{:else if f.type === 'cascade'}
|
|
440
|
+
<Cascader {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
441
|
+
{:else if f.type === 'tree-select'}
|
|
442
|
+
<TreeSelect {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
443
|
+
{:else if f.type === 'slider'}
|
|
444
|
+
<Slider {...f} min={f.min ?? 0} max={f.max ?? 100} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
445
|
+
{:else if f.type === 'switch'}
|
|
446
|
+
<Switch {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
447
|
+
{:else if f.type === 'date-picker'}
|
|
448
|
+
<DatePicker {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
449
|
+
{:else if f.type === 'number-input'}
|
|
450
|
+
<NumberInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
451
|
+
{:else if f.type === 'text-input'}
|
|
452
|
+
<TextInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
453
|
+
{:else if f.type === 'chart-line' || f.type === 'chart-bar' || f.type === 'chart-pie' || f.type === 'chart-scatter'}
|
|
454
|
+
<Chart {...(f as any)} type={f.type as 'chart-line' | 'chart-bar' | 'chart-pie' | 'chart-scatter'} labels={f.labels ?? []} datasets={f.datasets ?? []} onchange={(v) => set(f.id, v)} />
|
|
455
|
+
{:else if f.type === 'text'}
|
|
456
|
+
<Text {...f} />
|
|
457
|
+
{/if}
|
|
458
|
+
</div>
|
|
459
|
+
{/each}
|
|
460
|
+
</div>
|
|
461
|
+
{/key}
|
|
462
|
+
{:else}
|
|
463
|
+
{#key resetCount}
|
|
464
|
+
{#each normRows as row, ri (ri)}
|
|
465
|
+
{@const visibleFields = row.fields.filter(isVisible)}
|
|
466
|
+
{@const spans = computeSpans(visibleFields)}
|
|
467
|
+
<div class="widget-form-row" style={row.gutter !== undefined ? `--row-gutter: ${row.gutter}px` : ''}>
|
|
468
|
+
{#each visibleFields as f, fi (f.id)}
|
|
469
|
+
<div class="widget-form-field" data-field-id={f.id} data-field-type={f.type} style={`grid-column: span ${spans[fi]}`}>
|
|
470
|
+
{#if f.type === 'button'}
|
|
471
|
+
<Button {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
472
|
+
{:else if f.type === 'button-group'}
|
|
473
|
+
<ButtonGroup {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
474
|
+
{:else if f.type === 'select'}
|
|
475
|
+
<Select {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
476
|
+
{:else if f.type === 'select-multi'}
|
|
477
|
+
<SelectMulti {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
478
|
+
{:else if f.type === 'radio-group'}
|
|
479
|
+
<RadioGroup {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
480
|
+
{:else if f.type === 'cascade'}
|
|
481
|
+
<Cascader {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
482
|
+
{:else if f.type === 'tree-select'}
|
|
483
|
+
<TreeSelect {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
484
|
+
{:else if f.type === 'slider'}
|
|
485
|
+
<Slider {...f} min={f.min ?? 0} max={f.max ?? 100} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
486
|
+
{:else if f.type === 'switch'}
|
|
487
|
+
<Switch {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
488
|
+
{:else if f.type === 'date-picker'}
|
|
489
|
+
<DatePicker {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
490
|
+
{:else if f.type === 'number-input'}
|
|
491
|
+
<NumberInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
492
|
+
{:else if f.type === 'text-input'}
|
|
493
|
+
<TextInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
494
|
+
{:else if f.type === 'chart-line' || f.type === 'chart-bar' || f.type === 'chart-pie' || f.type === 'chart-scatter'}
|
|
495
|
+
<Chart {...(f as any)} type={f.type as 'chart-line' | 'chart-bar' | 'chart-pie' | 'chart-scatter'} labels={f.labels ?? []} datasets={f.datasets ?? []} onchange={(v) => set(f.id, v)} />
|
|
496
|
+
{:else if f.type === 'text'}
|
|
497
|
+
<Text {...f} />
|
|
498
|
+
{/if}
|
|
499
|
+
</div>
|
|
500
|
+
{/each}
|
|
501
|
+
</div>
|
|
502
|
+
{/each}
|
|
503
|
+
{/key}
|
|
504
|
+
{/if}
|
|
505
|
+
{#if cancelLabel || resetLabel || submitLabel}
|
|
506
|
+
<!-- 按钮组:data-align 控制对齐 -->
|
|
507
|
+
<div class="widget-form-actions" data-align={buttonsAlign || 'left'}>
|
|
508
|
+
{#if cancelLabel}<button type="button" class="widget-form-cancel" onclick={cancel} {disabled}>{cancelLabel}</button>{/if}
|
|
509
|
+
{#if resetLabel}<button type="button" class="widget-form-reset" onclick={reset} {disabled}>{resetLabel}</button>{/if}
|
|
510
|
+
{#if submitLabel}<button type="button" class="widget-form-submit" onclick={submit} {disabled}>{submitLabel}</button>{/if}
|
|
511
|
+
</div>
|
|
512
|
+
{/if}
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
<!-- 传统纵向模式(向后兼容;字段带 span 时切换为 24 栅格) -->
|
|
516
|
+
{:else}
|
|
517
|
+
<div class="widget-form {layoutClass} {fieldsGridMode ? 'widget-form-fields-grid' : ''}" style={layoutStyle}>
|
|
518
|
+
{#key resetCount}
|
|
519
|
+
{#each uniqueFields as f (f.id)}
|
|
520
|
+
{#if isVisible(f)}
|
|
521
|
+
<div class="widget-form-field" data-field-id={f.id} data-field-type={f.type} style={fieldsSpanStyle(f)}>
|
|
522
|
+
{#if f.type === 'button'}
|
|
523
|
+
<Button {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
524
|
+
{:else if f.type === 'button-group'}
|
|
525
|
+
<ButtonGroup {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
526
|
+
{:else if f.type === 'select'}
|
|
527
|
+
<Select {...f} choices={f.choices ?? []} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
528
|
+
{:else if f.type === 'select-multi'}
|
|
529
|
+
<SelectMulti {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
530
|
+
{:else if f.type === 'radio-group'}
|
|
531
|
+
<RadioGroup {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
532
|
+
{:else if f.type === 'cascade'}
|
|
533
|
+
<Cascader {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
534
|
+
{:else if f.type === 'tree-select'}
|
|
535
|
+
<TreeSelect {...f} choices={f.choices ?? []} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
536
|
+
{:else if f.type === 'slider'}
|
|
537
|
+
<Slider
|
|
538
|
+
{...f}
|
|
539
|
+
min={f.min ?? 0}
|
|
540
|
+
max={f.max ?? 100}
|
|
541
|
+
disabled={disabled || !!f.disabled}
|
|
542
|
+
onchange={(v) => set(f.id, v)}
|
|
543
|
+
/>
|
|
544
|
+
{:else if f.type === 'switch'}
|
|
545
|
+
<Switch {...f} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
546
|
+
{:else if f.type === 'date-picker'}
|
|
547
|
+
<DatePicker {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
548
|
+
{:else if f.type === 'number-input'}
|
|
549
|
+
<NumberInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
550
|
+
{:else if f.type === 'text-input'}
|
|
551
|
+
<TextInput {...f} error={errors[f.id] || ''} disabled={disabled || !!f.disabled} onchange={(v) => set(f.id, v)} />
|
|
552
|
+
{:else if f.type === 'chart-line' || f.type === 'chart-bar' || f.type === 'chart-pie' || f.type === 'chart-scatter'}
|
|
553
|
+
<Chart {...(f as any)} type={f.type as 'chart-line' | 'chart-bar' | 'chart-pie' | 'chart-scatter'} labels={f.labels ?? []} datasets={f.datasets ?? []} onchange={(v) => set(f.id, v)} />
|
|
554
|
+
{:else if f.type === 'text'}
|
|
555
|
+
<Text {...f} />
|
|
556
|
+
{/if}
|
|
557
|
+
</div>
|
|
558
|
+
{/if}
|
|
559
|
+
{/each}
|
|
560
|
+
{/key}
|
|
561
|
+
|
|
562
|
+
{#if cancelLabel || resetLabel || submitLabel}
|
|
563
|
+
<!-- 按钮组:data-align 控制对齐 -->
|
|
564
|
+
<div class="widget-form-actions" data-align={buttonsAlign || 'left'}>
|
|
565
|
+
{#if cancelLabel}<button type="button" class="widget-form-cancel" onclick={cancel} {disabled}>{cancelLabel}</button>{/if}
|
|
566
|
+
{#if resetLabel}<button type="button" class="widget-form-reset" onclick={reset} {disabled}>{resetLabel}</button>{/if}
|
|
567
|
+
{#if submitLabel}<button type="button" class="widget-form-submit" onclick={submit} {disabled}>{submitLabel}</button>{/if}
|
|
568
|
+
</div>
|
|
569
|
+
{/if}
|
|
570
|
+
</div>
|
|
571
|
+
{/if}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Component for when widget is still streaming/loading
|
|
3
|
+
// This is shown when we can't determine the widget type yet
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="incomplete-widget">
|
|
7
|
+
<div class="loading-skeleton">
|
|
8
|
+
<div class="skeleton-line"></div>
|
|
9
|
+
<div class="skeleton-line short"></div>
|
|
10
|
+
</div>
|
|
11
|
+
<span class="loading-text">Parsing widget</span>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.incomplete-widget {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: 8px;
|
|
19
|
+
padding: 12px 16px;
|
|
20
|
+
background: linear-gradient(90deg, #fafafa 0%, #f5f5f5 50%, #fafafa 100%);
|
|
21
|
+
background-size: 200% 100%;
|
|
22
|
+
animation: shimmer 2s ease-in-out infinite;
|
|
23
|
+
border: 1px solid #f0f0f0;
|
|
24
|
+
border-radius: 8px;
|
|
25
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
26
|
+
font-size: 14px;
|
|
27
|
+
color: rgba(0, 0, 0, 0.65);
|
|
28
|
+
min-height: 60px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.loading-skeleton {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
gap: 6px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.skeleton-line {
|
|
38
|
+
height: 12px;
|
|
39
|
+
background: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 0.06) 75%);
|
|
40
|
+
background-size: 200% 100%;
|
|
41
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
42
|
+
border-radius: 4px;
|
|
43
|
+
width: 100%;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.skeleton-line.short {
|
|
47
|
+
width: 60%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.loading-text {
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
opacity: 0.6;
|
|
53
|
+
text-align: center;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.loading-text::after {
|
|
57
|
+
content: '';
|
|
58
|
+
animation: dots 1.5s steps(4, end) infinite;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes shimmer {
|
|
62
|
+
0% { background-position: 200% 0; }
|
|
63
|
+
100% { background-position: -200% 0; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@keyframes dots {
|
|
67
|
+
0%, 20% { content: ''; }
|
|
68
|
+
40% { content: '.'; }
|
|
69
|
+
60% { content: '..'; }
|
|
70
|
+
80%, 100% { content: '...'; }
|
|
71
|
+
}
|
|
72
|
+
</style>
|