工作流模板

This commit is contained in:
rjb
2026-01-20 18:28:32 +08:00
parent b8f340401a
commit 47dac9f33b

View File

@@ -69,6 +69,10 @@
<el-icon><Operation /></el-icon> <el-icon><Operation /></el-icon>
自动布局 自动布局
</el-button> </el-button>
<el-button @click="handleApplyTemplate" title="应用工作流模板">
<el-icon><Document /></el-icon>
应用模板
</el-button>
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<div class="zoom-controls"> <div class="zoom-controls">
<el-button size="small" @click="zoomIn" title="放大 (Ctrl +)"> <el-button size="small" @click="zoomIn" title="放大 (Ctrl +)">
@@ -1284,6 +1288,57 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 工作流模板应用对话框 -->
<el-dialog
v-model="templateDialogVisible"
title="应用工作流模板"
width="800px"
:close-on-click-modal="false"
>
<div v-loading="loadingWorkflowTemplates">
<el-input
v-model="templateSearchKeyword"
placeholder="搜索模板..."
clearable
style="margin-bottom: 15px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="template-list">
<div
v-for="template in filteredTemplates"
:key="template.id"
class="template-item"
@click="handleSelectTemplate(template)"
>
<div class="template-header">
<h4>{{ template.name }}</h4>
<el-tag v-if="template.is_featured" type="warning" size="small">精选</el-tag>
</div>
<div class="template-description">
{{ template.description || '无描述' }}
</div>
<div class="template-meta">
<span>节点数: {{ getTemplateNodeCount(template) }}</span>
<span>使用次数: {{ template.use_count || 0 }}</span>
<span v-if="template.rating_avg">评分: {{ template.rating_avg.toFixed(1) }} </span>
</div>
</div>
<div v-if="filteredTemplates.length === 0" class="empty-templates">
<el-empty description="暂无可用模板" />
</div>
</div>
</div>
<template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button>
</template>
</el-dialog>
<!-- 节点执行详情面板 --> <!-- 节点执行详情面板 -->
<NodeExecutionDetail <NodeExecutionDetail
v-model:visible="nodeDetailVisible" v-model:visible="nodeDetailVisible"
@@ -1304,7 +1359,7 @@ import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap' import { MiniMap } from '@vue-flow/minimap'
import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core' import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation } from '@element-plus/icons-vue' import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document, Search } from '@element-plus/icons-vue'
import { useWorkflowStore } from '@/stores/workflow' import { useWorkflowStore } from '@/stores/workflow'
import api from '@/api' import api from '@/api'
import type { WorkflowNode, WorkflowEdge } from '@/types' import type { WorkflowNode, WorkflowEdge } from '@/types'
@@ -1407,6 +1462,203 @@ const handleManageTemplates = () => {
router.push({ name: 'node-templates' }) router.push({ name: 'node-templates' })
} }
// 工作流模板相关
const templateDialogVisible = ref(false)
const workflowTemplates = ref<any[]>([])
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<string, string> = {}
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<Set<string>>(new Set()) const executedNodeIds = ref<Set<string>>(new Set())
const runningNodeId = ref<string | null>(null) const runningNodeId = ref<string | null>(null)
@@ -3885,4 +4137,66 @@ onUnmounted(() => {
stroke-width: 2.5 !important; stroke-width: 2.5 !important;
stroke-dasharray: 5,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;
}
</style> </style>