From 47dac9f33b667b21bbbbec612c4be82efbd8b5a5 Mon Sep 17 00:00:00 2001 From: rjb <263303411@qq.com> Date: Tue, 20 Jan 2026 18:28:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkflowEditor/WorkflowEditor.vue | 316 +++++++++++++++++- 1 file changed, 315 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/WorkflowEditor/WorkflowEditor.vue b/frontend/src/components/WorkflowEditor/WorkflowEditor.vue index f469924..a77e2af 100644 --- a/frontend/src/components/WorkflowEditor/WorkflowEditor.vue +++ b/frontend/src/components/WorkflowEditor/WorkflowEditor.vue @@ -69,6 +69,10 @@ 自动布局 + + + 应用模板 +
@@ -1284,6 +1288,57 @@ + + +
+ + + + +
+
+
+

{{ template.name }}

+ 精选 +
+
+ {{ template.description || '无描述' }} +
+
+ 节点数: {{ getTemplateNodeCount(template) }} + 使用次数: {{ template.use_count || 0 }} + 评分: {{ template.rating_avg.toFixed(1) }} ⭐ +
+
+ +
+ +
+
+
+ + +
+ { router.push({ name: 'node-templates' }) } +// 工作流模板相关 +const templateDialogVisible = ref(false) +const workflowTemplates = ref([]) +const templateSearchKeyword = ref('') +const loadingWorkflowTemplates = ref(false) + +// 加载工作流模板列表 +const loadWorkflowTemplates = async () => { + loadingWorkflowTemplates.value = true + try { + // 优先从模板市场获取 + try { + const response = await api.get('/api/v1/template-market', { + params: { + limit: 50, + sort_by: 'use_count', + sort_order: 'desc' + } + }) + workflowTemplates.value = response.data || [] + } catch (e) { + // 如果模板市场失败,尝试从工作流模板API获取 + const response = await api.get('/api/v1/workflows/templates') + workflowTemplates.value = response.data || [] + } + } catch (error: any) { + console.error('加载工作流模板失败:', error) + ElMessage.error('加载模板列表失败') + workflowTemplates.value = [] + } finally { + loadingWorkflowTemplates.value = false + } +} + +// 过滤模板 +const filteredTemplates = computed(() => { + if (!templateSearchKeyword.value) { + return workflowTemplates.value + } + const keyword = templateSearchKeyword.value.toLowerCase() + return workflowTemplates.value.filter(template => + template.name?.toLowerCase().includes(keyword) || + template.description?.toLowerCase().includes(keyword) || + template.category?.toLowerCase().includes(keyword) + ) +}) + +// 获取模板节点数 +const getTemplateNodeCount = (template: any) => { + if (template.nodes && Array.isArray(template.nodes)) { + return template.nodes.length + } + if (template.workflow_config?.nodes) { + return template.workflow_config.nodes.length + } + return 0 +} + +// 打开模板对话框 +const handleApplyTemplate = async () => { + templateDialogVisible.value = true + if (workflowTemplates.value.length === 0) { + await loadWorkflowTemplates() + } +} + +// 选择并应用模板 +const handleSelectTemplate = async (template: any) => { + try { + // 获取模板详情 + let templateData: any + try { + const response = await api.get(`/api/v1/template-market/${template.id}`) + templateData = response.data + } catch (e) { + // 如果模板市场API失败,尝试从工作流模板API获取 + const response = await api.get(`/api/v1/workflows/templates/${template.id}`) + templateData = response.data + } + + if (!templateData) { + ElMessage.error('获取模板详情失败') + return + } + + // 获取模板的节点和边 + let templateNodes = templateData.nodes || templateData.workflow_config?.nodes || [] + let templateEdges = templateData.edges || templateData.workflow_config?.edges || [] + + if (!templateNodes || templateNodes.length === 0) { + ElMessage.warning('模板中没有节点') + return + } + + // 生成节点ID映射(避免ID冲突) + const nodeIdMapping: Record = {} + const timestamp = Date.now() + + templateNodes.forEach((node: any, index: number) => { + const oldId = node.id + const newId = `${node.type || 'node'}_${timestamp}_${index}` + nodeIdMapping[oldId] = newId + node.id = newId + }) + + // 更新边的源节点和目标节点ID + templateEdges.forEach((edge: any) => { + if (edge.source && nodeIdMapping[edge.source]) { + edge.source = nodeIdMapping[edge.source] + } + if (edge.target && nodeIdMapping[edge.target]) { + edge.target = nodeIdMapping[edge.target] + } + // 生成新的边ID + edge.id = `edge_${edge.source}_${edge.target}_${timestamp}` + }) + + // 计算偏移量,将模板节点添加到画布右侧 + const existingNodes = nodes.value + let offsetX = 100 + let offsetY = 100 + + if (existingNodes.length > 0) { + // 找到现有节点的最大X坐标 + const maxX = Math.max(...existingNodes.map(n => n.position.x)) + offsetX = maxX + 400 // 在现有节点右侧400px处开始 + + // 找到现有节点的最小Y坐标 + const minY = Math.min(...existingNodes.map(n => n.position.y)) + offsetY = minY + } + + // 调整节点位置 + templateNodes.forEach((node: any) => { + node.position = { + x: (node.position?.x || 0) + offsetX, + y: (node.position?.y || 0) + offsetY + } + }) + + // 转换为Vue Flow节点格式 + const vueFlowNodes = templateNodes.map((node: any) => ({ + id: node.id, + type: node.type || 'default', + position: node.position || { x: 0, y: 0 }, + data: node.data || { label: node.label || node.type } + })) + + // 转换为Vue Flow边格式 + const vueFlowEdges = templateEdges.map((edge: any) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle || 'right', + targetHandle: edge.targetHandle || 'left', + type: 'bezier', + animated: true, + selectable: true, + deletable: true, + focusable: true, + style: { + stroke: '#409eff', + strokeWidth: 2.5, + strokeDasharray: '0' + }, + markerEnd: { + type: 'arrowclosed', + color: '#409eff', + width: 20, + height: 20 + } + })) + + // 添加到画布 + addNodes(vueFlowNodes) + await nextTick() + addEdges(vueFlowEdges) + + // 关闭对话框 + templateDialogVisible.value = false + ElMessage.success(`已应用模板: ${template.name} (${templateNodes.length}个节点)`) + + // 标记有变更 + hasChanges.value = true + + // 可选:自动布局新添加的节点 + await nextTick() + setTimeout(() => { + handleAutoLayout() + }, 300) + + } catch (error: any) { + console.error('应用模板失败:', error) + ElMessage.error(error.response?.data?.detail || '应用模板失败') + } +} + // 执行状态相关的节点高亮 const executedNodeIds = ref>(new Set()) const runningNodeId = ref(null) @@ -3885,4 +4137,66 @@ onUnmounted(() => { stroke-width: 2.5 !important; stroke-dasharray: 5,5 !important; } + +/* 工作流模板对话框样式 */ +.template-list { + max-height: 500px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.template-item { + padding: 15px; + border: 1px solid #e4e7ed; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s; + background: #fff; +} + +.template-item:hover { + border-color: #409eff; + box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15); + transform: translateY(-2px); +} + +.template-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.template-header h4 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #303133; +} + +.template-description { + color: #606266; + font-size: 14px; + line-height: 1.5; + margin-bottom: 10px; +} + +.template-meta { + display: flex; + gap: 15px; + font-size: 12px; + color: #909399; +} + +.template-meta span { + display: flex; + align-items: center; +} + +.empty-templates { + padding: 40px 0; + text-align: center; +}