@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.
- package/package.json +32 -6
- package/src/components/_internal/bottom-sheet/bottom-sheet.vue +81 -0
- package/src/components/_internal/bottom-sheet/index.ts +1 -0
- package/src/components/_internal/bottom-sheet/style.scss +116 -0
- package/src/components/_internal/bottom-sheet/style.ts +1 -0
- package/src/components/_internal/overlay-container.ts +34 -0
- package/src/components/avatar/avatar.vue +44 -0
- package/src/components/avatar/index.ts +1 -0
- package/src/components/avatar/style.scss +53 -0
- package/src/components/avatar/style.ts +1 -0
- package/src/components/badge/badge.vue +59 -0
- package/src/components/badge/index.ts +1 -0
- package/src/components/badge/style.scss +62 -0
- package/src/components/badge/style.ts +1 -0
- package/src/components/button/button.vue +76 -0
- package/src/components/button/index.ts +1 -0
- package/src/components/button/style.scss +207 -0
- package/src/components/button/style.ts +1 -0
- package/src/components/card/card.vue +24 -0
- package/src/components/card/index.ts +1 -0
- package/src/components/card/style.scss +35 -0
- package/src/components/card/style.ts +1 -0
- package/src/components/cascade/cascade.vue +595 -0
- package/src/components/cascade/index.ts +1 -0
- package/src/components/cascade/node.ts +29 -0
- package/src/components/cascade/style.scss +424 -0
- package/src/components/cascade/style.ts +2 -0
- package/src/components/check-tag/check-tag.vue +35 -0
- package/src/components/check-tag/index.ts +1 -0
- package/src/components/check-tag/style.scss +46 -0
- package/src/components/check-tag/style.ts +1 -0
- package/src/components/checkbox/checkbox.vue +67 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/checkbox/style.scss +132 -0
- package/src/components/checkbox/style.ts +2 -0
- package/src/components/checkbox-group/checkbox-group.vue +94 -0
- package/src/components/checkbox-group/index.ts +1 -0
- package/src/components/checkbox-group/style.scss +57 -0
- package/src/components/checkbox-group/style.ts +2 -0
- package/src/components/collapse/collapse-item.vue +134 -0
- package/src/components/collapse/collapse.vue +117 -0
- package/src/components/collapse/di.ts +18 -0
- package/src/components/collapse/index.ts +3 -0
- package/src/components/collapse/style.scss +157 -0
- package/src/components/collapse/style.ts +1 -0
- package/src/components/date-picker/date-picker.vue +326 -0
- package/src/components/date-picker/index.ts +1 -0
- package/src/components/date-picker/style.scss +236 -0
- package/src/components/date-picker/style.ts +2 -0
- package/src/components/descriptions/descriptions-item.vue +12 -0
- package/src/components/descriptions/descriptions.vue +50 -0
- package/src/components/descriptions/index.ts +2 -0
- package/src/components/descriptions/style.scss +120 -0
- package/src/components/descriptions/style.ts +1 -0
- package/src/components/dialog/dialog.vue +143 -0
- package/src/components/dialog/index.ts +1 -0
- package/src/components/dialog/style.scss +163 -0
- package/src/components/dialog/style.ts +4 -0
- package/src/components/divider/divider.vue +29 -0
- package/src/components/divider/index.ts +1 -0
- package/src/components/divider/style.scss +91 -0
- package/src/components/divider/style.ts +1 -0
- package/src/components/drawer/drawer.vue +168 -0
- package/src/components/drawer/index.ts +1 -0
- package/src/components/drawer/style.scss +240 -0
- package/src/components/drawer/style.ts +2 -0
- package/src/components/empty/empty.vue +22 -0
- package/src/components/empty/index.ts +1 -0
- package/src/components/empty/style.scss +36 -0
- package/src/components/empty/style.ts +1 -0
- package/src/components/form/form.vue +119 -0
- package/src/components/form/index.ts +1 -0
- package/src/components/form/is-field-modified.ts +16 -0
- package/src/components/form/style.scss +58 -0
- package/src/components/form/style.ts +2 -0
- package/src/components/form/use-form-fields.ts +115 -0
- package/src/components/form/use-node-interceptor.ts +101 -0
- package/src/components/form-item/form-item.vue +171 -0
- package/src/components/form-item/helper.ts +9 -0
- package/src/components/form-item/index.ts +1 -0
- package/src/components/form-item/style.scss +121 -0
- package/src/components/form-item/style.ts +1 -0
- package/src/components/form-item/validate.ts +131 -0
- package/src/components/icon/icon.vue +23 -0
- package/src/components/icon/index.ts +1 -0
- package/src/components/icon/style.scss +37 -0
- package/src/components/icon/style.ts +1 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.vue +187 -0
- package/src/components/input/style.scss +121 -0
- package/src/components/input/style.ts +1 -0
- package/src/components/list/index.ts +3 -0
- package/src/components/list/list-item.vue +20 -0
- package/src/components/list/list.vue +20 -0
- package/src/components/list/style.scss +50 -0
- package/src/components/list/style.ts +1 -0
- package/src/components/loading/directive.ts +34 -0
- package/src/components/loading/index.ts +3 -0
- package/src/components/loading/loading.vue +25 -0
- package/src/components/loading/style.scss +179 -0
- package/src/components/loading/style.ts +2 -0
- package/src/components/message/helper.ts +28 -0
- package/src/components/message/index.ts +3 -0
- package/src/components/message/message-box.vue +31 -0
- package/src/components/message/message.ts +122 -0
- package/src/components/message/message.vue +54 -0
- package/src/components/message/style.scss +130 -0
- package/src/components/message/style.ts +1 -0
- package/src/components/message-confirm/index.ts +3 -0
- package/src/components/message-confirm/message-confirm-box.vue +36 -0
- package/src/components/message-confirm/message-confirm.ts +139 -0
- package/src/components/message-confirm/message-confirm.vue +53 -0
- package/src/components/message-confirm/style.scss +138 -0
- package/src/components/message-confirm/style.ts +1 -0
- package/src/components/multi-select/index.ts +1 -0
- package/src/components/multi-select/multi-select.vue +410 -0
- package/src/components/multi-select/style.scss +376 -0
- package/src/components/multi-select/style.ts +2 -0
- package/src/components/number-input/index.ts +1 -0
- package/src/components/number-input/number-input.vue +407 -0
- package/src/components/number-input/style.scss +118 -0
- package/src/components/number-input/style.ts +2 -0
- package/src/components/password-input/index.ts +1 -0
- package/src/components/password-input/password-input.vue +134 -0
- package/src/components/password-input/style.scss +40 -0
- package/src/components/password-input/style.ts +2 -0
- package/src/components/progress/index.ts +1 -0
- package/src/components/progress/progress.vue +90 -0
- package/src/components/progress/style.scss +73 -0
- package/src/components/progress/style.ts +1 -0
- package/src/components/radio/index.ts +1 -0
- package/src/components/radio/radio.vue +57 -0
- package/src/components/radio/style.scss +113 -0
- package/src/components/radio/style.ts +1 -0
- package/src/components/radio-group/index.ts +1 -0
- package/src/components/radio-group/radio-group.vue +60 -0
- package/src/components/radio-group/style.scss +29 -0
- package/src/components/radio-group/style.ts +2 -0
- package/src/components/segment/index.ts +1 -0
- package/src/components/segment/segment.vue +121 -0
- package/src/components/segment/style.scss +118 -0
- package/src/components/segment/style.ts +1 -0
- package/src/components/select/index.ts +1 -0
- package/src/components/select/select.vue +278 -0
- package/src/components/select/style.scss +237 -0
- package/src/components/select/style.ts +2 -0
- package/src/components/select/use-options.ts +195 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.vue +37 -0
- package/src/components/skeleton/style.scss +95 -0
- package/src/components/skeleton/style.ts +1 -0
- package/src/components/space/index.ts +1 -0
- package/src/components/space/space.vue +45 -0
- package/src/components/space/style.scss +41 -0
- package/src/components/space/style.ts +1 -0
- package/src/components/steps/index.ts +1 -0
- package/src/components/steps/steps.vue +114 -0
- package/src/components/steps/style.scss +231 -0
- package/src/components/steps/style.ts +1 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/style.scss +137 -0
- package/src/components/switch/style.ts +1 -0
- package/src/components/switch/switch.vue +63 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/style.scss +285 -0
- package/src/components/tabs/style.ts +1 -0
- package/src/components/tabs/tabs.vue +127 -0
- package/src/components/tag/index.ts +1 -0
- package/src/components/tag/style.scss +114 -0
- package/src/components/tag/style.ts +1 -0
- package/src/components/tag/tag.vue +49 -0
- package/src/components/text/index.ts +1 -0
- package/src/components/text/style.scss +63 -0
- package/src/components/text/style.ts +1 -0
- package/src/components/text/text.vue +63 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/style.scss +124 -0
- package/src/components/textarea/style.ts +1 -0
- package/src/components/textarea/textarea.vue +121 -0
- package/src/components/textarea/utils.ts +11 -0
- package/src/components/time-picker/index.ts +1 -0
- package/src/components/time-picker/style.scss +216 -0
- package/src/components/time-picker/style.ts +2 -0
- package/src/components/time-picker/time-picker.vue +263 -0
- package/src/components/timeline/index.ts +2 -0
- package/src/components/timeline/style.scss +107 -0
- package/src/components/timeline/style.ts +1 -0
- package/src/components/timeline/timeline-item.vue +43 -0
- package/src/components/timeline/timeline.vue +16 -0
- package/src/components/uploader/index.ts +1 -0
- package/src/components/uploader/style.scss +220 -0
- package/src/components/uploader/style.ts +1 -0
- package/src/components/uploader/uploader.vue +306 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +99 -1
- package/src/shared/bem.ts +12 -0
- package/src/shared/use-scroll-lock.ts +111 -0
- package/src/shared/use-touch-gesture.ts +150 -0
- package/src/styles/_tokens.scss +129 -0
- package/src/types/avatar.ts +31 -0
- package/src/types/badge.ts +28 -0
- package/src/types/button.ts +48 -0
- package/src/types/card.ts +16 -0
- package/src/types/cascade.ts +87 -0
- package/src/types/check-tag.ts +24 -0
- package/src/types/checkbox-group.ts +20 -0
- package/src/types/checkbox.ts +15 -0
- package/src/types/collapse.ts +79 -0
- package/src/types/date-picker.ts +39 -0
- package/src/types/descriptions.ts +22 -0
- package/src/types/dialog.ts +54 -0
- package/src/types/divider.ts +14 -0
- package/src/types/drawer.ts +38 -0
- package/src/types/empty.ts +19 -0
- package/src/types/form-item.ts +22 -0
- package/src/types/form.ts +54 -0
- package/src/types/icon.ts +10 -0
- package/src/types/input.ts +51 -0
- package/src/types/list.ts +13 -0
- package/src/types/loading.ts +20 -0
- package/src/types/message-confirm.ts +72 -0
- package/src/types/message.ts +71 -0
- package/src/types/multi-select.ts +38 -0
- package/src/types/number-input.ts +32 -0
- package/src/types/password-input.ts +19 -0
- package/src/types/progress.ts +22 -0
- package/src/types/radio-group.ts +33 -0
- package/src/types/radio.ts +18 -0
- package/src/types/segment.ts +42 -0
- package/src/types/select.ts +63 -0
- package/src/types/skeleton.ts +29 -0
- package/src/types/space.ts +22 -0
- package/src/types/steps.ts +67 -0
- package/src/types/switch.ts +17 -0
- package/src/types/tabs.ts +63 -0
- package/src/types/tag.ts +19 -0
- package/src/types/text.ts +28 -0
- package/src/types/textarea.ts +76 -0
- package/src/types/time-picker.ts +40 -0
- package/src/types/timeline.ts +15 -0
- package/src/types/uploader.ts +50 -0
- package/.turbo/turbo-build.log +0 -2
- package/.turbo/turbo-check-types.log +0 -1
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -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,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 @@
|
|
|
1
|
+
export { default as UFormItem } from './form-item.vue'
|