@neatui/vue 5.0.0 → 5.1.1
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 +17 -6
- package/package.json +2 -6
- package/src/components/basic/IRouterView.vue +1 -1
- package/src/components/form/Input.vue +1 -1
- package/src/components/form/InputNumber.vue +1 -1
- package/src/components/form/InputRange.vue +1 -1
- package/src/components/form/Select.vue +18 -24
- package/src/components/form/Textarea.vue +1 -1
- package/src/components/form/TimePicker.vue +1 -1
- package/src/components/form/index.ts +0 -2
- package/src/components/loader/FormLoader/FormLoader.vue +469 -201
- package/src/components/loader/FormLoader/FormRender.vue +149 -34
- package/src/components/loader/TableLoader/TableLoader.vue +233 -24
- package/src/components/loader/index.ts +1 -1
- package/src/components/form/RichTextInput.vue +0 -99
|
@@ -1,173 +1,143 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Level :bare="bare" v-bind="bare ? undefined : { 'ui-row': rows || undefined, class: rootClass }">
|
|
3
3
|
<template v-for="(col, idx) in state.form" :key="idx">
|
|
4
|
-
<
|
|
5
|
-
v-if="
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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>
|
|
4
|
+
<template v-if="isGroup(col) || !isCleared(col)">
|
|
5
|
+
<FormRender v-if="isBareContent(col)" bare :col="col" :mode="mode" :interacted="touched" :ctx="context()" v-bind="nodeAttrs(col)" />
|
|
6
|
+
|
|
7
|
+
<div v-else v-bind="nodeAttrs(col)" v-on="isGroup(col) || col._array ? eventsOf(col, context()) : {}">
|
|
8
|
+
<div v-if="rowSlot(col)" class="flex-block" @focusout="col._touched = true">
|
|
9
|
+
<slot :name="rowSlot(col)" v-bind="fieldScope(col)"></slot>
|
|
10
|
+
<FieldMessage :col="col" :mode="mode" />
|
|
11
|
+
</div>
|
|
61
12
|
|
|
62
|
-
|
|
63
|
-
|
|
13
|
+
<FormLoader v-else-if="isGroup(col) && !hasLabel(col)" v-bind="childProps(col)">
|
|
14
|
+
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
15
|
+
</FormLoader>
|
|
16
|
+
|
|
17
|
+
<template v-else>
|
|
64
18
|
<FormLoader
|
|
65
|
-
|
|
19
|
+
v-if="labelNodes(col.label)"
|
|
20
|
+
:form="labelNodes(col.label)"
|
|
66
21
|
:clearContext="{ value: isCleared(col), restore: canRestore(col) }"
|
|
67
|
-
:arrayContext="arrayContextProp"
|
|
68
|
-
:data="
|
|
22
|
+
:arrayContext="col._array ? arrayContext(col) : arrayContextProp"
|
|
23
|
+
:data="col._data"
|
|
69
24
|
:root="rootData"
|
|
70
|
-
:path="
|
|
25
|
+
:path="[path, col.paths].filter(Boolean).join('.')"
|
|
71
26
|
:index="index"
|
|
72
|
-
:idx="
|
|
73
|
-
:rows="col.attrs?.['ui-row'] ?? rows"
|
|
74
|
-
:style="col.attrs?.['ui-flex'] ? { display: 'contents' } : undefined"
|
|
27
|
+
:idx="baseIdx"
|
|
75
28
|
:mode="mode"
|
|
76
|
-
:interacted="
|
|
77
|
-
:colon="colon"
|
|
78
|
-
:labelClass="labelClass"
|
|
79
|
-
:labelWidth="labelWidth"
|
|
80
|
-
:vertical="vertical"
|
|
29
|
+
:interacted="touched"
|
|
81
30
|
:debug="debug"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
31
|
+
rows=""
|
|
32
|
+
class="w-full"
|
|
33
|
+
ui-flex="row lm"
|
|
34
|
+
/>
|
|
35
|
+
<h6 v-else-if="labelText(col.label) && isVertical(col)" :style="showStar(col) ? 'text-indent: -1em' : undefined" :class="labelClass || undefined" v-bind="labelAttrs(col.label)">
|
|
36
|
+
<slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
|
|
37
|
+
<template v-else-if="labelText(col.label) !== '*' && showStar(col)"><b class="co-risk pr" style="left: 0.1em">﹡</b>{{ formatLabel(col) }}</template>
|
|
38
|
+
<template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
|
|
39
|
+
</h6>
|
|
40
|
+
<p v-else-if="labelText(col.label)" :class="[labelClass, clh({ ...(col.model[1] || {}) })]" :style="labelStyle(col)" class="ar nr-sm flex-fixed o-ls" 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) !== '*' && showStar(col)"><b class="co-risk">﹡</b>{{ formatLabel(col) }}</template>
|
|
43
|
+
<template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
|
|
44
|
+
</p>
|
|
86
45
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
:interacted="interacted"
|
|
103
|
-
:colon="colon"
|
|
104
|
-
:labelClass="labelClass"
|
|
105
|
-
:labelWidth="labelWidth"
|
|
106
|
-
:vertical="vertical"
|
|
107
|
-
:debug="debug"
|
|
108
|
-
>
|
|
46
|
+
<div v-if="isGroup(col)" v-bind="boxAttrs(col)">
|
|
47
|
+
<FormLoader v-bind="childProps(col)">
|
|
48
|
+
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
49
|
+
</FormLoader>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div v-else-if="col._array" class="flex-block">
|
|
53
|
+
<div v-if="listOf(col).length" ui-row>
|
|
54
|
+
<div v-for="(row, i) in listOf(col)" :key="rowKey(row)" v-bind="rowAttrs(col)">
|
|
55
|
+
<div v-if="hasActions(col)" v-bind="rowBox(col)">
|
|
56
|
+
<FormLoader v-bind="rowProps(col, row, i)">
|
|
57
|
+
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
58
|
+
</FormLoader>
|
|
59
|
+
</div>
|
|
60
|
+
<FormLoader v-else v-bind="rowProps(col, row, i)">
|
|
109
61
|
<template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
|
|
110
62
|
</FormLoader>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
63
|
+
<div v-if="hasActions(col)" class="flex-fixed nl-sm" ui-flex="row lm">
|
|
64
|
+
<slot v-if="hasSlot(`${col.field}-actions`)" :name="`${col.field}-actions`" v-bind="rowScope(col, i)"></slot>
|
|
65
|
+
<template v-else>
|
|
66
|
+
<template v-if="col._array.sortable">
|
|
67
|
+
<button ui-btn="@a s" type="button" :disabled="i === 0" @click="moveRow(col, i, -1)">上移</button>
|
|
68
|
+
<button ui-btn="@a s" type="button" class="ml-xs" :disabled="i === listOf(col).length - 1" @click="moveRow(col, i, 1)">下移</button>
|
|
69
|
+
</template>
|
|
70
|
+
<button
|
|
71
|
+
v-if="col._array.del !== false"
|
|
72
|
+
ui-btn="@a s risk"
|
|
73
|
+
type="button"
|
|
74
|
+
class="ml-xs"
|
|
75
|
+
:disabled="listOf(col).length <= (col._array.min || 0)"
|
|
76
|
+
@click="removeRow(col, i)"
|
|
77
|
+
>
|
|
78
|
+
{{ col._array.del || '删除' }}
|
|
79
|
+
</button>
|
|
118
80
|
</template>
|
|
119
|
-
|
|
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>
|
|
81
|
+
</div>
|
|
130
82
|
</div>
|
|
131
83
|
</div>
|
|
84
|
+
<template v-if="editable && col._array.add !== false">
|
|
85
|
+
<slot v-if="hasSlot(`${col.field}-add`)" :name="`${col.field}-add`" :col="col" :list="listOf(col)" :add="() => addRow(col)"></slot>
|
|
86
|
+
<button v-else ui-btn="@a s" type="button" :disabled="!!col._array.max && listOf(col).length >= col._array.max" @click="addRow(col)">
|
|
87
|
+
{{ col._array.add || '+ 添加一项' }}
|
|
88
|
+
</button>
|
|
89
|
+
</template>
|
|
90
|
+
<div v-if="!editable && !listOf(col).length" class="lh-ls">-</div>
|
|
91
|
+
<FieldMessage :col="col" :mode="mode" />
|
|
132
92
|
</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>
|
|
138
|
-
</template>
|
|
139
|
-
<div v-if="!editable && !listOf(col).length" class="lh-ls">-</div>
|
|
140
|
-
<FieldMessage :col="col" :mode="mode" />
|
|
141
|
-
</div>
|
|
142
93
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
94
|
+
<FormRender v-else :col="col" :mode="mode" :entry="resolveEntry(col)" :interacted="touched" :ctx="context()" :custom="(!editable && hasSlot(`${col.field}-value`)) || !resolveEntry(col)">
|
|
95
|
+
<slot v-if="!editable && hasSlot(`${col.field}-value`)" :name="`${col.field}-value`" v-bind="fieldScope(col)"></slot>
|
|
96
|
+
<template v-else>
|
|
97
|
+
<slot v-if="col.slot" :name="col.slot" v-bind="fieldScope(col)"></slot>
|
|
98
|
+
<slot v-else-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
|
|
99
|
+
<slot v-else :name="col.field" v-bind="fieldScope(col)"></slot>
|
|
100
|
+
</template>
|
|
101
|
+
</FormRender>
|
|
102
|
+
</template>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
153
105
|
</template>
|
|
154
|
-
<div v-if="$slots.submit" :ui-flex="
|
|
155
|
-
<div
|
|
156
|
-
<slot name="submit"></slot>
|
|
157
|
-
</div>
|
|
158
|
-
<div v-else :style="submitStyle">
|
|
106
|
+
<div v-if="!bare && $slots.submit" :ui-flex="vertical ? 'col xm' : 'row xt'">
|
|
107
|
+
<div :style="vertical ? undefined : submitStyle || undefined">
|
|
159
108
|
<slot name="submit"></slot>
|
|
160
109
|
</div>
|
|
161
110
|
</div>
|
|
162
|
-
</
|
|
111
|
+
</Level>
|
|
163
112
|
</template>
|
|
164
113
|
<script lang="ts" setup>
|
|
165
|
-
import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick,
|
|
114
|
+
import { Comment, h, watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, watchEffect, useId, isReactive, isReadonly } from 'vue';
|
|
166
115
|
import { useRoute, useRouter } from 'vue-router';
|
|
167
116
|
import { isString, isObject, deepcopy, isArray, isFunction } from '@fekit/utils';
|
|
168
117
|
import FormRender, { getField, emptyValueOf, clh, isEmpty, labelText, labelAttrs, rulesOf, displayOf, valueBinding, labelNodes, eventsOf, writeValue, FieldMessage } from './FormRender.vue';
|
|
169
|
-
import type {
|
|
118
|
+
import type { PropType, ShallowRef } from 'vue';
|
|
119
|
+
import type { FormItem, FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
|
|
120
|
+
|
|
121
|
+
// 层级根:最外层输出 div(接收透传属性),分组 / 数组内部的层级(bare)只输出子节点;
|
|
122
|
+
// 顶层的条件占位注释(没有 submit 等)一并过滤,客户端与 SSR 一致。模板根部不能有注释,否则透传属性失效。
|
|
123
|
+
const Level: any = (props: { bare?: boolean }, { slots, attrs }: any) => {
|
|
124
|
+
const children = (slots.default?.() || []).filter((node: any) => node.type !== Comment);
|
|
125
|
+
return props.bare ? children : h('div', attrs, children);
|
|
126
|
+
};
|
|
127
|
+
Level.props = ['bare'];
|
|
128
|
+
Level.inheritAttrs = false;
|
|
129
|
+
|
|
130
|
+
// 未传 idx 时的 id 前缀:由 useId 算出的 6 位短串,SSR 与客户端一致,同页多个表单不冲突
|
|
131
|
+
const shortHash = (text: string) => {
|
|
132
|
+
let h = 2166136261;
|
|
133
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
|
|
134
|
+
return (h >>> 0).toString(36).padStart(6, '0').slice(-6);
|
|
135
|
+
};
|
|
170
136
|
|
|
137
|
+
// child 两种写法:节点数组,或 { attrs, lists }(兼容 child 作为 lists 的别名);动态配置交给联动
|
|
138
|
+
const isChildBox = (child: any) => isObject(child) && !isArray(child) && !isFunction(child.setup) && child.watch === undefined
|
|
139
|
+
&& (isArray(child.lists) || isArray(child.child) || child.attrs !== undefined);
|
|
140
|
+
const childList = (child: any): any[] | null => (isArray(child) ? child : isChildBox(child) ? child.lists || child.child || [] : null);
|
|
171
141
|
// 计算Label宽度
|
|
172
142
|
const clw = (label: any = '') => {
|
|
173
143
|
let emWidth = 0;
|
|
@@ -185,12 +155,12 @@
|
|
|
185
155
|
return emWidth;
|
|
186
156
|
};
|
|
187
157
|
|
|
188
|
-
//
|
|
189
|
-
const
|
|
158
|
+
// 路径缺失时补全对象,不把嵌套字段静默写到根层;onCreate 用于记录新建对象的初始值。
|
|
159
|
+
const ensurePath = (data: any, paths: string = '', onCreate?: (target: any, key: string) => void) => {
|
|
190
160
|
let target = data;
|
|
191
161
|
(paths?.split('.') || []).forEach((key: string) => {
|
|
192
162
|
if (!key) return;
|
|
193
|
-
if (target[key] == null) { target[key] = {};
|
|
163
|
+
if (target[key] == null) { target[key] = {}; onCreate?.(target, key); }
|
|
194
164
|
if (!isObject(target[key])) throw new TypeError(`[FormLoader] paths 的 ${key} 必须是对象`);
|
|
195
165
|
target = target[key];
|
|
196
166
|
});
|
|
@@ -254,6 +224,13 @@
|
|
|
254
224
|
return _model;
|
|
255
225
|
};
|
|
256
226
|
|
|
227
|
+
// child: { attrs, lists } 拆成子节点数组和容器属性
|
|
228
|
+
const splitChild = (item: any) => {
|
|
229
|
+
if (!isChildBox(item.child)) return;
|
|
230
|
+
item._childAttrs = item.child.attrs;
|
|
231
|
+
item.child = childList(item.child);
|
|
232
|
+
};
|
|
233
|
+
|
|
257
234
|
// 规范化数组配置,兼容旧版 array: 'field' 和 lists
|
|
258
235
|
const normalizeArray = (array: any) => {
|
|
259
236
|
if (isString(array)) return { child: [{ field: array }] };
|
|
@@ -266,12 +243,13 @@
|
|
|
266
243
|
const row: any = {};
|
|
267
244
|
list.forEach((item: any = {}) => {
|
|
268
245
|
if (!isObject(item)) return;
|
|
269
|
-
const target =
|
|
270
|
-
|
|
246
|
+
const target = ensurePath(row, item.paths);
|
|
247
|
+
const child = childList(item.child);
|
|
248
|
+
if (child && !item.array) {
|
|
271
249
|
if (item.field) {
|
|
272
|
-
target[item.field] = createRow(
|
|
250
|
+
target[item.field] = createRow(child, getEmpty);
|
|
273
251
|
} else {
|
|
274
|
-
Object.assign(target, createRow(
|
|
252
|
+
Object.assign(target, createRow(child, getEmpty));
|
|
275
253
|
}
|
|
276
254
|
} else if (item.field) {
|
|
277
255
|
target[item.field] = getEmpty(item);
|
|
@@ -314,18 +292,83 @@
|
|
|
314
292
|
});
|
|
315
293
|
};
|
|
316
294
|
|
|
295
|
+
// 可写的数据视图:未写入的字段直接读原数据(响应式读取,便于精确追踪依赖),写入与删除只进入覆盖层。
|
|
296
|
+
const DELETED = Symbol('deleted');
|
|
297
|
+
const plainObject = (value: any) => !!value && typeof value === 'object' && !(value instanceof Date);
|
|
298
|
+
const dataView = (source: any, onRead?: () => void): any => {
|
|
299
|
+
if (!plainObject(source)) return source;
|
|
300
|
+
const src = isReactive(source) || isReadonly(source) ? source : reactive(source);
|
|
301
|
+
const array = isArray(toRaw(src));
|
|
302
|
+
const overlay = new Map<PropertyKey, any>();
|
|
303
|
+
const children = new Map<PropertyKey, { raw: any; view: any }>();
|
|
304
|
+
const read = (key: PropertyKey) => {
|
|
305
|
+
// Vue 内部标记(__v_raw、__v_isReactive 等)按普通对象处理,视图本身不是响应式对象。
|
|
306
|
+
if (typeof key === 'string' && key.startsWith('__v_')) return undefined;
|
|
307
|
+
if (overlay.has(key)) {
|
|
308
|
+
const value = overlay.get(key);
|
|
309
|
+
return value === DELETED ? undefined : value;
|
|
310
|
+
}
|
|
311
|
+
// 方法取原生函数(不走 Vue 的数组方法包装),调用时通过视图逐项读取,依赖仍然精确。
|
|
312
|
+
const native = Reflect.get(toRaw(src), key);
|
|
313
|
+
if (typeof native === 'function') return native;
|
|
314
|
+
onRead?.();
|
|
315
|
+
const value = Reflect.get(src, key);
|
|
316
|
+
if (!plainObject(value) || typeof key === 'symbol') return value;
|
|
317
|
+
const cached = children.get(key);
|
|
318
|
+
if (cached && cached.raw === toRaw(value)) return cached.view;
|
|
319
|
+
const view = dataView(value, onRead);
|
|
320
|
+
children.set(key, { raw: toRaw(value), view });
|
|
321
|
+
return view;
|
|
322
|
+
};
|
|
323
|
+
const exists = (key: PropertyKey) => (typeof key === 'string' && key.startsWith('__v_') ? false : overlay.has(key) ? overlay.get(key) !== DELETED : (onRead?.(), Reflect.has(src, key)));
|
|
324
|
+
return new Proxy(array ? [] : {}, {
|
|
325
|
+
get: (_, key) => read(key),
|
|
326
|
+
set: (_, key, value) => { overlay.set(key, value); children.delete(key); return true; },
|
|
327
|
+
deleteProperty: (_, key) => { overlay.set(key, DELETED); children.delete(key); return true; },
|
|
328
|
+
defineProperty: (_, key, descriptor) => { overlay.set(key, descriptor.value); children.delete(key); return true; },
|
|
329
|
+
has: (_, key) => exists(key),
|
|
330
|
+
ownKeys: () => {
|
|
331
|
+
onRead?.();
|
|
332
|
+
const keys = new Set<string | symbol>(Reflect.ownKeys(src));
|
|
333
|
+
overlay.forEach((value, key) => (value === DELETED ? keys.delete(key as string | symbol) : keys.add(key as string | symbol)));
|
|
334
|
+
if (array) {
|
|
335
|
+
const length = read('length');
|
|
336
|
+
keys.forEach((key) => { if (typeof key === 'string' && /^\d+$/.test(key) && Number(key) >= length) keys.delete(key); });
|
|
337
|
+
}
|
|
338
|
+
return [...keys];
|
|
339
|
+
},
|
|
340
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
341
|
+
if (!exists(key)) return undefined;
|
|
342
|
+
if (array && key === 'length') return { value: read(key), writable: true, enumerable: false, configurable: false };
|
|
343
|
+
return { value: read(key), writable: true, enumerable: true, configurable: true };
|
|
344
|
+
},
|
|
345
|
+
getPrototypeOf: () => Reflect.getPrototypeOf(toRaw(src)),
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// 动态属性联动:函数或 { value, setup, watch };按实际读取的字段追踪依赖,丢弃过期的异步结果
|
|
350
|
+
// 联动引擎需要的共享状态:数据版本(watch: true 与 await 后读取的回退)、结构版本、未完成的异步任务
|
|
351
|
+
interface LinkageShared {
|
|
352
|
+
dataVersion: ShallowRef<number>;
|
|
353
|
+
revision: number;
|
|
354
|
+
pending: Set<Promise<any>>;
|
|
355
|
+
}
|
|
356
|
+
|
|
317
357
|
interface LinkageOptions {
|
|
318
358
|
// 按下标取当前渲染的字段
|
|
319
359
|
target: (idx: number) => any;
|
|
320
|
-
//
|
|
360
|
+
// 联动上下文:根数据、当前层数据、行下标、路由等;属性按需读取,未用到的不产生依赖
|
|
321
361
|
context: () => FormContext;
|
|
362
|
+
shared: LinkageShared;
|
|
363
|
+
// 把联动结果写回字段(规范化 model / array、初始化子结构由表单负责)
|
|
364
|
+
apply: (target: any, key: string, result: any) => void;
|
|
322
365
|
}
|
|
323
366
|
|
|
324
367
|
// 带 setup 或 watch 的对象才是联动配置,attrs 等普通对象原样保留
|
|
325
368
|
const isLinkage = (val: any) => isObject(val) && (isFunction(val.setup) || isArray(val.watch) || val.watch === true);
|
|
326
369
|
|
|
327
370
|
// 动态属性:函数或 { value, setup, watch },监听字段变化时重新计算,支持 Promise
|
|
328
|
-
const useLinkage = ({ target: getTarget, context }: LinkageOptions) => {
|
|
371
|
+
const useLinkage = ({ target: getTarget, context, shared, apply: applyResult }: LinkageOptions) => {
|
|
329
372
|
// 结构变化时重建字段监听;普通字段变化只触发对应的联动任务
|
|
330
373
|
const unwatchs: Array<() => void> = [];
|
|
331
374
|
const pendingRuns = new Map<LinkageTask, () => void>();
|
|
@@ -339,14 +382,11 @@
|
|
|
339
382
|
});
|
|
340
383
|
};
|
|
341
384
|
|
|
342
|
-
//
|
|
343
|
-
|
|
385
|
+
// 联动函数拿到的是可写的数据视图:读取直接访问表单数据,Vue 只按实际读到的字段收集依赖;
|
|
386
|
+
// 写入只落在视图自己的覆盖层,不改动表单数据(与 4.x 传副本的语义一致)。
|
|
387
|
+
const call = (setup: any, item: any, onRead?: () => void) => {
|
|
344
388
|
const ctx = context();
|
|
345
|
-
const track = (data: any) =>
|
|
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
|
-
});
|
|
389
|
+
const track = (data: any) => dataView(data, onRead);
|
|
350
390
|
// 统一回调协议,不通过参数个数猜测用途。path 保持已发布版路由含义。
|
|
351
391
|
const scope = new Proxy(ctx, {
|
|
352
392
|
get: (target, key) => key === 'path' ? ctx.route?.path : key === 'formPath' ? ctx.path
|
|
@@ -370,15 +410,25 @@
|
|
|
370
410
|
// 处理函数
|
|
371
411
|
if (isFunction(val)) {
|
|
372
412
|
const fn = val;
|
|
373
|
-
|
|
413
|
+
// 同步读取由 computed 精确追踪;异步函数在 await 之后才读取的数据无法追踪,
|
|
414
|
+
// 一旦出现就退回到跟随根数据版本重算。
|
|
415
|
+
let lateReads = false;
|
|
374
416
|
const version = shallowRef(0);
|
|
375
|
-
unwatchs.push(watch(
|
|
376
|
-
if (
|
|
377
|
-
}
|
|
417
|
+
unwatchs.push(watch(shared.dataVersion, () => {
|
|
418
|
+
if (lateReads) version.value++;
|
|
419
|
+
}));
|
|
378
420
|
// 初次解析与监听共用计算结果,避免异步函数被重复调用。
|
|
379
421
|
const evaluated = computed(() => {
|
|
380
422
|
version.value;
|
|
381
|
-
|
|
423
|
+
let evaluating = true;
|
|
424
|
+
let settled = false;
|
|
425
|
+
const result = call(fn, reactive(item), () => {
|
|
426
|
+
if (!evaluating && !settled) lateReads = true;
|
|
427
|
+
});
|
|
428
|
+
evaluating = false;
|
|
429
|
+
if (result instanceof Promise) result.then(() => (settled = true), () => (settled = true));
|
|
430
|
+
else settled = true;
|
|
431
|
+
return result;
|
|
382
432
|
});
|
|
383
433
|
val = evaluated.value;
|
|
384
434
|
if (isLinkage(val)) {
|
|
@@ -396,12 +446,12 @@
|
|
|
396
446
|
} else {
|
|
397
447
|
hasInitialResult = true;
|
|
398
448
|
initialResult = val;
|
|
399
|
-
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true };
|
|
449
|
+
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true, source: () => evaluated.value };
|
|
400
450
|
}
|
|
401
451
|
}
|
|
402
452
|
if (isLinkage(val)) {
|
|
403
|
-
const { value = null, setup = () => {}, watch: _watch = [] } = val || {};
|
|
404
|
-
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult });
|
|
453
|
+
const { value = null, setup = () => {}, watch: _watch = [], source } = val || {};
|
|
454
|
+
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult, source });
|
|
405
455
|
item[key] = value;
|
|
406
456
|
} else {
|
|
407
457
|
item[key] = val;
|
|
@@ -415,7 +465,7 @@
|
|
|
415
465
|
const taskRuns = new Map<LinkageTask, number>();
|
|
416
466
|
const runsetup = (task: LinkageTask) => {
|
|
417
467
|
if (current !== generation) return;
|
|
418
|
-
const { idx, key,
|
|
468
|
+
const { idx, key, setup } = task;
|
|
419
469
|
const target = getTarget(idx);
|
|
420
470
|
if (target && isFunction(setup)) {
|
|
421
471
|
pendingRuns.get(task)?.();
|
|
@@ -426,12 +476,10 @@
|
|
|
426
476
|
const isCurrent = () => current === generation && getTarget(idx) === target && taskRuns.get(task) === runId;
|
|
427
477
|
const apply = (result: any) => {
|
|
428
478
|
shared.revision++;
|
|
429
|
-
target
|
|
430
|
-
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
431
|
-
if (key === 'child' || key === 'array') initializeField(target);
|
|
479
|
+
applyResult(target, key, result);
|
|
432
480
|
};
|
|
433
481
|
if (v instanceof Promise) {
|
|
434
|
-
|
|
482
|
+
// 等待期间保留上一次的结果,避免选项、标题等先变空再出现。
|
|
435
483
|
let release!: () => void;
|
|
436
484
|
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
437
485
|
const finish = () => {
|
|
@@ -466,10 +514,10 @@
|
|
|
466
514
|
const { watch: keys } = task;
|
|
467
515
|
if (keys === true) {
|
|
468
516
|
task.hasInitialResult = false;
|
|
469
|
-
|
|
470
|
-
|
|
517
|
+
// 函数属性只在计算结果变化时应用;联动配置的 watch: true 跟随根数据版本。
|
|
518
|
+
unwatchs.push(watch(task.source || shared.dataVersion, () => {
|
|
471
519
|
runsetup(task);
|
|
472
|
-
}));
|
|
520
|
+
}, { immediate: true }));
|
|
473
521
|
return;
|
|
474
522
|
}
|
|
475
523
|
unwatchs.push(
|
|
@@ -487,6 +535,7 @@
|
|
|
487
535
|
return { stop, resolve, start };
|
|
488
536
|
};
|
|
489
537
|
|
|
538
|
+
|
|
490
539
|
const route = useRoute();
|
|
491
540
|
const router = useRouter();
|
|
492
541
|
const attrs = useAttrs();
|
|
@@ -503,12 +552,12 @@
|
|
|
503
552
|
},
|
|
504
553
|
// JSON规则
|
|
505
554
|
form: {
|
|
506
|
-
type: Array
|
|
555
|
+
type: Array as PropType<FormItem[]>,
|
|
507
556
|
default: () => [],
|
|
508
557
|
},
|
|
509
558
|
// 表单数据
|
|
510
559
|
data: {
|
|
511
|
-
type: Object as any
|
|
560
|
+
type: Object as PropType<Record<string, any>>,
|
|
512
561
|
default: () => ({}),
|
|
513
562
|
},
|
|
514
563
|
// 栅格系统
|
|
@@ -552,6 +601,11 @@
|
|
|
552
601
|
default: false,
|
|
553
602
|
},
|
|
554
603
|
// 以下由分组和数组内部传递
|
|
604
|
+
// 内部层级:不输出根元素,子节点直接落在上一层的容器里
|
|
605
|
+
bare: {
|
|
606
|
+
type: Boolean,
|
|
607
|
+
default: false,
|
|
608
|
+
},
|
|
555
609
|
// 根表单数据,联动函数和 watch 都基于它
|
|
556
610
|
root: {
|
|
557
611
|
type: Object as any,
|
|
@@ -575,7 +629,7 @@
|
|
|
575
629
|
interface Shared {
|
|
576
630
|
state: { lw: number; validated: boolean };
|
|
577
631
|
cache: ClearCache;
|
|
578
|
-
instances: Set<{ collect: () => any[] }>;
|
|
632
|
+
instances: Set<{ collect: () => any[]; hidden: () => string[] }>;
|
|
579
633
|
snapshot: any;
|
|
580
634
|
initialObjects: WeakMap<object, any>;
|
|
581
635
|
pending: Set<Promise<any>>;
|
|
@@ -583,6 +637,10 @@
|
|
|
583
637
|
validationEpoch: number;
|
|
584
638
|
cancelChecks: Set<() => void>;
|
|
585
639
|
validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
|
|
640
|
+
// 根数据任意变化时递增,供 watch: true 的联动共用
|
|
641
|
+
dataVersion: ShallowRef<number>;
|
|
642
|
+
// 外部回填的错误(如服务端校验),按字段路径保存;字段值变化后自动失效
|
|
643
|
+
serverErrors: Map<string, { msg: string; type: string; input: any }>;
|
|
586
644
|
}
|
|
587
645
|
const FORM_SHARED = Symbol.for('neatui:form-loader');
|
|
588
646
|
const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
|
|
@@ -596,10 +654,15 @@
|
|
|
596
654
|
validationEpoch: 0,
|
|
597
655
|
cancelChecks: new Set(),
|
|
598
656
|
validations: new WeakMap(),
|
|
657
|
+
dataVersion: shallowRef(0),
|
|
658
|
+
serverErrors: reactive(new Map()),
|
|
599
659
|
};
|
|
600
660
|
provide(FORM_SHARED, shared);
|
|
601
661
|
|
|
602
662
|
const rootData = computed(() => props.root || props.data);
|
|
663
|
+
// 根表单未传 idx 时用 useId 生成前缀,同页多个表单的同名字段 id 不再冲突;SSR 与客户端一致。
|
|
664
|
+
const autoIdx = `f${shortHash(useId())}`;
|
|
665
|
+
const baseIdx = computed(() => (props.idx !== '' ? String(props.idx) : props.root ? '' : autoIdx));
|
|
603
666
|
const cancelValidation = () => {
|
|
604
667
|
shared.validationEpoch++;
|
|
605
668
|
shared.cancelChecks.forEach(cancel => cancel());
|
|
@@ -616,7 +679,7 @@
|
|
|
616
679
|
};
|
|
617
680
|
const captureInitial = () => {
|
|
618
681
|
shared.initialObjects = new WeakMap();
|
|
619
|
-
shared.snapshot = cloneInitial(
|
|
682
|
+
shared.snapshot = cloneInitial(rootData.value);
|
|
620
683
|
shared.cache = new WeakMap();
|
|
621
684
|
cancelValidation();
|
|
622
685
|
};
|
|
@@ -627,6 +690,7 @@
|
|
|
627
690
|
if (!props.root) captureInitial();
|
|
628
691
|
watch(() => props.data, () => {
|
|
629
692
|
shared.revision++;
|
|
693
|
+
if (!props.root) shared.dataVersion.value++;
|
|
630
694
|
}, { deep: true, flush: 'sync' });
|
|
631
695
|
const editable = computed(() => props.mode !== 'view');
|
|
632
696
|
// 调用过 validate 后所有字段展示校验状态
|
|
@@ -666,6 +730,10 @@
|
|
|
666
730
|
const label = labelText(col.label);
|
|
667
731
|
if (!label) return '';
|
|
668
732
|
if (/[::]\s*$/.test(label)) return label;
|
|
733
|
+
// 4.x 兼容:字段级 colon 优先,false 不显示,string 自定义字符,true 为 ':'
|
|
734
|
+
if (col.colon === false) return label;
|
|
735
|
+
if (isString(col.colon)) return `${label}${col.colon}`;
|
|
736
|
+
if (col.colon === true) return `${label}:`;
|
|
669
737
|
// 自定义节点树和标签属性可以直接决定呈现;默认文本标签跟随表单冒号。
|
|
670
738
|
if ((isGroup(col) || col._array || col.array) && isVertical(col)) return label;
|
|
671
739
|
const suffix = isString(props.colon) ? props.colon : props.colon ? ':' : '';
|
|
@@ -679,6 +747,106 @@
|
|
|
679
747
|
// 整行插槽
|
|
680
748
|
const rowSlot = (col: any = {}) => (col.field && hasSlot(`${col.field}-row`) ? `${col.field}-row` : '');
|
|
681
749
|
|
|
750
|
+
// ---- 渲染结构:节点即元素,只补 id / ui-col / 布局 / 调试 / 隐藏,空值不输出 ----
|
|
751
|
+
// 模板分支:${field}-row 整行插槽 → 无标题分组(节点元素即容器)→ 标题(节点树 / h6 / p,插槽 ${field}-label、array-label、child-label、label)
|
|
752
|
+
// → 有标题分组(标题 + 内容容器)→ 数组(行;有操作按钮时分字段区与按钮区)→ 字段(控件 + 错误提示;字段级 slot 兼容 4.x)
|
|
753
|
+
const hasLabel = (col: any = {}) => !!labelNodes(col.label) || !!labelText(col.label);
|
|
754
|
+
// 无标题的 Text / Html / Button 内容节点直接输出内容元素
|
|
755
|
+
const isBareContent = (col: any = {}) => !col.field && !isGroup(col) && !col._array && !hasLabel(col) && !rowSlot(col)
|
|
756
|
+
&& ['Text', 'Html', 'Button'].includes(col.model?.[0]);
|
|
757
|
+
// 所在层有栅格时才输出 ui-col;分组、数组默认占满一行;false 不用栅格
|
|
758
|
+
const mediaOf = (col: any = {}) => (!props.rows || col.media === false ? undefined : col.media || (isGroup(col) || col._array ? 'mob-24' : undefined));
|
|
759
|
+
const joinClass = (...list: any[]) => {
|
|
760
|
+
const out = list.filter((v) => v !== undefined && v !== null && v !== '' && !(isArray(v) && !v.length));
|
|
761
|
+
return out.length ? (out.length === 1 ? out[0] : out) : undefined;
|
|
762
|
+
};
|
|
763
|
+
// 子节点容器:child.attrs(可为函数)+ 这一层的栅格;ui-flex 布局时不加栅格和默认间距;写了 class 时由外部接管间距(与根元素一致)
|
|
764
|
+
const childAttrsOf = (col: any) => {
|
|
765
|
+
const attrs = col._childAttrs;
|
|
766
|
+
return (isFunction(attrs) ? attrs(col._data, col, context()) : attrs) || {};
|
|
767
|
+
};
|
|
768
|
+
const levelOf = (col: any, flex?: string, styled = false) => {
|
|
769
|
+
const { 'ui-row': row, ...own } = childAttrsOf(col);
|
|
770
|
+
const layout = own['ui-flex'] ?? flex;
|
|
771
|
+
const rows = col.media === false || layout ? '' : String(row ?? col.attrs?.['ui-row'] ?? props.rows ?? '');
|
|
772
|
+
return { rows, attrs: { ...own, 'ui-row': rows || undefined, class: 'class' in own ? own.class : layout || styled ? undefined : 'n-sl-sub' } };
|
|
773
|
+
};
|
|
774
|
+
// 节点元素;无标题分组的节点元素同时是下一层容器
|
|
775
|
+
const nodeAttrs = (col: any) => {
|
|
776
|
+
const { 'ui-row': _row, ...own } = col.attrs || {};
|
|
777
|
+
const out: Record<string, any> = { ...own };
|
|
778
|
+
const group = isGroup(col);
|
|
779
|
+
const box = group && !hasLabel(col) && !rowSlot(col);
|
|
780
|
+
if (col._fid) out.id = col._fid;
|
|
781
|
+
const media = mediaOf(col);
|
|
782
|
+
if (media) out['ui-col'] = media;
|
|
783
|
+
if (box) {
|
|
784
|
+
// 无标题分组的节点元素就是容器:节点写了 class 时不再加默认间距
|
|
785
|
+
const level = levelOf(col, own['ui-flex'], own.class !== undefined).attrs;
|
|
786
|
+
Object.assign(out, level, { class: joinClass(own.class, level.class) });
|
|
787
|
+
} else if (!isBareContent(col) && own['ui-flex'] === undefined) {
|
|
788
|
+
out['ui-flex'] = isVertical(col) ? 'col xm' : 'row lt';
|
|
789
|
+
}
|
|
790
|
+
if (props.debug) {
|
|
791
|
+
out['data-path'] = fieldPath(col) || undefined;
|
|
792
|
+
out.title = debugInfo(col);
|
|
793
|
+
}
|
|
794
|
+
if (isCleared(col)) out.style = joinClass(own.style, { display: 'none' });
|
|
795
|
+
return out;
|
|
796
|
+
};
|
|
797
|
+
// 有标题分组的子节点容器
|
|
798
|
+
const boxAttrs = (col: any) => {
|
|
799
|
+
const { attrs } = levelOf(col);
|
|
800
|
+
return { ...attrs, class: joinClass('flex-block', attrs.class) };
|
|
801
|
+
};
|
|
802
|
+
const levelProps = () => ({
|
|
803
|
+
bare: true,
|
|
804
|
+
root: rootData.value,
|
|
805
|
+
mode: props.mode,
|
|
806
|
+
interacted: props.interacted,
|
|
807
|
+
colon: props.colon,
|
|
808
|
+
labelClass: props.labelClass,
|
|
809
|
+
labelWidth: props.labelWidth,
|
|
810
|
+
vertical: props.vertical,
|
|
811
|
+
debug: props.debug,
|
|
812
|
+
idx: baseIdx.value,
|
|
813
|
+
});
|
|
814
|
+
const childProps = (col: any) => ({
|
|
815
|
+
...levelProps(),
|
|
816
|
+
form: col.child || [],
|
|
817
|
+
clearContext: { value: isCleared(col), restore: canRestore(col) },
|
|
818
|
+
arrayContext: props.arrayContext,
|
|
819
|
+
data: childData(col),
|
|
820
|
+
path: fieldPath(col),
|
|
821
|
+
index: props.index,
|
|
822
|
+
rows: levelOf(col, hasLabel(col) ? undefined : col.attrs?.['ui-flex']).rows,
|
|
823
|
+
});
|
|
824
|
+
// 数组:行栅格、行元素、行内字段区
|
|
825
|
+
const hasActions = (col: any) => editable.value && (hasSlot(`${col.field}-actions`) || !!col._array.sortable || col._array.del !== false);
|
|
826
|
+
const rowRows = (col: any) => (col.media === false ? '' : col._array.rows || col.attrs?.['ui-row'] || props.rows || '');
|
|
827
|
+
const rowAttrs = (col: any) => {
|
|
828
|
+
const own = col._array.attrs || {};
|
|
829
|
+
const out: Record<string, any> = { ...own, 'ui-col': own['ui-col'] || 'mob-24', class: joinClass('mb-xs', own.class) };
|
|
830
|
+
if (hasActions(col)) {
|
|
831
|
+
if (own['ui-flex'] === undefined) out['ui-flex'] = 'row lt';
|
|
832
|
+
} else if (own['ui-flex'] === undefined) {
|
|
833
|
+
// 没有操作按钮时行元素本身承载这一行的字段
|
|
834
|
+
out['ui-row'] = rowRows(col) || undefined;
|
|
835
|
+
out.class = joinClass('mb-xs', own.class ?? 'n-sl-sub');
|
|
836
|
+
}
|
|
837
|
+
return out;
|
|
838
|
+
};
|
|
839
|
+
const rowBox = (col: any) => ({ class: 'flex-block n-sl-sub', 'ui-row': rowRows(col) || undefined });
|
|
840
|
+
const rowProps = (col: any, row: any, i: number) => ({
|
|
841
|
+
...levelProps(),
|
|
842
|
+
form: col._array.child,
|
|
843
|
+
arrayContext: arrayContext(col, row),
|
|
844
|
+
data: row,
|
|
845
|
+
path: `${fieldPath(col)}.${i}`,
|
|
846
|
+
index: i,
|
|
847
|
+
rows: hasActions(col) || (col._array.attrs || {})['ui-flex'] === undefined ? rowRows(col) : '',
|
|
848
|
+
});
|
|
849
|
+
|
|
682
850
|
// 表单默认标签宽度;单个标签用 label[1].style 覆盖。
|
|
683
851
|
const widthOf = (value: any) => {
|
|
684
852
|
if (value === 'auto') return '';
|
|
@@ -691,9 +859,8 @@
|
|
|
691
859
|
return style ? style.replace(/^width:([^;]+);.*$/, 'padding-left:$1;') : '';
|
|
692
860
|
});
|
|
693
861
|
|
|
694
|
-
//
|
|
862
|
+
// 字段数据路径(也是字段 id 的主体)
|
|
695
863
|
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
864
|
const childData = (col: any = {}) => (col.field ? col._data[col.field] : col._data);
|
|
698
865
|
const listOf = (col: any = {}) => (isArray(col._data?.[col.field]) ? col._data[col.field] : []);
|
|
699
866
|
|
|
@@ -703,6 +870,7 @@
|
|
|
703
870
|
// 控件:已注册的 model;没写 model 且没有对应插槽时默认 Input
|
|
704
871
|
const resolveEntry = (col: any = {}) => {
|
|
705
872
|
const model = col.model?.[0];
|
|
873
|
+
if (col.slot && !getField(model)) return undefined;
|
|
706
874
|
if (model) return getField(model);
|
|
707
875
|
if (col.field && hasSlot(col.field)) return undefined;
|
|
708
876
|
return getField(col.field ? 'Input' : 'Text');
|
|
@@ -712,7 +880,7 @@
|
|
|
712
880
|
const initialOf = (item: any = {}) => {
|
|
713
881
|
if (valueBinding(item.value) && item.value.default !== undefined) return deepcopy(item.value.default);
|
|
714
882
|
if (item.array) return [];
|
|
715
|
-
if (
|
|
883
|
+
if (childList(item.child)) return {};
|
|
716
884
|
return emptyValueOf(isString(item.model) || isArray(item.model) ? normalizeModel(item.model) : ['']);
|
|
717
885
|
};
|
|
718
886
|
|
|
@@ -776,7 +944,31 @@
|
|
|
776
944
|
form: [],
|
|
777
945
|
});
|
|
778
946
|
|
|
779
|
-
|
|
947
|
+
// 路径缺失时补全对象,并把新建的对象记入重置基线
|
|
948
|
+
const resolvePath = (data: any, paths: string = '') => ensurePath(data, paths, recordInitial);
|
|
949
|
+
|
|
950
|
+
// 按需读取的联动上下文:函数没用到的属性(如数组行上下文)变化时不触发重算。
|
|
951
|
+
const lazyContext = (): FormContext => ({
|
|
952
|
+
get root() { return rootData.value; },
|
|
953
|
+
get data() { return props.data; },
|
|
954
|
+
get index() { return props.index; },
|
|
955
|
+
get array() { return props.arrayContext; },
|
|
956
|
+
get path() { return props.path; },
|
|
957
|
+
get mode() { return props.mode; },
|
|
958
|
+
route,
|
|
959
|
+
router,
|
|
960
|
+
});
|
|
961
|
+
const linkage = useLinkage({
|
|
962
|
+
target: (idx) => state.form[idx],
|
|
963
|
+
context: lazyContext,
|
|
964
|
+
shared,
|
|
965
|
+
apply: (target, key, result) => {
|
|
966
|
+
target[key] = key === 'model' ? normalizeModel(result) : result;
|
|
967
|
+
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
968
|
+
if (key === 'child') splitChild(target);
|
|
969
|
+
if (key === 'child' || key === 'array') initializeField(target);
|
|
970
|
+
},
|
|
971
|
+
});
|
|
780
972
|
|
|
781
973
|
// 初始结构和异步生成的子树都经过相同的数据初始化。
|
|
782
974
|
const initializeField = (item: any) => {
|
|
@@ -814,6 +1006,7 @@
|
|
|
814
1006
|
if (isObject(item)) {
|
|
815
1007
|
linkage.resolve(item, idx, tasks);
|
|
816
1008
|
}
|
|
1009
|
+
splitChild(item);
|
|
817
1010
|
|
|
818
1011
|
item._array = item.array ? normalizeArray(item.array) : item._kind === 'array' ? { child: [] } : null;
|
|
819
1012
|
|
|
@@ -830,7 +1023,7 @@
|
|
|
830
1023
|
}
|
|
831
1024
|
|
|
832
1025
|
// 添加ID
|
|
833
|
-
item._fid =
|
|
1026
|
+
item._fid = item.field ? [baseIdx.value, fieldPath(item).replace(/\./g, '-')].filter(Boolean).join('-') : '';
|
|
834
1027
|
|
|
835
1028
|
// 数据路径处理
|
|
836
1029
|
item._data = resolvePath(props.data, item.paths);
|
|
@@ -842,9 +1035,6 @@
|
|
|
842
1035
|
fields.add(key);
|
|
843
1036
|
initializeField(item);
|
|
844
1037
|
}
|
|
845
|
-
|
|
846
|
-
// 分组、数组默认占满一行
|
|
847
|
-
if ((isGroup(item) || item._array) && !item.media) item.media = 'mob-24';
|
|
848
1038
|
return item;
|
|
849
1039
|
});
|
|
850
1040
|
linkage.start(tasks);
|
|
@@ -873,14 +1063,15 @@
|
|
|
873
1063
|
watch(
|
|
874
1064
|
[() => state.form, () => props.data, () => props.clearContext],
|
|
875
1065
|
() => {
|
|
876
|
-
state.form.forEach((item: any = {}) => {
|
|
1066
|
+
state.form.forEach((item: any = {}, idx: number) => {
|
|
877
1067
|
if (isGroup(item)) return; // 保留递归实例,由解析后的子节点各自清空和恢复。
|
|
878
1068
|
const cache = canRestore(item) ? shared.cache : undefined;
|
|
1069
|
+
const key = item._fid || `#${idx}`;
|
|
879
1070
|
if (isCleared(item)) {
|
|
880
|
-
hiddenFields.add(
|
|
1071
|
+
hiddenFields.add(key);
|
|
881
1072
|
emptyField(resolvePath(props.data, item.paths), item.field, cache);
|
|
882
|
-
} else if (hiddenFields.has(
|
|
883
|
-
hiddenFields.delete(
|
|
1073
|
+
} else if (hiddenFields.has(key) || firstRun) {
|
|
1074
|
+
hiddenFields.delete(key);
|
|
884
1075
|
if (cache) restoreField(resolvePath(props.data, item.paths), item.field, cache);
|
|
885
1076
|
}
|
|
886
1077
|
});
|
|
@@ -895,6 +1086,8 @@
|
|
|
895
1086
|
state.form
|
|
896
1087
|
.filter((col: any) => !isCleared(col) && col.field && !isGroup(col))
|
|
897
1088
|
.map((col: any) => ({ col, path: fieldPath(col), ctx: context() })),
|
|
1089
|
+
// 当前层被隐藏(clear)的字段路径,供 getData({ visibleOnly }) 剔除
|
|
1090
|
+
hidden: (): string[] => state.form.filter((col: any) => col.field && isCleared(col)).map((col: any) => fieldPath(col)),
|
|
898
1091
|
};
|
|
899
1092
|
onMounted(() => {
|
|
900
1093
|
shared.instances.add(instance);
|
|
@@ -917,7 +1110,10 @@
|
|
|
917
1110
|
const keys = Object.keys(a);
|
|
918
1111
|
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
|
|
919
1112
|
};
|
|
920
|
-
|
|
1113
|
+
// path 由收集字段的那一层计算(validate 在根表单调用时,嵌套字段的路径不能用根表单拼)
|
|
1114
|
+
const validateField = (col: any, ctx: FormContext, path: string, force = false): Promise<any> => {
|
|
1115
|
+
const server = shared.serverErrors.get(path);
|
|
1116
|
+
if (server) return Promise.resolve({ msg: server.msg, type: server.type });
|
|
921
1117
|
const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
|
|
922
1118
|
const previous = shared.validations.get(col);
|
|
923
1119
|
if (previous && previous.epoch === shared.validationEpoch && (!force || previous.pending) && sameInput(previous.input, input)) return previous.result;
|
|
@@ -930,7 +1126,7 @@
|
|
|
930
1126
|
error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
|
|
931
1127
|
} catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
|
|
932
1128
|
job.pending = false;
|
|
933
|
-
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch) { col._error = error; col._checked = true; }
|
|
1129
|
+
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !shared.serverErrors.has(path)) { col._error = error; col._checked = true; }
|
|
934
1130
|
return error;
|
|
935
1131
|
})();
|
|
936
1132
|
return job.result;
|
|
@@ -938,11 +1134,22 @@
|
|
|
938
1134
|
watchEffect(() => {
|
|
939
1135
|
for (const col of state.form) {
|
|
940
1136
|
// 只读取校验输入,不监听内部错误结果;异步结果按执行顺序丢弃过期响应。
|
|
941
|
-
deepcopy(displayOf(col, context()));
|
|
1137
|
+
const value = deepcopy(displayOf(col, context()));
|
|
942
1138
|
deepcopy(rulesOf(col));
|
|
943
1139
|
deepcopy(col.model);
|
|
1140
|
+
// 回填的错误在字段值变化前一直显示,值一变就交回正常校验
|
|
1141
|
+
const path = col.field ? fieldPath(col) : '';
|
|
1142
|
+
const server = path ? shared.serverErrors.get(path) : undefined;
|
|
1143
|
+
if (server && !isCleared(col)) {
|
|
1144
|
+
if (sameInput(server.input, value)) {
|
|
1145
|
+
col._error = { msg: server.msg, type: server.type };
|
|
1146
|
+
col._checked = true;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
shared.serverErrors.delete(path);
|
|
1150
|
+
}
|
|
944
1151
|
if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
|
|
945
|
-
void validateField(col, context());
|
|
1152
|
+
void validateField(col, context(), path);
|
|
946
1153
|
} else {
|
|
947
1154
|
shared.validations.delete(col);
|
|
948
1155
|
col._error = null;
|
|
@@ -970,15 +1177,19 @@
|
|
|
970
1177
|
nextTick(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
971
1178
|
};
|
|
972
1179
|
|
|
973
|
-
//
|
|
974
|
-
const
|
|
1180
|
+
// 路径匹配:'contact' 包含 'contact.phone','members.0' 包含该行全部字段
|
|
1181
|
+
const withinPaths = (path: string, paths?: string[]) => !paths || paths.some((p) => path === p || path.startsWith(`${p}.`));
|
|
1182
|
+
|
|
1183
|
+
// 校验已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid;paths 限定只校验这些路径
|
|
1184
|
+
const validate = async ({ scroll = false, paths }: { scroll?: boolean; paths?: string[] } = {}): Promise<FormValidateResult> => {
|
|
975
1185
|
const epoch = shared.validationEpoch;
|
|
976
1186
|
const cancelledResult = (): FormValidateResult => ({ valid: false, errors: [{ field: '', path: '', label: '', msg: '表单已重置或切换,请重新校验', type: 'risk' }], warnings: [] });
|
|
977
1187
|
let cancel!: () => void;
|
|
978
1188
|
const cancelled = new Promise<FormValidateResult>(resolve => { cancel = () => resolve(cancelledResult()); });
|
|
979
1189
|
shared.cancelChecks.add(cancel);
|
|
980
1190
|
const run = async (): Promise<FormValidateResult> => {
|
|
981
|
-
|
|
1191
|
+
// 局部校验只让这些字段显示提示,其他字段保持原状
|
|
1192
|
+
if (!paths) shared.state.validated = true;
|
|
982
1193
|
let results: any[];
|
|
983
1194
|
let revision: number;
|
|
984
1195
|
do {
|
|
@@ -990,8 +1201,9 @@
|
|
|
990
1201
|
if (shared.pending.size) await Promise.all([...shared.pending]);
|
|
991
1202
|
} while (revision !== shared.revision || shared.pending.size);
|
|
992
1203
|
revision = shared.revision;
|
|
993
|
-
results = await Promise.all(fieldsOf().map(async ({ col, path, ctx }) => {
|
|
994
|
-
|
|
1204
|
+
results = await Promise.all(fieldsOf().filter(({ path }) => withinPaths(path, paths)).map(async ({ col, path, ctx }) => {
|
|
1205
|
+
if (paths) col._touched = true;
|
|
1206
|
+
const res = await validateField(col, ctx, path, true);
|
|
995
1207
|
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
996
1208
|
}));
|
|
997
1209
|
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
@@ -1021,10 +1233,66 @@
|
|
|
1021
1233
|
|
|
1022
1234
|
const clearValidate = () => {
|
|
1023
1235
|
cancelValidation();
|
|
1236
|
+
shared.serverErrors.clear();
|
|
1024
1237
|
shared.validations = new WeakMap();
|
|
1025
1238
|
shared.state.validated = false;
|
|
1026
1239
|
fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
|
|
1027
1240
|
};
|
|
1028
1241
|
|
|
1029
|
-
|
|
1242
|
+
// 回填外部错误(如服务端校验结果):{ 'contact.phone': '号码已被占用' } 或 [{ path, msg, type }]。
|
|
1243
|
+
// 字段值变化后错误自动消失;返回没有匹配到已显示字段的路径,便于改用全局提示。
|
|
1244
|
+
const setErrors = (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, { scroll = false }: { scroll?: boolean } = {}): string[] => {
|
|
1245
|
+
const list: Array<{ path: string; msg: string; type?: string }> = Array.isArray(errors)
|
|
1246
|
+
? errors
|
|
1247
|
+
: Object.entries(errors || {}).map(([path, error]) => (isString(error) ? { path, msg: error } : { path, ...(error as any) }));
|
|
1248
|
+
const fields = fieldsOf();
|
|
1249
|
+
const missing: string[] = [];
|
|
1250
|
+
list.forEach(({ path, msg, type = 'risk' }) => {
|
|
1251
|
+
const found = fields.find((item) => item.path === path);
|
|
1252
|
+
if (!found) return missing.push(path);
|
|
1253
|
+
shared.serverErrors.set(path, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
|
|
1254
|
+
});
|
|
1255
|
+
const first = list.find((item) => !missing.includes(item.path));
|
|
1256
|
+
if (scroll && first) scrollToField(first.path);
|
|
1257
|
+
return missing;
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// 与初始值(挂载时的数据及自动补全的默认值,或 setBaseline 之后的数据)比较,返回变化的字段路径
|
|
1261
|
+
const getChanges = (): string[] => {
|
|
1262
|
+
const changes: string[] = [];
|
|
1263
|
+
const diff = (current: any, initial: any, path: string) => {
|
|
1264
|
+
if (sameInput(current, initial)) return;
|
|
1265
|
+
const bothArrays = isArray(current) && isArray(initial);
|
|
1266
|
+
const bothObjects = isPlain(current) && isPlain(initial);
|
|
1267
|
+
if ((bothArrays && current.length === initial.length) || bothObjects) {
|
|
1268
|
+
const keys = new Set([...Object.keys(current), ...Object.keys(initial)]);
|
|
1269
|
+
keys.forEach((key) => diff(current[key], initial[key], path ? `${path}.${key}` : key));
|
|
1270
|
+
} else changes.push(path);
|
|
1271
|
+
};
|
|
1272
|
+
diff(toRaw(rootData.value), shared.snapshot, '');
|
|
1273
|
+
return changes;
|
|
1274
|
+
};
|
|
1275
|
+
const isDirty = (path?: string) => getChanges().some((item) => !path || withinPaths(item, [path]) || withinPaths(path, [item]));
|
|
1276
|
+
|
|
1277
|
+
// 把当前数据设为新的初始值:同一对象上加载记录(Object.assign)或保存成功后调用,reset() 与 isDirty() 以它为准
|
|
1278
|
+
const setBaseline = () => captureInitial();
|
|
1279
|
+
|
|
1280
|
+
// 取数据副本;visibleOnly 时去掉被 clear 隐藏的字段
|
|
1281
|
+
const getData = ({ visibleOnly = false }: { visibleOnly?: boolean } = {}) => {
|
|
1282
|
+
const data = deepcopy(toRaw(rootData.value));
|
|
1283
|
+
if (visibleOnly) {
|
|
1284
|
+
[...shared.instances].flatMap((item) => item.hidden()).forEach((path) => {
|
|
1285
|
+
const keys = path.split('.');
|
|
1286
|
+
const last = keys.pop() as string;
|
|
1287
|
+
const parent = keys.reduce((target: any, key) => (target == null ? undefined : target[key]), data);
|
|
1288
|
+
if (parent && typeof parent === 'object') delete parent[last];
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return data;
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
// 插槽名由字段名、model 名动态生成,参数随插槽种类不同;字段与控件插槽的参数见 FormSlotScope
|
|
1295
|
+
defineSlots<Record<string, (scope: any) => any>>();
|
|
1296
|
+
|
|
1297
|
+
defineExpose({ validate, reset, clearValidate, scrollToField, setErrors, getChanges, isDirty, setBaseline, getData });
|
|
1030
1298
|
</script>
|