@veltra/desktop 1.7.17 → 1.7.19

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 (37) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +6 -1
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit-list.js +45 -27
  4. package/dist/components/batch-edit/batch-edit-list.js.map +1 -1
  5. package/dist/components/batch-edit/batch-edit.js +2 -2
  6. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  7. package/dist/components/batch-edit/di.js.map +1 -1
  8. package/dist/components/batch-edit/use-edit-state.js +14 -10
  9. package/dist/components/batch-edit/use-edit-state.js.map +1 -1
  10. package/dist/components/batch-edit/use-handlers.js +1 -0
  11. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  12. package/dist/components/dialog/style2.css +1 -1
  13. package/dist/components/expression-editor/core/chip.js +1 -15
  14. package/dist/components/expression-editor/core/chip.js.map +1 -1
  15. package/dist/components/expression-editor/core/editor.js +0 -18
  16. package/dist/components/expression-editor/core/editor.js.map +1 -1
  17. package/dist/components/expression-editor/expression-editor.js +0 -4
  18. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  19. package/dist/components/expression-editor/style2.css +0 -23
  20. package/dist/index.d.ts +2 -2
  21. package/dist/types/batch-edit.d.ts +0 -3
  22. package/dist/types/index.d.ts +2 -2
  23. package/package.json +1 -1
  24. package/src/components/batch-edit/__test__/batch-edit.test.ts +165 -38
  25. package/src/components/batch-edit/batch-edit-form.vue +7 -1
  26. package/src/components/batch-edit/batch-edit-list.vue +53 -22
  27. package/src/components/batch-edit/batch-edit.vue +2 -1
  28. package/src/components/batch-edit/di.ts +2 -0
  29. package/src/components/batch-edit/use-edit-state.ts +25 -32
  30. package/src/components/batch-edit/use-handlers.ts +2 -0
  31. package/src/components/dialog/style.scss +2 -1
  32. package/src/components/expression-editor/__test__/chip.test.ts +3 -14
  33. package/src/components/expression-editor/core/chip.ts +3 -29
  34. package/src/components/expression-editor/core/editor.ts +0 -21
  35. package/src/components/expression-editor/expression-editor.vue +0 -4
  36. package/src/components/expression-editor/style.scss +0 -29
  37. package/src/types/batch-edit.ts +0 -4
@@ -107,6 +107,10 @@ function queryRows(host: HTMLElement) {
107
107
  return [...host.querySelectorAll<HTMLElement>('tr.u-table__row')]
108
108
  }
109
109
 
110
+ function queryEditButton(host: HTMLElement, index: number) {
111
+ return queryRows(host)[index]?.querySelector<HTMLElement>('button[title="编辑"]')
112
+ }
113
+
110
114
  async function clickRow(host: HTMLElement, index: number) {
111
115
  const row = queryRows(host)[index]
112
116
  expect(row, `第 ${index} 行应存在`).toBeTruthy()
@@ -115,19 +119,27 @@ async function clickRow(host: HTMLElement, index: number) {
115
119
  await nextTick()
116
120
  }
117
121
 
122
+ async function clickEditRow(host: HTMLElement, index: number) {
123
+ const btn = queryEditButton(host, index)
124
+ expect(btn, `第 ${index} 行的编辑按钮应存在`).toBeTruthy()
125
+ btn!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
126
+ await nextTick()
127
+ await nextTick()
128
+ }
129
+
118
130
  describe('UBatchEdit quick-edit 切换编辑行', () => {
119
131
  it('切换行时先重置表单再回显,不污染行数据', async () => {
120
132
  const { host, data, model, unmount } = mountBatchEdit()
121
133
 
122
- // 第一次点击第 1 行:正常回显
123
- await clickRow(host, 0)
134
+ // 第一次点击第 1 行的编辑按钮:正常回显
135
+ await clickEditRow(host, 0)
124
136
  expect(model.label).toBe('存草稿')
125
137
  expect(model.behavior).toBe('common-resource')
126
138
  expect(model.submitType).toBe('DRAFT')
127
139
  expect(model.apiMethod).toBeUndefined()
128
140
 
129
- // 再点击第 2 行:先重置回 model 初始快照,再回显第 2 行数据
130
- await clickRow(host, 1)
141
+ // 再点击第 2 行的编辑按钮:先重置回 model 初始快照,再回显第 2 行数据
142
+ await clickEditRow(host, 1)
131
143
  expect(model.label).toBe('调接口')
132
144
  expect(model.behavior).toBe('api')
133
145
  expect(model.submitType).toBe('SUBMIT')
@@ -142,14 +154,33 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
142
154
  unmount()
143
155
  })
144
156
 
145
- it('取消选中行后表单数据重置为 model 初始值', async () => {
157
+ it('点击行打开表单,再次点击当前行关闭表单', async () => {
158
+ const { host, model, unmount } = mountBatchEdit()
159
+
160
+ const aside = host.querySelector<HTMLElement>('.u-batch-edit__form')!
161
+
162
+ await clickRow(host, 0)
163
+ expect(aside.style.display).not.toBe('none')
164
+ expect(model.label).toBe('存草稿')
165
+
166
+ // 再次点击当前行:取消选中并关闭表单,model 恢复初始值
167
+ await clickRow(host, 0)
168
+ expect(aside.style.display).toBe('none')
169
+ expect(model.label).toBe('')
170
+
171
+ unmount()
172
+ })
173
+
174
+ it('关闭表单后表单数据重置为 model 初始值', async () => {
146
175
  const { host, data, model, unmount } = mountBatchEdit()
147
176
 
148
- await clickRow(host, 1)
177
+ await clickEditRow(host, 1)
149
178
  expect(model.label).toBe('调接口')
150
179
 
151
- // 再次点击当前行 → 取消选中,表单恢复到 model 初始快照
152
- await clickRow(host, 1)
180
+ // 点击「取消」关闭表单,model 恢复到初始快照
181
+ clickEl(queryCancelButton(host)!)
182
+ await nextTick()
183
+ await nextTick()
153
184
  expect(model.label).toBe('')
154
185
  expect(model.behavior).toBe('event')
155
186
  expect(model.submitType).toBeUndefined()
@@ -169,16 +200,16 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
169
200
  it('表单常驻挂载(v-show 控制显隐),切换行不重建表单', async () => {
170
201
  const { host, unmount } = mountBatchEdit()
171
202
 
172
- // model 存在即挂载表单,未选中行时仅隐藏(快照只在首次挂载时拍一次)
203
+ // model 存在即挂载表单,未编辑行时仅隐藏(快照只在首次挂载时拍一次)
173
204
  const aside = host.querySelector<HTMLElement>('.u-batch-edit__form')
174
205
  expect(aside).toBeTruthy()
175
206
  expect(aside!.style.display).toBe('none')
176
207
 
177
- await clickRow(host, 0)
208
+ await clickEditRow(host, 0)
178
209
  const formBody = host.querySelector('.u-batch-edit__form-body')
179
210
  expect(aside!.style.display).not.toBe('none')
180
211
 
181
- await clickRow(host, 1)
212
+ await clickEditRow(host, 1)
182
213
  // 同一 DOM 节点:表单未因切行销毁重建,初始快照不会被重新生成
183
214
  expect(host.querySelector('.u-batch-edit__form-body')).toBe(formBody)
184
215
 
@@ -188,7 +219,7 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
188
219
  it('quick-edit 下用户输入仍实时写回行数据', async () => {
189
220
  const { host, data, unmount } = mountBatchEdit()
190
221
 
191
- await clickRow(host, 0)
222
+ await clickEditRow(host, 0)
192
223
 
193
224
  const input = host.querySelector<HTMLInputElement>('.u-input input')
194
225
  expect(input).toBeTruthy()
@@ -205,18 +236,71 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
205
236
  })
206
237
  })
207
238
 
239
+ describe('UBatchEdit 操作列', () => {
240
+ it('按钮顺序:编辑在最前,上/下插入在最后', async () => {
241
+ const { host, unmount } = mountBatchEdit({ props: { tree: true } })
242
+ await nextTick()
243
+
244
+ const titles = [...queryRows(host)[0]!.querySelectorAll<HTMLElement>('button[title]')].map(
245
+ (btn) => btn.title
246
+ )
247
+ expect(titles).toEqual(['编辑', '添加子级', '删除', '在上方插入', '在下方插入'])
248
+
249
+ unmount()
250
+ })
251
+
252
+ it('readonly 模式操作列只保留「查看」按钮,点击打开只读表单', async () => {
253
+ const { host, unmount } = mountBatchEdit({ props: { readonly: true } })
254
+ await nextTick()
255
+
256
+ const row = queryRows(host)[0]!
257
+ const viewBtn = row.querySelector<HTMLElement>('button[title="查看"]')
258
+ expect(viewBtn).toBeTruthy()
259
+ expect(row.querySelector('button[title="删除"]')).toBeNull()
260
+ expect(row.querySelector('button[title="在上方插入"]')).toBeNull()
261
+ expect(host.querySelector('.u-batch-edit__add-btn')).toBeNull()
262
+
263
+ viewBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
264
+ await nextTick()
265
+ await nextTick()
266
+
267
+ expect(host.querySelector('.u-batch-edit__form-title-text')!.textContent).toBe('查看详情')
268
+
269
+ unmount()
270
+ })
271
+ })
272
+
208
273
  function queryDialog() {
209
274
  return document.body.querySelector<HTMLElement>('.u-batch-edit__form-dialog')
210
275
  }
211
276
 
212
- /** 等弹框(teleport 到 body)的进入 / 离开过渡走完 */
213
- async function flushDialog() {
277
+ /** 等弹框(teleport 到 body)打开:进入过渡在 CI 高负载下可能超过固定时长,轮询等待 */
278
+ async function waitDialogOpen() {
279
+ await vi.waitFor(() => {
280
+ expect(queryDialog()).toBeTruthy()
281
+ })
282
+ }
283
+
284
+ /** 等弹框关闭卸载:离开过渡在 CI 高负载下可能超过固定时长,轮询等待 */
285
+ async function waitDialogClosed() {
286
+ await vi.waitFor(() => {
287
+ expect(queryDialog()).toBeNull()
288
+ })
289
+ }
290
+
291
+ /** 等一次校验 / 保存等异步处理落地(固定短等,避免两次点击的 handleSave 重叠) */
292
+ async function waitDialogSettled() {
214
293
  await nextTick()
215
294
  await nextTick()
216
295
  await new Promise((resolve) => setTimeout(resolve, 20))
217
296
  await nextTick()
218
297
  }
219
298
 
299
+ /** 当前用例弹框的遮罩(body 上可能残留前序用例未卸载的弹框,必须按当前弹框定位) */
300
+ function currentOverlay() {
301
+ return queryDialog()!.closest<HTMLElement>('.u-dialog__overlay')!
302
+ }
303
+
220
304
  function clickEl(el: HTMLElement) {
221
305
  el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
222
306
  }
@@ -232,8 +316,8 @@ function querySaveButton(dialog: HTMLElement) {
232
316
  return dialog.querySelector<HTMLElement>('.u-batch-edit__form-actions button[title="保存"]')
233
317
  }
234
318
 
235
- function queryCancelButton(dialog: HTMLElement) {
236
- return [...dialog.querySelectorAll<HTMLElement>('.u-batch-edit__form-actions button')].find(
319
+ function queryCancelButton(container: HTMLElement) {
320
+ return [...container.querySelectorAll<HTMLElement>('.u-batch-edit__form-actions button')].find(
237
321
  (btn) => btn.textContent?.includes('取消')
238
322
  )
239
323
  }
@@ -242,7 +326,7 @@ describe('UBatchEdit formMode', () => {
242
326
  it('默认(不传 formMode)仍渲染右侧面板,不出现弹框', async () => {
243
327
  const { host, unmount } = mountBatchEdit()
244
328
 
245
- await clickRow(host, 0)
329
+ await clickEditRow(host, 0)
246
330
  const aside = host.querySelector<HTMLElement>('.u-batch-edit__form')
247
331
  expect(aside).toBeTruthy()
248
332
  expect(aside!.style.display).not.toBe('none')
@@ -259,8 +343,8 @@ describe('UBatchEdit formMode', () => {
259
343
  expect(host.querySelector('.u-batch-edit__form')).toBeNull()
260
344
  expect(queryDialog()).toBeNull()
261
345
 
262
- await clickRow(host, 0)
263
- await flushDialog()
346
+ await clickEditRow(host, 0)
347
+ await waitDialogOpen()
264
348
 
265
349
  const dialog = queryDialog()
266
350
  expect(dialog).toBeTruthy()
@@ -285,12 +369,11 @@ describe('UBatchEdit formMode', () => {
285
369
 
286
370
  // 打开新增弹框
287
371
  clickEl(host.querySelector<HTMLElement>('.u-batch-edit__add-btn')!)
288
- await flushDialog()
289
- expect(queryDialog()).toBeTruthy()
372
+ await waitDialogOpen()
290
373
 
291
374
  // 必填校验失败:不调用 saveMethod,弹框保持打开
292
375
  clickEl(querySaveButton(queryDialog()!)!)
293
- await flushDialog()
376
+ await waitDialogSettled()
294
377
  expect(saveMethod).not.toHaveBeenCalled()
295
378
  expect(queryDialog()).toBeTruthy()
296
379
 
@@ -299,14 +382,13 @@ describe('UBatchEdit formMode', () => {
299
382
  await nextTick()
300
383
  await nextTick()
301
384
  clickEl(querySaveButton(queryDialog()!)!)
302
- await flushDialog()
385
+ await waitDialogClosed()
303
386
 
304
387
  expect(saveMethod).toHaveBeenCalledTimes(1)
305
388
  expect(saveMethod.mock.calls[0]![0]).toMatchObject({ label: '新名称' })
306
389
  expect(saveMethod.mock.calls[0]![1]).toBe('create')
307
390
  expect(data.value).toHaveLength(3)
308
391
  expect(data.value[2]).toMatchObject({ label: '新名称' })
309
- expect(queryDialog()).toBeNull()
310
392
 
311
393
  unmount()
312
394
  })
@@ -318,24 +400,23 @@ describe('UBatchEdit formMode', () => {
318
400
  })
319
401
 
320
402
  // 编辑第 1 行后取消
321
- await clickRow(host, 0)
322
- await flushDialog()
403
+ await clickEditRow(host, 0)
404
+ await waitDialogOpen()
323
405
  setInputValue(queryDialog()!.querySelector<HTMLInputElement>('.u-input input')!, '改过的名称')
324
406
  await nextTick()
325
407
  await nextTick()
326
408
  clickEl(queryCancelButton(queryDialog()!)!)
327
- await flushDialog()
409
+ await waitDialogClosed()
328
410
 
329
411
  expect(saveMethod).not.toHaveBeenCalled()
330
412
  expect(data.value[0]).toMatchObject({ label: '存草稿' })
331
413
  // model 恢复初始快照,而不是残留上一行数据
332
414
  expect(model.label).toBe('')
333
415
  expect(model.submitType).toBeUndefined()
334
- expect(queryDialog()).toBeNull()
335
416
 
336
417
  // 取消后再新增:表单应为初始值(快照不被上一行污染)
337
418
  clickEl(host.querySelector<HTMLElement>('.u-batch-edit__add-btn')!)
338
- await flushDialog()
419
+ await waitDialogOpen()
339
420
  expect(queryDialog()!.querySelector<HTMLInputElement>('.u-input input')!.value).toBe('')
340
421
 
341
422
  // 表单头部的关闭按钮同样不保存
@@ -343,27 +424,73 @@ describe('UBatchEdit formMode', () => {
343
424
  await nextTick()
344
425
  await nextTick()
345
426
  clickEl(queryDialog()!.querySelector<HTMLElement>('.u-batch-edit__form-close')!)
346
- await flushDialog()
427
+ await waitDialogClosed()
347
428
  expect(saveMethod).not.toHaveBeenCalled()
348
429
  expect(model.label).toBe('')
349
430
  expect(data.value).toHaveLength(2)
350
- expect(queryDialog()).toBeNull()
351
431
 
352
432
  // 弹框自身关闭交互(点击遮罩)也不保存
353
- await clickRow(host, 1)
354
- await flushDialog()
433
+ await clickEditRow(host, 1)
434
+ await waitDialogOpen()
355
435
  setInputValue(queryDialog()!.querySelector<HTMLInputElement>('.u-input input')!, '改过的名称')
356
436
  await nextTick()
357
437
  await nextTick()
358
- document
359
- .querySelector<HTMLElement>('.u-dialog__overlay')!
360
- .dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
361
- await flushDialog()
438
+ currentOverlay().dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
439
+ await waitDialogClosed()
362
440
 
363
441
  expect(saveMethod).not.toHaveBeenCalled()
364
442
  expect(data.value[1]).toMatchObject({ label: '调接口' })
365
443
  expect(model.label).toBe('')
366
- expect(queryDialog()).toBeNull()
444
+
445
+ unmount()
446
+ })
447
+ })
448
+
449
+ describe('UBatchEdit 重入保护', () => {
450
+ it('保存进行中重复触发(快捷键/连点)被忽略:saveMethod 只调一次、只插一行、弹框正常关闭', async () => {
451
+ const saveMethod = vi.fn()
452
+ const { host, data, unmount } = mountBatchEdit({
453
+ props: { formMode: 'dialog', quickEdit: false, saveMethod },
454
+ labelRules: { required: true }
455
+ })
456
+
457
+ clickEl(host.querySelector<HTMLElement>('.u-batch-edit__add-btn')!)
458
+ await waitDialogOpen()
459
+
460
+ setInputValue(queryDialog()!.querySelector<HTMLInputElement>('.u-input input')!, '新名称')
461
+ await nextTick()
462
+ await nextTick()
463
+
464
+ // 同一 tick 内连续两次触发保存(模拟保存进行中再按 Ctrl/Cmd+S),中间故意不等待
465
+ const saveBtn = querySaveButton(queryDialog()!)!
466
+ clickEl(saveBtn)
467
+ clickEl(saveBtn)
468
+
469
+ await waitDialogClosed()
470
+
471
+ expect(saveMethod).toHaveBeenCalledTimes(1)
472
+ expect(data.value).toHaveLength(3)
473
+ expect(data.value[2]).toMatchObject({ label: '新名称' })
474
+
475
+ unmount()
476
+ })
477
+
478
+ it('删除进行中重复触发被忽略:deleteMethod 只调一次、只删一行', async () => {
479
+ const deleteMethod = vi.fn()
480
+ const { host, data, unmount } = mountBatchEdit({ props: { deleteMethod } })
481
+ await nextTick()
482
+
483
+ // 同一 tick 内连续两次点击删除,中间故意不等待
484
+ const deleteBtn = queryRows(host)[0]!.querySelector<HTMLElement>('button[title="删除"]')!
485
+ clickEl(deleteBtn)
486
+ clickEl(deleteBtn)
487
+
488
+ await waitDialogSettled()
489
+
490
+ expect(deleteMethod).toHaveBeenCalledTimes(1)
491
+ expect(deleteMethod.mock.calls[0]![0]).toHaveLength(1)
492
+ expect(data.value).toHaveLength(1)
493
+ expect(data.value[0]).toMatchObject({ id: '2' })
367
494
 
368
495
  unmount()
369
496
  })
@@ -7,7 +7,7 @@
7
7
  v-show="state.formVisible"
8
8
  >
9
9
  <header :class="cls.e('form-header')">
10
- <span :class="[cls.e('form-icon'), bem.is(state.formActionType)]">
10
+ <span :class="[cls.e('form-icon'), iconToneCls]">
11
11
  <u-icon>
12
12
  <component :is="headerInfo.icon" />
13
13
  </u-icon>
@@ -152,4 +152,10 @@ const showSaveBtn = computed(() => {
152
152
  })
153
153
 
154
154
  const headerInfo = computed(() => FORM_ACTION_HEADER_MAP[state.formActionType])
155
+
156
+ /** 头部图标色调修饰类:样式中 createChild 对应的修饰类为 is-child */
157
+ const iconToneCls = computed(() => {
158
+ const type = state.formActionType
159
+ return bem.is(type === 'createChild' ? 'child' : type)
160
+ })
155
161
  </script>
@@ -11,21 +11,16 @@
11
11
  @update:selected="emit('update:selected', $event)"
12
12
  >
13
13
  <template #column:__action__="{ row }">
14
- <u-action-group :class="cls.e('row-actions')" :max="5" @click.stop circle>
14
+ <u-action-group :max="5" circle @click.stop>
15
15
  <u-action
16
- v-if="allowed('create', row)"
17
- :icon="InsertToPrev"
18
- title="在上方插入"
19
- @run="handleInsertToPrev(row)"
20
- />
21
- <u-action
22
- v-if="allowed('create', row)"
23
- :icon="InsertToNext"
24
- title="在下方插入"
25
- @run="handleInsertToNext(row)"
16
+ v-if="allowed('update', row)"
17
+ :icon="props.readonly ? View : EditPen"
18
+ :title="props.readonly ? '查看' : '编辑'"
19
+ @run="startEdit(row)"
20
+ v-bind="props.actionsProps?.update"
26
21
  />
27
22
  <u-action
28
- v-if="!!props.tree && allowed('createChild', row)"
23
+ v-if="!props.readonly && !!props.tree && allowed('createChild', row)"
29
24
  :icon="AddChild"
30
25
  title="添加子级"
31
26
  @run="handleInsertChild(row)"
@@ -38,6 +33,18 @@
38
33
  @run="handleDelete(row)"
39
34
  v-bind="props.actionsProps?.delete"
40
35
  />
36
+ <u-action
37
+ v-if="!props.readonly && allowed('create', row)"
38
+ :icon="InsertToPrev"
39
+ title="在上方插入"
40
+ @run="handleInsertToPrev(row)"
41
+ />
42
+ <u-action
43
+ v-if="!props.readonly && allowed('create', row)"
44
+ :icon="InsertToNext"
45
+ title="在下方插入"
46
+ @run="handleInsertToNext(row)"
47
+ />
41
48
  </u-action-group>
42
49
  </template>
43
50
 
@@ -61,7 +68,15 @@
61
68
 
62
69
  <script setup lang="ts">
63
70
  import { o } from '@cat-kit/core'
64
- import { AddChild, Delete, InsertToNext, InsertToPrev, Plus } from '@veltra/icons/normal'
71
+ import {
72
+ AddChild,
73
+ Delete,
74
+ EditPen,
75
+ InsertToNext,
76
+ InsertToPrev,
77
+ Plus,
78
+ View
79
+ } from '@veltra/icons/normal'
65
80
  import { computed, inject, type Slots } from 'vue'
66
81
 
67
82
  import type { BatchEditFeature, TableRow } from '../../types'
@@ -81,6 +96,8 @@ const {
81
96
  emit,
82
97
  staticFeatures,
83
98
  dynamicFeatures,
99
+ startEdit,
100
+ handleClose,
84
101
  handleCreate,
85
102
  handleDelete,
86
103
  handleInsertToNext,
@@ -97,9 +114,11 @@ const tableProps = computed(() => {
97
114
  'deleteMethod',
98
115
  'saveMethod',
99
116
  'features',
100
- 'mode',
101
117
  'beforeCreate',
102
- 'formMode'
118
+ 'formMode',
119
+ 'quickEdit',
120
+ 'labelWidth',
121
+ 'actionsProps'
103
122
  ])
104
123
  })
105
124
 
@@ -110,7 +129,11 @@ const allowed = (feature: BatchEditFeature, row?: TableRow) => {
110
129
  }
111
130
 
112
131
  const columns = computed(() => {
113
- if (props.readonly || hasNot(['create', 'delete', 'createChild', 'update'])) return props.columns
132
+ // 只读模式的操作列只保留「查看」入口
133
+ const related: BatchEditFeature[] = props.readonly
134
+ ? ['update']
135
+ : ['create', 'delete', 'createChild', 'update']
136
+ if (hasNot(related)) return props.columns
114
137
 
115
138
  return (props.columns ?? []).concat({
116
139
  name: '操作',
@@ -122,16 +145,24 @@ const columns = computed(() => {
122
145
  })
123
146
  })
124
147
 
125
- function handleUpdateCurrentRow(row?: TableRow) {
126
- if (allowed('update', row)) {
127
- state.row = row
128
- }
129
- }
130
-
131
148
  function canDelete(row: TableRow) {
132
149
  return !props.readonly && allowed('delete', row)
133
150
  }
134
151
 
152
+ /**
153
+ * 点击行打开编辑/查看表单;再次点击当前行取消选中并关闭表单。
154
+ * update 特性关闭时点击行不响应
155
+ */
156
+ function handleUpdateCurrentRow(row?: TableRow) {
157
+ if (!allowed('update', row)) return
158
+
159
+ if (row) {
160
+ startEdit(row)
161
+ } else {
162
+ handleClose()
163
+ }
164
+ }
165
+
135
166
  const showCreateBtn = computed(() => {
136
167
  if (props.readonly) return false
137
168
 
@@ -64,7 +64,7 @@ const cls = bem('batch-edit')
64
64
 
65
65
  const formRef = useTemplateRef<FormExposed>('form')
66
66
 
67
- const { state, resetState, syncing } = useEditState({ props, formRef })
67
+ const { state, resetState, startEdit, syncing } = useEditState({ props, formRef })
68
68
 
69
69
  const { staticFeatures, dynamicFeatures } = useFeatures({ props })
70
70
 
@@ -91,6 +91,7 @@ provide(BatchEditDIKey, {
91
91
  dynamicFeatures,
92
92
  focused,
93
93
  syncing,
94
+ startEdit,
94
95
  ...handlers
95
96
  })
96
97
  </script>
@@ -24,5 +24,7 @@ export const BatchEditDIKey: InjectionKey<
24
24
  focused: ShallowRef<boolean>
25
25
  /** 是否正在以编程方式重置/回显表单,此期间禁止 quick-edit 回写行数据 */
26
26
  syncing: ShallowRef<boolean>
27
+ /** 开始编辑/查看一行(点击行或操作列「编辑」按钮触发) */
28
+ startEdit: (row: TableRow) => void
27
29
  } & EditReturned
28
30
  > = Symbol('BatchEditDIKey')
@@ -1,7 +1,7 @@
1
1
  import { copy, o } from '@cat-kit/core'
2
2
  import { nextTick, shallowReactive, shallowRef, watch, type ShallowRef } from 'vue'
3
3
 
4
- import type { BatchEditProps, BatchEditStates, FormExposed } from '../../types'
4
+ import type { BatchEditProps, BatchEditStates, FormExposed, TableRow } from '../../types'
5
5
 
6
6
  interface Options {
7
7
  props: BatchEditProps
@@ -56,10 +56,6 @@ export function useEditState(options: Options) {
56
56
  function resetState() {
57
57
  resetModel()
58
58
 
59
- if (state.row) {
60
- state.row.isCurrent = false
61
- }
62
-
63
59
  Object.keys(state).forEach((key) => {
64
60
  delete state[key]
65
61
  })
@@ -67,33 +63,30 @@ export function useEditState(options: Options) {
67
63
  Object.assign(state, defaultState)
68
64
  }
69
65
 
70
- watch(
71
- () => state.row,
72
- (row) => {
73
- syncing.value = true
74
-
75
- // 先重置回 model 初始快照,再同步回显行数据。
76
- // 同一 tick 内的连续写入会被表单字段 watcher 合并,
77
- // 重置产生的默认值不会触发 field:update 回写行数据
78
- resetModel()
79
-
80
- if (row) {
81
- if (props.model) {
82
- o(props.model).deepExtend(row.data)
83
- }
84
- state.formActionType = props.readonly ? 'view' : 'update'
85
- state.formVisible = true
86
- state.depth = row.depth
87
- state.indexPath = [...row.indexes]
88
- } else {
89
- resetState()
90
- }
91
-
92
- nextTick(() => {
93
- syncing.value = false
94
- })
66
+ /**
67
+ * 开始编辑/查看一行:先重置回 model 初始快照,再回显行数据。
68
+ * 同一 tick 内的连续写入会被表单字段 watcher 合并,
69
+ * 重置产生的默认值不会触发 field:update 回写行数据
70
+ */
71
+ function startEdit(row: TableRow) {
72
+ syncing.value = true
73
+
74
+ resetModel()
75
+
76
+ if (props.model) {
77
+ o(props.model).deepExtend(row.data)
95
78
  }
96
- )
97
79
 
98
- return { state, resetState, syncing }
80
+ state.row = row
81
+ state.formActionType = props.readonly ? 'view' : 'update'
82
+ state.formVisible = true
83
+ state.depth = row.depth
84
+ state.indexPath = [...row.indexes]
85
+
86
+ nextTick(() => {
87
+ syncing.value = false
88
+ })
89
+ }
90
+
91
+ return { state, resetState, startEdit, syncing }
99
92
  }
@@ -135,6 +135,8 @@ export function useHandlers(options: Options): EditReturned {
135
135
 
136
136
  function runWithLoading<Arg extends any[]>(fn: (...args: Arg) => Promise<void> | void) {
137
137
  return async (...args: Arg) => {
138
+ // 重入保护:进行中(快捷键/按钮并发触发)直接忽略
139
+ if (state.loading) return
138
140
  state.loading = true
139
141
  if (state.row) {
140
142
  state.row.operating = true
@@ -81,7 +81,8 @@ $root-name: dialog;
81
81
  flex-grow: 1;
82
82
 
83
83
  // padding 加在滚动内容而非 body 本身:u-scroll 的 overflow 会裁切子元素 box-shadow
84
- @include m.bem(scroll, content) {
84
+ // 用子代链限定 body 自己的滚动内容,避免命中弹框内嵌套的 u-scroll
85
+ > #{fn.bem(scroll, container)} > #{fn.bem(scroll, content)} {
85
86
  padding: 8px;
86
87
  }
87
88
  }
@@ -14,7 +14,6 @@ const cls = {
14
14
  } as any
15
15
 
16
16
  function makeChip(overrides: Partial<{ value: string; label: string; type?: string }> = {}) {
17
- const onRemove = vi.fn<(el: HTMLElement) => void>()
18
17
  const onReselect = vi.fn<(el: HTMLElement) => void>()
19
18
  const segment = {
20
19
  kind: 'var' as const,
@@ -22,8 +21,8 @@ function makeChip(overrides: Partial<{ value: string; label: string; type?: stri
22
21
  label: overrides.label ?? '用户姓名',
23
22
  ...(overrides.type ? { type: overrides.type } : {})
24
23
  }
25
- const el = createChip(segment, { cls, onRemove, onReselect })
26
- return { el, onRemove, onReselect, segment }
24
+ const el = createChip(segment, { cls, onReselect })
25
+ return { el, onReselect, segment }
27
26
  }
28
27
 
29
28
  describe('createChip', () => {
@@ -54,21 +53,11 @@ describe('createChip', () => {
54
53
  })
55
54
 
56
55
  it('mousedown on chip body triggers onReselect', () => {
57
- const { el, onReselect, onRemove } = makeChip()
56
+ const { el, onReselect } = makeChip()
58
57
  const label = el.querySelector(`.${cls.e('chip-label')}`) as HTMLElement
59
58
  label.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
60
59
  expect(onReselect).toHaveBeenCalledTimes(1)
61
60
  expect(onReselect).toHaveBeenCalledWith(el)
62
- expect(onRemove).not.toHaveBeenCalled()
63
- })
64
-
65
- it('mousedown on close (×) triggers onRemove and not onReselect', () => {
66
- const { el, onReselect, onRemove } = makeChip()
67
- const close = el.querySelector(`[${ChipAttrs.close}]`) as HTMLElement
68
- close.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
69
- expect(onRemove).toHaveBeenCalledTimes(1)
70
- expect(onRemove).toHaveBeenCalledWith(el)
71
- expect(onReselect).not.toHaveBeenCalled()
72
61
  })
73
62
 
74
63
  it('readChipValue returns the bound value', () => {