@neatui/vue 5.1.0 → 5.1.2

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,175 +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>
12
+
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>
61
16
 
62
- <!-- 分组:递归渲染,带 field 时写入 data[field],否则和当前层共用数据 -->
63
- <div v-if="isGroup(col)" class="flex-block" :style="col.attrs?.['ui-flex'] ? { display: 'contents' } : undefined">
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
- <!-- 4.x 兼容:字段级 slot 优先于 model / field 同名插槽 -->
148
- <slot v-if="col.slot" :name="col.slot" v-bind="fieldScope(col)"></slot>
149
- <slot v-else-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
150
- <slot v-else :name="col.field" v-bind="fieldScope(col)"></slot>
151
- </template>
152
- </FormRender>
153
- </template>
154
- </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>
155
105
  </template>
156
- <div v-if="$slots.submit" :ui-flex="`${vertical ? 'col xm' : 'row xt'}`">
157
- <div v-if="vertical">
158
- <slot name="submit"></slot>
159
- </div>
160
- <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">
161
108
  <slot name="submit"></slot>
162
109
  </div>
163
110
  </div>
164
- </div>
111
+ </Level>
165
112
  </template>
166
113
  <script lang="ts" setup>
167
- import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, watchEffect, useId, isReactive, isReadonly } from 'vue';
114
+ import { Comment, h, watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, watchEffect, useId, isReactive, isReadonly } from 'vue';
168
115
  import { useRoute, useRouter } from 'vue-router';
169
116
  import { isString, isObject, deepcopy, isArray, isFunction } from '@fekit/utils';
170
117
  import FormRender, { getField, emptyValueOf, clh, isEmpty, labelText, labelAttrs, rulesOf, displayOf, valueBinding, labelNodes, eventsOf, writeValue, FieldMessage } from './FormRender.vue';
171
118
  import type { PropType, ShallowRef } from 'vue';
172
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
+ };
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);
173
141
  // 计算Label宽度
174
142
  const clw = (label: any = '') => {
175
143
  let emWidth = 0;
@@ -256,6 +224,13 @@
256
224
  return _model;
257
225
  };
258
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
+
259
234
  // 规范化数组配置,兼容旧版 array: 'field' 和 lists
260
235
  const normalizeArray = (array: any) => {
261
236
  if (isString(array)) return { child: [{ field: array }] };
@@ -269,11 +244,12 @@
269
244
  list.forEach((item: any = {}) => {
270
245
  if (!isObject(item)) return;
271
246
  const target = ensurePath(row, item.paths);
272
- if (isArray(item.child) && !item.array) {
247
+ const child = childList(item.child);
248
+ if (child && !item.array) {
273
249
  if (item.field) {
274
- target[item.field] = createRow(item.child, getEmpty);
250
+ target[item.field] = createRow(child, getEmpty);
275
251
  } else {
276
- Object.assign(target, createRow(item.child, getEmpty));
252
+ Object.assign(target, createRow(child, getEmpty));
277
253
  }
278
254
  } else if (item.field) {
279
255
  target[item.field] = getEmpty(item);
@@ -625,6 +601,11 @@
625
601
  default: false,
626
602
  },
627
603
  // 以下由分组和数组内部传递
604
+ // 内部层级:不输出根元素,子节点直接落在上一层的容器里
605
+ bare: {
606
+ type: Boolean,
607
+ default: false,
608
+ },
628
609
  // 根表单数据,联动函数和 watch 都基于它
629
610
  root: {
630
611
  type: Object as any,
@@ -680,7 +661,7 @@
680
661
 
681
662
  const rootData = computed(() => props.root || props.data);
682
663
  // 根表单未传 idx 时用 useId 生成前缀,同页多个表单的同名字段 id 不再冲突;SSR 与客户端一致。
683
- const autoIdx = useId().replace(/[^\w-]/g, '-');
664
+ const autoIdx = `f${shortHash(useId())}`;
684
665
  const baseIdx = computed(() => (props.idx !== '' ? String(props.idx) : props.root ? '' : autoIdx));
685
666
  const cancelValidation = () => {
686
667
  shared.validationEpoch++;
@@ -766,6 +747,106 @@
766
747
  // 整行插槽
767
748
  const rowSlot = (col: any = {}) => (col.field && hasSlot(`${col.field}-row`) ? `${col.field}-row` : '');
768
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
+
769
850
  // 表单默认标签宽度;单个标签用 label[1].style 覆盖。
770
851
  const widthOf = (value: any) => {
771
852
  if (value === 'auto') return '';
@@ -778,9 +859,8 @@
778
859
  return style ? style.replace(/^width:([^;]+);.*$/, 'padding-left:$1;') : '';
779
860
  });
780
861
 
781
- // 分组、数组的路径和 id 前缀
862
+ // 字段数据路径(也是字段 id 的主体)
782
863
  const fieldPath = (col: any = {}) => [props.path, col.paths, col.field].filter(Boolean).join('.');
783
- const childIdx = (col: any = {}, idx: number) => [baseIdx.value, col.field || `g${idx}`].filter((v: any) => v !== '').join('-');
784
864
  const childData = (col: any = {}) => (col.field ? col._data[col.field] : col._data);
785
865
  const listOf = (col: any = {}) => (isArray(col._data?.[col.field]) ? col._data[col.field] : []);
786
866
 
@@ -800,7 +880,7 @@
800
880
  const initialOf = (item: any = {}) => {
801
881
  if (valueBinding(item.value) && item.value.default !== undefined) return deepcopy(item.value.default);
802
882
  if (item.array) return [];
803
- if (isArray(item.child)) return {};
883
+ if (childList(item.child)) return {};
804
884
  return emptyValueOf(isString(item.model) || isArray(item.model) ? normalizeModel(item.model) : ['']);
805
885
  };
806
886
 
@@ -885,6 +965,7 @@
885
965
  apply: (target, key, result) => {
886
966
  target[key] = key === 'model' ? normalizeModel(result) : result;
887
967
  if (key === 'array') target._array = result ? normalizeArray(result) : null;
968
+ if (key === 'child') splitChild(target);
888
969
  if (key === 'child' || key === 'array') initializeField(target);
889
970
  },
890
971
  });
@@ -925,6 +1006,7 @@
925
1006
  if (isObject(item)) {
926
1007
  linkage.resolve(item, idx, tasks);
927
1008
  }
1009
+ splitChild(item);
928
1010
 
929
1011
  item._array = item.array ? normalizeArray(item.array) : item._kind === 'array' ? { child: [] } : null;
930
1012
 
@@ -941,7 +1023,7 @@
941
1023
  }
942
1024
 
943
1025
  // 添加ID
944
- item._fid = `form-${baseIdx.value !== '' ? baseIdx.value + '-' : ''}${!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('-') : '';
945
1027
 
946
1028
  // 数据路径处理
947
1029
  item._data = resolvePath(props.data, item.paths);
@@ -953,9 +1035,6 @@
953
1035
  fields.add(key);
954
1036
  initializeField(item);
955
1037
  }
956
-
957
- // 分组、数组默认占满一行
958
- if ((isGroup(item) || item._array) && !item.media) item.media = 'mob-24';
959
1038
  return item;
960
1039
  });
961
1040
  linkage.start(tasks);
@@ -984,14 +1063,15 @@
984
1063
  watch(
985
1064
  [() => state.form, () => props.data, () => props.clearContext],
986
1065
  () => {
987
- state.form.forEach((item: any = {}) => {
1066
+ state.form.forEach((item: any = {}, idx: number) => {
988
1067
  if (isGroup(item)) return; // 保留递归实例,由解析后的子节点各自清空和恢复。
989
1068
  const cache = canRestore(item) ? shared.cache : undefined;
1069
+ const key = item._fid || `#${idx}`;
990
1070
  if (isCleared(item)) {
991
- hiddenFields.add(item._fid);
1071
+ hiddenFields.add(key);
992
1072
  emptyField(resolvePath(props.data, item.paths), item.field, cache);
993
- } else if (hiddenFields.has(item._fid) || firstRun) {
994
- hiddenFields.delete(item._fid);
1073
+ } else if (hiddenFields.has(key) || firstRun) {
1074
+ hiddenFields.delete(key);
995
1075
  if (cache) restoreField(resolvePath(props.data, item.paths), item.field, cache);
996
1076
  }
997
1077
  });
@@ -1,9 +1,12 @@
1
1
  <template>
2
- <div ref="root" class="flex-block pr" @focusout="col._touched = true">
2
+ <button v-if="bare && col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></button>
3
+ <div v-else-if="bare && col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
4
+ <div v-else-if="bare" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></div>
5
+ <div v-else ref="root" class="flex-block pr" @focusout="col._touched = true">
3
6
  <slot v-if="custom" />
4
- <button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></button>
7
+ <button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></button>
5
8
  <div v-else-if="col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
6
- <div v-else-if="col.model[0] === 'Html' || col.model[0] === 'Text'" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></div>
9
+ <div v-else-if="col.model[0] === 'Html' || col.model[0] === 'Text'" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></div>
7
10
  <div v-else-if="(mode === 'view' && !entry?.view) || entry?.component === FieldView" class="flex-block o-ls" :class="clh(col.model?.[1] || {})" v-on="events">{{ text }}</div>
8
11
  <component
9
12
  v-else-if="entry"
@@ -108,13 +111,21 @@
108
111
  item?: Record<string, any> | (() => Record<string, any>);
109
112
  }
110
113
 
114
+ /** 子节点容器:attrs 描述这一层容器(可以是函数),lists / child 是子节点 */
115
+ export interface FormChild {
116
+ attrs?: Record<string, any> | ((data: any, item: any, ctx: FormContext) => Record<string, any>);
117
+ lists?: FormItem[];
118
+ child?: FormItem[];
119
+ }
120
+
111
121
  /** 基础节点:标题和子节点复用同一套配置,不为装饰或交互增加专用字段。 */
112
122
  export interface FormItem {
113
123
  label?: Dynamic<string | [string, Record<string, any>] | FormItem[]>;
114
124
  field?: string;
115
125
  // 固定内容、{ text, html, attrs } 内容对象、VNode、数组;绑定字段时用 { default, get, set };也可以是函数或联动配置
116
126
  value?: FormValue | FormContent | FormSetup | FormLinkage<FormContent>;
117
- child?: Dynamic<FormItem[]>;
127
+ // 子节点;{ attrs, lists } 时 attrs 落在承载子节点的容器上(无标题分组即节点元素本身)
128
+ child?: Dynamic<FormItem[]> | FormChild;
118
129
  attrs?: Dynamic<Record<string, any>>;
119
130
  clear?: Dynamic<boolean | { value: boolean; restore?: boolean }>;
120
131
  enums?: Dynamic<any[]>;
@@ -123,7 +134,8 @@
123
134
  model?: Dynamic<string | [string, Record<string, any>?]>;
124
135
  rules?: Dynamic<FormRule[]>;
125
136
  // 兼容原有布局和数据路径;普通容器布局也可直接使用 attrs。
126
- media?: Dynamic<string>;
137
+ /** 自身 ui-col(只在所在层有栅格时输出);false 表示不用栅格:自身不带 ui-col,分组 / 数组的下一层也不带 ui-row */
138
+ media?: Dynamic<string | false>;
127
139
  vertical?: Dynamic<boolean>;
128
140
  paths?: string;
129
141
  // 4.x 兼容:自定义插槽名;字段级冒号(false 不显示,string 自定义字符)
@@ -253,7 +265,7 @@
253
265
  return handlers;
254
266
  };
255
267
 
256
- // 内容渲染:字符串按文本渲染;VNode 原样渲染;{ text, html, attrs } 只有 html 为 true 且内容来自配置时才按 HTML 渲染
268
+ // 内容渲染:字符串直接输出文本节点;VNode 原样渲染;{ text, html, attrs } 需要属性才包 span,只有 html 为 true 且内容来自配置时才按 HTML 渲染
257
269
  const render = (value: any, trusted: boolean): any => {
258
270
  if (value === null || value === undefined || value === '') return null;
259
271
  if (isVNode(value)) return value;
@@ -262,18 +274,19 @@
262
274
  const { text = '', html = false, attrs = {} } = value;
263
275
  return html && trusted ? h('span', { ...attrs, innerHTML: text }) : h('span', trusted ? attrs : {}, String(text));
264
276
  }
265
- return h('span', String(value));
277
+ return String(value);
266
278
  };
267
279
 
268
280
  export const FieldMessage = defineComponent({
269
281
  props: ['col', 'mode'],
282
+ // 没有内容时返回空数组:客户端渲染不留占位注释
270
283
  setup: (props) => () => props.mode !== 'view' ? [
271
284
  props.col.model?.[1]?.verify && props.col._checked
272
285
  ? h('i', { 'ui-form-verify': props.col._error ? 'no' : isEmpty(props.col._data?.[props.col.field]) ? '' : 'ok', 'aria-hidden': 'true' }) : null,
273
286
  props.col._error ? h('p', { id: `${props.col._fid}-error`, 'data-field-error': props.col.field,
274
287
  role: props.col._error.type === 'warn' ? 'status' : 'alert',
275
288
  class: `mt-xs ${props.col._error.type === 'warn' ? 'co-warn' : 'co-risk'}` }, props.col._error.msg) : null,
276
- ] : null,
289
+ ].filter(Boolean) : [],
277
290
  });
278
291
 
279
292
  export const FieldExtra = defineComponent({
@@ -396,6 +409,8 @@
396
409
  mode?: string;
397
410
  ctx: FormContext;
398
411
  custom?: boolean;
412
+ // 无标题的内容节点:不包字段容器,直接输出内容元素,节点 attrs 透传到这个元素上(模板根部不能有注释)
413
+ bare?: boolean;
399
414
  }>();
400
415
  // 在选项树里按 value 找 label
401
416
  const findLabel = (options: any[] = [], value: any): any => {
@@ -436,6 +451,12 @@
436
451
  const { value } = props.col;
437
452
  return value !== undefined && (!valueBinding(value) || isFunction(value.get));
438
453
  });
454
+ // 字符串、数字、布尔直接输出文本(空值不留占位注释);VNode、{ text, html }、数组交给 FieldExtra
455
+ const plain = computed(() => {
456
+ const value = displayOf(props.col, props.ctx);
457
+ if (value === null || value === undefined) return '';
458
+ return typeof value === 'object' ? null : String(value);
459
+ });
439
460
  const events = computed(() => eventsOf(props.col, props.ctx));
440
461
  // 先写回数据再通知配置里的 update:modelValue,与插槽的 update 顺序一致。
441
462
  const controlEvents = computed(() => {