@veltra/desktop 1.8.0 → 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 (56) hide show
  1. package/dist/components/condition-editor/condition-editor.js +8 -2
  2. package/dist/components/condition-editor/condition-editor.js.map +1 -1
  3. package/dist/components/expression-editor/core/editor.js +22 -0
  4. package/dist/components/expression-editor/core/editor.js.map +1 -1
  5. package/dist/components/expression-editor/expression-editor.js +17 -6
  6. package/dist/components/expression-editor/expression-editor.js.map +1 -1
  7. package/dist/components/expression-editor/style2.css +2 -2
  8. package/dist/components/form-item/style2.css +14 -0
  9. package/dist/components/index.d.ts +2 -1
  10. package/dist/components/index.js +3 -1
  11. package/dist/components/index.js.map +1 -1
  12. package/dist/components/kanban/di.js +7 -0
  13. package/dist/components/kanban/di.js.map +1 -0
  14. package/dist/components/kanban/kanban-column.js +67 -0
  15. package/dist/components/kanban/kanban-column.js.map +1 -0
  16. package/dist/components/kanban/kanban.js +89 -0
  17. package/dist/components/kanban/kanban.js.map +1 -0
  18. package/dist/components/kanban/style.d.ts +1 -0
  19. package/dist/components/kanban/style.js +1 -0
  20. package/dist/components/kanban/style2.css +128 -0
  21. package/dist/components/rich-text-editor/image-node.js +154 -0
  22. package/dist/components/rich-text-editor/image-node.js.map +1 -0
  23. package/dist/components/rich-text-editor/rich-text-editor.js +80 -22
  24. package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
  25. package/dist/components/rich-text-editor/style2.css +6 -0
  26. package/dist/components/rich-text-editor/toolbar.js +22 -2
  27. package/dist/components/rich-text-editor/toolbar.js.map +1 -1
  28. package/dist/index.d.ts +48 -46
  29. package/dist/index.js +46 -45
  30. package/dist/types/index.d.ts +3 -2
  31. package/dist/types/kanban.d.ts +69 -0
  32. package/dist/types/rich-text-editor.d.ts +13 -2
  33. package/package.json +1 -1
  34. package/src/components/condition-editor/__test__/condition-editor.test.ts +61 -0
  35. package/src/components/condition-editor/condition-editor.vue +6 -1
  36. package/src/components/expression-editor/__test__/editor.test.ts +66 -0
  37. package/src/components/expression-editor/__test__/expression-editor.test.ts +124 -0
  38. package/src/components/expression-editor/core/editor.ts +33 -0
  39. package/src/components/expression-editor/expression-editor.vue +13 -5
  40. package/src/components/expression-editor/style.scss +8 -9
  41. package/src/components/form-item/style.scss +16 -1
  42. package/src/components/index.ts +2 -0
  43. package/src/components/kanban/__test__/kanban.test.ts +212 -0
  44. package/src/components/kanban/di.ts +19 -0
  45. package/src/components/kanban/index.ts +1 -0
  46. package/src/components/kanban/kanban-column.vue +77 -0
  47. package/src/components/kanban/kanban.vue +81 -0
  48. package/src/components/kanban/style.scss +124 -0
  49. package/src/components/kanban/style.ts +1 -0
  50. package/src/components/rich-text-editor/image-node.ts +193 -0
  51. package/src/components/rich-text-editor/rich-text-editor.vue +131 -29
  52. package/src/components/rich-text-editor/style.scss +7 -0
  53. package/src/components/rich-text-editor/toolbar.vue +21 -1
  54. package/src/types/index.ts +1 -0
  55. package/src/types/kanban.ts +63 -0
  56. package/src/types/rich-text-editor.ts +14 -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'