@veltra/desktop 1.7.9 → 1.7.11

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 (52) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +7 -3
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit.js +1 -0
  4. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  5. package/dist/components/batch-edit/use-edit-state.js.map +1 -1
  6. package/dist/components/button/button.js +0 -1
  7. package/dist/components/button/button.js.map +1 -1
  8. package/dist/components/drawer/drawer.js +18 -10
  9. package/dist/components/drawer/drawer.js.map +1 -1
  10. package/dist/components/drawer/style2.css +15 -0
  11. package/dist/components/form/form.js.map +1 -1
  12. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +31 -6
  13. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  14. package/dist/components/form/is-field-modified.js +12 -0
  15. package/dist/components/form/is-field-modified.js.map +1 -0
  16. package/dist/components/form/style2.css +37 -1
  17. package/dist/components/form/use-form-fields.js +11 -6
  18. package/dist/components/form/use-form-fields.js.map +1 -1
  19. package/dist/components/form-item/form-item.js.map +1 -1
  20. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +26 -6
  21. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
  22. package/dist/types/action.d.ts +6 -1
  23. package/dist/types/batch-edit.d.ts +2 -0
  24. package/dist/types/button.d.ts +5 -1
  25. package/dist/types/drawer.d.ts +6 -3
  26. package/dist/types/form-item.d.ts +4 -1
  27. package/dist/types/form.d.ts +10 -1
  28. package/dist/types/layout.d.ts +2 -0
  29. package/package.json +6 -6
  30. package/src/components/batch-edit/__test__/batch-edit.test.ts +138 -0
  31. package/src/components/batch-edit/batch-edit-form.vue +18 -4
  32. package/src/components/batch-edit/use-edit-state.ts +1 -1
  33. package/src/components/button/__test__/button.test.ts +48 -0
  34. package/src/components/button/button.vue +1 -8
  35. package/src/components/drawer/__test__/drawer.test.ts +118 -0
  36. package/src/components/drawer/drawer.vue +5 -2
  37. package/src/components/drawer/style.scss +15 -0
  38. package/src/components/form/__test__/form.test.ts +124 -0
  39. package/src/components/form/__test__/is-field-modified.test.ts +37 -0
  40. package/src/components/form/form.vue +28 -4
  41. package/src/components/form/is-field-modified.ts +16 -0
  42. package/src/components/form/style.scss +53 -2
  43. package/src/components/form/use-form-fields.ts +20 -6
  44. package/src/components/form-item/__test__/form-item.test.ts +129 -0
  45. package/src/components/form-item/form-item.vue +47 -8
  46. package/src/types/action.ts +6 -1
  47. package/src/types/batch-edit.ts +2 -0
  48. package/src/types/button.ts +5 -1
  49. package/src/types/drawer.ts +6 -3
  50. package/src/types/form-item.ts +4 -1
  51. package/src/types/form.ts +10 -2
  52. package/src/types/layout.ts +2 -0
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
- <transition name="fade" mode="out-in" @enter="onEnter">
3
+ <transition name="fade" mode="out-in" @enter="onEnter" @after-leave="emit('closed')">
4
4
  <div v-if="overlayVisible" :class="overlayCls.b" :style="{ zIndex: zIndex() }" @click="close">
5
5
  <transition :name="transitionName" appear @after-leave="onAfterDrawerLeave">
6
6
  <div
@@ -10,6 +10,9 @@
10
10
  :style="drawerStyle"
11
11
  @click.stop
12
12
  >
13
+ <div v-if="title" :class="cls.e('header')">
14
+ <span :class="cls.e('title')">{{ title }}</span>
15
+ </div>
13
16
  <span :class="cls.e('close')" @click="close" v-if="showClose">
14
17
  <u-icon><Close /></u-icon>
15
18
  </span>
@@ -31,7 +34,7 @@ import { UIcon } from '../icon'
31
34
 
32
35
  defineOptions({ name: 'UDrawer', inheritAttrs: false })
33
36
 
34
- const props = withDefaults(defineProps<DrawerProps>(), { direction: 'right', closable: true })
37
+ const props = withDefaults(defineProps<DrawerProps>(), { direction: 'right' })
35
38
 
36
39
  const emit = defineEmits<DrawerEmits>()
37
40
 
@@ -40,6 +40,21 @@ $direction-map: (
40
40
  justify-content: center;
41
41
  }
42
42
 
43
+ // 标题栏:传入 title 时渲染在内容区上方,其余行为与把标题写进默认插槽一致
44
+ @include m.e(header) {
45
+ height: $close-size;
46
+ flex-shrink: 0;
47
+ padding: 0 fn.use-var(gap, default);
48
+ font-size: fn.use-var(font-size-title, default);
49
+ border-bottom: fn.use-var(border);
50
+ @include m.flex();
51
+ }
52
+
53
+ @include m.e(title) {
54
+ font-weight: 600;
55
+ line-height: 1em;
56
+ }
57
+
43
58
  @include m.is(right, left) {
44
59
  top: $edge-offset;
45
60
  bottom: $edge-offset;
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, reactive, ref } from 'vue'
3
+
4
+ import { UInput } from '../../input'
5
+ import { USelect } from '../../select'
6
+ import { UForm } from '../index'
7
+
8
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
9
+
10
+ const behaviorOptions = [
11
+ { label: '事件', value: 'event' },
12
+ { label: '接口', value: 'api' }
13
+ ]
14
+
15
+ async function openSelect(host: HTMLElement) {
16
+ const input = host.querySelector('input')!
17
+ input.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
18
+ await nextTick()
19
+ await nextTick()
20
+ }
21
+
22
+ async function clickSelectOption(label: string) {
23
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
24
+ (el) => el.textContent === label
25
+ )
26
+ expect(option, `选项「${label}」应存在`).toBeTruthy()
27
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
28
+ await nextTick()
29
+ await sleep(50)
30
+ }
31
+
32
+ describe('UForm field events', () => {
33
+ it('field:update and field:change are separate', async () => {
34
+ const host = document.createElement('div')
35
+ document.body.appendChild(host)
36
+
37
+ const model = reactive({ behavior: 'event' })
38
+ const updates: { field: string; value: unknown }[] = []
39
+ const changes: { field: string; args: unknown[] }[] = []
40
+
41
+ const app = createApp({
42
+ render() {
43
+ return h(
44
+ UForm,
45
+ {
46
+ model,
47
+ 'onField:update': (field: string, value: unknown) => {
48
+ updates.push({ field, value })
49
+ },
50
+ 'onField:change': (field: string, ...args: unknown[]) => {
51
+ changes.push({ field, args })
52
+ }
53
+ },
54
+ {
55
+ default: () =>
56
+ h(USelect, { field: 'behavior', label: '行为', options: behaviorOptions })
57
+ }
58
+ )
59
+ }
60
+ })
61
+
62
+ app.mount(host)
63
+ updates.length = 0
64
+ changes.length = 0
65
+
66
+ model.behavior = 'api'
67
+ await nextTick()
68
+
69
+ expect(updates.some((item) => item.field === 'behavior' && item.value === 'api')).toBe(true)
70
+ expect(changes).toHaveLength(0)
71
+
72
+ await openSelect(host)
73
+ await clickSelectOption('事件')
74
+
75
+ expect(changes).toHaveLength(1)
76
+ expect(changes[0]).toEqual({ field: 'behavior', args: [{ label: '事件', value: 'event' }] })
77
+
78
+ app.unmount()
79
+ host.remove()
80
+ })
81
+
82
+ it('父级重渲染时动态 props 同步到插槽控件', async () => {
83
+ const host = document.createElement('div')
84
+ document.body.appendChild(host)
85
+
86
+ const model = reactive({ username: '' })
87
+ const disabled = ref(false)
88
+ const placeholder = ref('第一次')
89
+
90
+ const app = createApp({
91
+ render() {
92
+ return h(
93
+ UForm,
94
+ { model },
95
+ {
96
+ default: () =>
97
+ h(UInput, {
98
+ field: 'username',
99
+ label: '账号',
100
+ disabled: disabled.value,
101
+ placeholder: placeholder.value
102
+ })
103
+ }
104
+ )
105
+ }
106
+ })
107
+
108
+ app.mount(host)
109
+
110
+ const input = () => host.querySelector('input')!
111
+ expect(input().disabled).toBe(false)
112
+ expect(input().placeholder).toBe('第一次')
113
+
114
+ disabled.value = true
115
+ placeholder.value = '第二次'
116
+ await nextTick()
117
+
118
+ expect(input().disabled).toBe(true)
119
+ expect(input().placeholder).toBe('第二次')
120
+
121
+ app.unmount()
122
+ host.remove()
123
+ })
124
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { isFieldModified } from '../is-field-modified'
4
+
5
+ describe('isFieldModified', () => {
6
+ it('primitive 严格相等视为未变更', () => {
7
+ expect(isFieldModified('a', 'a')).toBe(false)
8
+ expect(isFieldModified(1, 1)).toBe(false)
9
+ expect(isFieldModified(true, true)).toBe(false)
10
+ })
11
+
12
+ it('primitive 不等视为已变更', () => {
13
+ expect(isFieldModified('a', 'b')).toBe(true)
14
+ expect(isFieldModified(1, 2)).toBe(true)
15
+ expect(isFieldModified(true, false)).toBe(true)
16
+ })
17
+
18
+ it('nullish 空值等价视为未变更', () => {
19
+ expect(isFieldModified(null, undefined)).toBe(false)
20
+ expect(isFieldModified(undefined, '')).toBe(false)
21
+ expect(isFieldModified('', null)).toBe(false)
22
+ expect(isFieldModified(null, null)).toBe(false)
23
+ })
24
+
25
+ it('数组与对象按 JSON 深比较', () => {
26
+ expect(isFieldModified([1, 2], [1, 2])).toBe(false)
27
+ expect(isFieldModified([1, 2], [1, 3])).toBe(true)
28
+ expect(isFieldModified({ a: 1 }, { a: 1 })).toBe(false)
29
+ expect(isFieldModified({ a: 1 }, { a: 2 })).toBe(true)
30
+ })
31
+
32
+ it('undefined 与有值视为已变更', () => {
33
+ expect(isFieldModified(undefined, 'x')).toBe(true)
34
+ expect(isFieldModified('x', undefined)).toBe(true)
35
+ expect(isFieldModified(null, 'x')).toBe(true)
36
+ })
37
+ })
@@ -17,6 +17,10 @@
17
17
  :model-value="modelValue ?? o(model ?? {}).get(field)"
18
18
  @update:model-value="handleUpdateValue(field, $event)"
19
19
  />
20
+ <div v-if="shouldShowModified(field)" :class="cls.e('data-before')">
21
+ <span :class="cls.e('changed-tag')">{{ modifiedLabel }}</span>
22
+ <component :is="node" readonly :model-value="getBaselineFieldValue(field)" />
23
+ </div>
20
24
  </u-form-item>
21
25
 
22
26
  <component v-else :is="node" />
@@ -33,12 +37,13 @@ import { nextTick, toRef, useTemplateRef } from 'vue'
33
37
  import type { BreakCols, FormProps, _FormExposed, FormEmits } from '../../types'
34
38
  import { UFormItem } from '../form-item'
35
39
  import { UGrid } from '../grid'
40
+ import { isFieldModified } from './is-field-modified'
36
41
  import { useFormFields } from './use-form-fields'
37
42
  import { useNodeInterceptor } from './use-node-interceptor'
38
43
 
39
44
  defineOptions({ name: 'UForm' })
40
45
 
41
- const props = defineProps<FormProps>()
46
+ const props = withDefaults(defineProps<FormProps>(), { modifiedLabel: '变更前:' })
42
47
 
43
48
  const emit = defineEmits<FormEmits>()
44
49
 
@@ -56,7 +61,8 @@ const {
56
61
  reset,
57
62
  registerField,
58
63
  unregisterField,
59
- shouldValidate
64
+ shouldValidate,
65
+ getBaselineModel
60
66
  } = useFormFields({ props })
61
67
 
62
68
  async function validate(keys?: string[]) {
@@ -71,8 +77,12 @@ async function validate(keys?: string[]) {
71
77
  return valid
72
78
  }
73
79
 
74
- function handleFieldChange(field: string, value: any) {
75
- emit('field:change', field, value)
80
+ function handleFieldUpdate(field: string, value: any) {
81
+ emit('field:update', field, value)
82
+ }
83
+
84
+ function handleFieldChange(field: string, ...args: any[]) {
85
+ emit('field:change', field, ...args)
76
86
  }
77
87
 
78
88
  provideFormContext({
@@ -81,6 +91,7 @@ provideFormContext({
81
91
  unregisterField,
82
92
  validateFields: validate,
83
93
  shouldValidate,
94
+ handleFieldUpdate,
84
95
  handleFieldChange
85
96
  })
86
97
 
@@ -91,5 +102,18 @@ function handleUpdateValue(field: string, value: any) {
91
102
  o(props.model).set(field, value)
92
103
  }
93
104
 
105
+ function getBaselineFieldValue(field: string) {
106
+ return o(getBaselineModel() ?? {}).get(field)
107
+ }
108
+
109
+ function shouldShowModified(field: string) {
110
+ if (!props.showModified) return false
111
+ const baseline = getBaselineModel()
112
+ if (!baseline || !props.model) return false
113
+ const current = o(props.model).get(field)
114
+ const initial = o(baseline).get(field)
115
+ return isFieldModified(current, initial)
116
+ }
117
+
94
118
  defineExpose<_FormExposed>({ el: toRef(() => gridRef.value?.el), validate, clearValidate, reset })
95
119
  </script>
@@ -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
+ }
@@ -16,12 +16,63 @@
16
16
  }
17
17
 
18
18
  @include m.e(data-before) {
19
- width: 100%;
20
- line-height: 1.3;
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 4px;
22
+ flex-wrap: wrap;
23
+ margin-top: 4px;
24
+ padding: 2px 8px;
25
+ border-left: 2px solid fn.use-var(color, info);
26
+ background-color: fn.color-a(color, 8, info);
27
+ border-radius: 2px;
28
+ width: fit-content;
29
+ max-width: 100%;
30
+ line-height: 1.4;
31
+ font-size: fn.use-var(font-size, assist, default);
32
+
33
+ // 文本类控件保持全宽,便于展示较长内容
34
+ &:has(#{fn.bem(input)}),
35
+ &:has(#{fn.bem(textarea)}),
36
+ &:has(#{fn.bem(select)}),
37
+ &:has(#{fn.bem(multi-select)}),
38
+ &:has(#{fn.bem(multi-tree-select)}),
39
+ &:has(#{fn.bem(auto-complete)}),
40
+ &:has(#{fn.bem(cascade)}),
41
+ &:has(#{fn.bem(date-picker)}),
42
+ &:has(#{fn.bem(date-range-picker)}),
43
+ &:has(#{fn.bem(number-input)}),
44
+ &:has(#{fn.bem(tree-select)}) {
45
+ width: 100%;
46
+ }
47
+
48
+ // 让内部只读控件更像辅助说明,减少「第二个输入框」感
49
+ @include m.bem(form-item, content) {
50
+ background: transparent;
51
+ padding: 0;
52
+ }
53
+
54
+ #{fn.bem(input)},
55
+ #{fn.bem(textarea)},
56
+ #{fn.bem(select)},
57
+ #{fn.bem(multi-select)},
58
+ #{fn.bem(multi-tree-select)},
59
+ #{fn.bem(auto-complete)},
60
+ #{fn.bem(cascade)},
61
+ #{fn.bem(date-picker)},
62
+ #{fn.bem(date-range-picker)} {
63
+ border: none;
64
+ box-shadow: none;
65
+ background: transparent;
66
+ min-height: auto;
67
+ height: auto;
68
+ padding: 0;
69
+ }
21
70
  }
22
71
 
23
72
  @include m.e(changed-tag) {
24
73
  color: fn.use-var(color, info);
25
74
  font-size: fn.use-var(font-size, assist, default);
75
+ font-style: normal;
76
+ flex-shrink: 0;
26
77
  }
27
78
  }
@@ -1,6 +1,6 @@
1
1
  import { copy, o } from '@cat-kit/core'
2
2
  import type { FormFieldItem } from '@veltra/utils'
3
- import { nextTick, watch } from 'vue'
3
+ import { nextTick, shallowRef, watch } from 'vue'
4
4
 
5
5
  import type { FormProps } from '../../types/form'
6
6
 
@@ -42,7 +42,7 @@ export function useFormFields(options: Options) {
42
42
  let willValidate = true
43
43
 
44
44
  /** model 初始快照,供 reset 恢复 */
45
- let initialSnapshot: Record<string, any> | undefined
45
+ const initialSnapshot = shallowRef<Record<string, any> | undefined>()
46
46
 
47
47
  function runWithoutChangeValidate(fn: () => void) {
48
48
  willValidate = false
@@ -59,7 +59,7 @@ export function useFormFields(options: Options) {
59
59
  watch(
60
60
  () => props.model,
61
61
  (model) => {
62
- initialSnapshot = model ? copy(model) : undefined
62
+ initialSnapshot.value = model ? copy(model) : undefined
63
63
  },
64
64
  { immediate: true, deep: false }
65
65
  )
@@ -89,13 +89,27 @@ export function useFormFields(options: Options) {
89
89
  }
90
90
 
91
91
  function reset() {
92
- if (!props.model || !initialSnapshot) return
92
+ if (!props.model || !initialSnapshot.value) return
93
93
 
94
94
  runWithoutChangeValidate(() => {
95
- applySnapshot(props.model!, initialSnapshot!)
95
+ applySnapshot(props.model!, initialSnapshot.value!)
96
96
  Object.values(fields).forEach((item) => item.clearValidate?.())
97
97
  })
98
98
  }
99
99
 
100
- return { fields, registerField, validate, unregisterField, clearValidate, reset, shouldValidate }
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
+ }
101
115
  }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, reactive, ref } from 'vue'
3
+
4
+ import { UForm } from '../../form'
5
+ import { UInput } from '../../input'
6
+ import { USelect } from '../../select'
7
+ import { UFormItem } from '../index'
8
+
9
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
10
+
11
+ const behaviorOptions = [
12
+ { label: '事件', value: 'event' },
13
+ { label: '接口', value: 'api' }
14
+ ]
15
+
16
+ async function openSelect(host: HTMLElement) {
17
+ const input = host.querySelector('input')!
18
+ input.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
19
+ await nextTick()
20
+ await nextTick()
21
+ }
22
+
23
+ async function clickSelectOption(label: string) {
24
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
25
+ (el) => el.textContent === label
26
+ )
27
+ expect(option, `选项「${label}」应存在`).toBeTruthy()
28
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
29
+ await nextTick()
30
+ await sleep(50)
31
+ }
32
+
33
+ describe('UFormItem change', () => {
34
+ it('forwards Select change with option object', async () => {
35
+ const host = document.createElement('div')
36
+ document.body.appendChild(host)
37
+
38
+ const model = reactive({ behavior: 'event' })
39
+ const itemChanges: unknown[][] = []
40
+ const formChanges: { field: string; args: unknown[] }[] = []
41
+
42
+ const app = createApp({
43
+ render() {
44
+ return h(
45
+ UForm,
46
+ {
47
+ model,
48
+ 'onField:change': (field: string, ...args: unknown[]) => {
49
+ formChanges.push({ field, args })
50
+ }
51
+ },
52
+ {
53
+ default: () =>
54
+ h(
55
+ UFormItem,
56
+ {
57
+ field: 'behavior',
58
+ label: '行为',
59
+ onChange: (...args: unknown[]) => itemChanges.push(args)
60
+ },
61
+ {
62
+ default: () =>
63
+ h(USelect, {
64
+ modelValue: model.behavior,
65
+ 'onUpdate:modelValue': (value: string) => {
66
+ model.behavior = value
67
+ },
68
+ options: behaviorOptions
69
+ })
70
+ }
71
+ )
72
+ }
73
+ )
74
+ }
75
+ })
76
+
77
+ app.mount(host)
78
+
79
+ await openSelect(host)
80
+ await clickSelectOption('接口')
81
+
82
+ expect(itemChanges).toHaveLength(1)
83
+ expect(itemChanges[0]![0]).toEqual({ label: '接口', value: 'api' })
84
+
85
+ expect(formChanges).toHaveLength(1)
86
+ expect(formChanges[0]).toEqual({ field: 'behavior', args: [{ label: '接口', value: 'api' }] })
87
+
88
+ app.unmount()
89
+ host.remove()
90
+ })
91
+
92
+ it('显式 UFormItem 内控件的动态 props 随父级更新', async () => {
93
+ const host = document.createElement('div')
94
+ document.body.appendChild(host)
95
+
96
+ const model = reactive({ name: '' })
97
+ const disabled = ref(false)
98
+
99
+ const app = createApp({
100
+ render() {
101
+ return h(
102
+ UForm,
103
+ { model },
104
+ {
105
+ default: () =>
106
+ h(
107
+ UFormItem,
108
+ { field: 'name', label: '姓名' },
109
+ { default: () => h(UInput, { modelValue: model.name, disabled: disabled.value }) }
110
+ )
111
+ }
112
+ )
113
+ }
114
+ })
115
+
116
+ app.mount(host)
117
+
118
+ const input = () => host.querySelector('input')!
119
+ expect(input().disabled).toBe(false)
120
+
121
+ disabled.value = true
122
+ await nextTick()
123
+
124
+ expect(input().disabled).toBe(true)
125
+
126
+ app.unmount()
127
+ host.remove()
128
+ })
129
+ })
@@ -17,7 +17,9 @@
17
17
 
18
18
  <section :class="cls.e('wrapper')">
19
19
  <div :class="cls.e('content')">
20
- <slot></slot>
20
+ <ContentSlot>
21
+ <slot></slot>
22
+ </ContentSlot>
21
23
  </div>
22
24
 
23
25
  <!-- 只有表单控件处于非只读状态时,才显示错误提示 -->
@@ -35,11 +37,20 @@
35
37
  <script lang="tsx" setup>
36
38
  import { o } from '@cat-kit/core'
37
39
  import { useConfig, useFallbackProps } from '@veltra/compositions'
38
- import { bem, withUnit } from '@veltra/utils'
40
+ import { bem, extractNormalVNodes, withUnit } from '@veltra/utils'
39
41
  import { injectFormContext } from '@veltra/utils'
40
- import { type CSSProperties, computed, onBeforeUnmount, shallowRef, watch } from 'vue'
41
-
42
- import type { FormItemProps, ComponentSize } from '../../types'
42
+ import {
43
+ type CSSProperties,
44
+ type SetupContext,
45
+ type VNode,
46
+ cloneVNode,
47
+ computed,
48
+ onBeforeUnmount,
49
+ shallowRef,
50
+ watch
51
+ } from 'vue'
52
+
53
+ import type { FormItemEmits, FormItemProps, ComponentSize } from '../../types'
43
54
  import { UGridItem } from '../grid'
44
55
  import { UTip } from '../tip'
45
56
  import { formItemCls as cls, defineField } from './helper'
@@ -49,6 +60,8 @@ defineOptions({ name: 'UFormItem' })
49
60
 
50
61
  const props = withDefaults(defineProps<FormItemProps>(), { readonly: undefined })
51
62
 
63
+ const emit = defineEmits<FormItemEmits>()
64
+
52
65
  defineSlots<{
53
66
  /** 标签插槽 */
54
67
  label?: () => any
@@ -56,8 +69,34 @@ defineSlots<{
56
69
  }>()
57
70
 
58
71
  /** 表单组件上下文 */
59
- const { formProps, registerField, unregisterField, shouldValidate, handleFieldChange } =
60
- injectFormContext()
72
+ const {
73
+ formProps,
74
+ registerField,
75
+ unregisterField,
76
+ shouldValidate,
77
+ handleFieldUpdate,
78
+ handleFieldChange
79
+ } = injectFormContext()
80
+
81
+ function wrapControlChange(node: VNode) {
82
+ if (!node || typeof node.type === 'symbol') return node
83
+
84
+ return cloneVNode(node, {
85
+ onChange: (...args: any[]) => {
86
+ emit('change', ...args)
87
+ if (props.field) handleFieldChange?.(props.field, ...args)
88
+ }
89
+ })
90
+ }
91
+
92
+ /**
93
+ * 承载默认插槽并拦截控件 change。
94
+ * 必须经填充插槽接收内容(而非直接读 UFormItem 的 $slots):
95
+ * 读父级 $slots 时组件自身没有更新路径,父级重渲染后动态 props(disabled、data 等)会冻结在首帧。
96
+ */
97
+ function ContentSlot(_: unknown, { slots }: SetupContext) {
98
+ return extractNormalVNodes(slots.default?.() ?? []).map(wrapControlChange)
99
+ }
61
100
 
62
101
  const { config } = useConfig()
63
102
 
@@ -137,7 +176,7 @@ watch(
137
176
  stopWatchFieldValue = watch(
138
177
  () => o(model).get(field),
139
178
  (value) => {
140
- handleFieldChange?.(field, value)
179
+ handleFieldUpdate?.(field, value)
141
180
  fieldItem.validate()
142
181
  }
143
182
  )
@@ -4,7 +4,12 @@ import type { ButtonProps } from './button'
4
4
 
5
5
  /** 操作组件属性 */
6
6
  export interface ActionProps extends ButtonProps {
7
- /** 是否需要确认 */
7
+ /**
8
+ * 是否需要确认:true 时点击只弹确认气泡(UPopConfirm),点气泡里的「确认」才触发 `run`
9
+ * @default false
10
+ * @description 不要在本项的 `run` 回调里再调 `messageConfirm`:`run` 触发时气泡已经确认过一次,
11
+ * 会变成两次确认。需要弹窗级确认时去掉 `needConfirm`,只在 `run` 里用 `messageConfirm`
12
+ */
8
13
  needConfirm?: boolean
9
14
 
10
15
  /**
@@ -115,6 +115,8 @@ export interface BatchEditProps extends TableProps {
115
115
 
116
116
  /** 批量编辑组件定义的事件 */
117
117
  export interface BatchEditEmits extends TableEmits {
118
+ /** 表单控件 change,透传自 UForm */
119
+ (e: 'field:change', field: string, ...args: any[]): void
118
120
  /** 更新数据 */
119
121
  (e: 'update:data', value: Record<string, any>[]): void
120
122
  /** 点击底部「新增一行」 */
@@ -4,7 +4,11 @@ import type { Component, ShallowRef } from 'vue'
4
4
  /** 按钮类型 */
5
5
  export type ButtonType = ColorType
6
6
 
7
- /** 按钮属性类型 */
7
+ /**
8
+ * 按钮属性类型
9
+ * @description 组件不注入默认 `aria-label`:可访问名来自默认插槽文本,
10
+ * 或你自己传入的 `aria-label`(透传到根元素);只有图标的按钮必须自行传 `aria-label`
11
+ */
8
12
  export interface ButtonProps extends ComponentProps {
9
13
  /** 按钮类型 */
10
14
  type?: ButtonType