fix(workflow): 收口侧栏拖放生命周期 (#706)

* fix(workflow): guard sidebar drag lifecycle

* test: isolate music progress side effects
This commit is contained in:
InfinityPacer
2026-08-24 10:45:48 +08:00
committed by GitHub
parent c8b1047923
commit b8214c7f7a
6 changed files with 410 additions and 3 deletions
+199
View File
@@ -0,0 +1,199 @@
import useDragAndDrop from '@core/utils/workflow'
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
addNodes: vi.fn(),
screenToFlowCoordinate: vi.fn(),
onNodesInitialized: vi.fn(),
updateNode: vi.fn(),
off: vi.fn(),
onNodesInitializedCallback: undefined as (() => void) | undefined,
}))
vi.mock('@vue-flow/core', () => ({
useVueFlow: () => ({
addNodes: mocks.addNodes,
screenToFlowCoordinate: mocks.screenToFlowCoordinate,
onNodesInitialized: mocks.onNodesInitialized,
updateNode: mocks.updateNode,
}),
}))
type DragAndDrop = ReturnType<typeof useDragAndDrop>
let current: DragAndDrop | undefined
let wrapper: ReturnType<typeof mount> | undefined
const Harness = defineComponent({
name: 'WorkflowDragAndDropHarness',
setup() {
current = useDragAndDrop()
return () => h('div')
},
})
function mountHarness() {
wrapper = mount(Harness)
return current!
}
function createDataTransfer() {
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
}
}
describe('useDragAndDrop', () => {
beforeEach(() => {
mocks.addNodes.mockReset()
mocks.screenToFlowCoordinate.mockReset().mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y }))
mocks.onNodesInitialized.mockReset().mockImplementation((callback: () => void) => {
mocks.onNodesInitializedCallback = callback
return { off: mocks.off }
})
mocks.updateNode.mockReset()
mocks.off.mockReset()
mocks.onNodesInitializedCallback = undefined
document.body.style.userSelect = ''
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
current = undefined
document.body.style.userSelect = ''
})
it('does not enter drag-over or create a node before a drag starts', async () => {
const dnd = mountHarness()
const preventDefault = vi.fn()
dnd.onDragOver({ preventDefault, dataTransfer: createDataTransfer() })
dnd.onDrop({ clientX: 10, clientY: 20 })
await nextTick()
expect(preventDefault).toHaveBeenCalledOnce()
expect(dnd.isDragOver.value).toBe(false)
expect(mocks.addNodes).not.toHaveBeenCalled()
})
it('tracks drag state, prevents selection, and clears everything on document drop', async () => {
const dnd = mountHarness()
const dataTransfer = createDataTransfer()
const data = { type: 'ScanFileAction', name: '扫描目录' }
const addEventListener = vi.spyOn(document, 'addEventListener')
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer }, data)
await nextTick()
expect(dataTransfer.setData).toHaveBeenCalledWith('application/vueflow', data)
expect(dataTransfer.effectAllowed).toBe('move')
expect(dnd.draggedData.value).toEqual(data)
expect(dnd.isDragging.value).toBe(true)
expect(document.body.style.userSelect).toBe('none')
expect(addEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(addEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
const dragOverTransfer = createDataTransfer()
const preventDefault = vi.fn()
dnd.onDragOver({ preventDefault, dataTransfer: dragOverTransfer })
await nextTick()
expect(dnd.isDragOver.value).toBe(true)
expect(dragOverTransfer.dropEffect).toBe('move')
dnd.onDragLeave()
expect(dnd.isDragOver.value).toBe(false)
document.dispatchEvent(new Event('drop'))
await nextTick()
expect(dnd.draggedData.value).toBeNull()
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(document.body.style.userSelect).toBe('')
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
it('clears the drag state when the browser cancels the native drag', async () => {
const dnd = mountHarness()
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
await nextTick()
dnd.onDragOver({ preventDefault: vi.fn(), dataTransfer: createDataTransfer() })
await nextTick()
document.dispatchEvent(new Event('dragend'))
await nextTick()
expect(dnd.draggedData.value).toBeNull()
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(document.body.style.userSelect).toBe('')
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
it('deep clones dropped data and centers the node after Vue Flow initializes it', async () => {
const dnd = mountHarness()
const data = {
type: 'ScanFileAction',
name: '扫描目录',
description: '扫描媒体目录',
data: { nested: { enabled: true } },
}
dnd.onDragStart({ dataTransfer: createDataTransfer() }, data)
dnd.onDrop({ clientX: 80, clientY: 120 })
expect(mocks.screenToFlowCoordinate).toHaveBeenCalledWith({ x: 80, y: 120 })
expect(mocks.addNodes).toHaveBeenCalledOnce()
const node = mocks.addNodes.mock.calls[0][0]
expect(node).toMatchObject({
type: 'ScanFileAction',
name: '扫描目录',
description: '扫描媒体目录',
position: { x: 80, y: 120 },
data: { nested: { enabled: true } },
})
expect(node.id).toMatch(/^act_/)
expect(node.data).not.toBe(data.data)
data.data.nested.enabled = false
expect(node.data.nested.enabled).toBe(true)
expect(mocks.onNodesInitialized).toHaveBeenCalledOnce()
mocks.onNodesInitializedCallback?.()
expect(mocks.updateNode).toHaveBeenCalledWith(node.id, expect.any(Function))
const update = mocks.updateNode.mock.calls[0][1] as (node: {
position: { x: number; y: number }
dimensions: { width: number; height: number }
}) => { position: { x: number; y: number } }
expect(update({ position: { x: 80, y: 120 }, dimensions: { width: 40, height: 20 } })).toEqual({
position: { x: 60, y: 110 },
})
expect(mocks.off).toHaveBeenCalledOnce()
})
it('cleans the global selection style and drop listener when unmounted during a drag', async () => {
const dnd = mountHarness()
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
await nextTick()
expect(document.body.style.userSelect).toBe('none')
wrapper!.unmount()
await nextTick()
expect(document.body.style.userSelect).toBe('')
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(dnd.draggedData.value).toBeNull()
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
})
+15 -2
View File
@@ -1,5 +1,5 @@
import { useVueFlow } from '@vue-flow/core'
import { ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
/**
@@ -18,7 +18,7 @@ const state = {
/**
* The type of the node being dragged.
*/
draggedData: ref<any | null>({}),
draggedData: ref<any | null>(null),
isDragOver: ref(false),
isDragging: ref(false),
}
@@ -27,6 +27,7 @@ export default function useDragAndDrop() {
const { draggedData, isDragOver, isDragging } = state
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
let ownsDragListeners = false
watch(isDragging, dragging => {
document.body.style.userSelect = dragging ? 'none' : ''
@@ -42,6 +43,8 @@ export default function useDragAndDrop() {
isDragging.value = true
document.addEventListener('drop', onDragEnd)
document.addEventListener('dragend', onDragEnd)
ownsDragListeners = true
}
/**
@@ -70,6 +73,8 @@ export default function useDragAndDrop() {
isDragOver.value = false
draggedData.value = null
document.removeEventListener('drop', onDragEnd)
document.removeEventListener('dragend', onDragEnd)
ownsDragListeners = false
}
/**
@@ -78,6 +83,8 @@ export default function useDragAndDrop() {
* @param {DragEvent} event
*/
function onDrop(event: any) {
if (!draggedData.value) return
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
@@ -110,6 +117,12 @@ export default function useDragAndDrop() {
addNodes(newNode)
}
onUnmounted(() => {
if (!ownsDragListeners) return
onDragEnd()
document.body.style.userSelect = ''
})
return {
draggedData,
isDragOver,
+1 -1
View File
@@ -102,7 +102,7 @@ watch(
// 获取动作步骤文本
function getActionStepText(type: string | undefined) {
if (!type) return ''
return actionStepDict[type]
return actionStepDict[type] || type
}
onMounted(() => {
@@ -0,0 +1,175 @@
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
import i18n from '@/plugins/i18n'
import { fireEvent, waitFor } from '@testing-library/vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
onDragStart: vi.fn(),
smAndDown: { value: false },
appMode: { value: false },
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
vi.mock('@core/utils/workflow', () => ({
default: () => ({ onDragStart: mocks.onDragStart }),
}))
vi.mock('@/composables/usePWA', () => ({
usePWA: () => ({ appMode: mocks.appMode }),
}))
vi.mock('vuetify', async importOriginal => ({
...(await importOriginal<typeof import('vuetify')>()),
useDisplay: () => ({ smAndDown: mocks.smAndDown }),
}))
const ButtonStub = defineComponent({
name: 'VBtn',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
})
const IconButtonStub = defineComponent({
name: 'IconBtn',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
},
})
const IconStub = defineComponent({
name: 'VIcon',
inheritAttrs: false,
props: {
icon: {
type: String,
default: '',
},
},
setup(props, { attrs }) {
return () => h('span', { ...attrs, 'data-icon': props.icon })
},
})
const BoxStub = defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('div', attrs, slots.default?.())
},
})
function mountSidebar(): VueWrapper {
const wrapper = mount(WorkflowSidebar, {
global: {
plugins: [i18n],
stubs: {
VBtn: ButtonStub,
VIcon: IconStub,
VAvatar: BoxStub,
VCard: BoxStub,
IconBtn: IconButtonStub,
},
},
})
mountedWrappers.push(wrapper)
return wrapper
}
let mountedWrappers: VueWrapper[] = []
describe('WorkflowSidebar', () => {
beforeEach(() => {
mocks.apiGet.mockReset().mockResolvedValue([
{ name: '扫描目录', type: 'ScanFileAction' },
{ name: 'UnknownActionName', type: 'UnknownAction' },
])
mocks.onDragStart.mockReset()
mocks.smAndDown.value = false
mocks.appMode.value = false
})
afterEach(() => {
mountedWrappers.forEach(wrapper => wrapper.unmount())
mountedWrappers = []
})
it('loads actions, maps known icons, and keeps unknown action names visible', async () => {
const wrapper = mountSidebar()
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('workflow/actions'))
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
const items = wrapper.findAll('.component-item')
expect(items).toHaveLength(2)
expect(items[0].find('.component-name').text()).toBe('Scan Directory')
expect(items[0].find('[data-icon]').attributes('data-icon')).toBe('mdi-folder-search')
expect(items[1].find('.component-name').text()).toBe('UnknownActionName')
expect(items[1].find('[data-icon]').attributes('data-icon')).toBe('mdi-puzzle-outline')
})
it('keeps an API loading failure from rendering stale or malformed actions', async () => {
const error = new Error('workflow actions unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.apiGet.mockRejectedValueOnce(error)
const wrapper = mountSidebar()
await waitFor(() => expect(consoleError).toHaveBeenCalledWith(error))
expect(wrapper.findAll('.component-item')).toHaveLength(0)
consoleError.mockRestore()
})
it('collapses only on desktop and starts drag without treating a click as a drag', async () => {
const wrapper = mountSidebar()
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-collapsed')
await fireEvent.click(wrapper.find('.collapse-btn').element)
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-collapsed')
expect(wrapper.findAll('.component-info')).toHaveLength(0)
await fireEvent.click(wrapper.find('.component-item').element)
expect(mocks.onDragStart).not.toHaveBeenCalled()
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
await fireEvent.dragStart(wrapper.find('.component-item').element, { dataTransfer })
expect(mocks.onDragStart).toHaveBeenCalledWith(expect.any(Event), expect.objectContaining({ name: '扫描目录' }))
})
it('opens the mobile sidebar, emits the selected action, and closes after selection', async () => {
mocks.smAndDown.value = true
mocks.appMode.value = true
const wrapper = mountSidebar()
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
const trigger = wrapper.find('.workflow-sidebar-trigger')
expect(trigger.exists()).toBe(true)
expect(trigger.classes()).toContain('bottom-28')
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile')
expect(wrapper.find('.component-item').attributes('draggable')).toBe('false')
await fireEvent.click(trigger.element)
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile-open')
expect(wrapper.find('.workflow-sidebar-fab [data-icon]').attributes('data-icon')).toBe('mdi-close')
await fireEvent.click(wrapper.find('.component-item').element)
expect(wrapper.emitted('component-click')).toEqual([[{ name: '扫描目录', type: 'ScanFileAction' }]])
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open')
expect(mocks.onDragStart).not.toHaveBeenCalled()
mocks.smAndDown.value = false
await wrapper.vm.$nextTick()
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open')
})
})
+6
View File
@@ -21,6 +21,12 @@ vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: vi.fn(),
startNProgress: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
+14
View File
@@ -309,6 +309,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/utils/permission.ts',
'src/utils/pluginSidebarNav.ts',
'src/utils/requestOptimizer.ts',
'src/@core/utils/workflow.ts',
'src/utils/backgroundManager.ts',
'src/utils/sseManager.ts',
'src/utils/federationLoader.ts',
@@ -363,6 +364,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/UserAddEditDialog.vue',
'src/components/dialog/WorkflowAddEditDialog.vue',
'src/components/dialog/WorkflowActionsDialog.vue',
'src/components/workflow/WorkflowSidebar.vue',
'src/components/dialog/OTPAuthDialog.vue',
'src/components/dialog/PasskeyDialog.vue',
'src/components/dialog/SiteCookieUpdateDialog.vue',
@@ -571,6 +573,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
'src/components/workflow/WorkflowSidebar.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/@core/utils/workflow.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/components/dialog/OTPAuthDialog.vue': {
branches: 80,
functions: 85,