@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.
@@ -1,173 +1,143 @@
1
1
  <template>
2
- <div :ui-row="rows" :class="rootClass">
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
- <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>
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
- <!-- 分组:递归渲染,带 field 时写入 data[field],否则和当前层共用数据 -->
63
- <div v-if="isGroup(col)" class="flex-block" :style="col.attrs?.['ui-flex'] ? { display: 'contents' } : undefined">
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
- :form="col.child || []"
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="childData(col)"
22
+ :arrayContext="col._array ? arrayContext(col) : arrayContextProp"
23
+ :data="col._data"
69
24
  :root="rootData"
70
- :path="fieldPath(col)"
25
+ :path="[path, col.paths].filter(Boolean).join('.')"
71
26
  :index="index"
72
- :idx="childIdx(col, 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="interacted"
77
- :colon="colon"
78
- :labelClass="labelClass"
79
- :labelWidth="labelWidth"
80
- :vertical="vertical"
29
+ :interacted="touched"
81
30
  :debug="debug"
82
- >
83
- <template v-for="name in childSlots()" #[name]="scope"><slot :name="name" v-bind="scope || {}"></slot></template>
84
- </FormLoader>
85
- </div>
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
- <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
- >
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
- </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>
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
- <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>
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
- <!-- 叶节点:输入、内容、按钮和自定义控件使用相同的 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>
146
- <template v-else>
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>
149
- </template>
150
- </FormRender>
151
- </template>
152
- </div>
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="`${vertical ? 'col xm' : 'row xt'}`">
155
- <div v-if="vertical">
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
- </div>
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, readonly, watchEffect } from 'vue';
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 { FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
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 resolvePath = (data: any, paths: string = '') => {
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] = {}; recordInitial(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 = resolvePath(row, item.paths);
270
- if (isArray(item.child) && !item.array) {
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(item.child, getEmpty);
250
+ target[item.field] = createRow(child, getEmpty);
273
251
  } else {
274
- Object.assign(target, createRow(item.child, getEmpty));
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
- const call = (setup: any, item: any, used?: () => void) => {
385
+ // 联动函数拿到的是可写的数据视图:读取直接访问表单数据,Vue 只按实际读到的字段收集依赖;
386
+ // 写入只落在视图自己的覆盖层,不改动表单数据(与 4.x 传副本的语义一致)。
387
+ const call = (setup: any, item: any, onRead?: () => void) => {
344
388
  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
- });
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
- let usesData = false;
413
+ // 同步读取由 computed 精确追踪;异步函数在 await 之后才读取的数据无法追踪,
414
+ // 一旦出现就退回到跟随根数据版本重算。
415
+ let lateReads = false;
374
416
  const version = shallowRef(0);
375
- unwatchs.push(watch(() => context().root, () => {
376
- if (usesData) version.value++;
377
- }, { deep: true, flush: 'sync' }));
417
+ unwatchs.push(watch(shared.dataVersion, () => {
418
+ if (lateReads) version.value++;
419
+ }));
378
420
  // 初次解析与监听共用计算结果,避免异步函数被重复调用。
379
421
  const evaluated = computed(() => {
380
422
  version.value;
381
- return call(fn, reactive(item), () => { usesData = true; });
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, value, setup } = task;
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[key] = key === 'model' ? normalizeModel(result) : result;
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
- nextTick(() => { if (isCurrent()) apply(value); });
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
- unwatchs.push(watchEffect(() => {
470
- deepcopy(context().root);
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(props.data);
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
- // 分组、数组的路径和 id 前缀
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 (isArray(item.child)) return {};
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
- const linkage = useLinkage({ target: (idx) => state.form[idx], context });
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 = `form-${props.idx !== '' ? props.idx + '-' : ''}${!item.field && isGroup(item) ? `group-${idx}` : `field-${item.field || `node-${idx}`}`}`;
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(item._fid);
1071
+ hiddenFields.add(key);
881
1072
  emptyField(resolvePath(props.data, item.paths), item.field, cache);
882
- } else if (hiddenFields.has(item._fid) || firstRun) {
883
- hiddenFields.delete(item._fid);
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
- const validateField = (col: any, ctx: FormContext, force = false): Promise<any> => {
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
- // 校验所有已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid
974
- const validate = async ({ scroll = false }: { scroll?: boolean } = {}): Promise<FormValidateResult> => {
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
- shared.state.validated = true;
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
- const res = await validateField(col, ctx, true);
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
- defineExpose({ validate, reset, clearValidate, scrollToField });
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>