@c2-markdown-ui/vue 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.
Files changed (61) hide show
  1. package/README.md +112 -2
  2. package/dist/MarkdownUI.vue.d.ts +15 -0
  3. package/dist/favicon.ico +0 -0
  4. package/dist/index.css +1 -0
  5. package/dist/index.d.ts +23 -0
  6. package/dist/index.js +10843 -0
  7. package/dist/index.umd.cjs +53 -0
  8. package/dist/widgets/Button.vue.d.ts +19 -0
  9. package/dist/widgets/ButtonGroup.vue.d.ts +13 -0
  10. package/dist/widgets/Cascader.vue.d.ts +48 -0
  11. package/dist/widgets/Chart.vue.d.ts +24 -0
  12. package/dist/widgets/DatePicker.vue.d.ts +42 -0
  13. package/dist/widgets/Form.vue.d.ts +59 -0
  14. package/dist/widgets/Incomplete.vue.d.ts +2 -0
  15. package/dist/widgets/MultipleChoiceQuestion.vue.d.ts +20 -0
  16. package/dist/widgets/NumberInput.vue.d.ts +37 -0
  17. package/dist/widgets/Quiz.vue.d.ts +39 -0
  18. package/dist/widgets/RadioGroup.vue.d.ts +43 -0
  19. package/dist/widgets/Select.vue.d.ts +27 -0
  20. package/dist/widgets/SelectMulti.vue.d.ts +38 -0
  21. package/dist/widgets/ShortAnswerQuestion.vue.d.ts +22 -0
  22. package/dist/widgets/Slider.vue.d.ts +21 -0
  23. package/dist/widgets/Switch.vue.d.ts +28 -0
  24. package/dist/widgets/Text.vue.d.ts +22 -0
  25. package/dist/widgets/TextInput.vue.d.ts +32 -0
  26. package/dist/widgets/TreeSelect.vue.d.ts +109 -0
  27. package/dist/widgets/Widget.vue.d.ts +8 -0
  28. package/dist/widgets/chartRemoteData.d.ts +17 -0
  29. package/dist/widgets/index.d.ts +20 -0
  30. package/dist/widgets/remoteChoices.d.ts +22 -0
  31. package/dist/widgets/treeSelectTypes.d.ts +43 -0
  32. package/package.json +46 -4
  33. package/src/App.vue +11 -0
  34. package/src/lib/MarkdownUI.vue +105 -0
  35. package/src/lib/index.ts +28 -0
  36. package/src/lib/widgets/Button.vue +47 -0
  37. package/src/lib/widgets/ButtonGroup.vue +43 -0
  38. package/src/lib/widgets/Cascader.vue +475 -0
  39. package/src/lib/widgets/Chart.vue +287 -0
  40. package/src/lib/widgets/DatePicker.vue +659 -0
  41. package/src/lib/widgets/Form.vue +479 -0
  42. package/src/lib/widgets/Incomplete.vue +56 -0
  43. package/src/lib/widgets/MultipleChoiceQuestion.vue +73 -0
  44. package/src/lib/widgets/NumberInput.vue +179 -0
  45. package/src/lib/widgets/Quiz.vue +205 -0
  46. package/src/lib/widgets/RadioGroup.vue +237 -0
  47. package/src/lib/widgets/Select.vue +279 -0
  48. package/src/lib/widgets/SelectMulti.vue +145 -0
  49. package/src/lib/widgets/ShortAnswerQuestion.vue +79 -0
  50. package/src/lib/widgets/Slider.vue +61 -0
  51. package/src/lib/widgets/Switch.vue +68 -0
  52. package/src/lib/widgets/Text.vue +59 -0
  53. package/src/lib/widgets/TextInput.vue +83 -0
  54. package/src/lib/widgets/TreeSelect.vue +872 -0
  55. package/src/lib/widgets/Widget.vue +185 -0
  56. package/src/lib/widgets/chartRemoteData.ts +84 -0
  57. package/src/lib/widgets/index.ts +20 -0
  58. package/src/lib/widgets/remoteChoices.ts +64 -0
  59. package/src/lib/widgets/treeSelectTypes.ts +49 -0
  60. package/src/lib/widgets.css +1754 -0
  61. package/src/main.ts +4 -0
@@ -0,0 +1,479 @@
1
+ <template>
2
+ <!-- Row 分组模式(24 栅格,类似 antd Row/Col) -->
3
+ <div v-if="rows && rows.length > 0" :class="['widget-form', layoutClass]" :style="layoutStyle">
4
+ <!-- 设置 columns 时:所有 row 的字段合并流动,每行自动 columns 列(覆盖 row 手动分组) -->
5
+ <div
6
+ v-if="columns && columns >= 1"
7
+ class="widget-form-row"
8
+ :style="mergedRowStyle"
9
+ >
10
+ <div
11
+ v-for="(field, fi) in mergedVisibleFields"
12
+ :key="`${field.id}:${version}`"
13
+ class="widget-form-field"
14
+ :style="{ gridColumn: `span ${mergedSpans[fi]}` }"
15
+ >
16
+ <component
17
+ :is="getComponent(field.type)"
18
+ v-bind="fieldProps(field)"
19
+ @change="(value) => setValue(field.id, value)"
20
+ />
21
+ </div>
22
+ </div>
23
+
24
+ <!-- 未设置 columns:按 row 手动分组渲染 -->
25
+ <div
26
+ v-for="(rawRow, ri) in rows"
27
+ v-else
28
+ :key="ri"
29
+ class="widget-form-row"
30
+ :style="rowStyle(rawRow)"
31
+ >
32
+ <div
33
+ v-for="(field, fi) in visibleRowFields(rawRow)"
34
+ :key="`${field.id}:${version}`"
35
+ class="widget-form-field"
36
+ :style="{ gridColumn: `span ${computeSpans(visibleRowFields(rawRow))[fi]}` }"
37
+ >
38
+ <component
39
+ :is="getComponent(field.type)"
40
+ v-bind="fieldProps(field)"
41
+ @change="(value) => setValue(field.id, value)"
42
+ />
43
+ </div>
44
+ </div>
45
+ <!-- 按钮组:data-align 控制对齐,全部未设置则不渲染 -->
46
+ <div v-if="cancelLabel || resetLabel || submitLabel" class="widget-form-actions" :data-align="buttonsAlign || 'left'">
47
+ <button v-if="cancelLabel" type="button" class="widget-form-cancel" @click="handleCancel">{{ cancelLabel }}</button>
48
+ <button v-if="resetLabel" type="button" class="widget-form-reset" @click="handleReset">{{ resetLabel }}</button>
49
+ <button v-if="submitLabel" type="button" class="widget-form-submit" @click="handleSubmit">{{ submitLabel }}</button>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Inline 模式 / 传统模式 -->
54
+ <div :class="[containerClass, layoutClass, fieldsGridMode ? 'widget-form-fields-grid' : '']" :style="containerStyle">
55
+ <div
56
+ v-for="field in visibleFields"
57
+ :key="`${field.id}:${version}`"
58
+ class="widget-form-field"
59
+ :data-field-type="field.type"
60
+ :style="fieldsSpanStyle(field)"
61
+ >
62
+ <component
63
+ :is="getComponent(field.type)"
64
+ v-bind="fieldProps(field)"
65
+ @change="(value) => setValue(field.id, value)"
66
+ />
67
+ </div>
68
+ <!-- 按钮组:data-align 控制对齐,全部未设置则不渲染 -->
69
+ <div v-if="cancelLabel || resetLabel || submitLabel" class="widget-form-actions" :data-align="buttonsAlign || 'left'">
70
+ <button v-if="cancelLabel" type="button" class="widget-form-cancel" @click="handleCancel">{{ cancelLabel }}</button>
71
+ <button v-if="resetLabel" type="button" class="widget-form-reset" @click="handleReset">{{ resetLabel }}</button>
72
+ <button v-if="submitLabel" type="button" class="widget-form-submit" @click="handleSubmit">{{ submitLabel }}</button>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <script setup lang="ts">
78
+ import { reactive, ref, computed, watch, defineProps, defineEmits } from 'vue'
79
+ import Button from './Button.vue'
80
+ import ButtonGroup from './ButtonGroup.vue'
81
+ import Select from './Select.vue'
82
+ import TreeSelect from './TreeSelect.vue'
83
+ import Cascader from './Cascader.vue'
84
+ import Chart from './Chart.vue'
85
+ import SelectMulti from './SelectMulti.vue'
86
+ import RadioGroup from './RadioGroup.vue'
87
+ import Slider from './Slider.vue'
88
+ import Switch from './Switch.vue'
89
+ import TextInput from './TextInput.vue'
90
+ import NumberInput from './NumberInput.vue'
91
+ import DatePicker from './DatePicker.vue'
92
+ import Text from './Text.vue'
93
+
94
+ interface Field {
95
+ type: string
96
+ id: string
97
+ /** 在 row 内占据的栅格数(1-24,类似 antd Col 的 span) */
98
+ span?: number
99
+ [key: string]: any
100
+ }
101
+
102
+ interface FormRow {
103
+ /** 列间距(px),类似 antd Row 的 gutter */
104
+ gutter?: number
105
+ fields: Field[]
106
+ }
107
+
108
+ interface ChoiceItem {
109
+ value: string
110
+ label: string
111
+ }
112
+
113
+ interface FormProps {
114
+ /** 表单 id(字段级统计事件携带,用于区分多个表单) */
115
+ id?: string
116
+ fields: Field[]
117
+ /** 行分组(对象形态 { gutter, fields };兼容旧数组形态 Field[][]) */
118
+ rows?: (FormRow | Field[])[]
119
+ inline?: boolean
120
+ columns?: number
121
+ submitLabel?: string
122
+ /** 取消按钮文案,未设置则不显示 */
123
+ cancelLabel?: string
124
+ /** 清空/重置按钮文案,未设置则不显示 */
125
+ resetLabel?: string
126
+ /** 按钮组对齐:left / center / right */
127
+ buttonsAlign?: 'left' | 'center' | 'right'
128
+ /** 字段 label 与内容的布局:vertical 上下(默认)/ horizontal 左右 */
129
+ layout?: 'vertical' | 'horizontal'
130
+ /** 左右布局时 label 的宽度(px),默认 80 */
131
+ labelWidth?: number
132
+ /** 左右布局时内容区域的宽度(px 或省略自适应填满) */
133
+ contentWidth?: number
134
+ /** 是否禁用:级联禁用所有字段与按钮 */
135
+ disabled?: boolean
136
+ /** 外部注入的初始值 { 字段id: 值 },优先级高于字段 default;仅覆盖表单中已声明的字段 id */
137
+ initialData?: Record<string, unknown>
138
+ }
139
+
140
+ interface FormEvents {
141
+ change: [values: Record<string, unknown>]
142
+ /** 提交成功:校验通过后触发(独立于 change 的提交事件) */
143
+ submit: [values: Record<string, unknown>]
144
+ /** 字段级变化:任一表单项被填写时触发,携带统计信息(可见字段数/已填字段数)与最新表单数据 */
145
+ fieldchange: [stats: FormStats]
146
+ }
147
+
148
+ /** 表单统计信息:字段级变化事件携带 */
149
+ export interface FormStats {
150
+ /** 表单 id */
151
+ id: string
152
+ /** 当前展示(可见)的字段数 */
153
+ visibleCount: number
154
+ /** 已填写(非空)的字段数 */
155
+ filledCount: number
156
+ /** 当前表单数据(含隐藏字段值) */
157
+ values: Record<string, unknown>
158
+ }
159
+
160
+ const props = defineProps<FormProps>()
161
+
162
+ const emit = defineEmits<FormEvents>()
163
+
164
+ const containerClass = computed(() =>
165
+ props.inline ? 'widget-form widget-form-inline' : 'widget-form'
166
+ )
167
+
168
+ // 左右布局:容器类 + CSS 变量(label 宽度 / 内容宽度)
169
+ const isHorizontal = computed(() => props.layout === 'horizontal')
170
+ const layoutClass = computed(() => (isHorizontal.value ? 'widget-form-layout-horizontal' : ''))
171
+ const layoutStyle = computed(() => {
172
+ if (!isHorizontal.value) return undefined
173
+ const vars: Record<string, string> = { '--form-label-width': `${props.labelWidth ?? 80}px` }
174
+ if (props.contentWidth) vars['--form-content-width'] = `${props.contentWidth}px`
175
+ return vars
176
+ })
177
+
178
+ const containerStyle = computed(() => {
179
+ const merged: Record<string, string> = {}
180
+ if (props.columns) merged['--form-columns'] = String(props.columns)
181
+ if (layoutStyle.value) Object.assign(merged, layoutStyle.value)
182
+ return Object.keys(merged).length ? merged : undefined
183
+ })
184
+
185
+ // 字段模式(无 row 分组):任一字段带显式 span 时切换为 24 栅格布局,让 span 生效
186
+ // (无 span 的字段保持整行显示,不配 span 的表单仍走 flex 纵向布局,完全向后兼容)
187
+ const fieldsGridMode = computed(() =>
188
+ !props.inline && (!Array.isArray(props.rows) || props.rows.length === 0) &&
189
+ (props.fields || []).some(f => typeof f?.span === 'number' && f.span >= 1 && f.span <= 24)
190
+ )
191
+
192
+ /** 字段模式栅格样式:显式 span 占 N 列;无 span 占满整行(1/-1,保持传统一行一个);inline 模式用 flex-basis 覆盖宽度 */
193
+ const fieldsSpanStyle = (field: Field): Record<string, string> | undefined => {
194
+ const s = typeof field.span === 'number' && field.span >= 1 && field.span <= 24 ? Math.round(field.span) : 0
195
+ if (props.inline) {
196
+ return s >= 1 ? { flex: `0 1 calc(${s} / 24 * 100%)` } : undefined
197
+ }
198
+ if (!fieldsGridMode.value) return undefined
199
+ return { gridColumn: s >= 1 ? `span ${s}` : '1 / -1' }
200
+ }
201
+
202
+ /** 解析 key:label 格式取 key(表单值体系):"bj:北京" → "bj";无冒号原样返回 */
203
+ const parseChoiceValue = (c: string): string => {
204
+ const s = String(c ?? '')
205
+ const idx = s.indexOf(':')
206
+ if (idx > 0) {
207
+ const key = s.slice(0, idx)
208
+ const label = s.slice(idx + 1)
209
+ if (key && label && !/\s/.test(key) && !/^\d+$/.test(key) && !label.startsWith('//')) {
210
+ return key
211
+ }
212
+ }
213
+ return s
214
+ }
215
+
216
+ const buildDefaults = (fields: Field[]): Record<string, unknown> => {
217
+ const acc: Record<string, unknown> = {}
218
+ for (const f of fields) {
219
+ if (f.type === 'button') {
220
+ acc[f.id] = f.label ?? ''
221
+ } else if (f.type === 'button-group') {
222
+ acc[f.id] = f.default ?? (Array.isArray(f.choices) && f.choices.length ? f.choices[0] : undefined)
223
+ } else if (f.type === 'select') {
224
+ // choices[0]/default 可能是 key:label 格式,默认值取 key(与 Select 组件 value 体系一致)
225
+ acc[f.id] = f.default !== undefined && f.default !== null && f.default !== ''
226
+ ? parseChoiceValue(String(f.default))
227
+ : (Array.isArray(f.choices) && f.choices.length ? parseChoiceValue(String(f.choices[0])) : undefined)
228
+ } else if (f.type === 'select-multi') {
229
+ const d = f.default
230
+ // choices 可能是对象数组({value,key,label})或 key:label 字符串:default 写 label 时映射回 value 取值体系
231
+ const objChoices = (Array.isArray(f.choices) ? f.choices : [])
232
+ .filter((c: any) => c && typeof c === 'object') as Array<{ value?: string; key?: string; label?: string }>
233
+ const mapDefault = (v: any): string => {
234
+ const s = String(v)
235
+ const hit = objChoices.find(c => String(c.value ?? c.key) === s || c.label === s)
236
+ return hit ? String(hit.value ?? hit.key) : parseChoiceValue(s)
237
+ }
238
+ acc[f.id] = Array.isArray(d) ? d.map(mapDefault) : (d ? [mapDefault(d)] : [])
239
+ } else if (f.type === 'radio-group') {
240
+ // 单选:choices 可能是对象数组({value,key,label})或 key:label 字符串,default 写 label 时映射回 value;
241
+ // 无 default 时选中第一项(与 button-group 一致)
242
+ const items: ChoiceItem[] = []
243
+ for (const c of (Array.isArray(f.choices) ? f.choices : [])) {
244
+ if (c !== null && typeof c === 'object') {
245
+ const value = (c as any).value ?? (c as any).key
246
+ if (value === undefined || value === null || value === '') continue
247
+ const valueStr = String(value)
248
+ const label = (c as any).label !== undefined && (c as any).label !== null && (c as any).label !== ''
249
+ ? String((c as any).label)
250
+ : valueStr
251
+ items.push({ value: valueStr, label })
252
+ } else if (c !== undefined && c !== null) {
253
+ items.push({ value: parseChoiceValue(String(c)), label: String(c) })
254
+ }
255
+ }
256
+ const mapDefault = (v: any): string => {
257
+ const s = String(v)
258
+ const hit = items.find(c => c.value === s || c.label === s)
259
+ return hit ? hit.value : parseChoiceValue(s)
260
+ }
261
+ const d = f.default
262
+ if (d !== undefined && d !== null && d !== '') {
263
+ acc[f.id] = mapDefault(Array.isArray(d) ? d[0] : d)
264
+ } else {
265
+ acc[f.id] = items.length ? items[0].value : ''
266
+ }
267
+ } else if (f.type === 'slider') {
268
+ acc[f.id] = f.default ?? (typeof f.min === 'number' ? f.min : 0)
269
+ } else if (f.type === 'switch') {
270
+ acc[f.id] = f.default ?? false
271
+ } else if (f.type === 'number-input') {
272
+ // 无 default 时为 null(空值),由组件内部处理显示
273
+ acc[f.id] = f.default ?? null
274
+ } else if (f.type === 'text-input') {
275
+ acc[f.id] = f.default ?? ''
276
+ }
277
+ }
278
+ return acc
279
+ }
280
+
281
+ /** 合并后的初始值:字段 default 为基础,外部 initialData 覆盖(仅表单已声明的字段 id:fields 与 rows 的并集) */
282
+ const initialData = computed(() => {
283
+ const acc = buildDefaults(props.fields || [])
284
+ const external = props.initialData
285
+ if (external) {
286
+ const declaredIds = new Set<string>()
287
+ for (const f of props.fields ?? []) { if (f?.id) declaredIds.add(f.id) }
288
+ for (const r of props.rows ?? []) {
289
+ const rowFields = Array.isArray(r) ? r : (r?.fields ?? [])
290
+ for (const f of rowFields) { if (f?.id) declaredIds.add(f.id) }
291
+ }
292
+ for (const [k, v] of Object.entries(external)) {
293
+ if (declaredIds.has(k)) acc[k] = v
294
+ }
295
+ }
296
+ return acc
297
+ })
298
+
299
+ const data = reactive<Record<string, unknown>>({ ...initialData.value })
300
+
301
+ // 子组件以 default 初始化内部状态且仅挂载时读取一次;
302
+ // version 用于 initialData 变化(异步回填)/重置时 remount 子组件,让新值真正回显
303
+ const version = ref(0)
304
+
305
+ // 外部初始值/字段定义变化时重置表单(如异步获取的数据到达后回填)
306
+ watch(initialData, (v) => {
307
+ for (const k of Object.keys(data)) delete data[k]
308
+ Object.assign(data, v)
309
+ version.value++
310
+ })
311
+
312
+ /** 用当前表单值覆盖字段 default:子组件以 default 初始化内部状态,这样外部 data 才能回显到界面 */
313
+ const fieldProps = (field: Field) => ({
314
+ ...field,
315
+ default: data[field.id] !== undefined ? data[field.id] : field.default
316
+ })
317
+
318
+ const setValue = (id: string, value: unknown) => {
319
+ data[id] = value
320
+ // 字段级统计事件:任一表单项被填写即触发(data 为 reactive,统计即时反映新值)
321
+ emit('fieldchange', buildStats())
322
+ }
323
+
324
+ /** 基于当前 reactive data 计算统计信息(mergedVisibleAll 依赖 data,自动反映最新可见性) */
325
+ const buildStats = (): FormStats => {
326
+ const isEmptyVal = (v: unknown): boolean =>
327
+ v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
328
+ const visibleFields = mergedVisibleAll.value
329
+ const next = { ...data }
330
+ return {
331
+ id: props.id ?? '',
332
+ visibleCount: visibleFields.length,
333
+ filledCount: visibleFields.filter(f => !isEmptyVal(next[f.id])).length,
334
+ values: next
335
+ }
336
+ }
337
+
338
+ /** 单条件命中:eq 为依赖字段的值在 values 任一中(依赖值为数组时任一元素命中即可,
339
+ * 支持 select-multi 等多选字段作为依赖);比较运算符(gt/gte/lt/lte/ne)按数值比较
340
+ * (依赖值或任一匹配值非数字时该值视为不命中)。values 为空或条件非法视为忽略=true */
341
+ const matchCond = (c: any): boolean => {
342
+ if (!c?.field || !Array.isArray(c.values) || c.values.length === 0) return true // 非法条件忽略
343
+ const dep = data[c.field]
344
+ const op = c.op || 'eq'
345
+ if (op === 'eq') {
346
+ if (Array.isArray(dep)) return dep.some(v => c.values.includes(String(v ?? '')))
347
+ return c.values.includes(String(dep ?? ''))
348
+ }
349
+ const left = Number(dep)
350
+ if (dep === undefined || dep === null || dep === '' || isNaN(left)) return false
351
+ return c.values.some((v: string) => {
352
+ const right = Number(v)
353
+ if (isNaN(right)) return false
354
+ if (op === 'gt') return left > right
355
+ if (op === 'gte') return left >= right
356
+ if (op === 'lt') return left < right
357
+ if (op === 'lte') return left <= right
358
+ return left !== right // ne
359
+ })
360
+ }
361
+
362
+ /** 字段是否可见:无 when 时恒可见;
363
+ * - 单条件:依赖字段值命中 values 任一
364
+ * - 数组(FieldWhen[]):AND 组合,全部命中才显示
365
+ * - 分组(FieldWhenGroup):且组 all 全部命中 且 或组 any 任一命中 */
366
+ const isVisible = (f: Field): boolean => {
367
+ if (!f.when) return true
368
+ const when = f.when as any
369
+ if (!Array.isArray(when) && typeof when === 'object' && ('all' in when || 'any' in when)) {
370
+ const allOk = (when.all || []).every(matchCond)
371
+ const anyList = when.any || []
372
+ const anyOk = anyList.length === 0 ? true : anyList.some(matchCond)
373
+ return allOk && anyOk
374
+ }
375
+ const conds = Array.isArray(when) ? when : [when]
376
+ if (conds.length === 0) return true
377
+ return conds.every(matchCond)
378
+ }
379
+
380
+ // 过滤可见字段(data 响应式变化时自动重算,实现条件显隐联动)
381
+ const visibleFields = computed(() => (props.fields || []).filter(isVisible))
382
+
383
+ const visibleRowFields = (row: FormRow | Field[]): Field[] =>
384
+ rowFields(row).filter(isVisible)
385
+
386
+ const mergedVisibleFields = computed(() => mergedFields.value.filter(isVisible))
387
+
388
+ /** 全部可见字段(fields 模式 + rows 模式并集,供字段级统计使用) */
389
+ const mergedVisibleAll = computed(() => {
390
+ const list = [...(props.fields || []), ...mergedFields.value]
391
+ const seen = new Set<string>()
392
+ return list.filter(f => {
393
+ if (!f?.id || seen.has(f.id)) return false
394
+ seen.add(f.id)
395
+ return isVisible(f)
396
+ })
397
+ })
398
+
399
+ const handleSubmit = () => {
400
+ emit('change', { ...data })
401
+ emit('submit', { ...data })
402
+ }
403
+
404
+ const handleReset = () => {
405
+ Object.assign(data, initialData.value)
406
+ version.value++ // remount 子组件,界面回到初始值
407
+ // 重置同样派发统计事件:让面板即时回到初始统计口径
408
+ emit('fieldchange', buildStats())
409
+ }
410
+
411
+ const handleCancel = () => {
412
+ emit('change', { ...initialData.value, _action: 'cancel' })
413
+ }
414
+
415
+ // 规范化 row:兼容旧数组形态 Field[][]
416
+ const rowFields = (row: FormRow | Field[]): Field[] =>
417
+ Array.isArray(row) ? row : (row.fields || [])
418
+
419
+ const rowStyle = (row: FormRow | Field[]) => {
420
+ if (Array.isArray(row) || row.gutter === undefined) return undefined
421
+ return { '--row-gutter': `${row.gutter}px` }
422
+ }
423
+
424
+ // 设置 columns 时:合并所有 row 的字段为单个流动 grid
425
+ const mergedFields = computed(() =>
426
+ (props.rows || []).map(rowFields).flatMap(f => f)
427
+ )
428
+
429
+ const mergedSpans = computed(() => computeSpans(mergedFields.value))
430
+
431
+ const mergedRowStyle = computed(() => {
432
+ const row = (props.rows || []).find(r => !Array.isArray(r) && r.gutter !== undefined) as FormRow | undefined
433
+ return row?.gutter !== undefined ? { '--row-gutter': `${row.gutter}px` } : undefined
434
+ })
435
+
436
+ // 计算行内每个字段的 grid span(24 栅格,类似 antd)
437
+ // 显式 span 优先;无 span 时若 form 设置了 columns,则每字段占 24/columns(跨行对齐),
438
+ // 否则均分剩余栅格;总 span 超 24 时自动换行(由 grid auto-flow 处理)
439
+ const computeSpans = (fields: Field[]): number[] => {
440
+ const spans = fields.map(f =>
441
+ typeof f.span === 'number' && f.span >= 1 && f.span <= 24 ? Math.round(f.span) : undefined
442
+ )
443
+ const fixedTotal = spans.reduce<number>((acc, s) => acc + (s ?? 0), 0)
444
+ const autoCount = spans.filter(s => s === undefined).length
445
+ let autoSpan = 0
446
+ if (autoCount > 0) {
447
+ if (props.columns && props.columns >= 1) {
448
+ autoSpan = Math.max(Math.floor(24 / props.columns), 1)
449
+ } else {
450
+ const remaining = Math.max(24 - fixedTotal, 0)
451
+ autoSpan = Math.max(Math.floor(remaining / autoCount), 1)
452
+ }
453
+ }
454
+ return spans.map(s => s ?? autoSpan)
455
+ }
456
+
457
+ const getComponent = (type: string) => {
458
+ const typeMapping: Record<string, any> = {
459
+ 'button': Button,
460
+ 'button-group': ButtonGroup,
461
+ 'select': Select,
462
+ 'select-multi': SelectMulti,
463
+ 'radio-group': RadioGroup,
464
+ 'cascade': Cascader,
465
+ 'tree-select': TreeSelect,
466
+ 'slider': Slider,
467
+ 'switch': Switch,
468
+ 'text-input': TextInput,
469
+ 'number-input': NumberInput,
470
+ 'date-picker': DatePicker,
471
+ 'text': Text,
472
+ 'chart-line': Chart,
473
+ 'chart-bar': Chart,
474
+ 'chart-pie': Chart,
475
+ 'chart-scatter': Chart
476
+ }
477
+ return typeMapping[type] || 'div'
478
+ }
479
+ </script>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <div class="incomplete-widget">
3
+ <div class="loading-indicator">
4
+ <span class="loading-text">Loading...</span>
5
+ </div>
6
+ </div>
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import { onMounted } from 'vue'
11
+
12
+ const styles = `
13
+ .incomplete-widget {
14
+ display: flex;
15
+ align-items: center;
16
+ padding: 8px 12px;
17
+ background-color: rgba(0, 0, 0, 0.04);
18
+ border: 1px solid #f0f0f0;
19
+ border-radius: 4px;
20
+ font-family: system-ui, -apple-system, sans-serif;
21
+ font-size: 14px;
22
+ color: rgba(0, 0, 0, 0.65);
23
+ }
24
+
25
+ .loading-indicator {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 8px;
29
+ }
30
+
31
+ .loading-text {
32
+ opacity: 0.7;
33
+ }
34
+
35
+ .loading-text::after {
36
+ content: '';
37
+ animation: dots 1.5s infinite;
38
+ }
39
+
40
+ @keyframes dots {
41
+ 0%, 20% { content: ''; }
42
+ 40% { content: '.'; }
43
+ 60% { content: '..'; }
44
+ 80%, 100% { content: '...'; }
45
+ }
46
+ `
47
+
48
+ onMounted(() => {
49
+ if (typeof document !== 'undefined' && !document.getElementById('incomplete-widget-styles')) {
50
+ const styleSheet = document.createElement('style')
51
+ styleSheet.id = 'incomplete-widget-styles'
52
+ styleSheet.textContent = styles
53
+ document.head.appendChild(styleSheet)
54
+ }
55
+ })
56
+ </script>
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <div class="widget-mcq">
3
+ <div class="mcq-question">{{ question }}</div>
4
+ <div class="mcq-choices">
5
+ <button
6
+ v-for="(choice, idx) in choices"
7
+ :key="idx"
8
+ :class="choiceClass(choice)"
9
+ :disabled="hasAnswered && showFeedback"
10
+ @click="onSelect(choice)"
11
+ >
12
+ {{ choice }}
13
+ </button>
14
+ </div>
15
+ <div
16
+ v-if="hasAnswered && showFeedback && selectedAnswer"
17
+ :class="['mcq-feedback', selectedAnswer === correctAnswer ? 'correct' : 'incorrect']"
18
+ >
19
+ <template v-if="correctAnswer">
20
+ {{ selectedAnswer === correctAnswer ? '✓ Correct!' : `✗ Incorrect. The correct answer is: ${correctAnswer}` }}
21
+ </template>
22
+ <template v-else>
23
+ You selected: {{ selectedAnswer }}
24
+ </template>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { ref, withDefaults, defineProps, defineEmits } from 'vue'
31
+
32
+ interface Props {
33
+ question: string
34
+ choices: string[]
35
+ correctAnswer?: string
36
+ showFeedback?: boolean
37
+ }
38
+
39
+ interface Emits {
40
+ change: [value: { answer: string; isCorrect?: boolean }]
41
+ }
42
+
43
+ const props = withDefaults(defineProps<Props>(), {
44
+ showFeedback: false
45
+ })
46
+
47
+ const emit = defineEmits<Emits>()
48
+
49
+ const selectedAnswer = ref('')
50
+ const hasAnswered = ref(false)
51
+
52
+ function onSelect(answer: string) {
53
+ selectedAnswer.value = answer
54
+ hasAnswered.value = true
55
+ const isCorrect = props.correctAnswer ? answer === props.correctAnswer : undefined
56
+ emit('change', { answer, isCorrect })
57
+ }
58
+
59
+ function choiceClass(choice: string) {
60
+ let className = 'mcq-choice'
61
+ if (hasAnswered.value && props.showFeedback && props.correctAnswer) {
62
+ if (choice === props.correctAnswer) {
63
+ className += ' mcq-choice-correct'
64
+ } else if (choice === selectedAnswer.value && choice !== props.correctAnswer) {
65
+ className += ' mcq-choice-incorrect'
66
+ }
67
+ } else if (choice === selectedAnswer.value) {
68
+ className += ' mcq-choice-selected'
69
+ }
70
+ return className
71
+ }
72
+ </script>
73
+