@veltra/mobile 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/package.json +32 -6
  2. package/src/components/_internal/bottom-sheet/bottom-sheet.vue +81 -0
  3. package/src/components/_internal/bottom-sheet/index.ts +1 -0
  4. package/src/components/_internal/bottom-sheet/style.scss +116 -0
  5. package/src/components/_internal/bottom-sheet/style.ts +1 -0
  6. package/src/components/_internal/overlay-container.ts +34 -0
  7. package/src/components/avatar/avatar.vue +44 -0
  8. package/src/components/avatar/index.ts +1 -0
  9. package/src/components/avatar/style.scss +53 -0
  10. package/src/components/avatar/style.ts +1 -0
  11. package/src/components/badge/badge.vue +59 -0
  12. package/src/components/badge/index.ts +1 -0
  13. package/src/components/badge/style.scss +62 -0
  14. package/src/components/badge/style.ts +1 -0
  15. package/src/components/button/button.vue +76 -0
  16. package/src/components/button/index.ts +1 -0
  17. package/src/components/button/style.scss +207 -0
  18. package/src/components/button/style.ts +1 -0
  19. package/src/components/card/card.vue +24 -0
  20. package/src/components/card/index.ts +1 -0
  21. package/src/components/card/style.scss +35 -0
  22. package/src/components/card/style.ts +1 -0
  23. package/src/components/cascade/cascade.vue +595 -0
  24. package/src/components/cascade/index.ts +1 -0
  25. package/src/components/cascade/node.ts +29 -0
  26. package/src/components/cascade/style.scss +424 -0
  27. package/src/components/cascade/style.ts +2 -0
  28. package/src/components/check-tag/check-tag.vue +35 -0
  29. package/src/components/check-tag/index.ts +1 -0
  30. package/src/components/check-tag/style.scss +46 -0
  31. package/src/components/check-tag/style.ts +1 -0
  32. package/src/components/checkbox/checkbox.vue +67 -0
  33. package/src/components/checkbox/index.ts +1 -0
  34. package/src/components/checkbox/style.scss +132 -0
  35. package/src/components/checkbox/style.ts +2 -0
  36. package/src/components/checkbox-group/checkbox-group.vue +94 -0
  37. package/src/components/checkbox-group/index.ts +1 -0
  38. package/src/components/checkbox-group/style.scss +57 -0
  39. package/src/components/checkbox-group/style.ts +2 -0
  40. package/src/components/collapse/collapse-item.vue +134 -0
  41. package/src/components/collapse/collapse.vue +117 -0
  42. package/src/components/collapse/di.ts +18 -0
  43. package/src/components/collapse/index.ts +3 -0
  44. package/src/components/collapse/style.scss +157 -0
  45. package/src/components/collapse/style.ts +1 -0
  46. package/src/components/date-picker/date-picker.vue +326 -0
  47. package/src/components/date-picker/index.ts +1 -0
  48. package/src/components/date-picker/style.scss +236 -0
  49. package/src/components/date-picker/style.ts +2 -0
  50. package/src/components/descriptions/descriptions-item.vue +12 -0
  51. package/src/components/descriptions/descriptions.vue +50 -0
  52. package/src/components/descriptions/index.ts +2 -0
  53. package/src/components/descriptions/style.scss +120 -0
  54. package/src/components/descriptions/style.ts +1 -0
  55. package/src/components/dialog/dialog.vue +143 -0
  56. package/src/components/dialog/index.ts +1 -0
  57. package/src/components/dialog/style.scss +163 -0
  58. package/src/components/dialog/style.ts +4 -0
  59. package/src/components/divider/divider.vue +29 -0
  60. package/src/components/divider/index.ts +1 -0
  61. package/src/components/divider/style.scss +91 -0
  62. package/src/components/divider/style.ts +1 -0
  63. package/src/components/drawer/drawer.vue +168 -0
  64. package/src/components/drawer/index.ts +1 -0
  65. package/src/components/drawer/style.scss +240 -0
  66. package/src/components/drawer/style.ts +2 -0
  67. package/src/components/empty/empty.vue +22 -0
  68. package/src/components/empty/index.ts +1 -0
  69. package/src/components/empty/style.scss +36 -0
  70. package/src/components/empty/style.ts +1 -0
  71. package/src/components/form/form.vue +119 -0
  72. package/src/components/form/index.ts +1 -0
  73. package/src/components/form/is-field-modified.ts +16 -0
  74. package/src/components/form/style.scss +58 -0
  75. package/src/components/form/style.ts +2 -0
  76. package/src/components/form/use-form-fields.ts +115 -0
  77. package/src/components/form/use-node-interceptor.ts +101 -0
  78. package/src/components/form-item/form-item.vue +171 -0
  79. package/src/components/form-item/helper.ts +9 -0
  80. package/src/components/form-item/index.ts +1 -0
  81. package/src/components/form-item/style.scss +121 -0
  82. package/src/components/form-item/style.ts +1 -0
  83. package/src/components/form-item/validate.ts +131 -0
  84. package/src/components/icon/icon.vue +23 -0
  85. package/src/components/icon/index.ts +1 -0
  86. package/src/components/icon/style.scss +37 -0
  87. package/src/components/icon/style.ts +1 -0
  88. package/src/components/input/index.ts +1 -0
  89. package/src/components/input/input.vue +187 -0
  90. package/src/components/input/style.scss +121 -0
  91. package/src/components/input/style.ts +1 -0
  92. package/src/components/list/index.ts +3 -0
  93. package/src/components/list/list-item.vue +20 -0
  94. package/src/components/list/list.vue +20 -0
  95. package/src/components/list/style.scss +50 -0
  96. package/src/components/list/style.ts +1 -0
  97. package/src/components/loading/directive.ts +34 -0
  98. package/src/components/loading/index.ts +3 -0
  99. package/src/components/loading/loading.vue +25 -0
  100. package/src/components/loading/style.scss +179 -0
  101. package/src/components/loading/style.ts +2 -0
  102. package/src/components/message/helper.ts +28 -0
  103. package/src/components/message/index.ts +3 -0
  104. package/src/components/message/message-box.vue +31 -0
  105. package/src/components/message/message.ts +122 -0
  106. package/src/components/message/message.vue +54 -0
  107. package/src/components/message/style.scss +130 -0
  108. package/src/components/message/style.ts +1 -0
  109. package/src/components/message-confirm/index.ts +3 -0
  110. package/src/components/message-confirm/message-confirm-box.vue +36 -0
  111. package/src/components/message-confirm/message-confirm.ts +139 -0
  112. package/src/components/message-confirm/message-confirm.vue +53 -0
  113. package/src/components/message-confirm/style.scss +138 -0
  114. package/src/components/message-confirm/style.ts +1 -0
  115. package/src/components/multi-select/index.ts +1 -0
  116. package/src/components/multi-select/multi-select.vue +410 -0
  117. package/src/components/multi-select/style.scss +376 -0
  118. package/src/components/multi-select/style.ts +2 -0
  119. package/src/components/number-input/index.ts +1 -0
  120. package/src/components/number-input/number-input.vue +407 -0
  121. package/src/components/number-input/style.scss +118 -0
  122. package/src/components/number-input/style.ts +2 -0
  123. package/src/components/password-input/index.ts +1 -0
  124. package/src/components/password-input/password-input.vue +134 -0
  125. package/src/components/password-input/style.scss +40 -0
  126. package/src/components/password-input/style.ts +2 -0
  127. package/src/components/progress/index.ts +1 -0
  128. package/src/components/progress/progress.vue +90 -0
  129. package/src/components/progress/style.scss +73 -0
  130. package/src/components/progress/style.ts +1 -0
  131. package/src/components/radio/index.ts +1 -0
  132. package/src/components/radio/radio.vue +57 -0
  133. package/src/components/radio/style.scss +113 -0
  134. package/src/components/radio/style.ts +1 -0
  135. package/src/components/radio-group/index.ts +1 -0
  136. package/src/components/radio-group/radio-group.vue +60 -0
  137. package/src/components/radio-group/style.scss +29 -0
  138. package/src/components/radio-group/style.ts +2 -0
  139. package/src/components/segment/index.ts +1 -0
  140. package/src/components/segment/segment.vue +121 -0
  141. package/src/components/segment/style.scss +118 -0
  142. package/src/components/segment/style.ts +1 -0
  143. package/src/components/select/index.ts +1 -0
  144. package/src/components/select/select.vue +278 -0
  145. package/src/components/select/style.scss +237 -0
  146. package/src/components/select/style.ts +2 -0
  147. package/src/components/select/use-options.ts +195 -0
  148. package/src/components/skeleton/index.ts +1 -0
  149. package/src/components/skeleton/skeleton.vue +37 -0
  150. package/src/components/skeleton/style.scss +95 -0
  151. package/src/components/skeleton/style.ts +1 -0
  152. package/src/components/space/index.ts +1 -0
  153. package/src/components/space/space.vue +45 -0
  154. package/src/components/space/style.scss +41 -0
  155. package/src/components/space/style.ts +1 -0
  156. package/src/components/steps/index.ts +1 -0
  157. package/src/components/steps/steps.vue +114 -0
  158. package/src/components/steps/style.scss +231 -0
  159. package/src/components/steps/style.ts +1 -0
  160. package/src/components/switch/index.ts +1 -0
  161. package/src/components/switch/style.scss +137 -0
  162. package/src/components/switch/style.ts +1 -0
  163. package/src/components/switch/switch.vue +63 -0
  164. package/src/components/tabs/index.ts +1 -0
  165. package/src/components/tabs/style.scss +285 -0
  166. package/src/components/tabs/style.ts +1 -0
  167. package/src/components/tabs/tabs.vue +127 -0
  168. package/src/components/tag/index.ts +1 -0
  169. package/src/components/tag/style.scss +114 -0
  170. package/src/components/tag/style.ts +1 -0
  171. package/src/components/tag/tag.vue +49 -0
  172. package/src/components/text/index.ts +1 -0
  173. package/src/components/text/style.scss +63 -0
  174. package/src/components/text/style.ts +1 -0
  175. package/src/components/text/text.vue +63 -0
  176. package/src/components/textarea/index.ts +1 -0
  177. package/src/components/textarea/style.scss +124 -0
  178. package/src/components/textarea/style.ts +1 -0
  179. package/src/components/textarea/textarea.vue +121 -0
  180. package/src/components/textarea/utils.ts +11 -0
  181. package/src/components/time-picker/index.ts +1 -0
  182. package/src/components/time-picker/style.scss +216 -0
  183. package/src/components/time-picker/style.ts +2 -0
  184. package/src/components/time-picker/time-picker.vue +263 -0
  185. package/src/components/timeline/index.ts +2 -0
  186. package/src/components/timeline/style.scss +107 -0
  187. package/src/components/timeline/style.ts +1 -0
  188. package/src/components/timeline/timeline-item.vue +43 -0
  189. package/src/components/timeline/timeline.vue +16 -0
  190. package/src/components/uploader/index.ts +1 -0
  191. package/src/components/uploader/style.scss +220 -0
  192. package/src/components/uploader/style.ts +1 -0
  193. package/src/components/uploader/uploader.vue +306 -0
  194. package/src/env.d.ts +7 -0
  195. package/src/index.ts +99 -1
  196. package/src/shared/bem.ts +12 -0
  197. package/src/shared/use-scroll-lock.ts +111 -0
  198. package/src/shared/use-touch-gesture.ts +150 -0
  199. package/src/styles/_tokens.scss +129 -0
  200. package/src/types/avatar.ts +31 -0
  201. package/src/types/badge.ts +28 -0
  202. package/src/types/button.ts +48 -0
  203. package/src/types/card.ts +16 -0
  204. package/src/types/cascade.ts +87 -0
  205. package/src/types/check-tag.ts +24 -0
  206. package/src/types/checkbox-group.ts +20 -0
  207. package/src/types/checkbox.ts +15 -0
  208. package/src/types/collapse.ts +79 -0
  209. package/src/types/date-picker.ts +39 -0
  210. package/src/types/descriptions.ts +22 -0
  211. package/src/types/dialog.ts +54 -0
  212. package/src/types/divider.ts +14 -0
  213. package/src/types/drawer.ts +38 -0
  214. package/src/types/empty.ts +19 -0
  215. package/src/types/form-item.ts +22 -0
  216. package/src/types/form.ts +54 -0
  217. package/src/types/icon.ts +10 -0
  218. package/src/types/input.ts +51 -0
  219. package/src/types/list.ts +13 -0
  220. package/src/types/loading.ts +20 -0
  221. package/src/types/message-confirm.ts +72 -0
  222. package/src/types/message.ts +71 -0
  223. package/src/types/multi-select.ts +38 -0
  224. package/src/types/number-input.ts +32 -0
  225. package/src/types/password-input.ts +19 -0
  226. package/src/types/progress.ts +22 -0
  227. package/src/types/radio-group.ts +33 -0
  228. package/src/types/radio.ts +18 -0
  229. package/src/types/segment.ts +42 -0
  230. package/src/types/select.ts +63 -0
  231. package/src/types/skeleton.ts +29 -0
  232. package/src/types/space.ts +22 -0
  233. package/src/types/steps.ts +67 -0
  234. package/src/types/switch.ts +17 -0
  235. package/src/types/tabs.ts +63 -0
  236. package/src/types/tag.ts +19 -0
  237. package/src/types/text.ts +28 -0
  238. package/src/types/textarea.ts +76 -0
  239. package/src/types/time-picker.ts +40 -0
  240. package/src/types/timeline.ts +15 -0
  241. package/src/types/uploader.ts +50 -0
  242. package/.turbo/turbo-build.log +0 -2
  243. package/.turbo/turbo-check-types.log +0 -1
  244. package/tsconfig.json +0 -12
  245. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,2 @@
1
+ import '@veltra/styles/transitions/fade.scss'
2
+ import './style.scss'
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <span :class="cls.b">
3
+ <span :class="cls.e('icon')" :style="{ fontSize: withUnit(size, 'px') }">
4
+ <Empty />
5
+ </span>
6
+ <div :class="cls.e('text')">{{ text }}</div>
7
+ </span>
8
+ </template>
9
+
10
+ <script lang="ts" setup>
11
+ import { Empty } from '@veltra/icons/normal'
12
+ import { withUnit } from '@veltra/utils'
13
+
14
+ import { bem } from '../../shared/bem'
15
+ import type { EmptyProps } from '../../types/empty'
16
+
17
+ defineOptions({ name: 'UEmpty' })
18
+
19
+ const { size = 48, text = '暂无数据' } = defineProps<EmptyProps>()
20
+
21
+ const cls = bem('empty')
22
+ </script>
@@ -0,0 +1 @@
1
+ export { default as UEmpty } from './empty.vue'
@@ -0,0 +1,36 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m with ($namespace: um);
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+ @use '../../styles/tokens' as t;
4
+
5
+ // 方便拼接
6
+ $root-name: empty;
7
+
8
+ @include m.b($root-name) {
9
+ color: fn.use-var(text-color, assist);
10
+ text-align: center;
11
+ display: inline-flex;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ gap: t.use-var(spacing-md);
15
+ user-select: none;
16
+
17
+ // 图标占位:以 1em 跟随 size 缩放(移动端包内暂无 UIcon,直接承载图标 svg)
18
+ @include m.e(icon) {
19
+ display: inline-flex;
20
+ width: 1em;
21
+ height: 1em;
22
+ line-height: 1em;
23
+ fill: currentColor;
24
+
25
+ svg {
26
+ width: 1em;
27
+ height: 1em;
28
+ }
29
+ }
30
+
31
+ // 空态文本:移动端密度用次字号(14px)
32
+ @include m.e(text) {
33
+ font-size: t.use-var(font-size-secondary);
34
+ line-height: 1.5;
35
+ }
36
+ }
@@ -0,0 +1 @@
1
+ import './style.scss'
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <form ref="formRef" :class="[cls.b, bem.is('readonly', readonly)]" @submit.prevent>
3
+ <div v-if="props.title || $slots.header" :class="cls.e('title')">
4
+ <slot name="header">{{ props.title }}</slot>
5
+ </div>
6
+
7
+ <template
8
+ v-for="{ node, isFormItem, formItemProps, field, modelValue, renderKey } of getSlotsNodes()"
9
+ :key="renderKey"
10
+ >
11
+ <UFormItem v-if="!isFormItem && field" v-bind="formItemProps">
12
+ <component
13
+ :is="node"
14
+ :model-value="modelValue ?? o(model ?? {}).get(field)"
15
+ @update:model-value="handleUpdateValue(field, $event)"
16
+ />
17
+ <div v-if="shouldShowModified(field)" :class="cls.e('data-before')">
18
+ <span :class="cls.e('changed-tag')">{{ modifiedLabel }}</span>
19
+ {{ formatModifiedValue(getBaselineFieldValue(field)) }}
20
+ </div>
21
+ </UFormItem>
22
+
23
+ <component v-else :is="node" />
24
+ </template>
25
+ </form>
26
+ </template>
27
+
28
+ <script lang="ts" setup>
29
+ import { o } from '@cat-kit/core'
30
+ import { FORM_EMPTY_CONTENT, provideFormContext } from '@veltra/utils'
31
+ import { nextTick, toRef, useTemplateRef } from 'vue'
32
+
33
+ import { bem } from '../../shared/bem'
34
+ import type { FormEmits, FormProps, _FormExposed } from '../../types/form'
35
+ import { UFormItem } from '../form-item'
36
+ import { isFieldModified } from './is-field-modified'
37
+ import { useFormFields } from './use-form-fields'
38
+ import { useNodeInterceptor } from './use-node-interceptor'
39
+
40
+ defineOptions({ name: 'UForm' })
41
+
42
+ const props = withDefaults(defineProps<FormProps>(), { modifiedLabel: '变更前:' })
43
+
44
+ const emit = defineEmits<FormEmits>()
45
+
46
+ defineSlots<{
47
+ /** 分组标题插槽,优先于 title prop */
48
+ header?: () => any
49
+ default?: () => any
50
+ }>()
51
+
52
+ const cls = bem('form')
53
+ const formItemCls = bem('form-item')
54
+ const formRef = useTemplateRef<HTMLFormElement>('formRef')
55
+
56
+ const {
57
+ validate: runValidate,
58
+ clearValidate,
59
+ reset,
60
+ registerField,
61
+ unregisterField,
62
+ shouldValidate,
63
+ getBaselineModel
64
+ } = useFormFields({ props })
65
+
66
+ /** 校验失败后滚动到第一条错误提示,保证小屏上错误可见 */
67
+ async function validate(keys?: string[]) {
68
+ const valid = await runValidate(keys)
69
+ if (!valid) {
70
+ await nextTick()
71
+
72
+ formRef.value
73
+ ?.querySelector(`.${formItemCls.e('error-text')}`)
74
+ ?.scrollIntoView({ block: 'center' })
75
+ }
76
+ return valid
77
+ }
78
+
79
+ provideFormContext({
80
+ formProps: props,
81
+ registerField,
82
+ unregisterField,
83
+ validateFields: validate,
84
+ shouldValidate,
85
+ handleFieldUpdate(field: string, value: any) {
86
+ emit('field:update', field, value)
87
+ }
88
+ })
89
+
90
+ const { getSlotsNodes } = useNodeInterceptor()
91
+
92
+ function handleUpdateValue(field: string, value: any) {
93
+ if (!props.model) return
94
+ o(props.model).set(field, value)
95
+ }
96
+
97
+ function getBaselineFieldValue(field: string) {
98
+ return o(getBaselineModel() ?? {}).get(field)
99
+ }
100
+
101
+ function shouldShowModified(field: string) {
102
+ if (!props.showModified) return false
103
+ const baseline = getBaselineModel()
104
+ if (!baseline || !props.model) return false
105
+ const current = o(props.model).get(field)
106
+ const initial = o(baseline).get(field)
107
+ return isFieldModified(current, initial)
108
+ }
109
+
110
+ /** 变更前值按纯文本呈现(移动端密度),不克隆只读控件 */
111
+ function formatModifiedValue(value: unknown): string {
112
+ if (value === null || value === undefined || value === '') return FORM_EMPTY_CONTENT
113
+ if (Array.isArray(value)) return value.join('、')
114
+ if (typeof value === 'object') return JSON.stringify(value)
115
+ return String(value)
116
+ }
117
+
118
+ defineExpose<_FormExposed>({ el: toRef(() => formRef.value), validate, clearValidate, reset })
119
+ </script>
@@ -0,0 +1 @@
1
+ export { default as UForm } from './form.vue'
@@ -0,0 +1,16 @@
1
+ /** 判断字段当前值相对基准值是否已变更 */
2
+ export function isFieldModified(current: unknown, initial: unknown): boolean {
3
+ if (current === initial) return false
4
+
5
+ const currentNullish = current === null || current === undefined || current === ''
6
+ const initialNullish = initial === null || initial === undefined || initial === ''
7
+ if (currentNullish && initialNullish) return false
8
+
9
+ const currentIsObject = current !== null && typeof current === 'object'
10
+ const initialIsObject = initial !== null && typeof initial === 'object'
11
+ if (currentIsObject || initialIsObject) {
12
+ return JSON.stringify(current) !== JSON.stringify(initial)
13
+ }
14
+
15
+ return true
16
+ }
@@ -0,0 +1,58 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m with ($namespace: um);
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+ @use '../../styles/tokens' as t;
4
+
5
+ @include m.b(form) {
6
+ // 边框卡片式列表外观:行间分隔线由 form-item 的相邻选择器绘制
7
+ display: flex;
8
+ flex-direction: column;
9
+ box-sizing: border-box;
10
+ width: 100%;
11
+ background-color: fn.use-var(bg-color);
12
+ border-radius: t.use-var('control-radius-large');
13
+ overflow: hidden;
14
+
15
+ // 分组标题
16
+ @include m.e(title) {
17
+ padding: t.use-var('spacing-md') t.use-var('spacing-lg') t.use-var('spacing-sm');
18
+ color: fn.use-var(text-color, second);
19
+ font-size: t.use-var('font-size-secondary');
20
+ line-height: 1.4;
21
+ }
22
+
23
+ // 只读态:label 退为次级文本,值保持正文色(行式呈现)
24
+ @include m.is(readonly) {
25
+ @include m.bem(form-item, label) {
26
+ color: fn.use-var(text-color, second);
27
+ }
28
+
29
+ @include m.bem(form-item, content) {
30
+ color: fn.use-var(text-color, main);
31
+ }
32
+ }
33
+
34
+ // 变更前:以信息色文本行呈现(移动端密度)
35
+ @include m.e(data-before) {
36
+ display: flex;
37
+ align-items: baseline;
38
+ gap: t.use-var('spacing-xs');
39
+ flex-wrap: wrap;
40
+ width: fit-content;
41
+ max-width: 100%;
42
+ margin-top: t.use-var('spacing-xs');
43
+ padding: 2px t.use-var('spacing-xs');
44
+ border-left: 2px solid fn.use-var(color, info);
45
+ background-color: fn.color-a(color, 8, info);
46
+ border-radius: 2px;
47
+ line-height: 1.4;
48
+ font-size: t.use-var('font-size-secondary');
49
+ color: fn.use-var(text-color, main);
50
+ word-break: break-all;
51
+ }
52
+
53
+ @include m.e(changed-tag) {
54
+ color: fn.use-var(color, info);
55
+ font-style: normal;
56
+ flex-shrink: 0;
57
+ }
58
+ }
@@ -0,0 +1,2 @@
1
+ import '../form-item/style'
2
+ import './style.scss'
@@ -0,0 +1,115 @@
1
+ import { copy, o } from '@cat-kit/core'
2
+ import type { FormFieldItem } from '@veltra/utils'
3
+ import { nextTick, shallowRef, watch } from 'vue'
4
+
5
+ import type { FormProps } from '../../types/form'
6
+
7
+ interface Options {
8
+ props: FormProps
9
+ }
10
+
11
+ /** 按快照逐字段写回 model,支持 undefined 与嵌套对象 */
12
+ function applySnapshot(target: Record<string, any>, source: Record<string, any>) {
13
+ for (const key of Object.keys(source)) {
14
+ const sourceValue = source[key]
15
+ const targetValue = target[key]
16
+
17
+ if (
18
+ sourceValue !== null &&
19
+ typeof sourceValue === 'object' &&
20
+ !Array.isArray(sourceValue) &&
21
+ targetValue !== null &&
22
+ typeof targetValue === 'object' &&
23
+ !Array.isArray(targetValue)
24
+ ) {
25
+ applySnapshot(targetValue, sourceValue)
26
+ continue
27
+ }
28
+
29
+ if (Array.isArray(sourceValue)) {
30
+ o(target).set(key, copy(sourceValue))
31
+ continue
32
+ }
33
+
34
+ o(target).set(key, sourceValue)
35
+ }
36
+ }
37
+
38
+ export function useFormFields(options: Options) {
39
+ const { props } = options
40
+ const fields: Record<string, FormFieldItem> = {}
41
+
42
+ let willValidate = true
43
+
44
+ /** model 初始快照,供 reset 恢复 */
45
+ const initialSnapshot = shallowRef<Record<string, any> | undefined>()
46
+
47
+ function runWithoutChangeValidate(fn: () => void) {
48
+ willValidate = false
49
+ fn()
50
+ nextTick(() => {
51
+ willValidate = true
52
+ })
53
+ }
54
+
55
+ function shouldValidate() {
56
+ return willValidate
57
+ }
58
+
59
+ watch(
60
+ () => props.model,
61
+ (model) => {
62
+ initialSnapshot.value = model ? copy(model) : undefined
63
+ },
64
+ { immediate: true, deep: false }
65
+ )
66
+
67
+ function registerField(field: string, item: FormFieldItem) {
68
+ fields[field] = item
69
+ }
70
+
71
+ function unregisterField(field: string) {
72
+ delete fields[field]
73
+ }
74
+
75
+ function clearValidate() {
76
+ Object.values(fields).forEach((item) => item.clearValidate?.())
77
+ }
78
+
79
+ async function validate(keys?: string[]) {
80
+ let validList: boolean[]
81
+ if (keys) {
82
+ validList = await Promise.all(
83
+ keys.map((key) => fields[key]?.validate() ?? Promise.resolve(true))
84
+ )
85
+ } else {
86
+ validList = await Promise.all(Object.values(fields).map((field) => field.validate()))
87
+ }
88
+ return validList.every((valid) => valid)
89
+ }
90
+
91
+ function reset() {
92
+ if (!props.model || !initialSnapshot.value) return
93
+
94
+ runWithoutChangeValidate(() => {
95
+ applySnapshot(props.model!, initialSnapshot.value!)
96
+ Object.values(fields).forEach((item) => item.clearValidate?.())
97
+ })
98
+ }
99
+
100
+ /** 变更前基准数据:优先 initialModel,否则为 model 引用变更时的快照 */
101
+ function getBaselineModel() {
102
+ return props.initialModel ?? initialSnapshot.value
103
+ }
104
+
105
+ return {
106
+ fields,
107
+ registerField,
108
+ validate,
109
+ unregisterField,
110
+ clearValidate,
111
+ reset,
112
+ shouldValidate,
113
+ getBaselineModel
114
+ }
115
+ }
@@ -0,0 +1,101 @@
1
+ import { extractNormalVNodes } from '@veltra/utils'
2
+ import { useSlots, type VNode } from 'vue'
3
+
4
+ export type SlotRenderItem = {
5
+ isFormItem: boolean
6
+ formItemProps?: Record<string, any>
7
+ node: VNode
8
+ field?: string
9
+ modelValue?: any
10
+ /** v-for 身份:调用方 :key 优先,否则 type+field+出现次序 */
11
+ renderKey: string | number | symbol
12
+ }
13
+
14
+ function getVNodeTypeName(node: VNode): string {
15
+ const type = node.type as { name?: string; __name?: string } | string
16
+ if (typeof type === 'string') return type
17
+ if (typeof type === 'object' && type) return type.name || type.__name || ''
18
+ return ''
19
+ }
20
+
21
+ /** 裸控件 → 自动生成的 UFormItem 透传的属性白名单 */
22
+ const FORM_ITEM_KEYS = [
23
+ 'label',
24
+ 'labelPosition',
25
+ 'labelWidth',
26
+ 'rules',
27
+ 'span',
28
+ 'tips',
29
+ 'readonly',
30
+ 'field'
31
+ ] as const
32
+
33
+ /**
34
+ * 从裸控件 vnode 的 props 中挑出表单项属性。
35
+ * 模板 kebab 写法(label-position)在 vnode 原始 props 里不转驼峰,
36
+ * 因此驼峰 / kebab 双向取值,缺失的键不进入结果。
37
+ */
38
+ function pickFormItemProps(raw: Record<string, any>): Record<string, any> {
39
+ const result: Record<string, any> = {}
40
+
41
+ for (const key of FORM_ITEM_KEYS) {
42
+ const kebab = key.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)
43
+ const value = raw[key] ?? raw[kebab]
44
+ if (value !== undefined) result[key] = value
45
+ }
46
+
47
+ return result
48
+ }
49
+
50
+ /**
51
+ * 虚拟node拦截:带 field 的裸控件由 UForm 包进 UFormItem,表单项与其余节点原样渲染
52
+ * @returns
53
+ */
54
+ export function useNodeInterceptor(): { getSlotsNodes: () => SlotRenderItem[] | null } {
55
+ const slots = useSlots()
56
+
57
+ function getSlotsNodes() {
58
+ const nodes = slots.default?.()
59
+ if (!nodes?.length) return null
60
+
61
+ const flattedNodes = extractNormalVNodes(nodes)
62
+
63
+ const results: SlotRenderItem[] = []
64
+ const seen = new Map<string, number>()
65
+
66
+ let i = 0
67
+ while (i < flattedNodes.length) {
68
+ const node = flattedNodes[i]!
69
+ i++
70
+
71
+ const { props, type } = node
72
+ const field = props?.field as string | undefined
73
+ const isFormItem = (type as any)?.name === 'UFormItem'
74
+
75
+ const formItemProps = pickFormItemProps((props ?? {}) as Record<string, any>)
76
+
77
+ let renderKey: string | number | symbol
78
+ if (node.key != null) {
79
+ renderKey = node.key
80
+ } else {
81
+ const base = `${getVNodeTypeName(node)}:${field ?? ''}`
82
+ const n = seen.get(base) ?? 0
83
+ seen.set(base, n + 1)
84
+ renderKey = n === 0 ? `uform:${base}` : `uform:${base}#${n}`
85
+ }
86
+
87
+ results.push({
88
+ isFormItem,
89
+ formItemProps,
90
+ node,
91
+ field,
92
+ modelValue: props?.['model-value'] ?? props?.modelValue,
93
+ renderKey
94
+ })
95
+ }
96
+
97
+ return results
98
+ }
99
+
100
+ return { getSlotsNodes }
101
+ }
@@ -0,0 +1,171 @@
1
+ <template>
2
+ <div :class="className">
3
+ <label
4
+ v-if="props.label || $slots.label"
5
+ :class="[cls.e('label'), bem.is('required', !!rules?.required)]"
6
+ :style="labelStyles"
7
+ >
8
+ <slot name="label">{{ label }}</slot>
9
+ </label>
10
+
11
+ <section :class="cls.e('wrapper')">
12
+ <div :class="cls.e('content')">
13
+ <ContentSlot>
14
+ <slot></slot>
15
+ </ContentSlot>
16
+ </div>
17
+
18
+ <!-- 只有表单控件处于非只读状态时,才显示错误提示(行内下方,不弹桌面 tooltip) -->
19
+ <transition name="form-item-tips">
20
+ <span :class="cls.e('error-text')" v-if="!readonly && !formProps?.noTips && !!errorTip">
21
+ {{ errorTip }}
22
+ </span>
23
+ </transition>
24
+ </section>
25
+ </div>
26
+ </template>
27
+
28
+ <script lang="ts" setup>
29
+ import { o } from '@cat-kit/core'
30
+ import { useFallbackProps } from '@veltra/compositions'
31
+ import { extractNormalVNodes, injectFormContext, withUnit } from '@veltra/utils'
32
+ import type { ComponentSize } from '@veltra/utils'
33
+ import {
34
+ type CSSProperties,
35
+ type SetupContext,
36
+ type VNode,
37
+ cloneVNode,
38
+ computed,
39
+ onBeforeUnmount,
40
+ shallowRef,
41
+ watch
42
+ } from 'vue'
43
+
44
+ import { bem } from '../../shared/bem'
45
+ import type { FormItemEmits, FormItemProps } from '../../types/form-item'
46
+ import { formItemCls as cls, defineField } from './helper'
47
+ import { validateField } from './validate'
48
+
49
+ defineOptions({ name: 'UFormItem' })
50
+
51
+ const props = withDefaults(defineProps<FormItemProps>(), { readonly: undefined })
52
+
53
+ const emit = defineEmits<FormItemEmits>()
54
+
55
+ defineSlots<{
56
+ /** 标签插槽 */
57
+ label?: () => any
58
+ default?: () => any
59
+ }>()
60
+
61
+ /** 表单组件上下文 */
62
+ const { formProps, registerField, unregisterField, shouldValidate, handleFieldUpdate } =
63
+ injectFormContext()
64
+
65
+ function wrapControlChange(node: VNode) {
66
+ if (!node || typeof node.type === 'symbol') return node
67
+
68
+ return cloneVNode(node, {
69
+ onChange: (...args: any[]) => {
70
+ emit('change', ...args)
71
+ }
72
+ })
73
+ }
74
+
75
+ /**
76
+ * 承载默认插槽并拦截控件 change。
77
+ * 必须经填充插槽接收内容(而非直接读 UFormItem 的 $slots):
78
+ * 读父级 $slots 时组件自身没有更新路径,父级重渲染后动态 props(disabled、data 等)会冻结在首帧。
79
+ */
80
+ function ContentSlot(_: unknown, { slots }: SetupContext) {
81
+ return extractNormalVNodes(slots.default?.() ?? []).map(wrapControlChange)
82
+ }
83
+
84
+ const { size, readonly } = useFallbackProps([formProps ?? {}, props], {
85
+ size: 'default' as ComponentSize,
86
+ readonly: false
87
+ })
88
+
89
+ const errorTip = shallowRef<string>()
90
+ /** 递增序号,丢弃过期的异步校验结果 */
91
+ let validateSeq = 0
92
+ /** 最近一次 validate 的 Promise,供过期调用等待最新结果 */
93
+ let latestValidatePromise: Promise<boolean> = Promise.resolve(true)
94
+
95
+ /** 移动端默认列表行式:label 在左、控件占右侧;'top' 时纵向堆叠 */
96
+ const labelPosition = computed(() => props.labelPosition ?? formProps?.labelPosition ?? 'left')
97
+
98
+ const className = computed(() => {
99
+ return [
100
+ cls.b,
101
+ cls.m(size.value),
102
+ bem.is('error', !!errorTip.value),
103
+ bem.is('label-top', labelPosition.value === 'top')
104
+ ].join(' ')
105
+ })
106
+
107
+ /** label 宽度:传了 labelWidth(自身或 UForm)用固定宽,否则按内容自适应 */
108
+ const labelStyles = computed<CSSProperties>(() => {
109
+ if (labelPosition.value !== 'left') return {}
110
+ const width = props.labelWidth ?? formProps?.labelWidth
111
+ return width === undefined ? {} : { width: withUnit(width, 'px') }
112
+ })
113
+
114
+ const fieldItem = defineField({
115
+ clearValidate() {
116
+ errorTip.value = ''
117
+ },
118
+ async validate() {
119
+ if (!props.field || !formProps?.model || !props.rules || !shouldValidate?.()) return true
120
+
121
+ const seq = ++validateSeq
122
+
123
+ latestValidatePromise = validateField(formProps.model!, props.field!, props.rules!).then(
124
+ (tip) => {
125
+ if (seq !== validateSeq) return latestValidatePromise
126
+
127
+ errorTip.value = tip
128
+ return !errorTip.value
129
+ }
130
+ )
131
+ return latestValidatePromise
132
+ }
133
+ })
134
+
135
+ watch(
136
+ () => props.field,
137
+ (field, oldField) => {
138
+ if (oldField) {
139
+ unregisterField?.(oldField)
140
+ }
141
+ if (field) {
142
+ registerField?.(field, fieldItem)
143
+ }
144
+ },
145
+ { immediate: true }
146
+ )
147
+
148
+ let stopWatchFieldValue: (() => void) | undefined
149
+
150
+ watch(
151
+ [() => formProps?.model, () => props.field],
152
+ ([model, field]) => {
153
+ stopWatchFieldValue?.()
154
+
155
+ if (!field || !model) return
156
+
157
+ stopWatchFieldValue = watch(
158
+ () => o(model).get(field),
159
+ (value) => {
160
+ handleFieldUpdate?.(field, value)
161
+ fieldItem.validate()
162
+ }
163
+ )
164
+ },
165
+ { immediate: true }
166
+ )
167
+
168
+ onBeforeUnmount(() => {
169
+ props.field && unregisterField?.(props.field)
170
+ })
171
+ </script>
@@ -0,0 +1,9 @@
1
+ import { type FormFieldItem } from '@veltra/utils'
2
+
3
+ import { bem, type MobileBEM } from '../../shared/bem'
4
+
5
+ export const formItemCls: MobileBEM<'form-item'> = bem('form-item')
6
+
7
+ export function defineField(field: FormFieldItem) {
8
+ return field
9
+ }
@@ -0,0 +1 @@
1
+ export { default as UFormItem } from './form-item.vue'