@neatui/vue 4.8.1 → 5.0.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 +66 -0
- package/README.md +3 -0
- package/package.json +16 -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/RichTextInput.vue +99 -0
- package/src/components/form/SearchMoreSelect.vue +3 -2
- package/src/components/form/Select.vue +4 -4
- 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 +7 -0
- package/src/components/loader/FormLoader/FormLoader.vue +908 -349
- package/src/components/loader/FormLoader/FormRender.vue +409 -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,153 @@
|
|
|
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
|
+
<slot v-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
|
|
148
|
+
<slot v-else :name="col.field" v-bind="fieldScope(col)"></slot>
|
|
18
149
|
</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>
|
|
150
|
+
</FormRender>
|
|
171
151
|
</template>
|
|
172
152
|
</div>
|
|
173
153
|
</template>
|
|
@@ -175,60 +155,27 @@
|
|
|
175
155
|
<div v-if="vertical">
|
|
176
156
|
<slot name="submit"></slot>
|
|
177
157
|
</div>
|
|
178
|
-
<div v-else :style="
|
|
158
|
+
<div v-else :style="submitStyle">
|
|
179
159
|
<slot name="submit"></slot>
|
|
180
160
|
</div>
|
|
181
161
|
</div>
|
|
182
162
|
</div>
|
|
183
163
|
</template>
|
|
184
164
|
<script lang="ts" setup>
|
|
185
|
-
import { watch, reactive,
|
|
165
|
+
import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, readonly, watchEffect } from 'vue';
|
|
186
166
|
import { useRoute, useRouter } from 'vue-router';
|
|
187
|
-
import { isString, isObject,
|
|
188
|
-
import {
|
|
189
|
-
|
|
190
|
-
Switch,
|
|
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
|
-
};
|
|
167
|
+
import { isString, isObject, deepcopy, isArray, isFunction } from '@fekit/utils';
|
|
168
|
+
import FormRender, { getField, emptyValueOf, clh, isEmpty, labelText, labelAttrs, rulesOf, displayOf, valueBinding, labelNodes, eventsOf, writeValue, FieldMessage } from './FormRender.vue';
|
|
169
|
+
import type { FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
|
|
224
170
|
|
|
225
171
|
// 计算Label宽度
|
|
226
|
-
|
|
172
|
+
const clw = (label: any = '') => {
|
|
227
173
|
let emWidth = 0;
|
|
228
174
|
if (isString(label)) {
|
|
229
175
|
for (let i = 0; i < label.length; i++) {
|
|
230
176
|
const char = label[i];
|
|
231
|
-
|
|
177
|
+
// 汉字、中文标点和全角字符按 1em 计算
|
|
178
|
+
if (/^[\u3000-\u303f\u4e00-\u9fa5\uff00-\uffef]$/.test(char)) {
|
|
232
179
|
emWidth += 1;
|
|
233
180
|
} else {
|
|
234
181
|
emWidth += 0.5;
|
|
@@ -236,8 +183,317 @@
|
|
|
236
183
|
}
|
|
237
184
|
}
|
|
238
185
|
return emWidth;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// 路径缺失时补全对象,不把嵌套字段静默写到根层。
|
|
189
|
+
const resolvePath = (data: any, paths: string = '') => {
|
|
190
|
+
let target = data;
|
|
191
|
+
(paths?.split('.') || []).forEach((key: string) => {
|
|
192
|
+
if (!key) return;
|
|
193
|
+
if (target[key] == null) { target[key] = {}; recordInitial(target, key); }
|
|
194
|
+
if (!isObject(target[key])) throw new TypeError(`[FormLoader] paths 的 ${key} 必须是对象`);
|
|
195
|
+
target = target[key];
|
|
196
|
+
});
|
|
197
|
+
return target;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// 按 'a.b.c' 读取数据
|
|
201
|
+
const readPath = (data: any, path: string = '') =>
|
|
202
|
+
String(path)
|
|
203
|
+
.split('.')
|
|
204
|
+
.reduce((target: any, key: string) => (target === null || target === undefined ? undefined : target[key]), data);
|
|
205
|
+
|
|
206
|
+
// 同类型的空值
|
|
207
|
+
const emptyOf = (value: any) => {
|
|
208
|
+
if (isArray(value)) return [];
|
|
209
|
+
if (typeof value === 'boolean') return false;
|
|
210
|
+
return '';
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// 隐藏字段的值暂存在这里,重新显示时还原
|
|
214
|
+
type ClearCache = WeakMap<object, Map<string, any>>;
|
|
215
|
+
|
|
216
|
+
// 清空单个字段;restore 为 true 时先暂存原值。已为空时不再赋值,避免新数组反复触发 deep watcher
|
|
217
|
+
const emptyField = (data: any, field: string = '', cache?: ClearCache) => {
|
|
218
|
+
if (!data || !field) return;
|
|
219
|
+
const value = data[field];
|
|
220
|
+
if (isEmpty(value) || value === false) return;
|
|
221
|
+
if (cache) {
|
|
222
|
+
const saved = cache.get(data) || new Map();
|
|
223
|
+
saved.set(field, value);
|
|
224
|
+
cache.set(data, saved);
|
|
225
|
+
}
|
|
226
|
+
data[field] = emptyOf(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// 还原单个字段;只在当前仍为空时还原,避免覆盖用户新填的值
|
|
230
|
+
const restoreField = (data: any, field: string = '', cache?: ClearCache) => {
|
|
231
|
+
const saved = data && cache?.get(data);
|
|
232
|
+
if (!saved || !saved.has(field)) return;
|
|
233
|
+
if (isEmpty(data[field]) || data[field] === false) {
|
|
234
|
+
data[field] = saved.get(field);
|
|
235
|
+
}
|
|
236
|
+
saved.delete(field);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// 规范化 model 为 [type, props],props 默认 verify: false
|
|
240
|
+
const normalizeModel = (model: any) => {
|
|
241
|
+
let _model = model;
|
|
242
|
+
if (_model) {
|
|
243
|
+
if (isString(_model)) {
|
|
244
|
+
_model = [_model, { verify: false }];
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
_model = ['', { verify: false }];
|
|
248
|
+
}
|
|
249
|
+
if (!_model[1]) {
|
|
250
|
+
_model[1] = { verify: false };
|
|
251
|
+
} else if (!Object.prototype.hasOwnProperty.call(_model[1], 'verify')) {
|
|
252
|
+
_model[1].verify = false;
|
|
253
|
+
}
|
|
254
|
+
return _model;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// 规范化数组配置,兼容旧版 array: 'field' 和 lists
|
|
258
|
+
const normalizeArray = (array: any) => {
|
|
259
|
+
if (isString(array)) return { child: [{ field: array }] };
|
|
260
|
+
if (!isObject(array)) return null;
|
|
261
|
+
return { ...array, child: array.child || array.lists || [] };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// 按字段配置生成一条新数据,用于数组新增行
|
|
265
|
+
const createRow = (list: any[] = [], getEmpty: (item: any) => any, preset: any = {}) => {
|
|
266
|
+
const row: any = {};
|
|
267
|
+
list.forEach((item: any = {}) => {
|
|
268
|
+
if (!isObject(item)) return;
|
|
269
|
+
const target = resolvePath(row, item.paths);
|
|
270
|
+
if (isArray(item.child) && !item.array) {
|
|
271
|
+
if (item.field) {
|
|
272
|
+
target[item.field] = createRow(item.child, getEmpty);
|
|
273
|
+
} else {
|
|
274
|
+
Object.assign(target, createRow(item.child, getEmpty));
|
|
275
|
+
}
|
|
276
|
+
} else if (item.field) {
|
|
277
|
+
target[item.field] = getEmpty(item);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
return { ...row, ...deepcopy(isFunction(preset) ? preset() : preset) };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// 与组件内一致的校验:有值时跑 validator,无值时只看 required
|
|
284
|
+
const checkRules = async (value: any, rules: FormRule[] = []) => {
|
|
285
|
+
if (!isArray(rules)) return null;
|
|
286
|
+
if (!isEmpty(value)) {
|
|
287
|
+
for (const rule of rules) {
|
|
288
|
+
if (rule && isFunction(rule.validator) && !(await rule.validator!(value))) {
|
|
289
|
+
return { msg: rule.msg || '', type: rule.type || 'risk' };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const rule = rules.find((item) => item?.required);
|
|
295
|
+
return rule ? { msg: rule.msg || '', type: rule.type || 'risk' } : null;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const isPlain = (value: any) => isObject(value) && !isArray(value);
|
|
299
|
+
|
|
300
|
+
// 原地重置:保留嵌套对象的引用(分组绑定的是对象本身),快照里没有的字段置为同类型空值
|
|
301
|
+
const assignDeep = (target: any, source: any) => {
|
|
302
|
+
if (!isPlain(target)) return;
|
|
303
|
+
const keys = new Set([...Object.keys(target), ...Object.keys(source || {})]);
|
|
304
|
+
keys.forEach((key) => {
|
|
305
|
+
const from = source ? source[key] : undefined;
|
|
306
|
+
const to = target[key];
|
|
307
|
+
if (isPlain(to) && (isPlain(from) || from === undefined)) {
|
|
308
|
+
assignDeep(to, from || {});
|
|
309
|
+
} else if (source && Object.prototype.hasOwnProperty.call(source, key)) {
|
|
310
|
+
target[key] = deepcopy(from);
|
|
311
|
+
} else {
|
|
312
|
+
target[key] = emptyOf(to);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
interface LinkageOptions {
|
|
318
|
+
// 按下标取当前渲染的字段
|
|
319
|
+
target: (idx: number) => any;
|
|
320
|
+
// 联动上下文:根数据、当前层数据、行下标、路由等
|
|
321
|
+
context: () => FormContext;
|
|
239
322
|
}
|
|
240
323
|
|
|
324
|
+
// 带 setup 或 watch 的对象才是联动配置,attrs 等普通对象原样保留
|
|
325
|
+
const isLinkage = (val: any) => isObject(val) && (isFunction(val.setup) || isArray(val.watch) || val.watch === true);
|
|
326
|
+
|
|
327
|
+
// 动态属性:函数或 { value, setup, watch },监听字段变化时重新计算,支持 Promise
|
|
328
|
+
const useLinkage = ({ target: getTarget, context }: LinkageOptions) => {
|
|
329
|
+
// 结构变化时重建字段监听;普通字段变化只触发对应的联动任务
|
|
330
|
+
const unwatchs: Array<() => void> = [];
|
|
331
|
+
const pendingRuns = new Map<LinkageTask, () => void>();
|
|
332
|
+
let generation = 0;
|
|
333
|
+
const stop = () => {
|
|
334
|
+
generation += 1;
|
|
335
|
+
pendingRuns.forEach(finish => finish());
|
|
336
|
+
pendingRuns.clear();
|
|
337
|
+
unwatchs.splice(0).forEach((unwatch) => {
|
|
338
|
+
unwatch();
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// 传给联动函数的根数据是只读的,联动只改字段配置,不改数据
|
|
343
|
+
const call = (setup: any, item: any, used?: () => void) => {
|
|
344
|
+
const ctx = context();
|
|
345
|
+
const track = (data: any) => new Proxy(readonly(data), {
|
|
346
|
+
get(target, key, receiver) { used?.(); return Reflect.get(target, key, receiver); },
|
|
347
|
+
ownKeys(target) { used?.(); return Reflect.ownKeys(target); },
|
|
348
|
+
has(target, key) { used?.(); return Reflect.has(target, key); },
|
|
349
|
+
});
|
|
350
|
+
// 统一回调协议,不通过参数个数猜测用途。path 保持已发布版路由含义。
|
|
351
|
+
const scope = new Proxy(ctx, {
|
|
352
|
+
get: (target, key) => key === 'path' ? ctx.route?.path : key === 'formPath' ? ctx.path
|
|
353
|
+
: key === 'root' || key === 'data' ? track(ctx[key])
|
|
354
|
+
: key in target ? Reflect.get(target, key) : Reflect.get(ctx.route || {}, key),
|
|
355
|
+
has: (target, key) => key === 'formPath' || key in target || key in (ctx.route || {}),
|
|
356
|
+
ownKeys: target => [...new Set([...Reflect.ownKeys(ctx.route || {}), ...Reflect.ownKeys(target), 'formPath'])],
|
|
357
|
+
getOwnPropertyDescriptor: (target, key) => key === 'formPath' || key in target || key in (ctx.route || {})
|
|
358
|
+
? { enumerable: true, configurable: true } : undefined,
|
|
359
|
+
});
|
|
360
|
+
return setup(track(ctx.root), item, scope, ctx.router);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// 解析字段上的动态属性,写入初始值并收集任务
|
|
364
|
+
const resolve = (item: any, idx: number, tasks: LinkageTask[]) => {
|
|
365
|
+
for (const key in item) {
|
|
366
|
+
// 假值(false、0、'')原样保留,不能当作联动配置
|
|
367
|
+
let val = item[key];
|
|
368
|
+
let hasInitialResult = false;
|
|
369
|
+
let initialResult: any;
|
|
370
|
+
// 处理函数
|
|
371
|
+
if (isFunction(val)) {
|
|
372
|
+
const fn = val;
|
|
373
|
+
let usesData = false;
|
|
374
|
+
const version = shallowRef(0);
|
|
375
|
+
unwatchs.push(watch(() => context().root, () => {
|
|
376
|
+
if (usesData) version.value++;
|
|
377
|
+
}, { deep: true, flush: 'sync' }));
|
|
378
|
+
// 初次解析与监听共用计算结果,避免异步函数被重复调用。
|
|
379
|
+
const evaluated = computed(() => {
|
|
380
|
+
version.value;
|
|
381
|
+
return call(fn, reactive(item), () => { usesData = true; });
|
|
382
|
+
});
|
|
383
|
+
val = evaluated.value;
|
|
384
|
+
if (isLinkage(val)) {
|
|
385
|
+
// 函数返回联动配置但没写 setup 时,之后每次重新执行函数并取它的 value
|
|
386
|
+
if (!isFunction(val.setup)) {
|
|
387
|
+
val.setup = (_data: any, current: any) => {
|
|
388
|
+
const res = call(fn, current);
|
|
389
|
+
return isLinkage(res) ? res.value : res;
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// 未声明 watch 时监听全部数据
|
|
393
|
+
if (!isArray(val.watch)) {
|
|
394
|
+
val.watch = true;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
hasInitialResult = true;
|
|
398
|
+
initialResult = val;
|
|
399
|
+
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (isLinkage(val)) {
|
|
403
|
+
const { value = null, setup = () => {}, watch: _watch = [] } = val || {};
|
|
404
|
+
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult });
|
|
405
|
+
item[key] = value;
|
|
406
|
+
} else {
|
|
407
|
+
item[key] = val;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// 执行任务:不声明 watch 的只执行一次,其余按监听字段重新计算
|
|
413
|
+
const start = (tasks: LinkageTask[]) => {
|
|
414
|
+
const current = generation;
|
|
415
|
+
const taskRuns = new Map<LinkageTask, number>();
|
|
416
|
+
const runsetup = (task: LinkageTask) => {
|
|
417
|
+
if (current !== generation) return;
|
|
418
|
+
const { idx, key, value, setup } = task;
|
|
419
|
+
const target = getTarget(idx);
|
|
420
|
+
if (target && isFunction(setup)) {
|
|
421
|
+
pendingRuns.get(task)?.();
|
|
422
|
+
const runId = (taskRuns.get(task) || 0) + 1;
|
|
423
|
+
taskRuns.set(task, runId);
|
|
424
|
+
const v = task.hasInitialResult ? task.initialResult : call(setup, target);
|
|
425
|
+
task.hasInitialResult = false;
|
|
426
|
+
const isCurrent = () => current === generation && getTarget(idx) === target && taskRuns.get(task) === runId;
|
|
427
|
+
const apply = (result: any) => {
|
|
428
|
+
shared.revision++;
|
|
429
|
+
target[key] = key === 'model' ? normalizeModel(result) : result;
|
|
430
|
+
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
431
|
+
if (key === 'child' || key === 'array') initializeField(target);
|
|
432
|
+
};
|
|
433
|
+
if (v instanceof Promise) {
|
|
434
|
+
nextTick(() => { if (isCurrent()) apply(value); });
|
|
435
|
+
let release!: () => void;
|
|
436
|
+
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
437
|
+
const finish = () => {
|
|
438
|
+
shared.pending.delete(pending);
|
|
439
|
+
if (pendingRuns.get(task) === finish) pendingRuns.delete(task);
|
|
440
|
+
release();
|
|
441
|
+
};
|
|
442
|
+
pendingRuns.set(task, finish);
|
|
443
|
+
shared.pending.add(pending);
|
|
444
|
+
v.then(async (res: any) => {
|
|
445
|
+
await nextTick();
|
|
446
|
+
if (isCurrent()) apply(res);
|
|
447
|
+
}).catch((error: unknown) => {
|
|
448
|
+
if (isCurrent()) console.warn(`[FormLoader] ${key} 联动失败`, error);
|
|
449
|
+
}).finally(finish);
|
|
450
|
+
} else nextTick(() => { if (isCurrent()) apply(v); });
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
// 点路径从根数据读取,'.' 开头从当前层读取
|
|
454
|
+
const read = (key: string) => {
|
|
455
|
+
const ctx = context();
|
|
456
|
+
return key.startsWith('.') ? readPath(ctx.data, key.slice(1)) : readPath(ctx.root, key);
|
|
457
|
+
};
|
|
458
|
+
const once = (task: LinkageTask) => Array.isArray(task.watch) && !task.watch.length;
|
|
459
|
+
tasks.filter(once).forEach((task) => {
|
|
460
|
+
runsetup(task);
|
|
461
|
+
});
|
|
462
|
+
// 每个动态属性只建立一个 watcher,多个依赖在同一轮变化时只执行一次
|
|
463
|
+
tasks
|
|
464
|
+
.filter((task) => !once(task))
|
|
465
|
+
.forEach((task) => {
|
|
466
|
+
const { watch: keys } = task;
|
|
467
|
+
if (keys === true) {
|
|
468
|
+
task.hasInitialResult = false;
|
|
469
|
+
unwatchs.push(watchEffect(() => {
|
|
470
|
+
deepcopy(context().root);
|
|
471
|
+
runsetup(task);
|
|
472
|
+
}));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
unwatchs.push(
|
|
476
|
+
watch(
|
|
477
|
+
() => keys.map((key: string) => read(key)),
|
|
478
|
+
() => {
|
|
479
|
+
runsetup(task);
|
|
480
|
+
},
|
|
481
|
+
{ deep: true, immediate: true },
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
return { stop, resolve, start };
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const route = useRoute();
|
|
491
|
+
const router = useRouter();
|
|
492
|
+
const attrs = useAttrs();
|
|
493
|
+
const slots = useSlots();
|
|
494
|
+
// 外部写了 class 时去掉默认根 class,由外部接管
|
|
495
|
+
const rootClass = computed(() => (attrs.class ? undefined : 'n-sl-sub'));
|
|
496
|
+
|
|
241
497
|
// 入参
|
|
242
498
|
const props: any = defineProps({
|
|
243
499
|
// 用户是否交互
|
|
@@ -247,8 +503,8 @@
|
|
|
247
503
|
},
|
|
248
504
|
// JSON规则
|
|
249
505
|
form: {
|
|
250
|
-
type:
|
|
251
|
-
default: () =>
|
|
506
|
+
type: Array,
|
|
507
|
+
default: () => [],
|
|
252
508
|
},
|
|
253
509
|
// 表单数据
|
|
254
510
|
data: {
|
|
@@ -275,197 +531,500 @@
|
|
|
275
531
|
type: [String, Array, Object],
|
|
276
532
|
default: '',
|
|
277
533
|
},
|
|
534
|
+
// 统一的label宽度:数字为 em,'auto' 为自适应,不传时按最长的 label 计算
|
|
535
|
+
labelWidth: {
|
|
536
|
+
type: [Number, String],
|
|
537
|
+
default: undefined,
|
|
538
|
+
},
|
|
278
539
|
// 是否垂直排列
|
|
279
540
|
vertical: {
|
|
280
541
|
type: Boolean,
|
|
281
542
|
default: false,
|
|
282
543
|
},
|
|
544
|
+
// edit 编辑 | view 查看
|
|
545
|
+
mode: {
|
|
546
|
+
type: String,
|
|
547
|
+
default: 'edit',
|
|
548
|
+
},
|
|
549
|
+
// 调试:字段容器带上 data-path 和 title,重建时在控制台输出解析后的配置
|
|
550
|
+
debug: {
|
|
551
|
+
type: Boolean,
|
|
552
|
+
default: false,
|
|
553
|
+
},
|
|
554
|
+
// 以下由分组和数组内部传递
|
|
555
|
+
// 根表单数据,联动函数和 watch 都基于它
|
|
556
|
+
root: {
|
|
557
|
+
type: Object as any,
|
|
558
|
+
default: null,
|
|
559
|
+
},
|
|
560
|
+
// 当前层在根数据中的路径
|
|
561
|
+
path: {
|
|
562
|
+
type: String,
|
|
563
|
+
default: '',
|
|
564
|
+
},
|
|
565
|
+
arrayContext: { type: Object as () => FormArrayContext, default: undefined },
|
|
566
|
+
clearContext: { type: Object as () => { value: boolean; restore: boolean }, default: undefined },
|
|
567
|
+
// 数组行下标
|
|
568
|
+
index: {
|
|
569
|
+
type: Number,
|
|
570
|
+
default: undefined,
|
|
571
|
+
},
|
|
283
572
|
});
|
|
284
573
|
|
|
574
|
+
// 各层级共享:Label 宽度、校验状态、隐藏字段暂存、已挂载的表单实例
|
|
575
|
+
interface Shared {
|
|
576
|
+
state: { lw: number; validated: boolean };
|
|
577
|
+
cache: ClearCache;
|
|
578
|
+
instances: Set<{ collect: () => any[] }>;
|
|
579
|
+
snapshot: any;
|
|
580
|
+
initialObjects: WeakMap<object, any>;
|
|
581
|
+
pending: Set<Promise<any>>;
|
|
582
|
+
revision: number;
|
|
583
|
+
validationEpoch: number;
|
|
584
|
+
cancelChecks: Set<() => void>;
|
|
585
|
+
validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
|
|
586
|
+
}
|
|
587
|
+
const FORM_SHARED = Symbol.for('neatui:form-loader');
|
|
588
|
+
const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
|
|
589
|
+
state: reactive({ lw: 6, validated: false }),
|
|
590
|
+
cache: new WeakMap(),
|
|
591
|
+
instances: new Set(),
|
|
592
|
+
snapshot: null,
|
|
593
|
+
initialObjects: new WeakMap(),
|
|
594
|
+
pending: new Set(),
|
|
595
|
+
revision: 0,
|
|
596
|
+
validationEpoch: 0,
|
|
597
|
+
cancelChecks: new Set(),
|
|
598
|
+
validations: new WeakMap(),
|
|
599
|
+
};
|
|
600
|
+
provide(FORM_SHARED, shared);
|
|
601
|
+
|
|
602
|
+
const rootData = computed(() => props.root || props.data);
|
|
603
|
+
const cancelValidation = () => {
|
|
604
|
+
shared.validationEpoch++;
|
|
605
|
+
shared.cancelChecks.forEach(cancel => cancel());
|
|
606
|
+
shared.cancelChecks.clear();
|
|
607
|
+
};
|
|
608
|
+
// 将初始对象与快照对象对应;异步补出的默认值写入快照,用户后来新增的行不写入。
|
|
609
|
+
const cloneInitial = (value: any): any => {
|
|
610
|
+
if (!value || typeof value !== 'object' || value instanceof Date) return deepcopy(value);
|
|
611
|
+
const raw = toRaw(value);
|
|
612
|
+
const copy: any = isArray(value) ? [] : {};
|
|
613
|
+
shared.initialObjects.set(raw, copy);
|
|
614
|
+
Object.keys(value).forEach(key => { copy[key] = cloneInitial(value[key]); });
|
|
615
|
+
return copy;
|
|
616
|
+
};
|
|
617
|
+
const captureInitial = () => {
|
|
618
|
+
shared.initialObjects = new WeakMap();
|
|
619
|
+
shared.snapshot = cloneInitial(props.data);
|
|
620
|
+
shared.cache = new WeakMap();
|
|
621
|
+
cancelValidation();
|
|
622
|
+
};
|
|
623
|
+
const recordInitial = (data: any, key: string) => {
|
|
624
|
+
const initial = shared.initialObjects.get(toRaw(data));
|
|
625
|
+
if (initial) initial[key] = cloneInitial(data[key]);
|
|
626
|
+
};
|
|
627
|
+
if (!props.root) captureInitial();
|
|
628
|
+
watch(() => props.data, () => {
|
|
629
|
+
shared.revision++;
|
|
630
|
+
}, { deep: true, flush: 'sync' });
|
|
631
|
+
const editable = computed(() => props.mode !== 'view');
|
|
632
|
+
// 调用过 validate 后所有字段展示校验状态
|
|
633
|
+
const touched = computed(() => props.interacted || shared.state.validated);
|
|
634
|
+
|
|
635
|
+
const arrayContextProp = computed(() => props.arrayContext);
|
|
636
|
+
|
|
637
|
+
// 联动上下文
|
|
638
|
+
const context = (): FormContext => ({ root: rootData.value, data: props.data, index: props.index, array: props.arrayContext, path: props.path, mode: props.mode, route, router });
|
|
639
|
+
|
|
640
|
+
// value 保留原始值兼容已有插槽;bind.modelValue 使用显示转换后的值。
|
|
641
|
+
const fieldScope = (col: any) => {
|
|
642
|
+
const ctx = context();
|
|
643
|
+
const update = (value: any) => {
|
|
644
|
+
if (!editable.value || !col.field) return;
|
|
645
|
+
col._touched = true;
|
|
646
|
+
writeValue(col, value, context());
|
|
647
|
+
eventsOf(col, context())['update:modelValue']?.(value);
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
col, node: col, data: col._data, mode: props.mode, value: col._data?.[col.field], ctx,
|
|
651
|
+
update,
|
|
652
|
+
bind: { modelValue: displayOf(col, ctx), 'onUpdate:modelValue': update, disabled: !editable.value },
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// 字段未设置 vertical 时跟随表单
|
|
657
|
+
const isVertical = (col: any = {}) => (col.vertical === undefined || col.vertical === null ? props.vertical : !!col.vertical);
|
|
658
|
+
const isGroup = (col: any = {}) => (col._kind === 'child' || isArray(col.child)) && !col._array;
|
|
659
|
+
const isCleared = (col: any) => !!props.clearContext?.value || (isObject(col.clear) ? !!col.clear.value : !!col.clear);
|
|
660
|
+
const canRestore = (col: any) => props.clearContext?.restore !== false && !(isObject(col.clear) && col.clear.restore === false);
|
|
661
|
+
const hasSlot = (name: string) => !!slots[name];
|
|
662
|
+
const showStar = (col: any = {}) => editable.value && rulesOf(col).some((rule: any) => rule?.required);
|
|
663
|
+
|
|
285
664
|
// label展示文案,支持全局/单个字段开关冒号
|
|
286
665
|
const formatLabel = (col: any = {}) => {
|
|
287
|
-
const
|
|
666
|
+
const label = labelText(col.label);
|
|
288
667
|
if (!label) return '';
|
|
289
668
|
if (/[::]\s*$/.test(label)) return label;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (localColon === true) return ':';
|
|
294
|
-
if (isString(props.colon)) return props.colon;
|
|
295
|
-
return props.colon ? ':' : '';
|
|
296
|
-
})();
|
|
669
|
+
// 自定义节点树和标签属性可以直接决定呈现;默认文本标签跟随表单冒号。
|
|
670
|
+
if ((isGroup(col) || col._array || col.array) && isVertical(col)) return label;
|
|
671
|
+
const suffix = isString(props.colon) ? props.colon : props.colon ? ':' : '';
|
|
297
672
|
return suffix ? `${label}${suffix}` : label;
|
|
298
673
|
};
|
|
674
|
+
// 标签插槽:${field}-label > array-label / child-label > label
|
|
675
|
+
const labelSlot = (col: any = {}) => {
|
|
676
|
+
const names = [col.field && `${col.field}-label`, col._array && 'array-label', isGroup(col) && 'child-label', 'label'];
|
|
677
|
+
return (names.find((name) => name && hasSlot(name)) as string) || '';
|
|
678
|
+
};
|
|
679
|
+
// 整行插槽
|
|
680
|
+
const rowSlot = (col: any = {}) => (col.field && hasSlot(`${col.field}-row`) ? `${col.field}-row` : '');
|
|
681
|
+
|
|
682
|
+
// 表单默认标签宽度;单个标签用 label[1].style 覆盖。
|
|
683
|
+
const widthOf = (value: any) => {
|
|
684
|
+
if (value === 'auto') return '';
|
|
685
|
+
const n = Number(value);
|
|
686
|
+
return `width:${value !== undefined && value !== '' && !isNaN(n) ? n : shared.state.lw}em; max-width:50%;`;
|
|
687
|
+
};
|
|
688
|
+
const labelStyle = (col: any = {}) => widthOf(props.labelWidth);
|
|
689
|
+
const submitStyle = computed(() => {
|
|
690
|
+
const style = widthOf(props.labelWidth);
|
|
691
|
+
return style ? style.replace(/^width:([^;]+);.*$/, 'padding-left:$1;') : '';
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// 分组、数组的路径和 id 前缀
|
|
695
|
+
const fieldPath = (col: any = {}) => [props.path, col.paths, col.field].filter(Boolean).join('.');
|
|
696
|
+
const childIdx = (col: any = {}, idx: number) => [props.idx, col.field || `g${idx}`].filter((v: any) => v !== '').join('-');
|
|
697
|
+
const childData = (col: any = {}) => (col.field ? col._data[col.field] : col._data);
|
|
698
|
+
const listOf = (col: any = {}) => (isArray(col._data?.[col.field]) ? col._data[col.field] : []);
|
|
699
|
+
|
|
700
|
+
// submit 只在最外层渲染,其余插槽透传给分组和数组
|
|
701
|
+
const childSlots = () => Object.keys(slots).filter((name) => name !== 'submit');
|
|
702
|
+
|
|
703
|
+
// 控件:已注册的 model;没写 model 且没有对应插槽时默认 Input
|
|
704
|
+
const resolveEntry = (col: any = {}) => {
|
|
705
|
+
const model = col.model?.[0];
|
|
706
|
+
if (model) return getField(model);
|
|
707
|
+
if (col.field && hasSlot(col.field)) return undefined;
|
|
708
|
+
return getField(col.field ? 'Input' : 'Text');
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// 字段缺失时的初始值:default > 数组 [] > 分组 {} > 控件空值
|
|
712
|
+
const initialOf = (item: any = {}) => {
|
|
713
|
+
if (valueBinding(item.value) && item.value.default !== undefined) return deepcopy(item.value.default);
|
|
714
|
+
if (item.array) return [];
|
|
715
|
+
if (isArray(item.child)) return {};
|
|
716
|
+
return emptyValueOf(isString(item.model) || isArray(item.model) ? normalizeModel(item.model) : ['']);
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// 数组
|
|
720
|
+
const rowKeys = new WeakMap<object, number>();
|
|
721
|
+
let rowSeq = 0;
|
|
722
|
+
// 行的稳定 key,不往数据里写额外字段
|
|
723
|
+
const rowKey = (row: any) => {
|
|
724
|
+
if (!isObject(row)) return row;
|
|
725
|
+
if (!rowKeys.has(row)) rowKeys.set(row, (rowSeq += 1));
|
|
726
|
+
return rowKeys.get(row);
|
|
727
|
+
};
|
|
728
|
+
const newRow = (col: any) => createRow(col._array.child, initialOf, col._array.item);
|
|
729
|
+
const validIndex = (list: any[], i: number) => Number.isInteger(i) && i >= 0 && i < list.length;
|
|
730
|
+
const canAdd = (col: any) => editable.value && (!col._array.max || listOf(col).length < col._array.max);
|
|
731
|
+
const insertRow = (col: any, i: number) => {
|
|
732
|
+
const list = listOf(col);
|
|
733
|
+
if (!canAdd(col) || !Number.isInteger(i) || i < 0 || i > list.length) return;
|
|
734
|
+
col._touched = true;
|
|
735
|
+
list.splice(i, 0, newRow(col));
|
|
736
|
+
};
|
|
737
|
+
const addRow = (col: any) => insertRow(col, listOf(col).length);
|
|
738
|
+
const removeRow = (col: any, i: number) => {
|
|
739
|
+
const list = listOf(col);
|
|
740
|
+
if (!editable.value || !validIndex(list, i) || list.length <= (col._array.min || 0)) return;
|
|
741
|
+
col._touched = true;
|
|
742
|
+
list.splice(i, 1);
|
|
743
|
+
};
|
|
744
|
+
const moveRow = (col: any, i: number, step: number) => {
|
|
745
|
+
const list = listOf(col);
|
|
746
|
+
const j = i + step;
|
|
747
|
+
if (!editable.value || !validIndex(list, i) || !validIndex(list, j)) return;
|
|
748
|
+
const [row] = list.splice(i, 1);
|
|
749
|
+
list.splice(j, 0, row);
|
|
750
|
+
};
|
|
751
|
+
// 每次调用按行身份取下标,避免插入、排序或异步事件后误操作其他行。
|
|
752
|
+
const arrayContext = (col: any, row?: any): FormArrayContext => {
|
|
753
|
+
const ctx: FormArrayContext = {
|
|
754
|
+
get list() { return listOf(col); },
|
|
755
|
+
get row() { return row; },
|
|
756
|
+
get index() { return row === undefined ? -1 : listOf(col).indexOf(row); },
|
|
757
|
+
get canAdd() { return canAdd(col); },
|
|
758
|
+
get canRemove() { return editable.value && this.index >= 0 && this.list.length > (col._array.min || 0); },
|
|
759
|
+
get canMoveUp() { return editable.value && this.index > 0; },
|
|
760
|
+
get canMoveDown() { return editable.value && this.index >= 0 && this.index < this.list.length - 1; },
|
|
761
|
+
add: () => addRow(col),
|
|
762
|
+
insertAfter(i?: number) {
|
|
763
|
+
const index = i ?? ctx.index;
|
|
764
|
+
if (validIndex(ctx.list, index)) insertRow(col, index + 1);
|
|
765
|
+
},
|
|
766
|
+
remove() { removeRow(col, ctx.index); },
|
|
767
|
+
moveUp() { moveRow(col, ctx.index, -1); },
|
|
768
|
+
moveDown() { moveRow(col, ctx.index, 1); },
|
|
769
|
+
};
|
|
770
|
+
return ctx;
|
|
771
|
+
};
|
|
772
|
+
const rowScope = (col: any, i: number) => Object.assign(arrayContext(col, listOf(col)[i]), { col });
|
|
299
773
|
|
|
300
774
|
// 数据
|
|
301
775
|
const state: any = reactive({
|
|
302
776
|
form: [],
|
|
303
|
-
_lw: 6,
|
|
304
777
|
});
|
|
305
778
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
state._lw = _w;
|
|
779
|
+
const linkage = useLinkage({ target: (idx) => state.form[idx], context });
|
|
780
|
+
|
|
781
|
+
// 初始结构和异步生成的子树都经过相同的数据初始化。
|
|
782
|
+
const initializeField = (item: any) => {
|
|
783
|
+
if (!item.field || !item._data) return;
|
|
784
|
+
const target = item._data;
|
|
785
|
+
let changed = false;
|
|
786
|
+
if (item._array) {
|
|
787
|
+
if (!isArray(target[item.field])) {
|
|
788
|
+
const initial = initialOf(item);
|
|
789
|
+
target[item.field] = isArray(initial) ? initial : [];
|
|
790
|
+
changed = true;
|
|
791
|
+
}
|
|
792
|
+
while (target[item.field].length < (item._array.min || 0)) { target[item.field].push(newRow(item)); changed = true; }
|
|
793
|
+
} else if (isGroup(item)) {
|
|
794
|
+
if (!isObject(target[item.field]) || isArray(target[item.field])) {
|
|
795
|
+
const initial = initialOf(item);
|
|
796
|
+
target[item.field] = isObject(initial) && !isArray(initial) ? initial : {};
|
|
797
|
+
changed = true;
|
|
326
798
|
}
|
|
799
|
+
} else if (target[item.field] === undefined) {
|
|
800
|
+
target[item.field] = initialOf(item);
|
|
801
|
+
changed = true;
|
|
802
|
+
}
|
|
803
|
+
if (changed) recordInitial(target, item.field);
|
|
804
|
+
};
|
|
327
805
|
|
|
806
|
+
// 结构变化时重建字段和联动
|
|
807
|
+
const rebuild = (form: any = []) => {
|
|
808
|
+
shared.revision++;
|
|
809
|
+
linkage.stop();
|
|
810
|
+
const tasks: LinkageTask[] = [];
|
|
811
|
+
const fields = new Set<string>();
|
|
812
|
+
state.form = (deepcopy(form) || []).map((item: any = {}, idx: number) => {
|
|
813
|
+
item._kind = item.array ? 'array' : Object.prototype.hasOwnProperty.call(item, 'child') ? 'child' : 'field';
|
|
328
814
|
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
|
-
}
|
|
815
|
+
linkage.resolve(item, idx, tasks);
|
|
365
816
|
}
|
|
366
817
|
|
|
818
|
+
item._array = item.array ? normalizeArray(item.array) : item._kind === 'array' ? { child: [] } : null;
|
|
819
|
+
|
|
367
820
|
// 模型处理
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
821
|
+
item.model = normalizeModel(item.model || (!item.field && !item.child && !item.array ? 'Text' : undefined));
|
|
822
|
+
|
|
823
|
+
// 计算Label宽度:必填星号 + 文案(含冒号)+ 右边距
|
|
824
|
+
if (!isVertical(item)) {
|
|
825
|
+
const required = rulesOf(item).some((rule: any) => rule?.required);
|
|
826
|
+
const _w = (required ? 1 : 0) + clw(formatLabel(item)) + 1;
|
|
827
|
+
if (_w > shared.state.lw) {
|
|
828
|
+
shared.state.lw = _w;
|
|
371
829
|
}
|
|
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
830
|
}
|
|
378
831
|
|
|
379
832
|
// 添加ID
|
|
380
|
-
item._fid = `form-${props.idx !== '' ? props.idx + '-' : ''}field-${item.field}`;
|
|
833
|
+
item._fid = `form-${props.idx !== '' ? props.idx + '-' : ''}${!item.field && isGroup(item) ? `group-${idx}` : `field-${item.field || `node-${idx}`}`}`;
|
|
381
834
|
|
|
382
835
|
// 数据路径处理
|
|
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
|
-
});
|
|
836
|
+
item._data = resolvePath(props.data, item.paths);
|
|
837
|
+
|
|
838
|
+
// 补全缺失的数据字段,重复字段给出提示
|
|
839
|
+
if (item.field) {
|
|
840
|
+
const key = `${item.paths || ''}.${item.field}`;
|
|
841
|
+
if (fields.has(key)) console.warn(`[FormLoader] 字段重复:${fieldPath(item)}`);
|
|
842
|
+
fields.add(key);
|
|
843
|
+
initializeField(item);
|
|
414
844
|
}
|
|
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
|
-
);
|
|
845
|
+
|
|
846
|
+
// 分组、数组默认占满一行
|
|
847
|
+
if ((isGroup(item) || item._array) && !item.media) item.media = 'mob-24';
|
|
848
|
+
return item;
|
|
431
849
|
});
|
|
850
|
+
linkage.start(tasks);
|
|
851
|
+
if (props.debug) console.debug(`[FormLoader] ${props.path || 'root'}`, deepcopy(toRaw(state.form)));
|
|
432
852
|
};
|
|
853
|
+
|
|
854
|
+
// 调试信息
|
|
855
|
+
const debugInfo = (col: any = {}) => [fieldPath(col) || '(group)', col.model?.[0] || (isGroup(col) ? 'child' : col._array ? 'array' : 'Input')].join(' · ');
|
|
433
856
|
watch(
|
|
434
857
|
() => props.form,
|
|
435
858
|
(form = []) => {
|
|
436
|
-
|
|
859
|
+
rebuild(form);
|
|
437
860
|
},
|
|
438
861
|
{ deep: true, immediate: true },
|
|
439
862
|
);
|
|
440
|
-
watch(
|
|
441
|
-
|
|
442
|
-
()
|
|
443
|
-
|
|
444
|
-
},
|
|
445
|
-
);
|
|
863
|
+
watch([() => props.data, () => props.idx], ([data], [previous]) => {
|
|
864
|
+
if (!props.root && data !== previous) captureInitial();
|
|
865
|
+
rebuild(props.form);
|
|
866
|
+
});
|
|
446
867
|
|
|
447
|
-
//
|
|
868
|
+
// 隐藏的字段清空数据并暂存,重新显示时还原(restore: false 时只清空)
|
|
869
|
+
// 只在「隐藏 → 显示」的那一次和首次运行时还原,避免和分组内的子表单来回争抢同一个值
|
|
870
|
+
// 分组重新显示时由重新挂载的子表单自己还原
|
|
871
|
+
const hiddenFields = new Set<string>();
|
|
872
|
+
let firstRun = true;
|
|
448
873
|
watch(
|
|
449
|
-
[() => state.form, () => props.data],
|
|
874
|
+
[() => state.form, () => props.data, () => props.clearContext],
|
|
450
875
|
() => {
|
|
451
876
|
state.form.forEach((item: any = {}) => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
});
|
|
461
|
-
_data[field] = isArray(_data[field]) ? [] : '';
|
|
877
|
+
if (isGroup(item)) return; // 保留递归实例,由解析后的子节点各自清空和恢复。
|
|
878
|
+
const cache = canRestore(item) ? shared.cache : undefined;
|
|
879
|
+
if (isCleared(item)) {
|
|
880
|
+
hiddenFields.add(item._fid);
|
|
881
|
+
emptyField(resolvePath(props.data, item.paths), item.field, cache);
|
|
882
|
+
} else if (hiddenFields.has(item._fid) || firstRun) {
|
|
883
|
+
hiddenFields.delete(item._fid);
|
|
884
|
+
if (cache) restoreField(resolvePath(props.data, item.paths), item.field, cache);
|
|
462
885
|
}
|
|
463
886
|
});
|
|
887
|
+
firstRun = false;
|
|
464
888
|
},
|
|
465
|
-
{ deep: true },
|
|
889
|
+
{ deep: true, immediate: true },
|
|
466
890
|
);
|
|
467
891
|
|
|
892
|
+
// 当前层可校验的字段(已显示、非分组)
|
|
893
|
+
const instance = {
|
|
894
|
+
collect: () =>
|
|
895
|
+
state.form
|
|
896
|
+
.filter((col: any) => !isCleared(col) && col.field && !isGroup(col))
|
|
897
|
+
.map((col: any) => ({ col, path: fieldPath(col), ctx: context() })),
|
|
898
|
+
};
|
|
899
|
+
onMounted(() => {
|
|
900
|
+
shared.instances.add(instance);
|
|
901
|
+
shared.revision++;
|
|
902
|
+
});
|
|
903
|
+
|
|
468
904
|
onBeforeUnmount(() => {
|
|
469
|
-
|
|
905
|
+
if (!props.root) cancelValidation();
|
|
906
|
+
shared.instances.delete(instance);
|
|
907
|
+
shared.revision++;
|
|
908
|
+
linkage.stop();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const fieldsOf = () => [...shared.instances].flatMap((item) => item.collect());
|
|
912
|
+
|
|
913
|
+
const sameInput = (a: any, b: any): boolean => {
|
|
914
|
+
if (Object.is(a, b)) return true;
|
|
915
|
+
if (a instanceof Date && b instanceof Date) return +a === +b;
|
|
916
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
917
|
+
const keys = Object.keys(a);
|
|
918
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
|
|
919
|
+
};
|
|
920
|
+
const validateField = (col: any, ctx: FormContext, force = false): Promise<any> => {
|
|
921
|
+
const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
|
|
922
|
+
const previous = shared.validations.get(col);
|
|
923
|
+
if (previous && previous.epoch === shared.validationEpoch && (!force || previous.pending) && sameInput(previous.input, input)) return previous.result;
|
|
924
|
+
const job = { input, epoch: shared.validationEpoch, pending: true, result: null as any };
|
|
925
|
+
shared.validations.set(col, job);
|
|
926
|
+
job.result = (async () => {
|
|
927
|
+
let error;
|
|
928
|
+
try {
|
|
929
|
+
const message = await resolveEntry(col)?.validate?.(input[0], input[2]?.[1] || {});
|
|
930
|
+
error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
|
|
931
|
+
} catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
|
|
932
|
+
job.pending = false;
|
|
933
|
+
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch) { col._error = error; col._checked = true; }
|
|
934
|
+
return error;
|
|
935
|
+
})();
|
|
936
|
+
return job.result;
|
|
937
|
+
};
|
|
938
|
+
watchEffect(() => {
|
|
939
|
+
for (const col of state.form) {
|
|
940
|
+
// 只读取校验输入,不监听内部错误结果;异步结果按执行顺序丢弃过期响应。
|
|
941
|
+
deepcopy(displayOf(col, context()));
|
|
942
|
+
deepcopy(rulesOf(col));
|
|
943
|
+
deepcopy(col.model);
|
|
944
|
+
if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
|
|
945
|
+
void validateField(col, context());
|
|
946
|
+
} else {
|
|
947
|
+
shared.validations.delete(col);
|
|
948
|
+
col._error = null;
|
|
949
|
+
col._checked = false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
470
952
|
});
|
|
953
|
+
|
|
954
|
+
// 按页面位置排序
|
|
955
|
+
const byPosition = (a: any, b: any) => {
|
|
956
|
+
if (typeof document === 'undefined') return 0;
|
|
957
|
+
const ea = document.getElementById(a.fid);
|
|
958
|
+
const eb = document.getElementById(b.fid);
|
|
959
|
+
if (!ea || !eb) return 0;
|
|
960
|
+
return ea.compareDocumentPosition(eb) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// 滚动到指定路径的字段,比如 'contact.phone'、'members.0.name'
|
|
964
|
+
const scrollToField = (path: string) => {
|
|
965
|
+
const found = fieldsOf().find((item) => item.path === path);
|
|
966
|
+
const el = found && typeof document !== 'undefined' ? document.getElementById(found.col._fid) : null;
|
|
967
|
+
if (!el) return;
|
|
968
|
+
// 通用定位事件:由配置中的 event.reveal 展开折叠区、切换标签页等。
|
|
969
|
+
el.dispatchEvent(new CustomEvent('reveal', { bubbles: true, detail: { path } }));
|
|
970
|
+
nextTick(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
// 校验所有已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid
|
|
974
|
+
const validate = async ({ scroll = false }: { scroll?: boolean } = {}): Promise<FormValidateResult> => {
|
|
975
|
+
const epoch = shared.validationEpoch;
|
|
976
|
+
const cancelledResult = (): FormValidateResult => ({ valid: false, errors: [{ field: '', path: '', label: '', msg: '表单已重置或切换,请重新校验', type: 'risk' }], warnings: [] });
|
|
977
|
+
let cancel!: () => void;
|
|
978
|
+
const cancelled = new Promise<FormValidateResult>(resolve => { cancel = () => resolve(cancelledResult()); });
|
|
979
|
+
shared.cancelChecks.add(cancel);
|
|
980
|
+
const run = async (): Promise<FormValidateResult> => {
|
|
981
|
+
shared.state.validated = true;
|
|
982
|
+
let results: any[];
|
|
983
|
+
let revision: number;
|
|
984
|
+
do {
|
|
985
|
+
// Vue 渲染、联动和异步子树稳定后,再收集可校验字段。
|
|
986
|
+
do {
|
|
987
|
+
revision = shared.revision;
|
|
988
|
+
await nextTick();
|
|
989
|
+
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
990
|
+
if (shared.pending.size) await Promise.all([...shared.pending]);
|
|
991
|
+
} while (revision !== shared.revision || shared.pending.size);
|
|
992
|
+
revision = shared.revision;
|
|
993
|
+
results = await Promise.all(fieldsOf().map(async ({ col, path, ctx }) => {
|
|
994
|
+
const res = await validateField(col, ctx, true);
|
|
995
|
+
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
996
|
+
}));
|
|
997
|
+
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
998
|
+
} while (revision !== shared.revision);
|
|
999
|
+
const found = (results.filter(Boolean) as any[]).sort(byPosition);
|
|
1000
|
+
const strip = ({ fid, ...rest }: any): FormError => rest;
|
|
1001
|
+
const errors = found.filter((item) => item.type !== 'warn');
|
|
1002
|
+
if (scroll && errors[0]) scrollToField(errors[0].path);
|
|
1003
|
+
return { valid: !errors.length, errors: errors.map(strip), warnings: found.filter((item) => item.type === 'warn').map(strip) };
|
|
1004
|
+
};
|
|
1005
|
+
try { return await Promise.race([run(), cancelled]); }
|
|
1006
|
+
finally { shared.cancelChecks.delete(cancel); }
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
// 还原到初始数据并清除校验状态
|
|
1010
|
+
const reset = () => {
|
|
1011
|
+
shared.cache = new WeakMap();
|
|
1012
|
+
if (shared.snapshot) assignDeep(rootData.value, shared.snapshot);
|
|
1013
|
+
const bindInitial = (data: any, initial: any) => {
|
|
1014
|
+
if (!data || !initial || typeof data !== 'object' || typeof initial !== 'object') return;
|
|
1015
|
+
shared.initialObjects.set(toRaw(data), initial);
|
|
1016
|
+
Object.keys(initial).forEach(key => bindInitial(data[key], initial[key]));
|
|
1017
|
+
};
|
|
1018
|
+
bindInitial(rootData.value, shared.snapshot);
|
|
1019
|
+
clearValidate();
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const clearValidate = () => {
|
|
1023
|
+
cancelValidation();
|
|
1024
|
+
shared.validations = new WeakMap();
|
|
1025
|
+
shared.state.validated = false;
|
|
1026
|
+
fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
defineExpose({ validate, reset, clearValidate, scrollToField });
|
|
471
1030
|
</script>
|