@veltra/desktop 1.7.8 → 1.7.10

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 (74) 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/form/form.js.map +1 -1
  7. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +31 -6
  8. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/form/is-field-modified.js +12 -0
  10. package/dist/components/form/is-field-modified.js.map +1 -0
  11. package/dist/components/form/style2.css +37 -1
  12. package/dist/components/form/use-form-fields.js +11 -6
  13. package/dist/components/form/use-form-fields.js.map +1 -1
  14. package/dist/components/form-item/form-item.js.map +1 -1
  15. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +18 -5
  16. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
  17. package/dist/components/image-cropper/draw-crop.js +63 -0
  18. package/dist/components/image-cropper/draw-crop.js.map +1 -0
  19. package/dist/components/image-cropper/image-cropper.js +344 -0
  20. package/dist/components/image-cropper/image-cropper.js.map +1 -0
  21. package/dist/components/image-cropper/style.d.ts +1 -0
  22. package/dist/components/image-cropper/style.js +1 -0
  23. package/dist/components/image-cropper/style2.css +205 -0
  24. package/dist/components/image-cropper/use-image-loader.js +59 -0
  25. package/dist/components/image-cropper/use-image-loader.js.map +1 -0
  26. package/dist/components/image-cropper/use-preview.js +44 -0
  27. package/dist/components/image-cropper/use-preview.js.map +1 -0
  28. package/dist/components/image-cropper/use-selection.js +183 -0
  29. package/dist/components/image-cropper/use-selection.js.map +1 -0
  30. package/dist/components/image-cropper/use-transform.js +182 -0
  31. package/dist/components/image-cropper/use-transform.js.map +1 -0
  32. package/dist/components/index.d.ts +2 -1
  33. package/dist/components/index.js +3 -1
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/index.d.ts +47 -45
  36. package/dist/index.js +46 -45
  37. package/dist/types/batch-edit.d.ts +2 -0
  38. package/dist/types/form-item.d.ts +4 -1
  39. package/dist/types/form.d.ts +10 -1
  40. package/dist/types/image-cropper.d.ts +68 -0
  41. package/dist/types/index.d.ts +2 -1
  42. package/package.json +6 -6
  43. package/src/components/auto-complete/style.scss +3 -15
  44. package/src/components/batch-edit/__test__/batch-edit.test.ts +138 -0
  45. package/src/components/batch-edit/batch-edit-form.vue +18 -4
  46. package/src/components/batch-edit/use-edit-state.ts +1 -1
  47. package/src/components/contextmenu/style.scss +4 -20
  48. package/src/components/form/__test__/form.test.ts +80 -0
  49. package/src/components/form/__test__/is-field-modified.test.ts +37 -0
  50. package/src/components/form/form.vue +28 -4
  51. package/src/components/form/is-field-modified.ts +16 -0
  52. package/src/components/form/style.scss +53 -2
  53. package/src/components/form/use-form-fields.ts +20 -6
  54. package/src/components/form-item/__test__/form-item.test.ts +90 -0
  55. package/src/components/form-item/form-item.vue +42 -7
  56. package/src/components/image-cropper/__test__/draw-crop.test.ts +131 -0
  57. package/src/components/image-cropper/__test__/image-cropper.test.ts +744 -0
  58. package/src/components/image-cropper/__test__/use-transform.test.ts +229 -0
  59. package/src/components/image-cropper/draw-crop.ts +112 -0
  60. package/src/components/image-cropper/image-cropper.vue +302 -0
  61. package/src/components/image-cropper/index.ts +1 -0
  62. package/src/components/image-cropper/style.scss +232 -0
  63. package/src/components/image-cropper/style.ts +1 -0
  64. package/src/components/image-cropper/use-image-loader.ts +79 -0
  65. package/src/components/image-cropper/use-preview.ts +58 -0
  66. package/src/components/image-cropper/use-selection.ts +255 -0
  67. package/src/components/image-cropper/use-transform.ts +256 -0
  68. package/src/components/index.ts +2 -0
  69. package/src/components/select/style.scss +3 -15
  70. package/src/types/batch-edit.ts +2 -0
  71. package/src/types/form-item.ts +4 -1
  72. package/src/types/form.ts +10 -2
  73. package/src/types/image-cropper.ts +73 -0
  74. package/src/types/index.ts +1 -0
@@ -30,6 +30,8 @@ function mountBatchEdit() {
30
30
  apiMethod: undefined as string | undefined
31
31
  })
32
32
 
33
+ const fieldChanges: { field: string; args: unknown[] }[] = []
34
+
33
35
  const app = createApp({
34
36
  render() {
35
37
  return h(
@@ -39,6 +41,9 @@ function mountBatchEdit() {
39
41
  'onUpdate:data': (value: any[]) => {
40
42
  data.value = value
41
43
  },
44
+ 'onField:change': (field: string, ...args: unknown[]) => {
45
+ fieldChanges.push({ field, args })
46
+ },
42
47
  columns,
43
48
  model,
44
49
  rowKey: 'id',
@@ -89,6 +94,7 @@ function mountBatchEdit() {
89
94
  host,
90
95
  data,
91
96
  model,
97
+ fieldChanges,
92
98
  unmount() {
93
99
  app.unmount()
94
100
  host.remove()
@@ -196,4 +202,136 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
196
202
 
197
203
  unmount()
198
204
  })
205
+
206
+ it('切换行回显不触发 field:change', async () => {
207
+ const { host, fieldChanges, unmount } = mountBatchEdit()
208
+
209
+ fieldChanges.length = 0
210
+ await clickRow(host, 0)
211
+ await clickRow(host, 1)
212
+
213
+ expect(fieldChanges).toHaveLength(0)
214
+
215
+ unmount()
216
+ })
217
+
218
+ it('用户变更 Select 触发 field:change', async () => {
219
+ const { host, fieldChanges, unmount } = mountBatchEdit()
220
+
221
+ await clickRow(host, 0)
222
+ fieldChanges.length = 0
223
+
224
+ const input = host.querySelector<HTMLInputElement>('.u-select input')
225
+ expect(input).toBeTruthy()
226
+ input!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
227
+ await nextTick()
228
+ await nextTick()
229
+
230
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
231
+ (el) => el.textContent === '接口'
232
+ )
233
+ expect(option).toBeTruthy()
234
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
235
+ await nextTick()
236
+ await nextTick()
237
+
238
+ expect(fieldChanges).toHaveLength(1)
239
+ expect(fieldChanges[0]).toEqual({ field: 'behavior', args: [{ label: '接口', value: 'api' }] })
240
+
241
+ unmount()
242
+ })
243
+
244
+ it('field:change 联动改其他字段不循环触发', async () => {
245
+ const host = document.createElement('div')
246
+ document.body.appendChild(host)
247
+
248
+ const columns = defineTableColumns([
249
+ { key: 'label', name: '名称' },
250
+ { key: 'behavior', name: '行为' }
251
+ ])
252
+
253
+ const data = ref([
254
+ { id: '1', label: '存草稿', behavior: 'common-resource', submitType: 'DRAFT' },
255
+ { id: '2', label: '调接口', behavior: 'api', submitType: 'SUBMIT', apiMethod: 'POST' }
256
+ ])
257
+
258
+ const model = reactive({
259
+ id: '',
260
+ label: '',
261
+ behavior: 'event',
262
+ submitType: undefined as string | undefined,
263
+ apiMethod: undefined as string | undefined
264
+ })
265
+
266
+ const fieldChanges: string[] = []
267
+
268
+ const app = createApp({
269
+ render() {
270
+ return h(
271
+ UBatchEdit,
272
+ {
273
+ data: data.value,
274
+ 'onUpdate:data': (value: any[]) => {
275
+ data.value = value
276
+ },
277
+ 'onField:change': (field: string) => {
278
+ fieldChanges.push(field)
279
+ if (field === 'behavior') {
280
+ model.submitType = undefined
281
+ }
282
+ },
283
+ columns,
284
+ model,
285
+ rowKey: 'id',
286
+ quickEdit: true
287
+ },
288
+ {
289
+ form: () => [
290
+ h(UInput, { field: 'label', label: '名称' }),
291
+ h(USelect, {
292
+ field: 'behavior',
293
+ label: '行为',
294
+ options: [
295
+ { label: '事件', value: 'event' },
296
+ { label: '通用资源', value: 'common-resource' },
297
+ { label: '接口', value: 'api' }
298
+ ]
299
+ }),
300
+ h(USelect, {
301
+ field: 'submitType',
302
+ label: '提交类型',
303
+ options: [
304
+ { label: 'DRAFT', value: 'DRAFT' },
305
+ { label: 'SUBMIT', value: 'SUBMIT' }
306
+ ]
307
+ })
308
+ ]
309
+ }
310
+ )
311
+ }
312
+ })
313
+
314
+ app.mount(host)
315
+
316
+ await clickRow(host, 0)
317
+ fieldChanges.length = 0
318
+
319
+ const input = host.querySelector<HTMLInputElement>('.u-select input')
320
+ input!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
321
+ await nextTick()
322
+ await nextTick()
323
+
324
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
325
+ (el) => el.textContent === '接口'
326
+ )
327
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
328
+ await nextTick()
329
+ await nextTick()
330
+
331
+ expect(fieldChanges).toEqual(['behavior'])
332
+ expect(model.submitType).toBeUndefined()
333
+
334
+ app.unmount()
335
+ host.remove()
336
+ })
199
337
  })
@@ -28,7 +28,8 @@
28
28
  :model="props.model"
29
29
  :readonly="props.readonly"
30
30
  :label-width="props.labelWidth"
31
- @field:change="handleFieldChange"
31
+ @field:update="handleFieldUpdate"
32
+ @field:change="handleFormFieldChange"
32
33
  >
33
34
  <slot
34
35
  v-bind="{
@@ -91,8 +92,17 @@ defineOptions({ name: 'UBatchEditForm' })
91
92
 
92
93
  const batchEditCtx = inject(BatchEditDIKey)!
93
94
 
94
- const { cls, props, state, handleClose, handleSave, staticFeatures, dynamicFeatures, syncing } =
95
- batchEditCtx
95
+ const {
96
+ cls,
97
+ props,
98
+ state,
99
+ emit,
100
+ handleClose,
101
+ handleSave,
102
+ staticFeatures,
103
+ dynamicFeatures,
104
+ syncing
105
+ } = batchEditCtx
96
106
 
97
107
  const focused = toRef(batchEditCtx, 'focused')
98
108
 
@@ -105,7 +115,7 @@ defineExpose<_FormExposed>({
105
115
  reset: () => formComponentRef.value?.reset()
106
116
  })
107
117
 
108
- function handleFieldChange(field: string, value: any) {
118
+ function handleFieldUpdate(field: string, value: any) {
109
119
  // 编程方式重置/回显期间不写回行数据,避免默认值污染源数据
110
120
  if (syncing.value) return
111
121
 
@@ -114,6 +124,10 @@ function handleFieldChange(field: string, value: any) {
114
124
  }
115
125
  }
116
126
 
127
+ function handleFormFieldChange(field: string, ...args: any[]) {
128
+ emit('field:change', field, ...args)
129
+ }
130
+
117
131
  const creatable = computed(() => {
118
132
  if (state.formActionType !== 'create') return false
119
133
  return staticFeatures.value.has('create') || dynamicFeatures.value.create?.(state.row)
@@ -46,7 +46,7 @@ export function useEditState(options: Options) {
46
46
 
47
47
  // 先重置回 model 初始快照,再同步回显行数据。
48
48
  // 同一 tick 内的连续写入会被表单字段 watcher 合并,
49
- // 重置产生的默认值不会触发 field:change 回写行数据
49
+ // 重置产生的默认值不会触发 field:update 回写行数据
50
50
  formRef.value?.reset()
51
51
 
52
52
  if (row) {
@@ -92,31 +92,15 @@ $arrow-size: 12px;
92
92
 
93
93
  #{$b}__item:not(.is-disabled):not(.is-loading) > & {
94
94
  &:hover {
95
- background-color: fn.component-var(
96
- contextmenu,
97
- item-hover-bg,
98
- fn.use-var(bg-color, hover)
99
- );
100
- color: fn.component-var(
101
- contextmenu,
102
- item-hover-color,
103
- fn.use-var(text-color, title)
104
- );
95
+ background-color: fn.component-var(contextmenu, item-hover-bg, fn.use-var(bg-color, hover));
96
+ color: fn.component-var(contextmenu, item-hover-color, fn.use-var(text-color, title));
105
97
  }
106
98
  }
107
99
  }
108
100
 
109
101
  #{$b}__item.is-open:not(.is-disabled) > #{$b}__item-content {
110
- background-color: fn.component-var(
111
- contextmenu,
112
- item-hover-bg,
113
- fn.use-var(bg-color, hover)
114
- );
115
- color: fn.component-var(
116
- contextmenu,
117
- item-hover-color,
118
- fn.use-var(text-color, title)
119
- );
102
+ background-color: fn.component-var(contextmenu, item-hover-bg, fn.use-var(bg-color, hover));
103
+ color: fn.component-var(contextmenu, item-hover-color, fn.use-var(text-color, title));
120
104
  }
121
105
 
122
106
  @include m.e(icon-place) {
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, reactive } from 'vue'
3
+
4
+ import { USelect } from '../../select'
5
+ import { UForm } from '../index'
6
+
7
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
8
+
9
+ const behaviorOptions = [
10
+ { label: '事件', value: 'event' },
11
+ { label: '接口', value: 'api' }
12
+ ]
13
+
14
+ async function openSelect(host: HTMLElement) {
15
+ const input = host.querySelector('input')!
16
+ input.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
17
+ await nextTick()
18
+ await nextTick()
19
+ }
20
+
21
+ async function clickSelectOption(label: string) {
22
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
23
+ (el) => el.textContent === label
24
+ )
25
+ expect(option, `选项「${label}」应存在`).toBeTruthy()
26
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
27
+ await nextTick()
28
+ await sleep(50)
29
+ }
30
+
31
+ describe('UForm field events', () => {
32
+ it('field:update and field:change are separate', async () => {
33
+ const host = document.createElement('div')
34
+ document.body.appendChild(host)
35
+
36
+ const model = reactive({ behavior: 'event' })
37
+ const updates: { field: string; value: unknown }[] = []
38
+ const changes: { field: string; args: unknown[] }[] = []
39
+
40
+ const app = createApp({
41
+ render() {
42
+ return h(
43
+ UForm,
44
+ {
45
+ model,
46
+ 'onField:update': (field: string, value: unknown) => {
47
+ updates.push({ field, value })
48
+ },
49
+ 'onField:change': (field: string, ...args: unknown[]) => {
50
+ changes.push({ field, args })
51
+ }
52
+ },
53
+ {
54
+ default: () =>
55
+ h(USelect, { field: 'behavior', label: '行为', options: behaviorOptions })
56
+ }
57
+ )
58
+ }
59
+ })
60
+
61
+ app.mount(host)
62
+ updates.length = 0
63
+ changes.length = 0
64
+
65
+ model.behavior = 'api'
66
+ await nextTick()
67
+
68
+ expect(updates.some((item) => item.field === 'behavior' && item.value === 'api')).toBe(true)
69
+ expect(changes).toHaveLength(0)
70
+
71
+ await openSelect(host)
72
+ await clickSelectOption('事件')
73
+
74
+ expect(changes).toHaveLength(1)
75
+ expect(changes[0]).toEqual({ field: 'behavior', args: [{ label: '事件', value: 'event' }] })
76
+
77
+ app.unmount()
78
+ host.remove()
79
+ })
80
+ })
@@ -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,90 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, reactive } from 'vue'
3
+
4
+ import { UForm } from '../../form'
5
+ import { USelect } from '../../select'
6
+ import { UFormItem } 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('UFormItem change', () => {
33
+ it('forwards Select change with option object', async () => {
34
+ const host = document.createElement('div')
35
+ document.body.appendChild(host)
36
+
37
+ const model = reactive({ behavior: 'event' })
38
+ const itemChanges: unknown[][] = []
39
+ const formChanges: { field: string; args: unknown[] }[] = []
40
+
41
+ const app = createApp({
42
+ render() {
43
+ return h(
44
+ UForm,
45
+ {
46
+ model,
47
+ 'onField:change': (field: string, ...args: unknown[]) => {
48
+ formChanges.push({ field, args })
49
+ }
50
+ },
51
+ {
52
+ default: () =>
53
+ h(
54
+ UFormItem,
55
+ {
56
+ field: 'behavior',
57
+ label: '行为',
58
+ onChange: (...args: unknown[]) => itemChanges.push(args)
59
+ },
60
+ {
61
+ default: () =>
62
+ h(USelect, {
63
+ modelValue: model.behavior,
64
+ 'onUpdate:modelValue': (value: string) => {
65
+ model.behavior = value
66
+ },
67
+ options: behaviorOptions
68
+ })
69
+ }
70
+ )
71
+ }
72
+ )
73
+ }
74
+ })
75
+
76
+ app.mount(host)
77
+
78
+ await openSelect(host)
79
+ await clickSelectOption('接口')
80
+
81
+ expect(itemChanges).toHaveLength(1)
82
+ expect(itemChanges[0]![0]).toEqual({ label: '接口', value: 'api' })
83
+
84
+ expect(formChanges).toHaveLength(1)
85
+ expect(formChanges[0]).toEqual({ field: 'behavior', args: [{ label: '接口', value: 'api' }] })
86
+
87
+ app.unmount()
88
+ host.remove()
89
+ })
90
+ })