@veltra/desktop 1.7.19 → 1.8.1

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 (114) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +40 -26
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/use-handlers.js +3 -3
  4. package/dist/components/batch-edit/use-handlers.js.map +1 -1
  5. package/dist/components/batch-edit/use-shortcut-key.js +4 -2
  6. package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
  7. package/dist/components/card/style2.css +0 -2
  8. package/dist/components/condition-editor/condition-editor.js +8 -2
  9. package/dist/components/condition-editor/condition-editor.js.map +1 -1
  10. package/dist/components/dropdown/dropdown.js +5 -3
  11. package/dist/components/dropdown/dropdown.js.map +1 -1
  12. package/dist/components/expression-editor/components/path-preview.js +3 -6
  13. package/dist/components/expression-editor/components/path-preview.js.map +1 -1
  14. package/dist/components/expression-editor/components/variable-picker.js +49 -12
  15. package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
  16. package/dist/components/expression-editor/core/editor.js +42 -0
  17. package/dist/components/expression-editor/core/editor.js.map +1 -1
  18. package/dist/components/expression-editor/expression-editor.js +68 -22
  19. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  20. package/dist/components/expression-editor/style2.css +135 -73
  21. package/dist/components/form/form.js.map +1 -1
  22. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
  23. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  24. package/dist/components/form/style2.css +8 -3
  25. package/dist/components/form-item/style2.css +14 -0
  26. package/dist/components/index.d.ts +27 -26
  27. package/dist/components/index.js +54 -52
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/kanban/di.js +7 -0
  30. package/dist/components/kanban/di.js.map +1 -0
  31. package/dist/components/kanban/kanban-column.js +67 -0
  32. package/dist/components/kanban/kanban-column.js.map +1 -0
  33. package/dist/components/kanban/kanban.js +89 -0
  34. package/dist/components/kanban/kanban.js.map +1 -0
  35. package/dist/components/kanban/style.d.ts +1 -0
  36. package/dist/components/kanban/style.js +1 -0
  37. package/dist/components/kanban/style2.css +128 -0
  38. package/dist/components/multi-select/multi-select.js +16 -12
  39. package/dist/components/multi-select/multi-select.js.map +1 -1
  40. package/dist/components/multi-select/style2.css +7 -0
  41. package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
  42. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  43. package/dist/components/multi-tree-select/style2.css +7 -0
  44. package/dist/components/rich-text-editor/image-node.js +154 -0
  45. package/dist/components/rich-text-editor/image-node.js.map +1 -0
  46. package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
  47. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
  48. package/dist/components/rich-text-editor/style2.css +6 -0
  49. package/dist/components/rich-text-editor/toolbar.js +22 -2
  50. package/dist/components/rich-text-editor/toolbar.js.map +1 -1
  51. package/dist/components/select/select.js +7 -3
  52. package/dist/components/select/select.js.map +1 -1
  53. package/dist/components/select/style2.css +7 -0
  54. package/dist/components/select/use-options.js +14 -3
  55. package/dist/components/select/use-options.js.map +1 -1
  56. package/dist/components/tree-select/style2.css +7 -0
  57. package/dist/components/tree-select/tree-select.js +49 -17
  58. package/dist/components/tree-select/tree-select.js.map +1 -1
  59. package/dist/index.d.ts +48 -46
  60. package/dist/index.js +47 -46
  61. package/dist/types/dropdown.d.ts +10 -0
  62. package/dist/types/index.d.ts +3 -2
  63. package/dist/types/kanban.d.ts +69 -0
  64. package/dist/types/multi-tree-select.d.ts +7 -1
  65. package/dist/types/rich-text-editor.d.ts +13 -2
  66. package/dist/types/tree-select.d.ts +7 -1
  67. package/package.json +1 -1
  68. package/src/components/batch-edit/__test__/batch-edit.test.ts +112 -1
  69. package/src/components/batch-edit/batch-edit-form.vue +21 -2
  70. package/src/components/batch-edit/use-handlers.ts +6 -5
  71. package/src/components/batch-edit/use-shortcut-key.ts +5 -2
  72. package/src/components/card/style.scss +0 -2
  73. package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
  74. package/src/components/condition-editor/condition-editor.vue +6 -1
  75. package/src/components/dropdown/dropdown.vue +9 -4
  76. package/src/components/expression-editor/__test__/editor.test.ts +66 -0
  77. package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
  78. package/src/components/expression-editor/components/path-preview.vue +1 -2
  79. package/src/components/expression-editor/components/variable-picker.vue +54 -13
  80. package/src/components/expression-editor/core/editor.ts +61 -0
  81. package/src/components/expression-editor/expression-editor.vue +58 -20
  82. package/src/components/expression-editor/style.scss +160 -76
  83. package/src/components/form/form.vue +0 -1
  84. package/src/components/form/style.scss +11 -4
  85. package/src/components/form-item/style.scss +16 -1
  86. package/src/components/index.ts +2 -0
  87. package/src/components/kanban/__test__/kanban.test.ts +212 -0
  88. package/src/components/kanban/di.ts +19 -0
  89. package/src/components/kanban/index.ts +1 -0
  90. package/src/components/kanban/kanban-column.vue +77 -0
  91. package/src/components/kanban/kanban.vue +81 -0
  92. package/src/components/kanban/style.scss +124 -0
  93. package/src/components/kanban/style.ts +1 -0
  94. package/src/components/multi-select/multi-select.vue +11 -2
  95. package/src/components/multi-select/style.scss +6 -0
  96. package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
  97. package/src/components/multi-tree-select/style.scss +6 -0
  98. package/src/components/rich-text-editor/image-node.ts +193 -0
  99. package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
  100. package/src/components/rich-text-editor/style.scss +7 -0
  101. package/src/components/rich-text-editor/toolbar.vue +21 -1
  102. package/src/components/select/__test__/select.test.ts +78 -0
  103. package/src/components/select/select.vue +13 -3
  104. package/src/components/select/style.scss +6 -0
  105. package/src/components/select/use-options.ts +25 -4
  106. package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
  107. package/src/components/tree-select/style.scss +6 -0
  108. package/src/components/tree-select/tree-select.vue +63 -13
  109. package/src/types/dropdown.ts +10 -0
  110. package/src/types/index.ts +1 -0
  111. package/src/types/kanban.ts +63 -0
  112. package/src/types/multi-tree-select.ts +12 -1
  113. package/src/types/rich-text-editor.ts +14 -1
  114. package/src/types/tree-select.ts +12 -1
@@ -0,0 +1,212 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, ref } from 'vue'
3
+
4
+ import type { KanbanColumnItem } from '../../types'
5
+ import UKanban from '../kanban.vue'
6
+
7
+ const defaultColumns: KanbanColumnItem[] = [
8
+ {
9
+ key: 'todo',
10
+ title: '待办',
11
+ items: [
12
+ { id: 1, title: '任务 1' },
13
+ { id: 2, title: '任务 2' }
14
+ ]
15
+ },
16
+ { key: 'done', title: '已完成', items: [{ id: 3, title: '任务 3' }] },
17
+ { key: 'archive', title: '归档', items: [] }
18
+ ]
19
+
20
+ function mountKanban(
21
+ props: Record<string, unknown> = {},
22
+ columns: KanbanColumnItem[] = defaultColumns,
23
+ slots: Record<string, any> = {}
24
+ ) {
25
+ const host = document.createElement('div')
26
+ document.body.appendChild(host)
27
+
28
+ const model = ref(columns)
29
+ const changes: KanbanColumnItem[][] = []
30
+
31
+ const app = createApp({
32
+ render() {
33
+ return h(
34
+ UKanban,
35
+ {
36
+ columns: model.value,
37
+ ...props,
38
+ 'onUpdate:columns': (value: KanbanColumnItem[]) => {
39
+ model.value = value
40
+ },
41
+ onChange: (value: KanbanColumnItem[]) => {
42
+ changes.push(value)
43
+ }
44
+ },
45
+ slots
46
+ )
47
+ }
48
+ })
49
+
50
+ app.mount(host)
51
+
52
+ return {
53
+ host,
54
+ model,
55
+ changes,
56
+ unmount() {
57
+ app.unmount()
58
+ host.remove()
59
+ }
60
+ }
61
+ }
62
+
63
+ function queryRoot(host: HTMLElement) {
64
+ return host.querySelector<HTMLElement>('.u-kanban')!
65
+ }
66
+
67
+ function queryColumns(host: HTMLElement) {
68
+ return [...host.querySelectorAll<HTMLElement>('.u-kanban__column')]
69
+ }
70
+
71
+ function queryCards(host: HTMLElement) {
72
+ return [...host.querySelectorAll<HTMLElement>('.u-kanban__card')]
73
+ }
74
+
75
+ describe('UKanban', () => {
76
+ it('基础渲染:列、列头标题、计数徽标与卡片', async () => {
77
+ const { host, unmount } = mountKanban()
78
+ await nextTick()
79
+
80
+ try {
81
+ const root = queryRoot(host)
82
+ expect(root).toBeTruthy()
83
+
84
+ const columns = queryColumns(host)
85
+ expect(columns.length).toBe(3)
86
+
87
+ const titles = [...host.querySelectorAll('.u-kanban__column-title')].map(
88
+ (el) => el.textContent
89
+ )
90
+ expect(titles).toEqual(['待办', '已完成', '归档'])
91
+
92
+ const counts = [...host.querySelectorAll('.u-kanban__column-count')].map(
93
+ (el) => el.textContent
94
+ )
95
+ expect(counts).toEqual(['2', '1', '0'])
96
+
97
+ const cards = queryCards(host)
98
+ expect(cards.length).toBe(3)
99
+ expect(cards[0]!.textContent).toContain('任务 1')
100
+ expect(cards[0]!.querySelector('.u-kanban__card-handle')).toBeTruthy()
101
+ } finally {
102
+ unmount()
103
+ }
104
+ })
105
+
106
+ it('空列渲染占位文案,自定义 placeholder 生效', async () => {
107
+ const { host, unmount } = mountKanban({ placeholder: '拖卡片到这里' })
108
+ await nextTick()
109
+
110
+ try {
111
+ const empties = [...host.querySelectorAll('.u-kanban__empty')]
112
+ expect(empties.length).toBe(1)
113
+ expect(empties[0]!.textContent).toBe('拖卡片到这里')
114
+ // 空列的卡片容器仍存在,作为拖放目标
115
+ expect(queryColumns(host)[2]!.querySelector('.u-kanban__cards')).toBeTruthy()
116
+ } finally {
117
+ unmount()
118
+ }
119
+ })
120
+
121
+ it('countable 为 false 时不显示计数徽标', async () => {
122
+ const { host, unmount } = mountKanban({ countable: false })
123
+ await nextTick()
124
+
125
+ try {
126
+ expect(host.querySelector('.u-kanban__column-count')).toBeNull()
127
+ } finally {
128
+ unmount()
129
+ }
130
+ })
131
+
132
+ it('支持自定义 cardKey 与 titleKey', async () => {
133
+ const columns: KanbanColumnItem[] = [
134
+ {
135
+ key: 'list',
136
+ name: '自定义列',
137
+ items: [
138
+ { no: 'a', name: '卡片 A' },
139
+ { no: 'b', name: '卡片 B' }
140
+ ]
141
+ }
142
+ ]
143
+ const { host, unmount } = mountKanban({ cardKey: 'no', titleKey: 'name' }, columns)
144
+ await nextTick()
145
+
146
+ try {
147
+ expect(host.querySelector('.u-kanban__column-title')!.textContent).toBe('自定义列')
148
+
149
+ const cards = queryCards(host)
150
+ expect(cards.length).toBe(2)
151
+ expect(cards[0]!.textContent).toContain('卡片 A')
152
+ expect(cards[1]!.textContent).toContain('卡片 B')
153
+ } finally {
154
+ unmount()
155
+ }
156
+ })
157
+
158
+ it('header / card / empty 插槽自定义内容', async () => {
159
+ const { host, unmount } = mountKanban({}, defaultColumns, {
160
+ header: ({ column, count }: { column: KanbanColumnItem; count: number }) =>
161
+ h('span', { class: 'custom-header' }, `${column.title}(${count})`),
162
+ card: ({ card }: { card: Record<string, any> }) =>
163
+ h('span', { class: 'custom-card' }, `#${card.id} ${card.title}`),
164
+ empty: ({ column }: { column: KanbanColumnItem }) =>
165
+ h('span', { class: 'custom-empty' }, `${column.title}空`)
166
+ })
167
+ await nextTick()
168
+
169
+ try {
170
+ const headers = [...host.querySelectorAll('.custom-header')].map((el) => el.textContent)
171
+ expect(headers).toEqual(['待办(2)', '已完成(1)', '归档(0)'])
172
+
173
+ const cards = [...host.querySelectorAll('.custom-card')].map((el) => el.textContent)
174
+ expect(cards).toEqual(['#1 任务 1', '#2 任务 2', '#3 任务 3'])
175
+
176
+ expect(host.querySelector('.custom-empty')!.textContent).toBe('归档空')
177
+ } finally {
178
+ unmount()
179
+ }
180
+ })
181
+
182
+ it('disabled 时根节点带 is-disabled 类名', async () => {
183
+ const { host, unmount } = mountKanban({ disabled: true })
184
+ await nextTick()
185
+
186
+ try {
187
+ expect(queryRoot(host).classList.contains('is-disabled')).toBe(true)
188
+ } finally {
189
+ unmount()
190
+ }
191
+ })
192
+
193
+ it('外部替换 columns 数据视图响应更新', async () => {
194
+ const { host, model, unmount } = mountKanban()
195
+ await nextTick()
196
+
197
+ try {
198
+ expect(queryCards(host).length).toBe(3)
199
+
200
+ model.value = [{ key: 'only', title: '唯一列', items: [{ id: 9, title: '新卡片' }] }]
201
+ await nextTick()
202
+
203
+ expect(queryColumns(host).length).toBe(1)
204
+ expect(queryCards(host).length).toBe(1)
205
+ expect(queryCards(host)[0]!.textContent).toContain('新卡片')
206
+ // 原空列占位消失
207
+ expect(host.querySelector('.u-kanban__empty')).toBeNull()
208
+ } finally {
209
+ unmount()
210
+ }
211
+ })
212
+ })
@@ -0,0 +1,19 @@
1
+ import type { BEM } from '@veltra/utils'
2
+ import type { InjectionKey } from 'vue'
3
+
4
+ import type { KanbanProps } from '../../types'
5
+
6
+ /** 看板上下文:主组件向列子组件注入 */
7
+ export interface KanbanContext {
8
+ /** 看板组件属性 */
9
+ kanbanProps: KanbanProps
10
+ /** BEM */
11
+ cls: BEM<'kanban'>
12
+ /** 某列卡片重排写回(getter 数据源 onReorder 链路),items 为该列最新完整卡片数组 */
13
+ reorderColumn: (columnKey: string, items: Record<string, any>[]) => void
14
+ /** 拖拽操作结束通知(一次操作只通知一次,主组件据此 emit change) */
15
+ notifyChange: () => void
16
+ }
17
+
18
+ /** 看板依赖注入key */
19
+ export const KanbanDIKey: InjectionKey<KanbanContext> = Symbol('KanbanDIKey')
@@ -0,0 +1 @@
1
+ export { default as UKanban } from './kanban.vue'
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <div :class="cls.e('column')">
3
+ <div :class="cls.e('column-header')">
4
+ <slot name="header" :column="column" :count="values.length">
5
+ <span :class="cls.e('column-title')">{{ title }}</span>
6
+ <span v-if="countable" :class="cls.e('column-count')">{{ values.length }}</span>
7
+ </slot>
8
+ </div>
9
+
10
+ <div ref="parentRef" :class="cls.e('cards')">
11
+ <div v-for="(card, index) of values" :key="card[cardKey]" :class="cls.e('card')">
12
+ <span :class="cls.e('card-handle')">≡</span>
13
+ <div :class="cls.e('card-body')">
14
+ <slot name="card" :card="card" :column="column" :index="index">
15
+ {{ card[titleKey] ?? card[cardKey] }}
16
+ </slot>
17
+ </div>
18
+ </div>
19
+
20
+ <div v-if="!values.length" :class="cls.e('empty')">
21
+ <slot name="empty" :column="column">{{ placeholder }}</slot>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script lang="ts" setup>
28
+ import { animations, useDnD, type VueParentConfig } from '@veltra/compositions'
29
+ import { computed, inject, watch } from 'vue'
30
+
31
+ import type { KanbanColumnItem, KanbanSlots } from '../../types'
32
+ import { KanbanDIKey } from './di'
33
+
34
+ defineOptions({ name: 'UKanbanColumn' })
35
+
36
+ defineSlots<KanbanSlots>()
37
+
38
+ const props = defineProps<{ column: KanbanColumnItem }>()
39
+
40
+ const ctx = inject(KanbanDIKey)!
41
+
42
+ const cls = ctx.cls
43
+
44
+ const titleKey = computed(() => ctx.kanbanProps.titleKey ?? 'title')
45
+ const cardKey = computed(() => ctx.kanbanProps.cardKey ?? 'id')
46
+ const countable = computed(() => ctx.kanbanProps.countable !== false)
47
+ const placeholder = computed(() => ctx.kanbanProps.placeholder ?? '暂无内容')
48
+
49
+ const title = computed(() => props.column[titleKey.value])
50
+
51
+ function makeConfig(): VueParentConfig<Record<string, any>> {
52
+ return {
53
+ group: 'u-kanban',
54
+ dragHandle: `.${cls.e('card-handle')}`,
55
+ draggable: (el) => el.classList.contains(cls.e('card')),
56
+ draggingClass: 'is-dragging',
57
+ dropZoneClass: 'is-dropzone',
58
+ disabled: ctx.kanbanProps.disabled,
59
+ // 排序/转移数据已由 onReorder 写回,这里只负责对外发 change 通知
60
+ onSort: () => ctx.notifyChange(),
61
+ onTransfer: () => ctx.notifyChange(),
62
+ plugins: [animations()]
63
+ }
64
+ }
65
+
66
+ const { parentRef, values, updateConfig } = useDnD<Record<string, any>>({
67
+ values: () => props.column.items,
68
+ onReorder: (items) => ctx.reorderColumn(props.column.key, items),
69
+ ...makeConfig()
70
+ })
71
+
72
+ // updateConfig 是整体替换配置,须传完整配置而非仅 disabled
73
+ watch(
74
+ () => ctx.kanbanProps.disabled,
75
+ () => updateConfig(makeConfig())
76
+ )
77
+ </script>
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <div v-bind="$attrs" :class="[cls.b, bem.is('disabled', disabled)]">
3
+ <KanbanColumn v-for="column of localColumns" :key="column.key" :column="column">
4
+ <template #header="{ column: col, count }">
5
+ <slot name="header" :column="col" :count="count" />
6
+ </template>
7
+ <template #card="{ card, column: col, index }">
8
+ <slot name="card" :card="card" :column="col" :index="index" />
9
+ </template>
10
+ <template #empty="{ column: col }">
11
+ <slot name="empty" :column="col" />
12
+ </template>
13
+ </KanbanColumn>
14
+ </div>
15
+ </template>
16
+
17
+ <script lang="ts" setup>
18
+ import { bem } from '@veltra/utils'
19
+ import { provide, ref, watch } from 'vue'
20
+
21
+ import type { KanbanColumnItem, KanbanEmits, KanbanProps, KanbanSlots } from '../../types'
22
+ import { KanbanDIKey, type KanbanContext } from './di'
23
+ import KanbanColumn from './kanban-column.vue'
24
+
25
+ defineOptions({ name: 'UKanban', inheritAttrs: false })
26
+
27
+ const props = withDefaults(defineProps<KanbanProps>(), {
28
+ cardKey: 'id',
29
+ titleKey: 'title',
30
+ disabled: false,
31
+ countable: true,
32
+ placeholder: '暂无内容'
33
+ })
34
+
35
+ const emit = defineEmits<KanbanEmits>()
36
+ defineSlots<KanbanSlots>()
37
+
38
+ const cls = bem('kanban')
39
+
40
+ // 拖拽写回的本地数据源:props 拷贝一份避免原地改父级数据,
41
+ // 拖拽结果经 update:columns 交给父级决定是否受控回写
42
+ const localColumns = ref<KanbanColumnItem[]>([])
43
+
44
+ watch(
45
+ () => props.columns,
46
+ (columns) => {
47
+ localColumns.value = (columns ?? []).map((column) => ({ ...column, items: [...column.items] }))
48
+ },
49
+ { immediate: true }
50
+ )
51
+
52
+ function snapshot(): KanbanColumnItem[] {
53
+ return localColumns.value.map((column) => ({ ...column, items: [...column.items] }))
54
+ }
55
+
56
+ function reorderColumn(columnKey: string, items: Record<string, any>[]) {
57
+ localColumns.value = localColumns.value.map((column) =>
58
+ column.key === columnKey ? { ...column, items } : column
59
+ )
60
+ emit('update:columns', snapshot())
61
+ }
62
+
63
+ // 一次拖拽可能同步触发多个回调(跨列转移在源列与目标列各触发一次 onTransfer),
64
+ // microtask 内合并保证一次操作只 emit 一次 change
65
+ let changePending = false
66
+ function notifyChange() {
67
+ if (changePending) return
68
+ changePending = true
69
+ queueMicrotask(() => {
70
+ changePending = false
71
+ emit('change', snapshot())
72
+ })
73
+ }
74
+
75
+ provide(KanbanDIKey, {
76
+ kanbanProps: props,
77
+ cls,
78
+ reorderColumn,
79
+ notifyChange
80
+ } satisfies KanbanContext)
81
+ </script>
@@ -0,0 +1,124 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m;
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+ @use 'pkg:@veltra/styles/vars';
4
+
5
+ @include m.b(kanban) {
6
+ @include m.flex($align: flex-start, $wrap: nowrap);
7
+ gap: fn.use-var(gap, default);
8
+ padding: fn.use-var(gap, default);
9
+ overflow-x: auto;
10
+ box-sizing: border-box;
11
+
12
+ @include m.e(column) {
13
+ flex: 0 0 auto;
14
+ width: 280px;
15
+ max-width: 100%;
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: fn.use-var(gap, small);
19
+ padding: fn.use-var(gap, small);
20
+ background-color: fn.use-var(bg-color, bottom);
21
+ border-radius: fn.use-var(radius, default);
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ @include m.e(column-header) {
26
+ @include m.flex;
27
+ gap: fn.use-var(gap, small);
28
+ padding: 0 2px;
29
+ }
30
+
31
+ @include m.e(column-title) {
32
+ @include m.ellipsis;
33
+ flex: 1 1 0%;
34
+ min-width: 0;
35
+ font-size: fn.use-var(font-size-main, default);
36
+ font-weight: 600;
37
+ color: fn.use-var(text-color, title);
38
+ }
39
+
40
+ @include m.e(column-count) {
41
+ flex: none;
42
+ min-width: 18px;
43
+ padding: 0 6px;
44
+ border-radius: fn.use-var(radius, large);
45
+ background-color: fn.color-a(color, 11, primary);
46
+ color: fn.use-var(color, primary);
47
+ font-size: fn.use-var(font-size-main, small);
48
+ line-height: 18px;
49
+ text-align: center;
50
+ }
51
+
52
+ @include m.e(cards) {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: fn.use-var(gap, small);
56
+ // 空列也保持可拖入的目标高度
57
+ min-height: 64px;
58
+ padding: 2px;
59
+
60
+ &.is-dropzone {
61
+ background-color: fn.color-a(color, 4, primary);
62
+ border-radius: fn.use-var(radius, default);
63
+ }
64
+ }
65
+
66
+ @include m.e(card) {
67
+ @include m.flex($align: flex-start);
68
+ gap: fn.use-var(gap, small);
69
+ padding: fn.use-var(gap, small);
70
+ background-color: fn.use-var(bg-color, top);
71
+ border-radius: fn.use-var(radius, default);
72
+ box-shadow: fn.use-var(shadow, sm);
73
+ font-size: fn.use-var(font-size-main, default);
74
+ color: fn.use-var(text-color, main);
75
+ transition:
76
+ box-shadow fn.use-var(transition, fast) fn.use-var(transition, ease),
77
+ opacity fn.use-var(transition, fast) fn.use-var(transition, ease);
78
+
79
+ &:hover {
80
+ box-shadow: fn.use-var(shadow);
81
+ }
82
+
83
+ &.is-dragging {
84
+ opacity: 0.6;
85
+ }
86
+ }
87
+
88
+ @include m.e(card-handle) {
89
+ flex: none;
90
+ line-height: 1;
91
+ color: fn.use-var(text-color, assist);
92
+ cursor: grab;
93
+ user-select: none;
94
+
95
+ &:active {
96
+ cursor: grabbing;
97
+ }
98
+ }
99
+
100
+ @include m.e(card-body) {
101
+ flex: 1 1 0%;
102
+ min-width: 0;
103
+ word-break: break-word;
104
+ }
105
+
106
+ @include m.e(empty) {
107
+ border: 1px dashed fn.use-var(border-color);
108
+ border-radius: fn.use-var(radius, default);
109
+ padding: fn.use-var(gap, default) fn.use-var(gap, small);
110
+ font-size: fn.use-var(font-size-main, default);
111
+ color: fn.use-var(text-color, assist);
112
+ text-align: center;
113
+ }
114
+
115
+ @include m.is(disabled) {
116
+ @include m.bem(kanban, card-handle) {
117
+ cursor: not-allowed;
118
+
119
+ &:active {
120
+ cursor: not-allowed;
121
+ }
122
+ }
123
+ }
124
+ }
@@ -0,0 +1 @@
1
+ import './style.scss'
@@ -80,11 +80,14 @@
80
80
  虚拟化启用时内容容器 height 由 useVirtualizer 命令式写入;
81
81
  模板不再绑定 totalSize,避免尺寸变化引起重渲染。
82
82
  -->
83
+ <div v-if="loading" :class="cls.e('loading')">
84
+ <ULoading type="dual-ring" />
85
+ </div>
83
86
  <u-scroll
84
87
  tag="ul"
85
88
  :class="[cls.e('options')]"
86
89
  ref="scrollRef"
87
- v-if="options.length"
90
+ v-else-if="options.length"
88
91
  :content-class="[cls.e('options-wrap'), bem.is('virtual', virtualEnabled)]"
89
92
  >
90
93
  <template v-if="virtualEnabled">
@@ -166,6 +169,7 @@ import { UCheckbox } from '../checkbox'
166
169
  import { UDropdown } from '../dropdown'
167
170
  import { UEmpty } from '../empty'
168
171
  import { UIcon } from '../icon'
172
+ import { ULoading } from '../loading'
169
173
  import { UScroll } from '../scroll'
170
174
  import { useOptions } from '../select/use-options'
171
175
  import { UTag } from '../tag'
@@ -204,7 +208,12 @@ const scrollRef = shallowRef<ScrollExposed>()
204
208
 
205
209
  const hovered = shallowRef(false)
206
210
 
207
- const { options: rawOptions, queryString, allOptions: rawAllOptions } = useOptions({ props })
211
+ const {
212
+ options: rawOptions,
213
+ queryString,
214
+ loading,
215
+ allOptions: rawAllOptions
216
+ } = useOptions({ props })
208
217
 
209
218
  const createdOptions = shallowRef<Record<string, any>[]>([])
210
219
 
@@ -170,4 +170,10 @@ $root-name: multi-select;
170
170
  text-align: center;
171
171
  padding: fn.use-var(gap, default) 0;
172
172
  }
173
+
174
+ @include m.e(loading) {
175
+ // 与选项内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
176
+ @include m.flex($justify: center, $align: center);
177
+ min-height: 240px;
178
+ }
173
179
  }
@@ -71,15 +71,19 @@
71
71
  {{ allExpanded ? '收起全部' : '展开全部' }}
72
72
  </u-button>
73
73
  </div>
74
+ <div v-if="remoteLoading" :class="cls.e('loading')">
75
+ <ULoading type="dual-ring" />
76
+ </div>
74
77
  <!-- 菜单列表 -->
75
78
  <u-tree
79
+ v-else
76
80
  v-bind="treeProps"
77
81
  v-model:checked="model"
78
82
  @update:checked="handleCheck"
79
83
  ref="treeRef"
80
84
  :class="cls.e('content-tree')"
81
85
  checkable
82
- :data="data"
86
+ :data="treeData"
83
87
  :slots="slots"
84
88
  scroll-to-view
85
89
  ></u-tree>
@@ -100,7 +104,7 @@
100
104
  </template>
101
105
 
102
106
  <script lang="ts" setup>
103
- import { dfs, o } from '@cat-kit/core'
107
+ import { debounce, dfs, o } from '@cat-kit/core'
104
108
  import { useFormFallbackProps } from '@veltra/compositions'
105
109
  import { ArrowDown, Close } from '@veltra/icons/normal'
106
110
  import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
@@ -113,6 +117,7 @@ import { UButton } from '../button'
113
117
  import { UCheckbox } from '../checkbox'
114
118
  import { UDropdown } from '../dropdown'
115
119
  import { UIcon } from '../icon'
120
+ import { ULoading } from '../loading'
116
121
  import { UTag } from '../tag'
117
122
  import { UTree } from '../tree'
118
123
  import type { TreeSlotsScope } from '../tree/di'
@@ -148,6 +153,7 @@ const treeProps = computed(() => {
148
153
  'label',
149
154
  'readonly',
150
155
  'modelValue',
156
+ 'data',
151
157
  'contentClass',
152
158
  'contentStyle',
153
159
  'minWidth',
@@ -159,11 +165,56 @@ const emit = defineEmits<MultiTreeSelectEmits>()
159
165
 
160
166
  const slots = defineSlots<{ default?: (props: TreeSlotsScope) => any }>()
161
167
 
162
- /**过滤 */
168
+ /** 是否可搜索:显式声明或数据源为远程函数 */
169
+ const filterable = computed(() => props.filterable || typeof props.data === 'function')
170
+
171
+ /** 过滤 */
163
172
  const qs = shallowRef('')
164
- watch(qs, (qs) => {
165
- treeRef.value?.filter(qs)
166
- })
173
+
174
+ const treeRef = shallowRef<TreeExposed>()
175
+
176
+ /** 远程搜索返回的树数据 */
177
+ const remoteData = shallowRef<Record<string, any>[]>()
178
+
179
+ /** 面板渲染的树数据:data 为函数时来自远程搜索,否则为静态数据 */
180
+ const treeData = computed(() => (typeof props.data === 'function' ? remoteData.value : props.data))
181
+
182
+ const remoteLoading = shallowRef(false)
183
+ /** 远程请求序号;响应返回时序号不一致说明已有更新的请求,结果作废 */
184
+ let remoteSeq = 0
185
+
186
+ const debouncedRemoteSearch = debounce(
187
+ async (
188
+ query: string,
189
+ dataFn: (qs: string) => Promise<Record<string, any>[]> | Record<string, any>[]
190
+ ) => {
191
+ const seq = ++remoteSeq
192
+ remoteLoading.value = true
193
+ try {
194
+ const data = await dataFn(query)
195
+ // 响应到达时查询词可能已变化或已有更新的请求,过期结果直接丢弃
196
+ if (seq !== remoteSeq || query !== qs.value) return
197
+ remoteData.value = data ?? []
198
+ // 搜索结果展示完整命中路径,与本地过滤自动展开祖先的行为对齐
199
+ if (query) nextTick(() => treeRef.value?.expandAll())
200
+ } finally {
201
+ if (seq === remoteSeq) remoteLoading.value = false
202
+ }
203
+ },
204
+ 200
205
+ )
206
+
207
+ watch(
208
+ [qs, () => props.data],
209
+ ([query, data]) => {
210
+ if (typeof data === 'function') {
211
+ debouncedRemoteSearch(query, data)
212
+ return
213
+ }
214
+ treeRef.value?.filter(query)
215
+ },
216
+ { immediate: true }
217
+ )
167
218
 
168
219
  const model = defineModel<(string | number)[]>({ default: () => [] })
169
220
 
@@ -179,8 +230,6 @@ const showClear = computed(() => {
179
230
  return props.clearable && model.value?.length && hovered.value && !disabled.value
180
231
  })
181
232
 
182
- const treeRef = shallowRef<TreeExposed>()
183
-
184
233
  const dropdownRef = shallowRef<DropdownExposed>()
185
234
 
186
235
  let changedByEvent = false
@@ -237,7 +286,7 @@ const handleClear = () => {
237
286
  const keyDicts = shallowRef(new Map<string | number, Record<string, any>>())
238
287
 
239
288
  watch(
240
- () => props.data,
289
+ treeData,
241
290
  (data) => {
242
291
  if (!data?.length) {
243
292
  keyDicts.value = new Map()