@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.
- package/README.md +112 -2
- package/dist/MarkdownUI.vue.d.ts +15 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +10843 -0
- package/dist/index.umd.cjs +53 -0
- package/dist/widgets/Button.vue.d.ts +19 -0
- package/dist/widgets/ButtonGroup.vue.d.ts +13 -0
- package/dist/widgets/Cascader.vue.d.ts +48 -0
- package/dist/widgets/Chart.vue.d.ts +24 -0
- package/dist/widgets/DatePicker.vue.d.ts +42 -0
- package/dist/widgets/Form.vue.d.ts +59 -0
- package/dist/widgets/Incomplete.vue.d.ts +2 -0
- package/dist/widgets/MultipleChoiceQuestion.vue.d.ts +20 -0
- package/dist/widgets/NumberInput.vue.d.ts +37 -0
- package/dist/widgets/Quiz.vue.d.ts +39 -0
- package/dist/widgets/RadioGroup.vue.d.ts +43 -0
- package/dist/widgets/Select.vue.d.ts +27 -0
- package/dist/widgets/SelectMulti.vue.d.ts +38 -0
- package/dist/widgets/ShortAnswerQuestion.vue.d.ts +22 -0
- package/dist/widgets/Slider.vue.d.ts +21 -0
- package/dist/widgets/Switch.vue.d.ts +28 -0
- package/dist/widgets/Text.vue.d.ts +22 -0
- package/dist/widgets/TextInput.vue.d.ts +32 -0
- package/dist/widgets/TreeSelect.vue.d.ts +109 -0
- package/dist/widgets/Widget.vue.d.ts +8 -0
- package/dist/widgets/chartRemoteData.d.ts +17 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/remoteChoices.d.ts +22 -0
- package/dist/widgets/treeSelectTypes.d.ts +43 -0
- package/package.json +46 -4
- package/src/App.vue +11 -0
- package/src/lib/MarkdownUI.vue +105 -0
- package/src/lib/index.ts +28 -0
- package/src/lib/widgets/Button.vue +47 -0
- package/src/lib/widgets/ButtonGroup.vue +43 -0
- package/src/lib/widgets/Cascader.vue +475 -0
- package/src/lib/widgets/Chart.vue +287 -0
- package/src/lib/widgets/DatePicker.vue +659 -0
- package/src/lib/widgets/Form.vue +479 -0
- package/src/lib/widgets/Incomplete.vue +56 -0
- package/src/lib/widgets/MultipleChoiceQuestion.vue +73 -0
- package/src/lib/widgets/NumberInput.vue +179 -0
- package/src/lib/widgets/Quiz.vue +205 -0
- package/src/lib/widgets/RadioGroup.vue +237 -0
- package/src/lib/widgets/Select.vue +279 -0
- package/src/lib/widgets/SelectMulti.vue +145 -0
- package/src/lib/widgets/ShortAnswerQuestion.vue +79 -0
- package/src/lib/widgets/Slider.vue +61 -0
- package/src/lib/widgets/Switch.vue +68 -0
- package/src/lib/widgets/Text.vue +59 -0
- package/src/lib/widgets/TextInput.vue +83 -0
- package/src/lib/widgets/TreeSelect.vue +872 -0
- package/src/lib/widgets/Widget.vue +185 -0
- package/src/lib/widgets/chartRemoteData.ts +84 -0
- package/src/lib/widgets/index.ts +20 -0
- package/src/lib/widgets/remoteChoices.ts +64 -0
- package/src/lib/widgets/treeSelectTypes.ts +49 -0
- package/src/lib/widgets.css +1754 -0
- 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
|
+
|