@neatui/vue 5.1.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 +4 -0
- package/package.json +2 -2
- 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/Textarea.vue +1 -1
- package/src/components/form/TimePicker.vue +1 -1
- package/src/components/loader/FormLoader/FormLoader.vue +239 -159
- package/src/components/loader/FormLoader/FormRender.vue +29 -8
- package/src/components/loader/TableLoader/TableLoader.vue +233 -24
package/MIGRATION-5.0.md
CHANGED
|
@@ -60,6 +60,10 @@ Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同
|
|
|
60
60
|
|
|
61
61
|
## 新增与行为修复
|
|
62
62
|
|
|
63
|
+
- 渲染结构精简:节点即元素,attrs 原样落在节点元素上。无标题的 Text / Html / Button 节点只输出内容元素本身;分组、数组内部的层级不再包根元素,子节点直接落在节点元素(无标题分组)或内容容器(有标题分组)里;纯文本不再包 span,不输出空的 style / ui-col。模板不输出说明注释,空内容、没有提示、没有 submit 时不留占位注释(客户端渲染)。依赖旧包裹层(`.flex-block > [ui-row]`、内容节点外层 div、`span`)的样式或测试需要调整。
|
|
64
|
+
- id 只给字段:`表单前缀-数据路径`(`.` 换成 `-`),如 `apply-contact-phone`、`apply-members-0-name`;前缀为 `idx`,未传时由 useId 生成 6 位短串。分组、容器、内容节点不再输出 id,需要时在 attrs 里写。旧的 `form-<idx>-g0-field-x` / `group-0` 形式不再生成,错误提示 id 为 `<字段 id>-error`。
|
|
65
|
+
- `child` 可写成 `{ attrs, lists }`:attrs(可为函数)描述承载子节点的容器;容器用 ui-flex 布局时不加栅格和默认间距 `n-sl-sub`,写了 class 时替换默认间距;无标题分组的节点元素就是容器,节点写了 class 时同样不加默认间距(与根元素规则一致)。
|
|
66
|
+
- `media: false`:这一层不用栅格,自身不带 ui-col,分组 / 数组的下一层也不带 ui-row;ui-col 只在所在层有栅格时输出;`rows=""` 时根元素不再输出空的 ui-row。
|
|
63
67
|
- 标题与内容均可递归组合;数组支持默认值、数量限制、增删排序和行上下文。
|
|
64
68
|
- 新增 `setErrors`(回填服务端错误)、`isDirty / getChanges`(未保存改动)、`setBaseline`(设置重置基线)、`getData({ visibleOnly })`(去掉隐藏字段),`validate({ paths })` 支持局部校验。在同一对象上用 `Object.assign` 加载记录后调用 `setBaseline()`。
|
|
65
69
|
- `validate()` 等待异步联动并检查最新数据;`reset()` 恢复当前数据对象的初始状态;`clearValidate()` 取消未完成校验。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "basic component ui",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@fekit/scrollto": "^3.0.3",
|
|
37
37
|
"@fekit/toast": "^2.7.2",
|
|
38
38
|
"@fekit/utils": "^6.0.0",
|
|
39
|
-
"@neatui/css": "^5.1.
|
|
39
|
+
"@neatui/css": "^5.1.1",
|
|
40
40
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
41
41
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
42
42
|
"@vue/compiler-sfc": "^3.4.30",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
:ui-form="
|
|
3
|
+
:ui-form="[theme, sz && `sz:${sz}`, state.error.co, 'type:input', block && ':block', disabled && 'disabled', readonly && 'readonly', preview && 'preview'].filter(Boolean).join(' ')"
|
|
4
4
|
@click="emits('click', $event)"
|
|
5
5
|
>
|
|
6
6
|
<div v-if="state.interacted" :ui-form-tips="state.error.ontips">{{ state.error.tips }}</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
:ui-form="
|
|
3
|
+
:ui-form="[theme, sz && `sz:${sz}`, state.error.co, 'type:input', block && ':block', disabled && 'disabled', readonly && 'readonly'].filter(Boolean).join(' ')"
|
|
4
4
|
@click="emits('click', $event)"
|
|
5
5
|
>
|
|
6
6
|
<div :ui-form-tips="state.error.ontips">{{ state.error.tips }}</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div :ui-form="
|
|
2
|
+
<div :ui-form="[theme, sz && `sz:${sz}`, state.co, 'type:input', block && ':block', disabled && 'disabled', readonly && 'readonly'].filter(Boolean).join(' ')" @click="emits('click', $event)">
|
|
3
3
|
<div v-if="ready" :ui-form-tips="state.ontips">{{ state.tips }}</div>
|
|
4
4
|
<div ui-form-prefix="">
|
|
5
5
|
<template v-for="(item, idx) in prefix" :key="idx">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
:ui-form="
|
|
3
|
+
:ui-form="[theme, sz && `sz:${sz}`, state.error.co, 'type:textarea', isAutoHeight && 'auto-height', block && ':block', disabled && 'disabled', readonly && 'readonly'].filter(Boolean).join(' ')"
|
|
4
4
|
@click="emits('click', $event)"
|
|
5
5
|
>
|
|
6
6
|
<div :ui-form-tips="state.error.ontips">{{ state.error.tips }}</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div :ui-form="
|
|
2
|
+
<div :ui-form="[theme, 'type:input', disabled && 'disabled'].filter(Boolean).join(' ')">
|
|
3
3
|
<input type="time" :value="modelValue || ''" :min="min" :max="max" :step="step" :disabled="disabled" :readonly="readonly"
|
|
4
4
|
:aria-invalid="error ? true : undefined" :aria-label="label" :required="required" @input="update" @change="change" @blur="emit('blur', $event)" @focus="emit('focus', $event)" />
|
|
5
5
|
<span v-if="verify && error" role="alert" class="co-risk">{{ error }}</span>
|
|
@@ -1,175 +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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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="
|
|
157
|
-
<div
|
|
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
|
-
</
|
|
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
|
-
|
|
247
|
+
const child = childList(item.child);
|
|
248
|
+
if (child && !item.array) {
|
|
273
249
|
if (item.field) {
|
|
274
|
-
target[item.field] = createRow(
|
|
250
|
+
target[item.field] = createRow(child, getEmpty);
|
|
275
251
|
} else {
|
|
276
|
-
Object.assign(target, createRow(
|
|
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()
|
|
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
|
-
//
|
|
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 (
|
|
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 =
|
|
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(
|
|
1071
|
+
hiddenFields.add(key);
|
|
992
1072
|
emptyField(resolvePath(props.data, item.paths), item.field, cache);
|
|
993
|
-
} else if (hiddenFields.has(
|
|
994
|
-
hiddenFields.delete(
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
] :
|
|
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(() => {
|
|
@@ -101,14 +101,15 @@
|
|
|
101
101
|
<td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padTop)"></td>
|
|
102
102
|
</tr>
|
|
103
103
|
<tr
|
|
104
|
-
v-for="
|
|
105
|
-
:key="
|
|
106
|
-
:ref="virtualEnabled ? (el: any) => bindVirtualRow(el, item) : undefined"
|
|
104
|
+
v-for="row in bodyRows"
|
|
105
|
+
:key="row.key"
|
|
106
|
+
:ref="virtualEnabled ? (el: any) => bindVirtualRow(el, row.item) : undefined"
|
|
107
107
|
v-bind="rows.attrs"
|
|
108
|
-
v-on="event(rows.event, item, 'td')"
|
|
108
|
+
v-on="event(rows.event, row.item, 'td')"
|
|
109
|
+
:data-level="treeEnabled ? row.level : undefined"
|
|
109
110
|
>
|
|
110
111
|
<td v-if="selected" col-fixed="1" min-width :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
|
|
111
|
-
<label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="item.__selected" /><span></span></label>
|
|
112
|
+
<label v-if="row.level === 1" class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="row.item.__selected" /><span></span></label>
|
|
112
113
|
</td>
|
|
113
114
|
<template v-for="(col, cid) in state.table" :key="cid">
|
|
114
115
|
<td
|
|
@@ -119,12 +120,41 @@
|
|
|
119
120
|
:col-fixed-side="fixedSide(col.field)"
|
|
120
121
|
:style="fixedStyle(col.field)"
|
|
121
122
|
:data-field="col.field"
|
|
123
|
+
:data-tree="row.level > 1 && col.field === treeField ? 1 : undefined"
|
|
122
124
|
>
|
|
123
|
-
<
|
|
125
|
+
<div v-if="treeEnabled && col.field === treeField && row.level === 1" class="table-tree-node" ui-flex="row lm">
|
|
126
|
+
<i
|
|
127
|
+
v-if="childrenOf(row.item).length"
|
|
128
|
+
class="table-tree-toggle ux-none"
|
|
129
|
+
:data-fold="row.item.__folding ? 1 : 0"
|
|
130
|
+
@click.stop="row.item.__folding = !row.item.__folding"
|
|
131
|
+
></i>
|
|
132
|
+
<i v-else class="table-tree-toggle-pad"></i>
|
|
133
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="1"><TableRender :node="col" :data="row.item" /></slot>
|
|
134
|
+
</div>
|
|
135
|
+
<div v-else-if="treeEnabled && col.field === treeField && row.level === 2" class="table-tree-child" :data-last="row.last ? 1 : 0">
|
|
136
|
+
<div v-if="depth > 2" class="table-tree-node" ui-flex="row lm">
|
|
137
|
+
<i
|
|
138
|
+
v-if="childrenOf(row.item).length"
|
|
139
|
+
class="table-tree-toggle ux-none"
|
|
140
|
+
:data-fold="row.item.__folding ? 1 : 0"
|
|
141
|
+
@click.stop="row.item.__folding = !row.item.__folding"
|
|
142
|
+
></i>
|
|
143
|
+
<i v-else class="table-tree-toggle-pad"></i>
|
|
144
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="2"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
145
|
+
</div>
|
|
146
|
+
<slot v-else :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="2"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
147
|
+
</div>
|
|
148
|
+
<div v-else-if="treeEnabled && col.field === treeField" class="table-tree-child" data-pass="1" :data-last="row.parentLast ? 1 : 0">
|
|
149
|
+
<div class="table-tree-child" :data-last="row.last ? 1 : 0">
|
|
150
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<slot v-else :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"><TableRender v-if="row.level === 1 || childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
124
154
|
</td>
|
|
125
155
|
</template>
|
|
126
156
|
<td v-if="slots.operate" col-fixed="r" class="ar" :col-fixed-side="fixedSide('__operate')" :style="fixedStyle('__operate')">
|
|
127
|
-
<slot name="operate" :item="item" :idx="idx"></slot>
|
|
157
|
+
<slot name="operate" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"></slot>
|
|
128
158
|
</td>
|
|
129
159
|
</tr>
|
|
130
160
|
<tr v-if="virtualSlice.padBottom" class="table-virtual-pad" aria-hidden="true">
|
|
@@ -158,21 +188,71 @@
|
|
|
158
188
|
</IFollowView>
|
|
159
189
|
</div>
|
|
160
190
|
</li>
|
|
161
|
-
<
|
|
162
|
-
<
|
|
163
|
-
<
|
|
164
|
-
<
|
|
165
|
-
<
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
<template v-for="(item, idx) in lists" :key="rowKeyOf(item, idx)">
|
|
192
|
+
<li class="bg-fore r-sm my-sl n-ms">
|
|
193
|
+
<table>
|
|
194
|
+
<tbody>
|
|
195
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
196
|
+
<tr v-if="!col.__close">
|
|
197
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
198
|
+
<td class="n-ss">
|
|
199
|
+
<TableRender :node="col" :data="item" />
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
</template>
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
<div v-if="slots.operate"><slot name="operate" :item="item" :idx="idx" :parent="noParent" :level="1"></slot></div>
|
|
206
|
+
<p v-if="treeEnabled && childrenOf(item).length" class="mt-ss">
|
|
207
|
+
<span class="ux-none co-main fs-sm" @click="item.__folding = !item.__folding">
|
|
208
|
+
{{ item.__folding ? words['common.expand'] || '展开' : words['common.collapse'] || '收起' }}
|
|
209
|
+
</span>
|
|
210
|
+
</p>
|
|
211
|
+
</li>
|
|
212
|
+
<template v-if="treeEnabled && !item.__folding">
|
|
213
|
+
<template v-for="(child, cidx) in childrenOf(item)" :key="`${rowKeyOf(item, idx)}-${cidx}`">
|
|
214
|
+
<li v-if="childVisible(child)" class="table-tree-item bg-fore r-sm my-sl n-ms">
|
|
215
|
+
<table>
|
|
216
|
+
<tbody>
|
|
217
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
218
|
+
<tr v-if="!col.__close && childHas(col, child)">
|
|
219
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
220
|
+
<td class="n-ss">
|
|
221
|
+
<TableRender :node="col" :data="child" />
|
|
222
|
+
</td>
|
|
223
|
+
</tr>
|
|
224
|
+
</template>
|
|
225
|
+
</tbody>
|
|
226
|
+
</table>
|
|
227
|
+
<div v-if="slots.operate"><slot name="operate" :item="child" :idx="cidx" :parent="item" :level="2"></slot></div>
|
|
228
|
+
<p v-if="depth > 2 && childrenOf(child).length" class="mt-ss">
|
|
229
|
+
<span class="ux-none co-main fs-sm" @click="child.__folding = !child.__folding">
|
|
230
|
+
{{ child.__folding ? words['common.expand'] || '展开' : words['common.collapse'] || '收起' }}
|
|
231
|
+
</span>
|
|
232
|
+
</p>
|
|
233
|
+
</li>
|
|
234
|
+
<template v-if="depth > 2 && !child.__folding">
|
|
235
|
+
<template v-for="(grand, gidx) in childrenOf(child)" :key="`${rowKeyOf(item, idx)}-${cidx}-${gidx}`">
|
|
236
|
+
<li v-if="childVisible(grand)" class="table-tree-item table-tree-item-3 bg-fore r-sm my-sl n-ms">
|
|
237
|
+
<table>
|
|
238
|
+
<tbody>
|
|
239
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
240
|
+
<tr v-if="!col.__close && childHas(col, grand)">
|
|
241
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
242
|
+
<td class="n-ss">
|
|
243
|
+
<TableRender :node="col" :data="grand" />
|
|
244
|
+
</td>
|
|
245
|
+
</tr>
|
|
246
|
+
</template>
|
|
247
|
+
</tbody>
|
|
248
|
+
</table>
|
|
249
|
+
<div v-if="slots.operate"><slot name="operate" :item="grand" :idx="gidx" :parent="child" :level="3"></slot></div>
|
|
250
|
+
</li>
|
|
251
|
+
</template>
|
|
171
252
|
</template>
|
|
172
|
-
</
|
|
173
|
-
</
|
|
174
|
-
|
|
175
|
-
</li>
|
|
253
|
+
</template>
|
|
254
|
+
</template>
|
|
255
|
+
</template>
|
|
176
256
|
</ul>
|
|
177
257
|
</div>
|
|
178
258
|
<div v-if="!lists.length" class="ny-ls o-mm" ui-flex="col cm">
|
|
@@ -245,6 +325,12 @@
|
|
|
245
325
|
rowKey?: string | ((item: any, idx: number) => string | number);
|
|
246
326
|
/** 虚拟滚动(仅表格形态):只渲染可视区附近的行,需要容器限定高度 */
|
|
247
327
|
virtual?: boolean | { estimate?: number; overscan?: number };
|
|
328
|
+
/** 子行:字段名或函数,返回子行数组;子行沿用同一套列和插槽,折叠状态记在行的 __folding 上 */
|
|
329
|
+
children?: string | ((item: any) => any[]);
|
|
330
|
+
/** 折叠按钮和树线所在列的 field,默认第一个可见的非固定列 */
|
|
331
|
+
tree?: string;
|
|
332
|
+
/** 树的层数:默认 2(父行 + 子行);3 时子行再按 children 取第三层 */
|
|
333
|
+
depth?: number;
|
|
248
334
|
}
|
|
249
335
|
// 外部入参
|
|
250
336
|
const props: any = withDefaults(defineProps<Props>(), {
|
|
@@ -258,6 +344,7 @@
|
|
|
258
344
|
allSelected: false,
|
|
259
345
|
layout: 'auto',
|
|
260
346
|
virtual: false,
|
|
347
|
+
depth: 2,
|
|
261
348
|
});
|
|
262
349
|
|
|
263
350
|
/**
|
|
@@ -347,7 +434,7 @@
|
|
|
347
434
|
watch(
|
|
348
435
|
() => props.table,
|
|
349
436
|
() => {
|
|
350
|
-
const _cache = env.client ? JSON.parse(window.localStorage.getItem(`${name}_table`) || '{}') : {};
|
|
437
|
+
const _cache = env.client ? JSON.parse(window.localStorage.getItem(`${name.value}_table`) || '{}') : {};
|
|
351
438
|
state.__last = 0;
|
|
352
439
|
state.table = deepcopy(props.table || [])
|
|
353
440
|
?.map((col: TableColumn, idx: number) => {
|
|
@@ -426,7 +513,7 @@
|
|
|
426
513
|
state.__last = idx;
|
|
427
514
|
}
|
|
428
515
|
});
|
|
429
|
-
if (env.client) window.localStorage.setItem(`${name}_table`, JSON.stringify(_table));
|
|
516
|
+
if (env.client) window.localStorage.setItem(`${name.value}_table`, JSON.stringify(_table));
|
|
430
517
|
},
|
|
431
518
|
{ deep: true },
|
|
432
519
|
);
|
|
@@ -540,6 +627,25 @@
|
|
|
540
627
|
fixedCells.clear();
|
|
541
628
|
});
|
|
542
629
|
|
|
630
|
+
// 子行
|
|
631
|
+
const treeEnabled = computed(() => !!props.children);
|
|
632
|
+
const childrenOf = (item: any): any[] => {
|
|
633
|
+
const key = props.children;
|
|
634
|
+
const list = isFunction(key) ? key(item) : key && item ? item[key] : undefined;
|
|
635
|
+
return Array.isArray(list) ? list : [];
|
|
636
|
+
};
|
|
637
|
+
// 父行的 parent 为空;声明成 any,插槽里两层行的 parent 类型才一致
|
|
638
|
+
const noParent: any = undefined;
|
|
639
|
+
// 子行常常只有部分字段,没有的列留空,不显示占位符
|
|
640
|
+
const childHas = (col: TableColumn, child: any) =>
|
|
641
|
+
(!!child && typeof child === 'object' && hasOwnProp(child, col.field)) || (col.value !== undefined && col.value !== null) || !!(col as any).child || !!(col as any).popup;
|
|
642
|
+
const childVisible = (child: any) => state.table.some((col) => !col.__close && childHas(col, child));
|
|
643
|
+
const treeField = computed(() => {
|
|
644
|
+
if (props.tree) return props.tree;
|
|
645
|
+
const visible = state.table.filter((col) => !col.__close);
|
|
646
|
+
return (visible.find((col) => !col.fixed) || visible[0])?.field;
|
|
647
|
+
});
|
|
648
|
+
|
|
543
649
|
// 行标识
|
|
544
650
|
const rowKeyOf = (item: any, idx: number) => {
|
|
545
651
|
const key = props.rowKey;
|
|
@@ -553,7 +659,8 @@
|
|
|
553
659
|
* 行高各不相同(单元格可能换行、放表单控件),按行对象缓存实测高度,
|
|
554
660
|
* 没测过的按已测平均值估算;行对象不变则删行、排序后缓存仍有效。
|
|
555
661
|
*/
|
|
556
|
-
|
|
662
|
+
// 行高按父行测量,展开的子行会让偏移失真,开了子行就不走虚拟滚动
|
|
663
|
+
const virtualEnabled = computed(() => !!props.virtual && !props.children);
|
|
557
664
|
const virtualEstimate = computed(() => (isObject(props.virtual) && props.virtual.estimate) || 48);
|
|
558
665
|
const virtualOverscan = computed(() => (isObject(props.virtual) && props.virtual.overscan) || 600);
|
|
559
666
|
const scrollTop = ref(0);
|
|
@@ -586,6 +693,30 @@
|
|
|
586
693
|
return { rows, padTop: offsets[start] || 0, padBottom: Math.max(0, offsets[offsets.length - 1] - (offsets[end] || 0)) };
|
|
587
694
|
});
|
|
588
695
|
|
|
696
|
+
/** 桌面表格逐行渲染:开了子行时把树按展开状态摊平,记下层级、上级和是否同级最后一行(画树线用) */
|
|
697
|
+
const bodyRows = computed(() => {
|
|
698
|
+
if (!treeEnabled.value) {
|
|
699
|
+
return virtualSlice.value.rows.map(({ item, idx }: any) => ({ item, idx, level: 1, parent: noParent, key: rowKeyOf(item, idx), last: false, parentLast: false }));
|
|
700
|
+
}
|
|
701
|
+
const out: any[] = [];
|
|
702
|
+
props.lists.forEach((item: any, idx: number) => {
|
|
703
|
+
const key = rowKeyOf(item, idx);
|
|
704
|
+
out.push({ item, idx, level: 1, parent: noParent, key, last: false, parentLast: false });
|
|
705
|
+
if (item?.__folding) return;
|
|
706
|
+
const kids = childrenOf(item);
|
|
707
|
+
kids.forEach((child, cidx) => {
|
|
708
|
+
const last = cidx === kids.length - 1;
|
|
709
|
+
out.push({ item: child, idx: cidx, level: 2, parent: item, key: `${key}-${cidx}`, last, parentLast: false });
|
|
710
|
+
if (props.depth < 3 || child?.__folding) return;
|
|
711
|
+
const grand = childrenOf(child);
|
|
712
|
+
grand.forEach((g, gidx) => {
|
|
713
|
+
out.push({ item: g, idx: gidx, level: 3, parent: child, key: `${key}-${cidx}-${gidx}`, last: gidx === grand.length - 1, parentLast: last });
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
return out;
|
|
718
|
+
});
|
|
719
|
+
|
|
589
720
|
let scrollFrame = 0;
|
|
590
721
|
const onVirtualScroll = () => {
|
|
591
722
|
if (!virtualEnabled.value || scrollFrame) return;
|
|
@@ -706,3 +837,81 @@
|
|
|
706
837
|
return { ..._event };
|
|
707
838
|
};
|
|
708
839
|
</script>
|
|
840
|
+
<style lang="scss" scoped>
|
|
841
|
+
.table-tree-node {
|
|
842
|
+
flex-wrap: nowrap;
|
|
843
|
+
}
|
|
844
|
+
.table-tree-toggle,
|
|
845
|
+
.table-tree-toggle-pad {
|
|
846
|
+
flex: none;
|
|
847
|
+
position: relative;
|
|
848
|
+
width: 1em;
|
|
849
|
+
height: 1em;
|
|
850
|
+
margin-right: 0.25em;
|
|
851
|
+
}
|
|
852
|
+
.table-tree-toggle {
|
|
853
|
+
cursor: pointer;
|
|
854
|
+
&::after {
|
|
855
|
+
position: absolute;
|
|
856
|
+
content: '';
|
|
857
|
+
top: 50%;
|
|
858
|
+
left: 50%;
|
|
859
|
+
border-width: 0.35em 0 0.35em 0.35em;
|
|
860
|
+
border-style: solid;
|
|
861
|
+
border-color: transparent transparent transparent currentColor;
|
|
862
|
+
border-radius: 30%;
|
|
863
|
+
transform: translate(-30%, -50%) rotate(90deg);
|
|
864
|
+
transition: transform 0.25s;
|
|
865
|
+
}
|
|
866
|
+
&[data-fold='1']::after {
|
|
867
|
+
transform: translate(-30%, -50%) rotate(0deg);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 树线画在内层,纵向裁到单元格高度,横向不裁,固定列的阴影照常显示
|
|
872
|
+
td[data-tree] {
|
|
873
|
+
clip-path: inset(0 -100vw);
|
|
874
|
+
&:not([col-fixed]) {
|
|
875
|
+
position: relative;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
.table-tree-child {
|
|
879
|
+
position: relative;
|
|
880
|
+
padding-left: 1.75em;
|
|
881
|
+
&::before,
|
|
882
|
+
&::after {
|
|
883
|
+
position: absolute;
|
|
884
|
+
content: '';
|
|
885
|
+
left: 0.5em;
|
|
886
|
+
background: var(--co-line);
|
|
887
|
+
}
|
|
888
|
+
&::before {
|
|
889
|
+
width: 1px;
|
|
890
|
+
top: -100vh;
|
|
891
|
+
bottom: -100vh;
|
|
892
|
+
}
|
|
893
|
+
&[data-last='1']::before {
|
|
894
|
+
bottom: 50%;
|
|
895
|
+
}
|
|
896
|
+
&::after {
|
|
897
|
+
width: 0.85em;
|
|
898
|
+
height: 1px;
|
|
899
|
+
top: 50%;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// 第三层:外层只画上一层的竖线(上一层是最后一行就不画),内层画自己的
|
|
903
|
+
.table-tree-child[data-pass='1'] {
|
|
904
|
+
&::after {
|
|
905
|
+
display: none;
|
|
906
|
+
}
|
|
907
|
+
&[data-last='1']::before {
|
|
908
|
+
display: none;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
.table-tree-item {
|
|
912
|
+
margin-left: 1.5em;
|
|
913
|
+
}
|
|
914
|
+
.table-tree-item-3 {
|
|
915
|
+
margin-left: 3em;
|
|
916
|
+
}
|
|
917
|
+
</style>
|