@neatui/vue 4.8.2 → 5.1.0
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/MIGRATION-5.0.md +73 -0
- package/README.md +3 -0
- package/package.json +12 -8
- package/src/components/basic/IFollowView.vue +1 -1
- package/src/components/basic/LayerView/Layer.vue +0 -2
- package/src/components/display/Avatar.vue +124 -1
- package/src/components/display/Tree.vue +10 -4
- package/src/components/display/TreeView.vue +12 -5
- package/src/components/display/index.ts +2 -1
- package/src/components/form/Cascader.vue +1 -1
- package/src/components/form/DateRangeView.vue +6 -6
- package/src/components/form/Gallery.vue +50 -0
- package/src/components/form/Input.vue +5 -2
- package/src/components/form/PageSelect.vue +3 -2
- package/src/components/form/Radio.vue +27 -0
- package/src/components/form/SearchMoreSelect.vue +3 -2
- package/src/components/form/Select.vue +22 -28
- package/src/components/form/SelectPicker.vue +3 -2
- package/src/components/form/Textarea.vue +169 -57
- package/src/components/form/TimePicker.vue +43 -7
- package/src/components/form/Upload.vue +1 -1
- package/src/components/form/index.ts +5 -0
- package/src/components/loader/FormLoader/FormLoader.vue +1098 -351
- package/src/components/loader/FormLoader/FormRender.vue +503 -0
- package/src/components/loader/TableLoader/TableLoader.vue +338 -32
- package/src/components/loader/TableLoader/virtual.ts +27 -0
- package/src/components/loader/index.ts +2 -0
- package/src/index.ts +2 -0
- package/.prettierrc +0 -12
- package/rollup.config.js +0 -75
- package/tsconfig.json +0 -23
|
@@ -1,173 +1,155 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-row="rows" :class="rootClass">
|
|
3
3
|
<template v-for="(col, idx) in state.form" :key="idx">
|
|
4
|
-
<div
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
<div
|
|
5
|
+
v-if="isGroup(col) || !isCleared(col)"
|
|
6
|
+
:id="col._fid"
|
|
7
|
+
:ui-col="col.media"
|
|
8
|
+
:ui-flex="`${isVertical(col) ? 'col xm' : 'row lt'}`"
|
|
9
|
+
:data-path="debug ? fieldPath(col) || undefined : undefined"
|
|
10
|
+
:title="debug ? debugInfo(col) : undefined"
|
|
11
|
+
v-bind="col.attrs || {}"
|
|
12
|
+
:style="isCleared(col) ? { display: 'none' } : undefined"
|
|
13
|
+
v-on="isGroup(col) || col._array ? eventsOf(col, context()) : {}"
|
|
14
|
+
>
|
|
15
|
+
<!-- ${field}-row:整行自定义,包括标签 -->
|
|
16
|
+
<div v-if="rowSlot(col)" class="flex-block" @focusout="col._touched = true">
|
|
17
|
+
<slot :name="rowSlot(col)" v-bind="fieldScope(col)"></slot>
|
|
18
|
+
<FieldMessage :col="col" :mode="mode" />
|
|
19
|
+
</div>
|
|
20
|
+
<template v-else>
|
|
21
|
+
<!-- 标签:${field}-label / array-label / child-label / label 插槽可以接管 -->
|
|
22
|
+
<FormLoader
|
|
23
|
+
v-if="labelNodes(col.label)"
|
|
24
|
+
:form="labelNodes(col.label)"
|
|
25
|
+
:clearContext="{ value: isCleared(col), restore: canRestore(col) }"
|
|
26
|
+
:arrayContext="col._array ? arrayContext(col) : arrayContextProp"
|
|
27
|
+
:data="col._data"
|
|
28
|
+
:root="rootData"
|
|
29
|
+
:path="[path, col.paths].filter(Boolean).join('.')"
|
|
30
|
+
:index="index"
|
|
31
|
+
:idx="`${childIdx(col, idx)}-label`"
|
|
32
|
+
:mode="mode"
|
|
33
|
+
:interacted="touched"
|
|
34
|
+
:debug="debug"
|
|
35
|
+
rows=""
|
|
36
|
+
class="w-full"
|
|
37
|
+
ui-flex="row lm"
|
|
38
|
+
/>
|
|
39
|
+
<template v-else-if="labelText(col.label)">
|
|
40
|
+
<h6 v-if="isVertical(col)" :style="`${showStar(col) ? 'text-indent: -1em' : ''}`" :class="labelClass" v-bind="labelAttrs(col.label)">
|
|
41
|
+
<slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
|
|
42
|
+
<template v-else-if="labelText(col.label) !== '*'">
|
|
43
|
+
<b v-if="showStar(col)" class="co-risk pr" style="left: 0.1em">﹡</b>
|
|
44
|
+
<span>{{ formatLabel(col) }}</span>
|
|
45
|
+
</template>
|
|
46
|
+
</h6>
|
|
47
|
+
<p
|
|
48
|
+
v-else
|
|
49
|
+
:class="[labelClass, `${clh({ ...(col.model[1] || {}) })}`]"
|
|
50
|
+
:style="labelStyle(col)"
|
|
51
|
+
class="ar nr-sm flex-fixed o-ls"
|
|
52
|
+
v-bind="labelAttrs(col.label)"
|
|
53
|
+
>
|
|
54
|
+
<slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
|
|
55
|
+
<template v-else-if="labelText(col.label) !== '*'">
|
|
56
|
+
<b v-if="showStar(col)" class="co-risk">﹡</b>
|
|
57
|
+
<span>{{ formatLabel(col) }}</span>
|
|
58
|
+
</template>
|
|
59
|
+
</p>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<!-- 分组:递归渲染,带 field 时写入 data[field],否则和当前层共用数据 -->
|
|
63
|
+
<div v-if="isGroup(col)" class="flex-block" :style="col.attrs?.['ui-flex'] ? { display: 'contents' } : undefined">
|
|
64
|
+
<FormLoader
|
|
65
|
+
:form="col.child || []"
|
|
66
|
+
:clearContext="{ value: isCleared(col), restore: canRestore(col) }"
|
|
67
|
+
:arrayContext="arrayContextProp"
|
|
68
|
+
:data="childData(col)"
|
|
69
|
+
:root="rootData"
|
|
70
|
+
:path="fieldPath(col)"
|
|
71
|
+
:index="index"
|
|
72
|
+
:idx="childIdx(col, idx)"
|
|
73
|
+
:rows="col.attrs?.['ui-row'] ?? rows"
|
|
74
|
+
:style="col.attrs?.['ui-flex'] ? { display: 'contents' } : undefined"
|
|
75
|
+
:mode="mode"
|
|
76
|
+
:interacted="interacted"
|
|
77
|
+
:colon="colon"
|
|
78
|
+
:labelClass="labelClass"
|
|
79
|
+
:labelWidth="labelWidth"
|
|
80
|
+
:vertical="vertical"
|
|
81
|
+
:debug="debug"
|
|
82
|
+
>
|
|
83
|
+
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
84
|
+
</FormLoader>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- 数组:每一行是一个子表单,支持增删和排序 -->
|
|
88
|
+
<div v-else-if="col._array" class="flex-block">
|
|
89
|
+
<div ui-row="">
|
|
90
|
+
<div v-for="(row, i) in listOf(col)" :key="rowKey(row)" :ui-col="col._array.attrs?.['ui-col'] || 'mob-24'" ui-flex="row lt" class="mb-xs" v-bind="col._array.attrs || {}">
|
|
91
|
+
<div class="flex-block">
|
|
92
|
+
<FormLoader
|
|
93
|
+
:form="col._array.child"
|
|
94
|
+
:arrayContext="arrayContext(col, row)"
|
|
95
|
+
:data="row"
|
|
96
|
+
:root="rootData"
|
|
97
|
+
:path="`${fieldPath(col)}.${i}`"
|
|
98
|
+
:index="i"
|
|
99
|
+
:idx="`${childIdx(col, idx)}-${i}`"
|
|
100
|
+
:rows="col._array.rows || col.attrs?.['ui-row'] || rows"
|
|
101
|
+
:mode="mode"
|
|
102
|
+
:interacted="interacted"
|
|
103
|
+
:colon="colon"
|
|
104
|
+
:labelClass="labelClass"
|
|
105
|
+
:labelWidth="labelWidth"
|
|
106
|
+
:vertical="vertical"
|
|
107
|
+
:debug="debug"
|
|
108
|
+
>
|
|
109
|
+
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
110
|
+
</FormLoader>
|
|
111
|
+
</div>
|
|
112
|
+
<div v-if="editable && (hasSlot(`${col.field}-actions`) || col._array.sortable || col._array.del !== false)" class="flex-fixed nl-sm" ui-flex="row lm">
|
|
113
|
+
<slot v-if="hasSlot(`${col.field}-actions`)" :name="`${col.field}-actions`" v-bind="rowScope(col, i)"></slot>
|
|
114
|
+
<template v-else>
|
|
115
|
+
<template v-if="col._array.sortable">
|
|
116
|
+
<button ui-btn="@a s" type="button" :disabled="i === 0" @click="moveRow(col, i, -1)">上移</button>
|
|
117
|
+
<button ui-btn="@a s" type="button" class="ml-xs" :disabled="i === listOf(col).length - 1" @click="moveRow(col, i, 1)">下移</button>
|
|
118
|
+
</template>
|
|
119
|
+
<button
|
|
120
|
+
v-if="col._array.del !== false"
|
|
121
|
+
ui-btn="@a s risk"
|
|
122
|
+
type="button"
|
|
123
|
+
class="ml-xs"
|
|
124
|
+
:disabled="listOf(col).length <= (col._array.min || 0)"
|
|
125
|
+
@click="removeRow(col, i)"
|
|
126
|
+
>
|
|
127
|
+
{{ col._array.del || '删除' }}
|
|
128
|
+
</button>
|
|
129
|
+
</template>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<template v-if="editable && col._array.add !== false">
|
|
134
|
+
<slot v-if="hasSlot(`${col.field}-add`)" :name="`${col.field}-add`" :col="col" :list="listOf(col)" :add="() => addRow(col)"></slot>
|
|
135
|
+
<button v-else ui-btn="@a s" type="button" :disabled="!!col._array.max && listOf(col).length >= col._array.max" @click="addRow(col)">
|
|
136
|
+
{{ col._array.add || '+ 添加一项' }}
|
|
137
|
+
</button>
|
|
11
138
|
</template>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
139
|
+
<div v-if="!editable && !listOf(col).length" class="lh-ls">-</div>
|
|
140
|
+
<FieldMessage :col="col" :mode="mode" />
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- 叶节点:输入、内容、按钮和自定义控件使用相同的 value / event -->
|
|
144
|
+
<FormRender v-else :col="col" :mode="mode" :entry="resolveEntry(col)" :interacted="touched" :ctx="context()" :custom="(!editable && hasSlot(`${col.field}-value`)) || !resolveEntry(col)">
|
|
145
|
+
<slot v-if="!editable && hasSlot(`${col.field}-value`)" :name="`${col.field}-value`" v-bind="fieldScope(col)"></slot>
|
|
15
146
|
<template v-else>
|
|
16
|
-
|
|
17
|
-
<
|
|
147
|
+
<!-- 4.x 兼容:字段级 slot 优先于 model / field 同名插槽 -->
|
|
148
|
+
<slot v-if="col.slot" :name="col.slot" v-bind="fieldScope(col)"></slot>
|
|
149
|
+
<slot v-else-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
|
|
150
|
+
<slot v-else :name="col.field" v-bind="fieldScope(col)"></slot>
|
|
18
151
|
</template>
|
|
19
|
-
</
|
|
20
|
-
</template>
|
|
21
|
-
<Input v-if="col.model[0] === 'Input'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" class="w-full" v-bind="{ ...(col.model[1] || {}) }" />
|
|
22
|
-
<Select
|
|
23
|
-
v-else-if="col.model[0] === 'Select'"
|
|
24
|
-
v-model="col._data[col.field]"
|
|
25
|
-
:interacted="interacted"
|
|
26
|
-
:options="col.enums"
|
|
27
|
-
:rules="col?.rules"
|
|
28
|
-
class="w-full"
|
|
29
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
30
|
-
></Select>
|
|
31
|
-
<SelectList
|
|
32
|
-
v-else-if="col.model[0] === 'SelectList'"
|
|
33
|
-
v-model="col._data[col.field]"
|
|
34
|
-
:interacted="interacted"
|
|
35
|
-
:options="col.enums"
|
|
36
|
-
:rules="col?.rules"
|
|
37
|
-
class="w-full"
|
|
38
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
39
|
-
/>
|
|
40
|
-
<SelectTags
|
|
41
|
-
v-else-if="col.model[0] === 'SelectTags'"
|
|
42
|
-
v-model="col._data[col.field]"
|
|
43
|
-
:interacted="interacted"
|
|
44
|
-
:options="col.enums"
|
|
45
|
-
:rules="col?.rules"
|
|
46
|
-
class="w-full"
|
|
47
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
48
|
-
/>
|
|
49
|
-
<SelectPicker
|
|
50
|
-
v-else-if="col.model[0] === 'SelectPicker'"
|
|
51
|
-
v-model="col._data[col.field]"
|
|
52
|
-
:interacted="interacted"
|
|
53
|
-
:options="col.enums"
|
|
54
|
-
:rules="col?.rules"
|
|
55
|
-
class="w-full"
|
|
56
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
57
|
-
/>
|
|
58
|
-
<SearchSelect
|
|
59
|
-
v-else-if="col.model[0] === 'SearchSelect'"
|
|
60
|
-
v-model="col._data[col.field]"
|
|
61
|
-
:interacted="interacted"
|
|
62
|
-
:options="col.enums"
|
|
63
|
-
:rules="col.rules"
|
|
64
|
-
class="w-full"
|
|
65
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
66
|
-
/>
|
|
67
|
-
<MoreSelect
|
|
68
|
-
v-else-if="col.model[0] === 'MoreSelect'"
|
|
69
|
-
v-model="col._data[col.field]"
|
|
70
|
-
:interacted="interacted"
|
|
71
|
-
:options="col.enums"
|
|
72
|
-
:rules="col.rules"
|
|
73
|
-
class="w-full"
|
|
74
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
75
|
-
/>
|
|
76
|
-
<MoreSelectList
|
|
77
|
-
v-else-if="col.model[0] === 'MoreSelectList'"
|
|
78
|
-
v-model="col._data[col.field]"
|
|
79
|
-
:interacted="interacted"
|
|
80
|
-
:options="col.enums"
|
|
81
|
-
:rules="col.rules"
|
|
82
|
-
class="w-full"
|
|
83
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
84
|
-
/>
|
|
85
|
-
<MoreSelectTags
|
|
86
|
-
v-else-if="col.model[0] === 'MoreSelectTags'"
|
|
87
|
-
v-model="col._data[col.field]"
|
|
88
|
-
:interacted="interacted"
|
|
89
|
-
:options="col.enums"
|
|
90
|
-
:rules="col.rules"
|
|
91
|
-
class="w-full"
|
|
92
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
93
|
-
/>
|
|
94
|
-
<MoreSelectPicker
|
|
95
|
-
v-else-if="col.model[0] === 'MoreSelectPicker'"
|
|
96
|
-
v-model="col._data[col.field]"
|
|
97
|
-
:interacted="interacted"
|
|
98
|
-
:options="col.enums"
|
|
99
|
-
:rules="col.rules"
|
|
100
|
-
class="w-full"
|
|
101
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
102
|
-
/>
|
|
103
|
-
<SearchMoreSelect
|
|
104
|
-
v-else-if="col.model[0] === 'SearchMoreSelect'"
|
|
105
|
-
v-model="col._data[col.field]"
|
|
106
|
-
:interacted="interacted"
|
|
107
|
-
:options="col.enums"
|
|
108
|
-
:rules="col.rules"
|
|
109
|
-
class="w-full"
|
|
110
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
111
|
-
/>
|
|
112
|
-
<PageSelect
|
|
113
|
-
v-else-if="col.model[0] === 'PageSelect'"
|
|
114
|
-
v-model="col._data[col.field]"
|
|
115
|
-
:interacted="interacted"
|
|
116
|
-
:options="col.enums"
|
|
117
|
-
:rules="col.rules"
|
|
118
|
-
class="w-full"
|
|
119
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
120
|
-
/>
|
|
121
|
-
<SelectTree
|
|
122
|
-
v-else-if="col.model[0] === 'SelectTree'"
|
|
123
|
-
v-model="col._data[col.field]"
|
|
124
|
-
:interacted="interacted"
|
|
125
|
-
:options="col.enums"
|
|
126
|
-
:rules="col.rules"
|
|
127
|
-
class="w-full"
|
|
128
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
129
|
-
/>
|
|
130
|
-
<DatePicker
|
|
131
|
-
v-else-if="col.model[0] === 'DatePicker'"
|
|
132
|
-
v-model="col._data[col.field]"
|
|
133
|
-
:interacted="interacted"
|
|
134
|
-
:rules="col.rules"
|
|
135
|
-
class="w-full"
|
|
136
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
137
|
-
/>
|
|
138
|
-
<DateRangePicker
|
|
139
|
-
v-else-if="col.model[0] === 'DateRangePicker'"
|
|
140
|
-
v-model="col._data[col.field]"
|
|
141
|
-
:interacted="interacted"
|
|
142
|
-
:rules="col.rules"
|
|
143
|
-
class="w-full"
|
|
144
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
145
|
-
/>
|
|
146
|
-
<ImgUpload v-else-if="col.model[0] === 'ImgUpload'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" v-bind="{ ...(col.model[1] || {}) }" />
|
|
147
|
-
<Upload v-else-if="col.model[0] === 'Upload'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" v-bind="{ ...(col.model[1] || {}) }" />
|
|
148
|
-
<Textarea
|
|
149
|
-
v-else-if="col.model[0] === 'Textarea'"
|
|
150
|
-
v-model="col._data[col.field]"
|
|
151
|
-
:interacted="interacted"
|
|
152
|
-
:rules="col.rules"
|
|
153
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
154
|
-
></Textarea>
|
|
155
|
-
<Switch v-else-if="col.model[0] === 'Switch'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" v-bind="{ ...(col.model[1] || {}) }" />
|
|
156
|
-
<Checkbox v-else-if="col.model[0] === 'Checkbox'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" v-bind="{ ...(col.model[1] || {}) }" />
|
|
157
|
-
<Tree v-else-if="col.model[0] === 'Tree'" v-model="col._data[col.field]" :interacted="interacted" :rules="col.rules" class="mt-sm" v-bind="{ ...(col.model[1] || {}) }" />
|
|
158
|
-
<Cascader
|
|
159
|
-
v-else-if="col.model[0] === 'Cascader'"
|
|
160
|
-
v-model="col._data[col.field]"
|
|
161
|
-
:interacted="interacted"
|
|
162
|
-
:options="col.enums"
|
|
163
|
-
:rules="col.rules"
|
|
164
|
-
class="w-full"
|
|
165
|
-
v-bind="{ ...(col.model[1] || {}) }"
|
|
166
|
-
/>
|
|
167
|
-
<template v-else>
|
|
168
|
-
<slot v-if="col.slot" :name="col.slot" :col="col"></slot>
|
|
169
|
-
<slot v-else-if="col.model[0]" :name="col.model[0]" :col="col"></slot>
|
|
170
|
-
<slot v-else :name="col.field" :col="col"></slot>
|
|
152
|
+
</FormRender>
|
|
171
153
|
</template>
|
|
172
154
|
</div>
|
|
173
155
|
</template>
|
|
@@ -175,60 +157,27 @@
|
|
|
175
157
|
<div v-if="vertical">
|
|
176
158
|
<slot name="submit"></slot>
|
|
177
159
|
</div>
|
|
178
|
-
<div v-else :style="
|
|
160
|
+
<div v-else :style="submitStyle">
|
|
179
161
|
<slot name="submit"></slot>
|
|
180
162
|
</div>
|
|
181
163
|
</div>
|
|
182
164
|
</div>
|
|
183
165
|
</template>
|
|
184
166
|
<script lang="ts" setup>
|
|
185
|
-
import { watch, reactive,
|
|
167
|
+
import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, watchEffect, useId, isReactive, isReadonly } from 'vue';
|
|
186
168
|
import { useRoute, useRouter } from 'vue-router';
|
|
187
|
-
import { isString, isObject,
|
|
188
|
-
import {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
Checkbox,
|
|
192
|
-
Textarea,
|
|
193
|
-
Select,
|
|
194
|
-
SelectList,
|
|
195
|
-
SelectTags,
|
|
196
|
-
SelectPicker,
|
|
197
|
-
SearchSelect,
|
|
198
|
-
PageSelect,
|
|
199
|
-
SelectTree,
|
|
200
|
-
MoreSelect,
|
|
201
|
-
MoreSelectList,
|
|
202
|
-
MoreSelectTags,
|
|
203
|
-
MoreSelectPicker,
|
|
204
|
-
DatePicker,
|
|
205
|
-
DateRangePicker,
|
|
206
|
-
SearchMoreSelect,
|
|
207
|
-
ImgUpload,
|
|
208
|
-
Upload,
|
|
209
|
-
Cascader,
|
|
210
|
-
} from '../../form';
|
|
211
|
-
import { Tree } from '../../display';
|
|
212
|
-
|
|
213
|
-
const route = useRoute();
|
|
214
|
-
const router = useRouter();
|
|
215
|
-
const attrs = useAttrs();
|
|
216
|
-
// 外部写了 class 时去掉默认根 class,由外部接管
|
|
217
|
-
const rootClass = computed(() => (attrs.class ? undefined : 'n-sl-sub'));
|
|
218
|
-
|
|
219
|
-
// 计算Label行高
|
|
220
|
-
const clh = ({ sz = '' }: any = {}) => {
|
|
221
|
-
const lhs: any = { xs: 'lh-sm', s: 'lh-mm', m: 'lh-ls', l: 'lh-ll', xl: 'lh-xl' };
|
|
222
|
-
return lhs[sz] || 'lh-ls';
|
|
223
|
-
};
|
|
224
|
-
|
|
169
|
+
import { isString, isObject, deepcopy, isArray, isFunction } from '@fekit/utils';
|
|
170
|
+
import FormRender, { getField, emptyValueOf, clh, isEmpty, labelText, labelAttrs, rulesOf, displayOf, valueBinding, labelNodes, eventsOf, writeValue, FieldMessage } from './FormRender.vue';
|
|
171
|
+
import type { PropType, ShallowRef } from 'vue';
|
|
172
|
+
import type { FormItem, FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
|
|
225
173
|
// 计算Label宽度
|
|
226
|
-
|
|
174
|
+
const clw = (label: any = '') => {
|
|
227
175
|
let emWidth = 0;
|
|
228
176
|
if (isString(label)) {
|
|
229
177
|
for (let i = 0; i < label.length; i++) {
|
|
230
178
|
const char = label[i];
|
|
231
|
-
|
|
179
|
+
// 汉字、中文标点和全角字符按 1em 计算
|
|
180
|
+
if (/^[\u3000-\u303f\u4e00-\u9fa5\uff00-\uffef]$/.test(char)) {
|
|
232
181
|
emWidth += 1;
|
|
233
182
|
} else {
|
|
234
183
|
emWidth += 0.5;
|
|
@@ -236,8 +185,388 @@
|
|
|
236
185
|
}
|
|
237
186
|
}
|
|
238
187
|
return emWidth;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// 路径缺失时补全对象,不把嵌套字段静默写到根层;onCreate 用于记录新建对象的初始值。
|
|
191
|
+
const ensurePath = (data: any, paths: string = '', onCreate?: (target: any, key: string) => void) => {
|
|
192
|
+
let target = data;
|
|
193
|
+
(paths?.split('.') || []).forEach((key: string) => {
|
|
194
|
+
if (!key) return;
|
|
195
|
+
if (target[key] == null) { target[key] = {}; onCreate?.(target, key); }
|
|
196
|
+
if (!isObject(target[key])) throw new TypeError(`[FormLoader] paths 的 ${key} 必须是对象`);
|
|
197
|
+
target = target[key];
|
|
198
|
+
});
|
|
199
|
+
return target;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// 按 'a.b.c' 读取数据
|
|
203
|
+
const readPath = (data: any, path: string = '') =>
|
|
204
|
+
String(path)
|
|
205
|
+
.split('.')
|
|
206
|
+
.reduce((target: any, key: string) => (target === null || target === undefined ? undefined : target[key]), data);
|
|
207
|
+
|
|
208
|
+
// 同类型的空值
|
|
209
|
+
const emptyOf = (value: any) => {
|
|
210
|
+
if (isArray(value)) return [];
|
|
211
|
+
if (typeof value === 'boolean') return false;
|
|
212
|
+
return '';
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// 隐藏字段的值暂存在这里,重新显示时还原
|
|
216
|
+
type ClearCache = WeakMap<object, Map<string, any>>;
|
|
217
|
+
|
|
218
|
+
// 清空单个字段;restore 为 true 时先暂存原值。已为空时不再赋值,避免新数组反复触发 deep watcher
|
|
219
|
+
const emptyField = (data: any, field: string = '', cache?: ClearCache) => {
|
|
220
|
+
if (!data || !field) return;
|
|
221
|
+
const value = data[field];
|
|
222
|
+
if (isEmpty(value) || value === false) return;
|
|
223
|
+
if (cache) {
|
|
224
|
+
const saved = cache.get(data) || new Map();
|
|
225
|
+
saved.set(field, value);
|
|
226
|
+
cache.set(data, saved);
|
|
227
|
+
}
|
|
228
|
+
data[field] = emptyOf(value);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// 还原单个字段;只在当前仍为空时还原,避免覆盖用户新填的值
|
|
232
|
+
const restoreField = (data: any, field: string = '', cache?: ClearCache) => {
|
|
233
|
+
const saved = data && cache?.get(data);
|
|
234
|
+
if (!saved || !saved.has(field)) return;
|
|
235
|
+
if (isEmpty(data[field]) || data[field] === false) {
|
|
236
|
+
data[field] = saved.get(field);
|
|
237
|
+
}
|
|
238
|
+
saved.delete(field);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// 规范化 model 为 [type, props],props 默认 verify: false
|
|
242
|
+
const normalizeModel = (model: any) => {
|
|
243
|
+
let _model = model;
|
|
244
|
+
if (_model) {
|
|
245
|
+
if (isString(_model)) {
|
|
246
|
+
_model = [_model, { verify: false }];
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
_model = ['', { verify: false }];
|
|
250
|
+
}
|
|
251
|
+
if (!_model[1]) {
|
|
252
|
+
_model[1] = { verify: false };
|
|
253
|
+
} else if (!Object.prototype.hasOwnProperty.call(_model[1], 'verify')) {
|
|
254
|
+
_model[1].verify = false;
|
|
255
|
+
}
|
|
256
|
+
return _model;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// 规范化数组配置,兼容旧版 array: 'field' 和 lists
|
|
260
|
+
const normalizeArray = (array: any) => {
|
|
261
|
+
if (isString(array)) return { child: [{ field: array }] };
|
|
262
|
+
if (!isObject(array)) return null;
|
|
263
|
+
return { ...array, child: array.child || array.lists || [] };
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// 按字段配置生成一条新数据,用于数组新增行
|
|
267
|
+
const createRow = (list: any[] = [], getEmpty: (item: any) => any, preset: any = {}) => {
|
|
268
|
+
const row: any = {};
|
|
269
|
+
list.forEach((item: any = {}) => {
|
|
270
|
+
if (!isObject(item)) return;
|
|
271
|
+
const target = ensurePath(row, item.paths);
|
|
272
|
+
if (isArray(item.child) && !item.array) {
|
|
273
|
+
if (item.field) {
|
|
274
|
+
target[item.field] = createRow(item.child, getEmpty);
|
|
275
|
+
} else {
|
|
276
|
+
Object.assign(target, createRow(item.child, getEmpty));
|
|
277
|
+
}
|
|
278
|
+
} else if (item.field) {
|
|
279
|
+
target[item.field] = getEmpty(item);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return { ...row, ...deepcopy(isFunction(preset) ? preset() : preset) };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// 与组件内一致的校验:有值时跑 validator,无值时只看 required
|
|
286
|
+
const checkRules = async (value: any, rules: FormRule[] = []) => {
|
|
287
|
+
if (!isArray(rules)) return null;
|
|
288
|
+
if (!isEmpty(value)) {
|
|
289
|
+
for (const rule of rules) {
|
|
290
|
+
if (rule && isFunction(rule.validator) && !(await rule.validator!(value))) {
|
|
291
|
+
return { msg: rule.msg || '', type: rule.type || 'risk' };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const rule = rules.find((item) => item?.required);
|
|
297
|
+
return rule ? { msg: rule.msg || '', type: rule.type || 'risk' } : null;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const isPlain = (value: any) => isObject(value) && !isArray(value);
|
|
301
|
+
|
|
302
|
+
// 原地重置:保留嵌套对象的引用(分组绑定的是对象本身),快照里没有的字段置为同类型空值
|
|
303
|
+
const assignDeep = (target: any, source: any) => {
|
|
304
|
+
if (!isPlain(target)) return;
|
|
305
|
+
const keys = new Set([...Object.keys(target), ...Object.keys(source || {})]);
|
|
306
|
+
keys.forEach((key) => {
|
|
307
|
+
const from = source ? source[key] : undefined;
|
|
308
|
+
const to = target[key];
|
|
309
|
+
if (isPlain(to) && (isPlain(from) || from === undefined)) {
|
|
310
|
+
assignDeep(to, from || {});
|
|
311
|
+
} else if (source && Object.prototype.hasOwnProperty.call(source, key)) {
|
|
312
|
+
target[key] = deepcopy(from);
|
|
313
|
+
} else {
|
|
314
|
+
target[key] = emptyOf(to);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// 可写的数据视图:未写入的字段直接读原数据(响应式读取,便于精确追踪依赖),写入与删除只进入覆盖层。
|
|
320
|
+
const DELETED = Symbol('deleted');
|
|
321
|
+
const plainObject = (value: any) => !!value && typeof value === 'object' && !(value instanceof Date);
|
|
322
|
+
const dataView = (source: any, onRead?: () => void): any => {
|
|
323
|
+
if (!plainObject(source)) return source;
|
|
324
|
+
const src = isReactive(source) || isReadonly(source) ? source : reactive(source);
|
|
325
|
+
const array = isArray(toRaw(src));
|
|
326
|
+
const overlay = new Map<PropertyKey, any>();
|
|
327
|
+
const children = new Map<PropertyKey, { raw: any; view: any }>();
|
|
328
|
+
const read = (key: PropertyKey) => {
|
|
329
|
+
// Vue 内部标记(__v_raw、__v_isReactive 等)按普通对象处理,视图本身不是响应式对象。
|
|
330
|
+
if (typeof key === 'string' && key.startsWith('__v_')) return undefined;
|
|
331
|
+
if (overlay.has(key)) {
|
|
332
|
+
const value = overlay.get(key);
|
|
333
|
+
return value === DELETED ? undefined : value;
|
|
334
|
+
}
|
|
335
|
+
// 方法取原生函数(不走 Vue 的数组方法包装),调用时通过视图逐项读取,依赖仍然精确。
|
|
336
|
+
const native = Reflect.get(toRaw(src), key);
|
|
337
|
+
if (typeof native === 'function') return native;
|
|
338
|
+
onRead?.();
|
|
339
|
+
const value = Reflect.get(src, key);
|
|
340
|
+
if (!plainObject(value) || typeof key === 'symbol') return value;
|
|
341
|
+
const cached = children.get(key);
|
|
342
|
+
if (cached && cached.raw === toRaw(value)) return cached.view;
|
|
343
|
+
const view = dataView(value, onRead);
|
|
344
|
+
children.set(key, { raw: toRaw(value), view });
|
|
345
|
+
return view;
|
|
346
|
+
};
|
|
347
|
+
const exists = (key: PropertyKey) => (typeof key === 'string' && key.startsWith('__v_') ? false : overlay.has(key) ? overlay.get(key) !== DELETED : (onRead?.(), Reflect.has(src, key)));
|
|
348
|
+
return new Proxy(array ? [] : {}, {
|
|
349
|
+
get: (_, key) => read(key),
|
|
350
|
+
set: (_, key, value) => { overlay.set(key, value); children.delete(key); return true; },
|
|
351
|
+
deleteProperty: (_, key) => { overlay.set(key, DELETED); children.delete(key); return true; },
|
|
352
|
+
defineProperty: (_, key, descriptor) => { overlay.set(key, descriptor.value); children.delete(key); return true; },
|
|
353
|
+
has: (_, key) => exists(key),
|
|
354
|
+
ownKeys: () => {
|
|
355
|
+
onRead?.();
|
|
356
|
+
const keys = new Set<string | symbol>(Reflect.ownKeys(src));
|
|
357
|
+
overlay.forEach((value, key) => (value === DELETED ? keys.delete(key as string | symbol) : keys.add(key as string | symbol)));
|
|
358
|
+
if (array) {
|
|
359
|
+
const length = read('length');
|
|
360
|
+
keys.forEach((key) => { if (typeof key === 'string' && /^\d+$/.test(key) && Number(key) >= length) keys.delete(key); });
|
|
361
|
+
}
|
|
362
|
+
return [...keys];
|
|
363
|
+
},
|
|
364
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
365
|
+
if (!exists(key)) return undefined;
|
|
366
|
+
if (array && key === 'length') return { value: read(key), writable: true, enumerable: false, configurable: false };
|
|
367
|
+
return { value: read(key), writable: true, enumerable: true, configurable: true };
|
|
368
|
+
},
|
|
369
|
+
getPrototypeOf: () => Reflect.getPrototypeOf(toRaw(src)),
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// 动态属性联动:函数或 { value, setup, watch };按实际读取的字段追踪依赖,丢弃过期的异步结果
|
|
374
|
+
// 联动引擎需要的共享状态:数据版本(watch: true 与 await 后读取的回退)、结构版本、未完成的异步任务
|
|
375
|
+
interface LinkageShared {
|
|
376
|
+
dataVersion: ShallowRef<number>;
|
|
377
|
+
revision: number;
|
|
378
|
+
pending: Set<Promise<any>>;
|
|
239
379
|
}
|
|
240
380
|
|
|
381
|
+
interface LinkageOptions {
|
|
382
|
+
// 按下标取当前渲染的字段
|
|
383
|
+
target: (idx: number) => any;
|
|
384
|
+
// 联动上下文:根数据、当前层数据、行下标、路由等;属性按需读取,未用到的不产生依赖
|
|
385
|
+
context: () => FormContext;
|
|
386
|
+
shared: LinkageShared;
|
|
387
|
+
// 把联动结果写回字段(规范化 model / array、初始化子结构由表单负责)
|
|
388
|
+
apply: (target: any, key: string, result: any) => void;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 带 setup 或 watch 的对象才是联动配置,attrs 等普通对象原样保留
|
|
392
|
+
const isLinkage = (val: any) => isObject(val) && (isFunction(val.setup) || isArray(val.watch) || val.watch === true);
|
|
393
|
+
|
|
394
|
+
// 动态属性:函数或 { value, setup, watch },监听字段变化时重新计算,支持 Promise
|
|
395
|
+
const useLinkage = ({ target: getTarget, context, shared, apply: applyResult }: LinkageOptions) => {
|
|
396
|
+
// 结构变化时重建字段监听;普通字段变化只触发对应的联动任务
|
|
397
|
+
const unwatchs: Array<() => void> = [];
|
|
398
|
+
const pendingRuns = new Map<LinkageTask, () => void>();
|
|
399
|
+
let generation = 0;
|
|
400
|
+
const stop = () => {
|
|
401
|
+
generation += 1;
|
|
402
|
+
pendingRuns.forEach(finish => finish());
|
|
403
|
+
pendingRuns.clear();
|
|
404
|
+
unwatchs.splice(0).forEach((unwatch) => {
|
|
405
|
+
unwatch();
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// 联动函数拿到的是可写的数据视图:读取直接访问表单数据,Vue 只按实际读到的字段收集依赖;
|
|
410
|
+
// 写入只落在视图自己的覆盖层,不改动表单数据(与 4.x 传副本的语义一致)。
|
|
411
|
+
const call = (setup: any, item: any, onRead?: () => void) => {
|
|
412
|
+
const ctx = context();
|
|
413
|
+
const track = (data: any) => dataView(data, onRead);
|
|
414
|
+
// 统一回调协议,不通过参数个数猜测用途。path 保持已发布版路由含义。
|
|
415
|
+
const scope = new Proxy(ctx, {
|
|
416
|
+
get: (target, key) => key === 'path' ? ctx.route?.path : key === 'formPath' ? ctx.path
|
|
417
|
+
: key === 'root' || key === 'data' ? track(ctx[key])
|
|
418
|
+
: key in target ? Reflect.get(target, key) : Reflect.get(ctx.route || {}, key),
|
|
419
|
+
has: (target, key) => key === 'formPath' || key in target || key in (ctx.route || {}),
|
|
420
|
+
ownKeys: target => [...new Set([...Reflect.ownKeys(ctx.route || {}), ...Reflect.ownKeys(target), 'formPath'])],
|
|
421
|
+
getOwnPropertyDescriptor: (target, key) => key === 'formPath' || key in target || key in (ctx.route || {})
|
|
422
|
+
? { enumerable: true, configurable: true } : undefined,
|
|
423
|
+
});
|
|
424
|
+
return setup(track(ctx.root), item, scope, ctx.router);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// 解析字段上的动态属性,写入初始值并收集任务
|
|
428
|
+
const resolve = (item: any, idx: number, tasks: LinkageTask[]) => {
|
|
429
|
+
for (const key in item) {
|
|
430
|
+
// 假值(false、0、'')原样保留,不能当作联动配置
|
|
431
|
+
let val = item[key];
|
|
432
|
+
let hasInitialResult = false;
|
|
433
|
+
let initialResult: any;
|
|
434
|
+
// 处理函数
|
|
435
|
+
if (isFunction(val)) {
|
|
436
|
+
const fn = val;
|
|
437
|
+
// 同步读取由 computed 精确追踪;异步函数在 await 之后才读取的数据无法追踪,
|
|
438
|
+
// 一旦出现就退回到跟随根数据版本重算。
|
|
439
|
+
let lateReads = false;
|
|
440
|
+
const version = shallowRef(0);
|
|
441
|
+
unwatchs.push(watch(shared.dataVersion, () => {
|
|
442
|
+
if (lateReads) version.value++;
|
|
443
|
+
}));
|
|
444
|
+
// 初次解析与监听共用计算结果,避免异步函数被重复调用。
|
|
445
|
+
const evaluated = computed(() => {
|
|
446
|
+
version.value;
|
|
447
|
+
let evaluating = true;
|
|
448
|
+
let settled = false;
|
|
449
|
+
const result = call(fn, reactive(item), () => {
|
|
450
|
+
if (!evaluating && !settled) lateReads = true;
|
|
451
|
+
});
|
|
452
|
+
evaluating = false;
|
|
453
|
+
if (result instanceof Promise) result.then(() => (settled = true), () => (settled = true));
|
|
454
|
+
else settled = true;
|
|
455
|
+
return result;
|
|
456
|
+
});
|
|
457
|
+
val = evaluated.value;
|
|
458
|
+
if (isLinkage(val)) {
|
|
459
|
+
// 函数返回联动配置但没写 setup 时,之后每次重新执行函数并取它的 value
|
|
460
|
+
if (!isFunction(val.setup)) {
|
|
461
|
+
val.setup = (_data: any, current: any) => {
|
|
462
|
+
const res = call(fn, current);
|
|
463
|
+
return isLinkage(res) ? res.value : res;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
// 未声明 watch 时监听全部数据
|
|
467
|
+
if (!isArray(val.watch)) {
|
|
468
|
+
val.watch = true;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
hasInitialResult = true;
|
|
472
|
+
initialResult = val;
|
|
473
|
+
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true, source: () => evaluated.value };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (isLinkage(val)) {
|
|
477
|
+
const { value = null, setup = () => {}, watch: _watch = [], source } = val || {};
|
|
478
|
+
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult, source });
|
|
479
|
+
item[key] = value;
|
|
480
|
+
} else {
|
|
481
|
+
item[key] = val;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// 执行任务:不声明 watch 的只执行一次,其余按监听字段重新计算
|
|
487
|
+
const start = (tasks: LinkageTask[]) => {
|
|
488
|
+
const current = generation;
|
|
489
|
+
const taskRuns = new Map<LinkageTask, number>();
|
|
490
|
+
const runsetup = (task: LinkageTask) => {
|
|
491
|
+
if (current !== generation) return;
|
|
492
|
+
const { idx, key, setup } = task;
|
|
493
|
+
const target = getTarget(idx);
|
|
494
|
+
if (target && isFunction(setup)) {
|
|
495
|
+
pendingRuns.get(task)?.();
|
|
496
|
+
const runId = (taskRuns.get(task) || 0) + 1;
|
|
497
|
+
taskRuns.set(task, runId);
|
|
498
|
+
const v = task.hasInitialResult ? task.initialResult : call(setup, target);
|
|
499
|
+
task.hasInitialResult = false;
|
|
500
|
+
const isCurrent = () => current === generation && getTarget(idx) === target && taskRuns.get(task) === runId;
|
|
501
|
+
const apply = (result: any) => {
|
|
502
|
+
shared.revision++;
|
|
503
|
+
applyResult(target, key, result);
|
|
504
|
+
};
|
|
505
|
+
if (v instanceof Promise) {
|
|
506
|
+
// 等待期间保留上一次的结果,避免选项、标题等先变空再出现。
|
|
507
|
+
let release!: () => void;
|
|
508
|
+
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
509
|
+
const finish = () => {
|
|
510
|
+
shared.pending.delete(pending);
|
|
511
|
+
if (pendingRuns.get(task) === finish) pendingRuns.delete(task);
|
|
512
|
+
release();
|
|
513
|
+
};
|
|
514
|
+
pendingRuns.set(task, finish);
|
|
515
|
+
shared.pending.add(pending);
|
|
516
|
+
v.then(async (res: any) => {
|
|
517
|
+
await nextTick();
|
|
518
|
+
if (isCurrent()) apply(res);
|
|
519
|
+
}).catch((error: unknown) => {
|
|
520
|
+
if (isCurrent()) console.warn(`[FormLoader] ${key} 联动失败`, error);
|
|
521
|
+
}).finally(finish);
|
|
522
|
+
} else nextTick(() => { if (isCurrent()) apply(v); });
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
// 点路径从根数据读取,'.' 开头从当前层读取
|
|
526
|
+
const read = (key: string) => {
|
|
527
|
+
const ctx = context();
|
|
528
|
+
return key.startsWith('.') ? readPath(ctx.data, key.slice(1)) : readPath(ctx.root, key);
|
|
529
|
+
};
|
|
530
|
+
const once = (task: LinkageTask) => Array.isArray(task.watch) && !task.watch.length;
|
|
531
|
+
tasks.filter(once).forEach((task) => {
|
|
532
|
+
runsetup(task);
|
|
533
|
+
});
|
|
534
|
+
// 每个动态属性只建立一个 watcher,多个依赖在同一轮变化时只执行一次
|
|
535
|
+
tasks
|
|
536
|
+
.filter((task) => !once(task))
|
|
537
|
+
.forEach((task) => {
|
|
538
|
+
const { watch: keys } = task;
|
|
539
|
+
if (keys === true) {
|
|
540
|
+
task.hasInitialResult = false;
|
|
541
|
+
// 函数属性只在计算结果变化时应用;联动配置的 watch: true 跟随根数据版本。
|
|
542
|
+
unwatchs.push(watch(task.source || shared.dataVersion, () => {
|
|
543
|
+
runsetup(task);
|
|
544
|
+
}, { immediate: true }));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
unwatchs.push(
|
|
548
|
+
watch(
|
|
549
|
+
() => keys.map((key: string) => read(key)),
|
|
550
|
+
() => {
|
|
551
|
+
runsetup(task);
|
|
552
|
+
},
|
|
553
|
+
{ deep: true, immediate: true },
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
return { stop, resolve, start };
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
const route = useRoute();
|
|
564
|
+
const router = useRouter();
|
|
565
|
+
const attrs = useAttrs();
|
|
566
|
+
const slots = useSlots();
|
|
567
|
+
// 外部写了 class 时去掉默认根 class,由外部接管
|
|
568
|
+
const rootClass = computed(() => (attrs.class ? undefined : 'n-sl-sub'));
|
|
569
|
+
|
|
241
570
|
// 入参
|
|
242
571
|
const props: any = defineProps({
|
|
243
572
|
// 用户是否交互
|
|
@@ -247,12 +576,12 @@
|
|
|
247
576
|
},
|
|
248
577
|
// JSON规则
|
|
249
578
|
form: {
|
|
250
|
-
type:
|
|
251
|
-
default: () =>
|
|
579
|
+
type: Array as PropType<FormItem[]>,
|
|
580
|
+
default: () => [],
|
|
252
581
|
},
|
|
253
582
|
// 表单数据
|
|
254
583
|
data: {
|
|
255
|
-
type: Object as any
|
|
584
|
+
type: Object as PropType<Record<string, any>>,
|
|
256
585
|
default: () => ({}),
|
|
257
586
|
},
|
|
258
587
|
// 栅格系统
|
|
@@ -275,197 +604,615 @@
|
|
|
275
604
|
type: [String, Array, Object],
|
|
276
605
|
default: '',
|
|
277
606
|
},
|
|
607
|
+
// 统一的label宽度:数字为 em,'auto' 为自适应,不传时按最长的 label 计算
|
|
608
|
+
labelWidth: {
|
|
609
|
+
type: [Number, String],
|
|
610
|
+
default: undefined,
|
|
611
|
+
},
|
|
278
612
|
// 是否垂直排列
|
|
279
613
|
vertical: {
|
|
280
614
|
type: Boolean,
|
|
281
615
|
default: false,
|
|
282
616
|
},
|
|
617
|
+
// edit 编辑 | view 查看
|
|
618
|
+
mode: {
|
|
619
|
+
type: String,
|
|
620
|
+
default: 'edit',
|
|
621
|
+
},
|
|
622
|
+
// 调试:字段容器带上 data-path 和 title,重建时在控制台输出解析后的配置
|
|
623
|
+
debug: {
|
|
624
|
+
type: Boolean,
|
|
625
|
+
default: false,
|
|
626
|
+
},
|
|
627
|
+
// 以下由分组和数组内部传递
|
|
628
|
+
// 根表单数据,联动函数和 watch 都基于它
|
|
629
|
+
root: {
|
|
630
|
+
type: Object as any,
|
|
631
|
+
default: null,
|
|
632
|
+
},
|
|
633
|
+
// 当前层在根数据中的路径
|
|
634
|
+
path: {
|
|
635
|
+
type: String,
|
|
636
|
+
default: '',
|
|
637
|
+
},
|
|
638
|
+
arrayContext: { type: Object as () => FormArrayContext, default: undefined },
|
|
639
|
+
clearContext: { type: Object as () => { value: boolean; restore: boolean }, default: undefined },
|
|
640
|
+
// 数组行下标
|
|
641
|
+
index: {
|
|
642
|
+
type: Number,
|
|
643
|
+
default: undefined,
|
|
644
|
+
},
|
|
283
645
|
});
|
|
284
646
|
|
|
647
|
+
// 各层级共享:Label 宽度、校验状态、隐藏字段暂存、已挂载的表单实例
|
|
648
|
+
interface Shared {
|
|
649
|
+
state: { lw: number; validated: boolean };
|
|
650
|
+
cache: ClearCache;
|
|
651
|
+
instances: Set<{ collect: () => any[]; hidden: () => string[] }>;
|
|
652
|
+
snapshot: any;
|
|
653
|
+
initialObjects: WeakMap<object, any>;
|
|
654
|
+
pending: Set<Promise<any>>;
|
|
655
|
+
revision: number;
|
|
656
|
+
validationEpoch: number;
|
|
657
|
+
cancelChecks: Set<() => void>;
|
|
658
|
+
validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
|
|
659
|
+
// 根数据任意变化时递增,供 watch: true 的联动共用
|
|
660
|
+
dataVersion: ShallowRef<number>;
|
|
661
|
+
// 外部回填的错误(如服务端校验),按字段路径保存;字段值变化后自动失效
|
|
662
|
+
serverErrors: Map<string, { msg: string; type: string; input: any }>;
|
|
663
|
+
}
|
|
664
|
+
const FORM_SHARED = Symbol.for('neatui:form-loader');
|
|
665
|
+
const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
|
|
666
|
+
state: reactive({ lw: 6, validated: false }),
|
|
667
|
+
cache: new WeakMap(),
|
|
668
|
+
instances: new Set(),
|
|
669
|
+
snapshot: null,
|
|
670
|
+
initialObjects: new WeakMap(),
|
|
671
|
+
pending: new Set(),
|
|
672
|
+
revision: 0,
|
|
673
|
+
validationEpoch: 0,
|
|
674
|
+
cancelChecks: new Set(),
|
|
675
|
+
validations: new WeakMap(),
|
|
676
|
+
dataVersion: shallowRef(0),
|
|
677
|
+
serverErrors: reactive(new Map()),
|
|
678
|
+
};
|
|
679
|
+
provide(FORM_SHARED, shared);
|
|
680
|
+
|
|
681
|
+
const rootData = computed(() => props.root || props.data);
|
|
682
|
+
// 根表单未传 idx 时用 useId 生成前缀,同页多个表单的同名字段 id 不再冲突;SSR 与客户端一致。
|
|
683
|
+
const autoIdx = useId().replace(/[^\w-]/g, '-');
|
|
684
|
+
const baseIdx = computed(() => (props.idx !== '' ? String(props.idx) : props.root ? '' : autoIdx));
|
|
685
|
+
const cancelValidation = () => {
|
|
686
|
+
shared.validationEpoch++;
|
|
687
|
+
shared.cancelChecks.forEach(cancel => cancel());
|
|
688
|
+
shared.cancelChecks.clear();
|
|
689
|
+
};
|
|
690
|
+
// 将初始对象与快照对象对应;异步补出的默认值写入快照,用户后来新增的行不写入。
|
|
691
|
+
const cloneInitial = (value: any): any => {
|
|
692
|
+
if (!value || typeof value !== 'object' || value instanceof Date) return deepcopy(value);
|
|
693
|
+
const raw = toRaw(value);
|
|
694
|
+
const copy: any = isArray(value) ? [] : {};
|
|
695
|
+
shared.initialObjects.set(raw, copy);
|
|
696
|
+
Object.keys(value).forEach(key => { copy[key] = cloneInitial(value[key]); });
|
|
697
|
+
return copy;
|
|
698
|
+
};
|
|
699
|
+
const captureInitial = () => {
|
|
700
|
+
shared.initialObjects = new WeakMap();
|
|
701
|
+
shared.snapshot = cloneInitial(rootData.value);
|
|
702
|
+
shared.cache = new WeakMap();
|
|
703
|
+
cancelValidation();
|
|
704
|
+
};
|
|
705
|
+
const recordInitial = (data: any, key: string) => {
|
|
706
|
+
const initial = shared.initialObjects.get(toRaw(data));
|
|
707
|
+
if (initial) initial[key] = cloneInitial(data[key]);
|
|
708
|
+
};
|
|
709
|
+
if (!props.root) captureInitial();
|
|
710
|
+
watch(() => props.data, () => {
|
|
711
|
+
shared.revision++;
|
|
712
|
+
if (!props.root) shared.dataVersion.value++;
|
|
713
|
+
}, { deep: true, flush: 'sync' });
|
|
714
|
+
const editable = computed(() => props.mode !== 'view');
|
|
715
|
+
// 调用过 validate 后所有字段展示校验状态
|
|
716
|
+
const touched = computed(() => props.interacted || shared.state.validated);
|
|
717
|
+
|
|
718
|
+
const arrayContextProp = computed(() => props.arrayContext);
|
|
719
|
+
|
|
720
|
+
// 联动上下文
|
|
721
|
+
const context = (): FormContext => ({ root: rootData.value, data: props.data, index: props.index, array: props.arrayContext, path: props.path, mode: props.mode, route, router });
|
|
722
|
+
|
|
723
|
+
// value 保留原始值兼容已有插槽;bind.modelValue 使用显示转换后的值。
|
|
724
|
+
const fieldScope = (col: any) => {
|
|
725
|
+
const ctx = context();
|
|
726
|
+
const update = (value: any) => {
|
|
727
|
+
if (!editable.value || !col.field) return;
|
|
728
|
+
col._touched = true;
|
|
729
|
+
writeValue(col, value, context());
|
|
730
|
+
eventsOf(col, context())['update:modelValue']?.(value);
|
|
731
|
+
};
|
|
732
|
+
return {
|
|
733
|
+
col, node: col, data: col._data, mode: props.mode, value: col._data?.[col.field], ctx,
|
|
734
|
+
update,
|
|
735
|
+
bind: { modelValue: displayOf(col, ctx), 'onUpdate:modelValue': update, disabled: !editable.value },
|
|
736
|
+
};
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// 字段未设置 vertical 时跟随表单
|
|
740
|
+
const isVertical = (col: any = {}) => (col.vertical === undefined || col.vertical === null ? props.vertical : !!col.vertical);
|
|
741
|
+
const isGroup = (col: any = {}) => (col._kind === 'child' || isArray(col.child)) && !col._array;
|
|
742
|
+
const isCleared = (col: any) => !!props.clearContext?.value || (isObject(col.clear) ? !!col.clear.value : !!col.clear);
|
|
743
|
+
const canRestore = (col: any) => props.clearContext?.restore !== false && !(isObject(col.clear) && col.clear.restore === false);
|
|
744
|
+
const hasSlot = (name: string) => !!slots[name];
|
|
745
|
+
const showStar = (col: any = {}) => editable.value && rulesOf(col).some((rule: any) => rule?.required);
|
|
746
|
+
|
|
285
747
|
// label展示文案,支持全局/单个字段开关冒号
|
|
286
748
|
const formatLabel = (col: any = {}) => {
|
|
287
|
-
const
|
|
749
|
+
const label = labelText(col.label);
|
|
288
750
|
if (!label) return '';
|
|
289
751
|
if (/[::]\s*$/.test(label)) return label;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
752
|
+
// 4.x 兼容:字段级 colon 优先,false 不显示,string 自定义字符,true 为 ':'
|
|
753
|
+
if (col.colon === false) return label;
|
|
754
|
+
if (isString(col.colon)) return `${label}${col.colon}`;
|
|
755
|
+
if (col.colon === true) return `${label}:`;
|
|
756
|
+
// 自定义节点树和标签属性可以直接决定呈现;默认文本标签跟随表单冒号。
|
|
757
|
+
if ((isGroup(col) || col._array || col.array) && isVertical(col)) return label;
|
|
758
|
+
const suffix = isString(props.colon) ? props.colon : props.colon ? ':' : '';
|
|
297
759
|
return suffix ? `${label}${suffix}` : label;
|
|
298
760
|
};
|
|
761
|
+
// 标签插槽:${field}-label > array-label / child-label > label
|
|
762
|
+
const labelSlot = (col: any = {}) => {
|
|
763
|
+
const names = [col.field && `${col.field}-label`, col._array && 'array-label', isGroup(col) && 'child-label', 'label'];
|
|
764
|
+
return (names.find((name) => name && hasSlot(name)) as string) || '';
|
|
765
|
+
};
|
|
766
|
+
// 整行插槽
|
|
767
|
+
const rowSlot = (col: any = {}) => (col.field && hasSlot(`${col.field}-row`) ? `${col.field}-row` : '');
|
|
768
|
+
|
|
769
|
+
// 表单默认标签宽度;单个标签用 label[1].style 覆盖。
|
|
770
|
+
const widthOf = (value: any) => {
|
|
771
|
+
if (value === 'auto') return '';
|
|
772
|
+
const n = Number(value);
|
|
773
|
+
return `width:${value !== undefined && value !== '' && !isNaN(n) ? n : shared.state.lw}em; max-width:50%;`;
|
|
774
|
+
};
|
|
775
|
+
const labelStyle = (col: any = {}) => widthOf(props.labelWidth);
|
|
776
|
+
const submitStyle = computed(() => {
|
|
777
|
+
const style = widthOf(props.labelWidth);
|
|
778
|
+
return style ? style.replace(/^width:([^;]+);.*$/, 'padding-left:$1;') : '';
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// 分组、数组的路径和 id 前缀
|
|
782
|
+
const fieldPath = (col: any = {}) => [props.path, col.paths, col.field].filter(Boolean).join('.');
|
|
783
|
+
const childIdx = (col: any = {}, idx: number) => [baseIdx.value, col.field || `g${idx}`].filter((v: any) => v !== '').join('-');
|
|
784
|
+
const childData = (col: any = {}) => (col.field ? col._data[col.field] : col._data);
|
|
785
|
+
const listOf = (col: any = {}) => (isArray(col._data?.[col.field]) ? col._data[col.field] : []);
|
|
786
|
+
|
|
787
|
+
// submit 只在最外层渲染,其余插槽透传给分组和数组
|
|
788
|
+
const childSlots = () => Object.keys(slots).filter((name) => name !== 'submit');
|
|
789
|
+
|
|
790
|
+
// 控件:已注册的 model;没写 model 且没有对应插槽时默认 Input
|
|
791
|
+
const resolveEntry = (col: any = {}) => {
|
|
792
|
+
const model = col.model?.[0];
|
|
793
|
+
if (col.slot && !getField(model)) return undefined;
|
|
794
|
+
if (model) return getField(model);
|
|
795
|
+
if (col.field && hasSlot(col.field)) return undefined;
|
|
796
|
+
return getField(col.field ? 'Input' : 'Text');
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
// 字段缺失时的初始值:default > 数组 [] > 分组 {} > 控件空值
|
|
800
|
+
const initialOf = (item: any = {}) => {
|
|
801
|
+
if (valueBinding(item.value) && item.value.default !== undefined) return deepcopy(item.value.default);
|
|
802
|
+
if (item.array) return [];
|
|
803
|
+
if (isArray(item.child)) return {};
|
|
804
|
+
return emptyValueOf(isString(item.model) || isArray(item.model) ? normalizeModel(item.model) : ['']);
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// 数组
|
|
808
|
+
const rowKeys = new WeakMap<object, number>();
|
|
809
|
+
let rowSeq = 0;
|
|
810
|
+
// 行的稳定 key,不往数据里写额外字段
|
|
811
|
+
const rowKey = (row: any) => {
|
|
812
|
+
if (!isObject(row)) return row;
|
|
813
|
+
if (!rowKeys.has(row)) rowKeys.set(row, (rowSeq += 1));
|
|
814
|
+
return rowKeys.get(row);
|
|
815
|
+
};
|
|
816
|
+
const newRow = (col: any) => createRow(col._array.child, initialOf, col._array.item);
|
|
817
|
+
const validIndex = (list: any[], i: number) => Number.isInteger(i) && i >= 0 && i < list.length;
|
|
818
|
+
const canAdd = (col: any) => editable.value && (!col._array.max || listOf(col).length < col._array.max);
|
|
819
|
+
const insertRow = (col: any, i: number) => {
|
|
820
|
+
const list = listOf(col);
|
|
821
|
+
if (!canAdd(col) || !Number.isInteger(i) || i < 0 || i > list.length) return;
|
|
822
|
+
col._touched = true;
|
|
823
|
+
list.splice(i, 0, newRow(col));
|
|
824
|
+
};
|
|
825
|
+
const addRow = (col: any) => insertRow(col, listOf(col).length);
|
|
826
|
+
const removeRow = (col: any, i: number) => {
|
|
827
|
+
const list = listOf(col);
|
|
828
|
+
if (!editable.value || !validIndex(list, i) || list.length <= (col._array.min || 0)) return;
|
|
829
|
+
col._touched = true;
|
|
830
|
+
list.splice(i, 1);
|
|
831
|
+
};
|
|
832
|
+
const moveRow = (col: any, i: number, step: number) => {
|
|
833
|
+
const list = listOf(col);
|
|
834
|
+
const j = i + step;
|
|
835
|
+
if (!editable.value || !validIndex(list, i) || !validIndex(list, j)) return;
|
|
836
|
+
const [row] = list.splice(i, 1);
|
|
837
|
+
list.splice(j, 0, row);
|
|
838
|
+
};
|
|
839
|
+
// 每次调用按行身份取下标,避免插入、排序或异步事件后误操作其他行。
|
|
840
|
+
const arrayContext = (col: any, row?: any): FormArrayContext => {
|
|
841
|
+
const ctx: FormArrayContext = {
|
|
842
|
+
get list() { return listOf(col); },
|
|
843
|
+
get row() { return row; },
|
|
844
|
+
get index() { return row === undefined ? -1 : listOf(col).indexOf(row); },
|
|
845
|
+
get canAdd() { return canAdd(col); },
|
|
846
|
+
get canRemove() { return editable.value && this.index >= 0 && this.list.length > (col._array.min || 0); },
|
|
847
|
+
get canMoveUp() { return editable.value && this.index > 0; },
|
|
848
|
+
get canMoveDown() { return editable.value && this.index >= 0 && this.index < this.list.length - 1; },
|
|
849
|
+
add: () => addRow(col),
|
|
850
|
+
insertAfter(i?: number) {
|
|
851
|
+
const index = i ?? ctx.index;
|
|
852
|
+
if (validIndex(ctx.list, index)) insertRow(col, index + 1);
|
|
853
|
+
},
|
|
854
|
+
remove() { removeRow(col, ctx.index); },
|
|
855
|
+
moveUp() { moveRow(col, ctx.index, -1); },
|
|
856
|
+
moveDown() { moveRow(col, ctx.index, 1); },
|
|
857
|
+
};
|
|
858
|
+
return ctx;
|
|
859
|
+
};
|
|
860
|
+
const rowScope = (col: any, i: number) => Object.assign(arrayContext(col, listOf(col)[i]), { col });
|
|
299
861
|
|
|
300
862
|
// 数据
|
|
301
863
|
const state: any = reactive({
|
|
302
864
|
form: [],
|
|
303
|
-
_lw: 6,
|
|
304
865
|
});
|
|
305
866
|
|
|
306
|
-
//
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
867
|
+
// 路径缺失时补全对象,并把新建的对象记入重置基线
|
|
868
|
+
const resolvePath = (data: any, paths: string = '') => ensurePath(data, paths, recordInitial);
|
|
869
|
+
|
|
870
|
+
// 按需读取的联动上下文:函数没用到的属性(如数组行上下文)变化时不触发重算。
|
|
871
|
+
const lazyContext = (): FormContext => ({
|
|
872
|
+
get root() { return rootData.value; },
|
|
873
|
+
get data() { return props.data; },
|
|
874
|
+
get index() { return props.index; },
|
|
875
|
+
get array() { return props.arrayContext; },
|
|
876
|
+
get path() { return props.path; },
|
|
877
|
+
get mode() { return props.mode; },
|
|
878
|
+
route,
|
|
879
|
+
router,
|
|
880
|
+
});
|
|
881
|
+
const linkage = useLinkage({
|
|
882
|
+
target: (idx) => state.form[idx],
|
|
883
|
+
context: lazyContext,
|
|
884
|
+
shared,
|
|
885
|
+
apply: (target, key, result) => {
|
|
886
|
+
target[key] = key === 'model' ? normalizeModel(result) : result;
|
|
887
|
+
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
888
|
+
if (key === 'child' || key === 'array') initializeField(target);
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// 初始结构和异步生成的子树都经过相同的数据初始化。
|
|
893
|
+
const initializeField = (item: any) => {
|
|
894
|
+
if (!item.field || !item._data) return;
|
|
895
|
+
const target = item._data;
|
|
896
|
+
let changed = false;
|
|
897
|
+
if (item._array) {
|
|
898
|
+
if (!isArray(target[item.field])) {
|
|
899
|
+
const initial = initialOf(item);
|
|
900
|
+
target[item.field] = isArray(initial) ? initial : [];
|
|
901
|
+
changed = true;
|
|
902
|
+
}
|
|
903
|
+
while (target[item.field].length < (item._array.min || 0)) { target[item.field].push(newRow(item)); changed = true; }
|
|
904
|
+
} else if (isGroup(item)) {
|
|
905
|
+
if (!isObject(target[item.field]) || isArray(target[item.field])) {
|
|
906
|
+
const initial = initialOf(item);
|
|
907
|
+
target[item.field] = isObject(initial) && !isArray(initial) ? initial : {};
|
|
908
|
+
changed = true;
|
|
326
909
|
}
|
|
910
|
+
} else if (target[item.field] === undefined) {
|
|
911
|
+
target[item.field] = initialOf(item);
|
|
912
|
+
changed = true;
|
|
913
|
+
}
|
|
914
|
+
if (changed) recordInitial(target, item.field);
|
|
915
|
+
};
|
|
327
916
|
|
|
917
|
+
// 结构变化时重建字段和联动
|
|
918
|
+
const rebuild = (form: any = []) => {
|
|
919
|
+
shared.revision++;
|
|
920
|
+
linkage.stop();
|
|
921
|
+
const tasks: LinkageTask[] = [];
|
|
922
|
+
const fields = new Set<string>();
|
|
923
|
+
state.form = (deepcopy(form) || []).map((item: any = {}, idx: number) => {
|
|
924
|
+
item._kind = item.array ? 'array' : Object.prototype.hasOwnProperty.call(item, 'child') ? 'child' : 'field';
|
|
328
925
|
if (isObject(item)) {
|
|
329
|
-
|
|
330
|
-
let val = item[key] || {};
|
|
331
|
-
let hasInitialResult = false;
|
|
332
|
-
let initialResult: any;
|
|
333
|
-
// 处理函数
|
|
334
|
-
if (isFunction(val)) {
|
|
335
|
-
const setup = val;
|
|
336
|
-
val = setup(deepcopy(props.data), props.form, route, router);
|
|
337
|
-
if (isObject(val)) {
|
|
338
|
-
if (!isFunction(val.setup)) {
|
|
339
|
-
val.setup = setup;
|
|
340
|
-
}
|
|
341
|
-
if (!isArray(val.watch)) {
|
|
342
|
-
val.watch = deepcopy(form).map(({ field = '' }: any = {}) => field);
|
|
343
|
-
}
|
|
344
|
-
} else {
|
|
345
|
-
hasInitialResult = true;
|
|
346
|
-
initialResult = val;
|
|
347
|
-
val = { value: val, setup, watch: deepcopy(form).map(({ field = '' }: any = {}) => field) };
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
// 对象处理
|
|
351
|
-
if (isObject(val)) {
|
|
352
|
-
const { value = null, setup = () => {}, watch: _watch = [] } = val || {};
|
|
353
|
-
const task = { idx, key, value, setup, watch: _watch, hasInitialResult, initialResult };
|
|
354
|
-
// 监听字段为空
|
|
355
|
-
if (_watch.length) {
|
|
356
|
-
watched.push(task);
|
|
357
|
-
} else {
|
|
358
|
-
onces.push(task);
|
|
359
|
-
}
|
|
360
|
-
item[key] = value;
|
|
361
|
-
} else {
|
|
362
|
-
item[key] = val;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
926
|
+
linkage.resolve(item, idx, tasks);
|
|
365
927
|
}
|
|
366
928
|
|
|
929
|
+
item._array = item.array ? normalizeArray(item.array) : item._kind === 'array' ? { child: [] } : null;
|
|
930
|
+
|
|
367
931
|
// 模型处理
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
932
|
+
item.model = normalizeModel(item.model || (!item.field && !item.child && !item.array ? 'Text' : undefined));
|
|
933
|
+
|
|
934
|
+
// 计算Label宽度:必填星号 + 文案(含冒号)+ 右边距
|
|
935
|
+
if (!isVertical(item)) {
|
|
936
|
+
const required = rulesOf(item).some((rule: any) => rule?.required);
|
|
937
|
+
const _w = (required ? 1 : 0) + clw(formatLabel(item)) + 1;
|
|
938
|
+
if (_w > shared.state.lw) {
|
|
939
|
+
shared.state.lw = _w;
|
|
371
940
|
}
|
|
372
|
-
} else {
|
|
373
|
-
item.model = ['', { verify: false }];
|
|
374
|
-
}
|
|
375
|
-
if (item.model[1] && !Object.prototype.hasOwnProperty.call(item.model[1], 'verify')) {
|
|
376
|
-
item.model[1].verify = false;
|
|
377
941
|
}
|
|
378
942
|
|
|
379
943
|
// 添加ID
|
|
380
|
-
item._fid = `form-${
|
|
944
|
+
item._fid = `form-${baseIdx.value !== '' ? baseIdx.value + '-' : ''}${!item.field && isGroup(item) ? `group-${idx}` : `field-${item.field || `node-${idx}`}`}`;
|
|
381
945
|
|
|
382
946
|
// 数据路径处理
|
|
383
|
-
item._data = props.data;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const runsetup = (item: any = {}) => {
|
|
392
|
-
if (generation !== fieldWatcherGeneration) return;
|
|
393
|
-
const { idx = null, key = null, value = null, setup = null }: any = item || {};
|
|
394
|
-
const target = state.form[idx];
|
|
395
|
-
if (target && isFunction(setup)) {
|
|
396
|
-
const runId = (taskRuns.get(item) || 0) + 1;
|
|
397
|
-
taskRuns.set(item, runId);
|
|
398
|
-
const v = item.hasInitialResult ? item.initialResult : setup(deepcopy(props.data), target, route, router);
|
|
399
|
-
item.hasInitialResult = false;
|
|
400
|
-
const isCurrent = () => generation === fieldWatcherGeneration && state.form[idx] === target && taskRuns.get(item) === runId;
|
|
401
|
-
nextTick(() => {
|
|
402
|
-
if (!isCurrent()) return;
|
|
403
|
-
if (v instanceof Promise) {
|
|
404
|
-
target[key] = value;
|
|
405
|
-
v.then((res: any) => {
|
|
406
|
-
if (isCurrent()) {
|
|
407
|
-
target[key] = res;
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
} else {
|
|
411
|
-
target[key] = v;
|
|
412
|
-
}
|
|
413
|
-
});
|
|
947
|
+
item._data = resolvePath(props.data, item.paths);
|
|
948
|
+
|
|
949
|
+
// 补全缺失的数据字段,重复字段给出提示
|
|
950
|
+
if (item.field) {
|
|
951
|
+
const key = `${item.paths || ''}.${item.field}`;
|
|
952
|
+
if (fields.has(key)) console.warn(`[FormLoader] 字段重复:${fieldPath(item)}`);
|
|
953
|
+
fields.add(key);
|
|
954
|
+
initializeField(item);
|
|
414
955
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
});
|
|
420
|
-
// 每个动态属性只建立一个 watcher,多个依赖在同一轮变化时只执行一次
|
|
421
|
-
watched.forEach((item: any = {}) => {
|
|
422
|
-
unwatchs.push(
|
|
423
|
-
watch(
|
|
424
|
-
() => item.watch.map((key: any) => props.data[key]),
|
|
425
|
-
() => {
|
|
426
|
-
runsetup(item);
|
|
427
|
-
},
|
|
428
|
-
{ deep: true, immediate: true },
|
|
429
|
-
),
|
|
430
|
-
);
|
|
956
|
+
|
|
957
|
+
// 分组、数组默认占满一行
|
|
958
|
+
if ((isGroup(item) || item._array) && !item.media) item.media = 'mob-24';
|
|
959
|
+
return item;
|
|
431
960
|
});
|
|
961
|
+
linkage.start(tasks);
|
|
962
|
+
if (props.debug) console.debug(`[FormLoader] ${props.path || 'root'}`, deepcopy(toRaw(state.form)));
|
|
432
963
|
};
|
|
964
|
+
|
|
965
|
+
// 调试信息
|
|
966
|
+
const debugInfo = (col: any = {}) => [fieldPath(col) || '(group)', col.model?.[0] || (isGroup(col) ? 'child' : col._array ? 'array' : 'Input')].join(' · ');
|
|
433
967
|
watch(
|
|
434
968
|
() => props.form,
|
|
435
969
|
(form = []) => {
|
|
436
|
-
|
|
970
|
+
rebuild(form);
|
|
437
971
|
},
|
|
438
972
|
{ deep: true, immediate: true },
|
|
439
973
|
);
|
|
440
|
-
watch(
|
|
441
|
-
|
|
442
|
-
()
|
|
443
|
-
|
|
444
|
-
},
|
|
445
|
-
);
|
|
974
|
+
watch([() => props.data, () => props.idx], ([data], [previous]) => {
|
|
975
|
+
if (!props.root && data !== previous) captureInitial();
|
|
976
|
+
rebuild(props.form);
|
|
977
|
+
});
|
|
446
978
|
|
|
447
|
-
//
|
|
979
|
+
// 隐藏的字段清空数据并暂存,重新显示时还原(restore: false 时只清空)
|
|
980
|
+
// 只在「隐藏 → 显示」的那一次和首次运行时还原,避免和分组内的子表单来回争抢同一个值
|
|
981
|
+
// 分组重新显示时由重新挂载的子表单自己还原
|
|
982
|
+
const hiddenFields = new Set<string>();
|
|
983
|
+
let firstRun = true;
|
|
448
984
|
watch(
|
|
449
|
-
[() => state.form, () => props.data],
|
|
985
|
+
[() => state.form, () => props.data, () => props.clearContext],
|
|
450
986
|
() => {
|
|
451
987
|
state.form.forEach((item: any = {}) => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
});
|
|
461
|
-
_data[field] = isArray(_data[field]) ? [] : '';
|
|
988
|
+
if (isGroup(item)) return; // 保留递归实例,由解析后的子节点各自清空和恢复。
|
|
989
|
+
const cache = canRestore(item) ? shared.cache : undefined;
|
|
990
|
+
if (isCleared(item)) {
|
|
991
|
+
hiddenFields.add(item._fid);
|
|
992
|
+
emptyField(resolvePath(props.data, item.paths), item.field, cache);
|
|
993
|
+
} else if (hiddenFields.has(item._fid) || firstRun) {
|
|
994
|
+
hiddenFields.delete(item._fid);
|
|
995
|
+
if (cache) restoreField(resolvePath(props.data, item.paths), item.field, cache);
|
|
462
996
|
}
|
|
463
997
|
});
|
|
998
|
+
firstRun = false;
|
|
464
999
|
},
|
|
465
|
-
{ deep: true },
|
|
1000
|
+
{ deep: true, immediate: true },
|
|
466
1001
|
);
|
|
467
1002
|
|
|
1003
|
+
// 当前层可校验的字段(已显示、非分组)
|
|
1004
|
+
const instance = {
|
|
1005
|
+
collect: () =>
|
|
1006
|
+
state.form
|
|
1007
|
+
.filter((col: any) => !isCleared(col) && col.field && !isGroup(col))
|
|
1008
|
+
.map((col: any) => ({ col, path: fieldPath(col), ctx: context() })),
|
|
1009
|
+
// 当前层被隐藏(clear)的字段路径,供 getData({ visibleOnly }) 剔除
|
|
1010
|
+
hidden: (): string[] => state.form.filter((col: any) => col.field && isCleared(col)).map((col: any) => fieldPath(col)),
|
|
1011
|
+
};
|
|
1012
|
+
onMounted(() => {
|
|
1013
|
+
shared.instances.add(instance);
|
|
1014
|
+
shared.revision++;
|
|
1015
|
+
});
|
|
1016
|
+
|
|
468
1017
|
onBeforeUnmount(() => {
|
|
469
|
-
|
|
1018
|
+
if (!props.root) cancelValidation();
|
|
1019
|
+
shared.instances.delete(instance);
|
|
1020
|
+
shared.revision++;
|
|
1021
|
+
linkage.stop();
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const fieldsOf = () => [...shared.instances].flatMap((item) => item.collect());
|
|
1025
|
+
|
|
1026
|
+
const sameInput = (a: any, b: any): boolean => {
|
|
1027
|
+
if (Object.is(a, b)) return true;
|
|
1028
|
+
if (a instanceof Date && b instanceof Date) return +a === +b;
|
|
1029
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
1030
|
+
const keys = Object.keys(a);
|
|
1031
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
|
|
1032
|
+
};
|
|
1033
|
+
// path 由收集字段的那一层计算(validate 在根表单调用时,嵌套字段的路径不能用根表单拼)
|
|
1034
|
+
const validateField = (col: any, ctx: FormContext, path: string, force = false): Promise<any> => {
|
|
1035
|
+
const server = shared.serverErrors.get(path);
|
|
1036
|
+
if (server) return Promise.resolve({ msg: server.msg, type: server.type });
|
|
1037
|
+
const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
|
|
1038
|
+
const previous = shared.validations.get(col);
|
|
1039
|
+
if (previous && previous.epoch === shared.validationEpoch && (!force || previous.pending) && sameInput(previous.input, input)) return previous.result;
|
|
1040
|
+
const job = { input, epoch: shared.validationEpoch, pending: true, result: null as any };
|
|
1041
|
+
shared.validations.set(col, job);
|
|
1042
|
+
job.result = (async () => {
|
|
1043
|
+
let error;
|
|
1044
|
+
try {
|
|
1045
|
+
const message = await resolveEntry(col)?.validate?.(input[0], input[2]?.[1] || {});
|
|
1046
|
+
error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
|
|
1047
|
+
} catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
|
|
1048
|
+
job.pending = false;
|
|
1049
|
+
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !shared.serverErrors.has(path)) { col._error = error; col._checked = true; }
|
|
1050
|
+
return error;
|
|
1051
|
+
})();
|
|
1052
|
+
return job.result;
|
|
1053
|
+
};
|
|
1054
|
+
watchEffect(() => {
|
|
1055
|
+
for (const col of state.form) {
|
|
1056
|
+
// 只读取校验输入,不监听内部错误结果;异步结果按执行顺序丢弃过期响应。
|
|
1057
|
+
const value = deepcopy(displayOf(col, context()));
|
|
1058
|
+
deepcopy(rulesOf(col));
|
|
1059
|
+
deepcopy(col.model);
|
|
1060
|
+
// 回填的错误在字段值变化前一直显示,值一变就交回正常校验
|
|
1061
|
+
const path = col.field ? fieldPath(col) : '';
|
|
1062
|
+
const server = path ? shared.serverErrors.get(path) : undefined;
|
|
1063
|
+
if (server && !isCleared(col)) {
|
|
1064
|
+
if (sameInput(server.input, value)) {
|
|
1065
|
+
col._error = { msg: server.msg, type: server.type };
|
|
1066
|
+
col._checked = true;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
shared.serverErrors.delete(path);
|
|
1070
|
+
}
|
|
1071
|
+
if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
|
|
1072
|
+
void validateField(col, context(), path);
|
|
1073
|
+
} else {
|
|
1074
|
+
shared.validations.delete(col);
|
|
1075
|
+
col._error = null;
|
|
1076
|
+
col._checked = false;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
470
1079
|
});
|
|
1080
|
+
|
|
1081
|
+
// 按页面位置排序
|
|
1082
|
+
const byPosition = (a: any, b: any) => {
|
|
1083
|
+
if (typeof document === 'undefined') return 0;
|
|
1084
|
+
const ea = document.getElementById(a.fid);
|
|
1085
|
+
const eb = document.getElementById(b.fid);
|
|
1086
|
+
if (!ea || !eb) return 0;
|
|
1087
|
+
return ea.compareDocumentPosition(eb) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
// 滚动到指定路径的字段,比如 'contact.phone'、'members.0.name'
|
|
1091
|
+
const scrollToField = (path: string) => {
|
|
1092
|
+
const found = fieldsOf().find((item) => item.path === path);
|
|
1093
|
+
const el = found && typeof document !== 'undefined' ? document.getElementById(found.col._fid) : null;
|
|
1094
|
+
if (!el) return;
|
|
1095
|
+
// 通用定位事件:由配置中的 event.reveal 展开折叠区、切换标签页等。
|
|
1096
|
+
el.dispatchEvent(new CustomEvent('reveal', { bubbles: true, detail: { path } }));
|
|
1097
|
+
nextTick(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// 路径匹配:'contact' 包含 'contact.phone','members.0' 包含该行全部字段
|
|
1101
|
+
const withinPaths = (path: string, paths?: string[]) => !paths || paths.some((p) => path === p || path.startsWith(`${p}.`));
|
|
1102
|
+
|
|
1103
|
+
// 校验已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid;paths 限定只校验这些路径
|
|
1104
|
+
const validate = async ({ scroll = false, paths }: { scroll?: boolean; paths?: string[] } = {}): Promise<FormValidateResult> => {
|
|
1105
|
+
const epoch = shared.validationEpoch;
|
|
1106
|
+
const cancelledResult = (): FormValidateResult => ({ valid: false, errors: [{ field: '', path: '', label: '', msg: '表单已重置或切换,请重新校验', type: 'risk' }], warnings: [] });
|
|
1107
|
+
let cancel!: () => void;
|
|
1108
|
+
const cancelled = new Promise<FormValidateResult>(resolve => { cancel = () => resolve(cancelledResult()); });
|
|
1109
|
+
shared.cancelChecks.add(cancel);
|
|
1110
|
+
const run = async (): Promise<FormValidateResult> => {
|
|
1111
|
+
// 局部校验只让这些字段显示提示,其他字段保持原状
|
|
1112
|
+
if (!paths) shared.state.validated = true;
|
|
1113
|
+
let results: any[];
|
|
1114
|
+
let revision: number;
|
|
1115
|
+
do {
|
|
1116
|
+
// Vue 渲染、联动和异步子树稳定后,再收集可校验字段。
|
|
1117
|
+
do {
|
|
1118
|
+
revision = shared.revision;
|
|
1119
|
+
await nextTick();
|
|
1120
|
+
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
1121
|
+
if (shared.pending.size) await Promise.all([...shared.pending]);
|
|
1122
|
+
} while (revision !== shared.revision || shared.pending.size);
|
|
1123
|
+
revision = shared.revision;
|
|
1124
|
+
results = await Promise.all(fieldsOf().filter(({ path }) => withinPaths(path, paths)).map(async ({ col, path, ctx }) => {
|
|
1125
|
+
if (paths) col._touched = true;
|
|
1126
|
+
const res = await validateField(col, ctx, path, true);
|
|
1127
|
+
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
1128
|
+
}));
|
|
1129
|
+
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
1130
|
+
} while (revision !== shared.revision);
|
|
1131
|
+
const found = (results.filter(Boolean) as any[]).sort(byPosition);
|
|
1132
|
+
const strip = ({ fid, ...rest }: any): FormError => rest;
|
|
1133
|
+
const errors = found.filter((item) => item.type !== 'warn');
|
|
1134
|
+
if (scroll && errors[0]) scrollToField(errors[0].path);
|
|
1135
|
+
return { valid: !errors.length, errors: errors.map(strip), warnings: found.filter((item) => item.type === 'warn').map(strip) };
|
|
1136
|
+
};
|
|
1137
|
+
try { return await Promise.race([run(), cancelled]); }
|
|
1138
|
+
finally { shared.cancelChecks.delete(cancel); }
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// 还原到初始数据并清除校验状态
|
|
1142
|
+
const reset = () => {
|
|
1143
|
+
shared.cache = new WeakMap();
|
|
1144
|
+
if (shared.snapshot) assignDeep(rootData.value, shared.snapshot);
|
|
1145
|
+
const bindInitial = (data: any, initial: any) => {
|
|
1146
|
+
if (!data || !initial || typeof data !== 'object' || typeof initial !== 'object') return;
|
|
1147
|
+
shared.initialObjects.set(toRaw(data), initial);
|
|
1148
|
+
Object.keys(initial).forEach(key => bindInitial(data[key], initial[key]));
|
|
1149
|
+
};
|
|
1150
|
+
bindInitial(rootData.value, shared.snapshot);
|
|
1151
|
+
clearValidate();
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const clearValidate = () => {
|
|
1155
|
+
cancelValidation();
|
|
1156
|
+
shared.serverErrors.clear();
|
|
1157
|
+
shared.validations = new WeakMap();
|
|
1158
|
+
shared.state.validated = false;
|
|
1159
|
+
fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// 回填外部错误(如服务端校验结果):{ 'contact.phone': '号码已被占用' } 或 [{ path, msg, type }]。
|
|
1163
|
+
// 字段值变化后错误自动消失;返回没有匹配到已显示字段的路径,便于改用全局提示。
|
|
1164
|
+
const setErrors = (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, { scroll = false }: { scroll?: boolean } = {}): string[] => {
|
|
1165
|
+
const list: Array<{ path: string; msg: string; type?: string }> = Array.isArray(errors)
|
|
1166
|
+
? errors
|
|
1167
|
+
: Object.entries(errors || {}).map(([path, error]) => (isString(error) ? { path, msg: error } : { path, ...(error as any) }));
|
|
1168
|
+
const fields = fieldsOf();
|
|
1169
|
+
const missing: string[] = [];
|
|
1170
|
+
list.forEach(({ path, msg, type = 'risk' }) => {
|
|
1171
|
+
const found = fields.find((item) => item.path === path);
|
|
1172
|
+
if (!found) return missing.push(path);
|
|
1173
|
+
shared.serverErrors.set(path, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
|
|
1174
|
+
});
|
|
1175
|
+
const first = list.find((item) => !missing.includes(item.path));
|
|
1176
|
+
if (scroll && first) scrollToField(first.path);
|
|
1177
|
+
return missing;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// 与初始值(挂载时的数据及自动补全的默认值,或 setBaseline 之后的数据)比较,返回变化的字段路径
|
|
1181
|
+
const getChanges = (): string[] => {
|
|
1182
|
+
const changes: string[] = [];
|
|
1183
|
+
const diff = (current: any, initial: any, path: string) => {
|
|
1184
|
+
if (sameInput(current, initial)) return;
|
|
1185
|
+
const bothArrays = isArray(current) && isArray(initial);
|
|
1186
|
+
const bothObjects = isPlain(current) && isPlain(initial);
|
|
1187
|
+
if ((bothArrays && current.length === initial.length) || bothObjects) {
|
|
1188
|
+
const keys = new Set([...Object.keys(current), ...Object.keys(initial)]);
|
|
1189
|
+
keys.forEach((key) => diff(current[key], initial[key], path ? `${path}.${key}` : key));
|
|
1190
|
+
} else changes.push(path);
|
|
1191
|
+
};
|
|
1192
|
+
diff(toRaw(rootData.value), shared.snapshot, '');
|
|
1193
|
+
return changes;
|
|
1194
|
+
};
|
|
1195
|
+
const isDirty = (path?: string) => getChanges().some((item) => !path || withinPaths(item, [path]) || withinPaths(path, [item]));
|
|
1196
|
+
|
|
1197
|
+
// 把当前数据设为新的初始值:同一对象上加载记录(Object.assign)或保存成功后调用,reset() 与 isDirty() 以它为准
|
|
1198
|
+
const setBaseline = () => captureInitial();
|
|
1199
|
+
|
|
1200
|
+
// 取数据副本;visibleOnly 时去掉被 clear 隐藏的字段
|
|
1201
|
+
const getData = ({ visibleOnly = false }: { visibleOnly?: boolean } = {}) => {
|
|
1202
|
+
const data = deepcopy(toRaw(rootData.value));
|
|
1203
|
+
if (visibleOnly) {
|
|
1204
|
+
[...shared.instances].flatMap((item) => item.hidden()).forEach((path) => {
|
|
1205
|
+
const keys = path.split('.');
|
|
1206
|
+
const last = keys.pop() as string;
|
|
1207
|
+
const parent = keys.reduce((target: any, key) => (target == null ? undefined : target[key]), data);
|
|
1208
|
+
if (parent && typeof parent === 'object') delete parent[last];
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
return data;
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
// 插槽名由字段名、model 名动态生成,参数随插槽种类不同;字段与控件插槽的参数见 FormSlotScope
|
|
1215
|
+
defineSlots<Record<string, (scope: any) => any>>();
|
|
1216
|
+
|
|
1217
|
+
defineExpose({ validate, reset, clearValidate, scrollToField, setErrors, getChanges, isDirty, setBaseline, getData });
|
|
471
1218
|
</script>
|