mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(workflow): restore persisted action nodes
This commit is contained in:
@@ -15,7 +15,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
// 多语言支持
|
// 多语言支持
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()
|
const { onConnect, addEdges, nodes, edges, addNodes, setNodes, setEdges, screenToFlowCoordinate } = useVueFlow()
|
||||||
|
|
||||||
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ const normalizeWorkflowEdge = (edge: any) => {
|
|||||||
|
|
||||||
// 标准化所有流程边,导入和保存前都会调用
|
// 标准化所有流程边,导入和保存前都会调用
|
||||||
const normalizeWorkflowEdges = () => {
|
const normalizeWorkflowEdges = () => {
|
||||||
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
|
setEdges((edges.value || []).map(edge => normalizeWorkflowEdge(edge)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一动作节点数据结构,保留后端可识别的运行配置,仅移除编辑器契约元数据。
|
// 统一动作节点数据结构,保留后端可识别的运行配置,仅移除编辑器契约元数据。
|
||||||
@@ -139,7 +139,13 @@ const normalizeWorkflowNode = (node: any) => {
|
|||||||
|
|
||||||
// 标准化所有动作节点,导入和保存前都会调用
|
// 标准化所有动作节点,导入和保存前都会调用
|
||||||
const normalizeWorkflowNodes = () => {
|
const normalizeWorkflowNodes = () => {
|
||||||
nodes.value = (nodes.value || []).map(node => normalizeWorkflowNode(node))
|
setNodes((nodes.value || []).map(node => normalizeWorkflowNode(node)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 Vue Flow 的 setter 导入节点和连线,确保初始图形具备尺寸、位置等内部运行状态。
|
||||||
|
function setWorkflowGraph(actions: NonNullable<Workflow['actions']> = [], flows: NonNullable<Workflow['flows']> = []) {
|
||||||
|
setNodes(actions.map(action => normalizeWorkflowNode(action)))
|
||||||
|
setEdges(flows.map(flow => normalizeWorkflowEdge(flow)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取节点名称,便于在边设置面板展示流转关系
|
// 获取节点名称,便于在边设置面板展示流转关系
|
||||||
@@ -267,6 +273,11 @@ const nodeTypes: Record<string, any> = ref({})
|
|||||||
// 自动扫描目录下所有的 .vue 文件
|
// 自动扫描目录下所有的 .vue 文件
|
||||||
const components = import.meta.glob('../workflow/*Action.vue')
|
const components = import.meta.glob('../workflow/*Action.vue')
|
||||||
|
|
||||||
|
// 从 import.meta.glob 返回的相对路径中提取动作组件名,兼容 ../workflow/ 前缀。
|
||||||
|
function getActionComponentName(path: string) {
|
||||||
|
return path.match(/(?:^|\/)workflow\/([^/]+)\.vue$/)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
// 动态加载某个组件
|
// 动态加载某个组件
|
||||||
const loadComponent = async (componentName: string) => {
|
const loadComponent = async (componentName: string) => {
|
||||||
const component = components[`../workflow/${componentName}.vue`]
|
const component = components[`../workflow/${componentName}.vue`]
|
||||||
@@ -277,14 +288,29 @@ const loadComponent = async (componentName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 将所有components中的组件加载到nodeTypes中
|
// 将所有components中的组件加载到nodeTypes中
|
||||||
|
let actionComponentLoaderActive = true
|
||||||
|
|
||||||
|
// 对话框销毁后忽略仍在途的懒加载结果,避免已卸载实例处理异步异常。
|
||||||
|
onUnmounted(() => {
|
||||||
|
actionComponentLoaderActive = false
|
||||||
|
})
|
||||||
|
|
||||||
for (const path in components) {
|
for (const path in components) {
|
||||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
const componentName = getActionComponentName(path)
|
||||||
if (!componentName) {
|
if (!componentName) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
loadComponent(componentName).then(component => {
|
loadComponent(componentName)
|
||||||
|
.then(component => {
|
||||||
|
if (!actionComponentLoaderActive) return
|
||||||
nodeTypes.value[componentName] = markRaw(component)
|
nodeTypes.value[componentName] = markRaw(component)
|
||||||
})
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// 单个动作组件加载失败时保留其余节点,并记录原因供排查。
|
||||||
|
if (actionComponentLoaderActive) {
|
||||||
|
console.error(`Failed to load workflow action component: ${componentName}`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载动作契约,供边条件构造器使用
|
// 加载动作契约,供边条件构造器使用
|
||||||
@@ -376,13 +402,10 @@ function saveCodeString(type: string, code: any) {
|
|||||||
if (code) {
|
if (code) {
|
||||||
const codeObject = JSON.parse(code.value)
|
const codeObject = JSON.parse(code.value)
|
||||||
if (type === 'workflow') {
|
if (type === 'workflow') {
|
||||||
nodes.value = codeObject.actions || []
|
setWorkflowGraph(codeObject.actions || [], codeObject.flows || [])
|
||||||
edges.value = codeObject.flows || []
|
|
||||||
if (codeObject.execution_config) {
|
if (codeObject.execution_config) {
|
||||||
workflowForm.value.execution_config = codeObject.execution_config
|
workflowForm.value.execution_config = codeObject.execution_config
|
||||||
}
|
}
|
||||||
normalizeWorkflowNodes()
|
|
||||||
normalizeWorkflowEdges()
|
|
||||||
}
|
}
|
||||||
importCodeDialog.value = false
|
importCodeDialog.value = false
|
||||||
$toast.success(t('dialog.workflowActions.importSuccess'))
|
$toast.success(t('dialog.workflowActions.importSuccess'))
|
||||||
@@ -409,10 +432,7 @@ function shareWorkflow() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadActionDefinitions()
|
loadActionDefinitions()
|
||||||
if (props.workflow) {
|
if (props.workflow) {
|
||||||
nodes.value = cloneDeep(workflowForm.value.actions ?? [])
|
setWorkflowGraph(cloneDeep(workflowForm.value.actions ?? []), cloneDeep(workflowForm.value.flows ?? []))
|
||||||
edges.value = cloneDeep(workflowForm.value.flows ?? [])
|
|
||||||
normalizeWorkflowNodes()
|
|
||||||
normalizeWorkflowEdges()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
toastWarning: vi.fn(),
|
toastWarning: vi.fn(),
|
||||||
flowNodes: undefined as Ref<FlowNode[]> | undefined,
|
flowNodes: undefined as Ref<FlowNode[]> | undefined,
|
||||||
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
|
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
|
||||||
|
nodeTypes: undefined as Record<string, unknown> | undefined,
|
||||||
|
setNodes: vi.fn(),
|
||||||
|
setEdges: vi.fn(),
|
||||||
onConnect: undefined as ((connection: Connection) => void) | undefined,
|
onConnect: undefined as ((connection: Connection) => void) | undefined,
|
||||||
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
|
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
|
||||||
conditionItems: [] as ConditionItem[],
|
conditionItems: [] as ConditionItem[],
|
||||||
@@ -58,11 +61,13 @@ vi.mock('@vue-flow/core', async () => {
|
|||||||
props: {
|
props: {
|
||||||
nodes: { type: Array as PropType<FlowNode[]>, default: () => [] },
|
nodes: { type: Array as PropType<FlowNode[]>, default: () => [] },
|
||||||
edges: { type: Array as PropType<FlowEdge[]>, default: () => [] },
|
edges: { type: Array as PropType<FlowEdge[]>, default: () => [] },
|
||||||
|
nodeTypes: { type: Object as PropType<Record<string, unknown>>, default: () => ({}) },
|
||||||
isValidConnection: { type: Function as PropType<(connection: Connection) => boolean> },
|
isValidConnection: { type: Function as PropType<(connection: Connection) => boolean> },
|
||||||
},
|
},
|
||||||
emits: ['edge-click'],
|
emits: ['edge-click'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
mocks.isValidConnection = props.isValidConnection
|
mocks.isValidConnection = props.isValidConnection
|
||||||
|
mocks.nodeTypes = props.nodeTypes
|
||||||
return () =>
|
return () =>
|
||||||
createElement('div', { 'data-testid': 'vue-flow' }, [
|
createElement('div', { 'data-testid': 'vue-flow' }, [
|
||||||
createElement(
|
createElement(
|
||||||
@@ -85,6 +90,14 @@ vi.mock('@vue-flow/core', async () => {
|
|||||||
mocks.flowEdges?.value.push(...(Array.isArray(newEdges) ? newEdges : [newEdges])),
|
mocks.flowEdges?.value.push(...(Array.isArray(newEdges) ? newEdges : [newEdges])),
|
||||||
addNodes: (newNodes: FlowNode[] | FlowNode) =>
|
addNodes: (newNodes: FlowNode[] | FlowNode) =>
|
||||||
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
|
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
|
||||||
|
setNodes: (newNodes: FlowNode[]) => {
|
||||||
|
mocks.setNodes(newNodes)
|
||||||
|
if (mocks.flowNodes) mocks.flowNodes.value = newNodes
|
||||||
|
},
|
||||||
|
setEdges: (newEdges: FlowEdge[]) => {
|
||||||
|
mocks.setEdges(newEdges)
|
||||||
|
if (mocks.flowEdges) mocks.flowEdges.value = newEdges
|
||||||
|
},
|
||||||
edges: mocks.flowEdges,
|
edges: mocks.flowEdges,
|
||||||
nodes: mocks.flowNodes,
|
nodes: mocks.flowNodes,
|
||||||
onConnect: (handler: (connection: Connection) => void) => {
|
onConnect: (handler: (connection: Connection) => void) => {
|
||||||
@@ -279,6 +292,9 @@ describe('WorkflowActionsDialog data contract', () => {
|
|||||||
mocks.toastWarning.mockReset()
|
mocks.toastWarning.mockReset()
|
||||||
mocks.flowNodes!.value = []
|
mocks.flowNodes!.value = []
|
||||||
mocks.flowEdges!.value = []
|
mocks.flowEdges!.value = []
|
||||||
|
mocks.nodeTypes = undefined
|
||||||
|
mocks.setNodes.mockReset()
|
||||||
|
mocks.setEdges.mockReset()
|
||||||
mocks.onConnect = undefined
|
mocks.onConnect = undefined
|
||||||
mocks.isValidConnection = undefined
|
mocks.isValidConnection = undefined
|
||||||
mocks.conditionItems = []
|
mocks.conditionItems = []
|
||||||
@@ -299,6 +315,25 @@ describe('WorkflowActionsDialog data contract', () => {
|
|||||||
mocks.apiPut.mockResolvedValue(null)
|
mocks.apiPut.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('registers workflow action components for Vue Flow nodes', async () => {
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.nodeTypes).toEqual(expect.objectContaining({ AddDownloadAction: expect.anything() })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads persisted workflow nodes and edges through Vue Flow setters', async () => {
|
||||||
|
await renderDialog()
|
||||||
|
|
||||||
|
expect(mocks.setNodes).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ id: 'source', type: 'SourceAction' })]),
|
||||||
|
)
|
||||||
|
expect(mocks.setEdges).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ id: 'flow-1', source: 'source', target: 'target' })]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('accepts only known output-to-input connections and rejects invalid endpoints', async () => {
|
it('accepts only known output-to-input connections and rejects invalid endpoints', async () => {
|
||||||
const { container } = await renderDialog()
|
const { container } = await renderDialog()
|
||||||
await waitFor(() => expect(mocks.isValidConnection).toBeTypeOf('function'))
|
await waitFor(() => expect(mocks.isValidConnection).toBeTypeOf('function'))
|
||||||
|
|||||||
Reference in New Issue
Block a user