WorkflowEditor.vue 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594
  1. <script setup>
  2. import { ref, computed, reactive, onMounted, markRaw } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import { VueFlow, useVueFlow } from '@vue-flow/core'
  5. import { Background } from '@vue-flow/background'
  6. import {
  7. NInput, NSelect, NButton, NIcon, useMessage
  8. } from 'naive-ui'
  9. import {
  10. ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
  11. SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
  12. PlayOutline, CloseOutline
  13. } from '@vicons/ionicons5'
  14. import { useWorkflowStore } from '../../stores/workflow'
  15. import { useSkillStore } from '../../stores/skill'
  16. import { runWorkflow } from '../../api/workflow'
  17. import InputNode from '../../components/workflow/nodes/InputNode.vue'
  18. import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
  19. import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
  20. import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
  21. import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
  22. import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
  23. import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES, extractTemplateVariables } from '../../utils/ioInference'
  24. import '@vue-flow/core/dist/style.css'
  25. import '@vue-flow/core/dist/theme-default.css'
  26. const route = useRoute()
  27. const router = useRouter()
  28. const wfStore = useWorkflowStore()
  29. const skillStore = useSkillStore()
  30. const message = useMessage()
  31. const workflowId = route.params.id ? Number(route.params.id) : null
  32. const workflowName = ref('')
  33. const workflowDesc = ref('')
  34. const saving = ref(false)
  35. // ========== 工作流运行状态 ==========
  36. const running = ref(false)
  37. const showRunDialog = ref(false)
  38. const showResult = ref(false)
  39. const workflowResult = ref(null)
  40. const nodeStatuses = reactive({}) // nodeId → 'running' | 'success' | 'failed' | 'skipped'
  41. const activeTab = ref('props') // 'props' | 'result'
  42. const nodeResults = reactive([]) // [{ nodeId, type, label, status, output, color, typeLabel }]
  43. // 从 userInput 节点提取输入字段定义
  44. const inputFields = computed(() => {
  45. const fields = []
  46. for (const node of getNodes.value) {
  47. if (node.type === 'userInput' && node.data?.variables) {
  48. for (const v of node.data.variables) {
  49. if (v.name) fields.push(v)
  50. }
  51. }
  52. }
  53. return fields
  54. })
  55. const formData = reactive({})
  56. const canRun = computed(() => !!workflowId && !running.value && getNodes.value.length > 0)
  57. // ========== Vue Flow ==========
  58. const {
  59. onConnect, addEdges, addNodes, removeEdges,
  60. updateNodeData: vfUpdateNode,
  61. project, vueFlowRef, getNodes, getEdges
  62. } = useVueFlow()
  63. const selectedNode = ref(null)
  64. const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
  65. const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
  66. onConnect((params) => {
  67. const sourceNode = getNodes.value.find(n => n.id === params.source)
  68. const targetNode = getNodes.value.find(n => n.id === params.target)
  69. const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode) : null
  70. addEdges([{
  71. ...params,
  72. type: 'bezier',
  73. animated: true,
  74. style: inferred?.style || defaultEdgeStyle,
  75. data: inferred?.data || {}
  76. }])
  77. })
  78. // 批量更新边的映射数据和样式
  79. function applyEdgeMappingUpdates(updates) {
  80. for (const upd of updates) {
  81. const edge = getEdges.value.find(e => e.id === upd.id)
  82. if (edge) {
  83. edge.data = { ...edge.data, ...upd.data }
  84. edge.style = upd.style
  85. }
  86. }
  87. }
  88. // ========== 节点类型定义 ==========
  89. const nodeTypes = [
  90. { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
  91. { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
  92. { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
  93. { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
  94. { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
  95. { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
  96. ]
  97. const nodeTypeInfo = {
  98. userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
  99. llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
  100. skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
  101. agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
  102. condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
  103. output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
  104. }
  105. function getDefaultData(type) {
  106. switch (type) {
  107. case 'userInput': return { label: '用户输入', variables: [] }
  108. case 'llm': return { label: '大模型处理', model: '', systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }] }
  109. case 'agent': return { label: '智能体', agentId: '', agentName: '' }
  110. case 'skill': return { label: '技能', skillId: '', skillName: '' }
  111. case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
  112. case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
  113. default: return { label: '节点' }
  114. }
  115. }
  116. let nodeIdCounter = 0
  117. function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
  118. // ========== 拖拽到画布 ==========
  119. function onDragOver(e) {
  120. e.preventDefault()
  121. e.dataTransfer.dropEffect = 'move'
  122. }
  123. function onDrop(e) {
  124. const type = e.dataTransfer.getData('application/vueflow')
  125. if (!type) return
  126. const bounds = vueFlowRef.value.getBoundingClientRect()
  127. const position = project({
  128. x: e.clientX - bounds.left,
  129. y: e.clientY - bounds.top
  130. })
  131. const id = genId()
  132. addNodes([{
  133. id,
  134. type,
  135. position,
  136. data: getDefaultData(type)
  137. }])
  138. }
  139. function onDragStart(e, type) {
  140. e.dataTransfer.setData('application/vueflow', type)
  141. e.dataTransfer.effectAllowed = 'move'
  142. }
  143. // ========== 节点选中 ==========
  144. function onNodeClick({ node }) {
  145. selectedNode.value = node
  146. }
  147. function onPaneClick() {
  148. selectedNode.value = null
  149. selectedEdgeId.value = null
  150. }
  151. // ========== 连接线选中与删除 ==========
  152. const selectedEdgeId = ref(null)
  153. function onEdgeClick({ edge }) {
  154. selectedEdgeId.value = edge.id
  155. selectedNode.value = null
  156. }
  157. function deleteSelectedEdge() {
  158. if (!selectedEdgeId.value) return
  159. const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
  160. const sourceId = edge?.source
  161. const targetId = edge?.target
  162. removeEdges(selectedEdgeId.value)
  163. selectedEdgeId.value = null
  164. if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
  165. if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
  166. }
  167. function onKeyDown(e) {
  168. if (e.key === 'Delete' || e.key === 'Backspace') {
  169. // 避免在输入框内触发删除
  170. const tag = e.target?.tagName
  171. if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
  172. deleteSelectedEdge()
  173. }
  174. }
  175. const selectedData = computed(() => selectedNode.value?.data || null)
  176. const selectedEdge = computed(() => {
  177. if (!selectedEdgeId.value) return null
  178. return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
  179. })
  180. // ========== 智能体选项 ==========
  181. const agentOptions = computed(() =>
  182. (skillStore.skills || []).map(s => ({
  183. label: s.translatedName || s.name || s.folderName,
  184. value: s.folderName
  185. }))
  186. )
  187. const modelOptions = [
  188. { label: 'GPT-4o', value: 'gpt-4o' },
  189. { label: 'GPT-4o-mini', value: 'gpt-4o-mini' },
  190. { label: 'Claude 3.5 Sonnet', value: 'claude-3.5-sonnet' },
  191. { label: 'Claude 3 Opus', value: 'claude-3-opus' },
  192. { label: 'DeepSeek V3', value: 'deepseek-v3' },
  193. { label: 'GLM-4', value: 'glm-4' }
  194. ]
  195. // ========== 条件分支操作 ==========
  196. function addElif() {
  197. if (!selectedData.value?.conditions) return
  198. const newConditions = [...selectedData.value.conditions, { type: 'ELIF', expression: '' }]
  199. vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
  200. // 同步 selectedNode 引用
  201. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  202. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  203. }
  204. function removeElif(index) {
  205. if (!selectedData.value?.conditions) return
  206. const newConditions = selectedData.value.conditions.filter((_, i) => i !== index)
  207. vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
  208. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  209. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  210. }
  211. // ========== 变量操作 ==========
  212. function addVariable() {
  213. if (!selectedData.value) return
  214. const newVars = [...(selectedData.value.variables || []), { name: '', label: '', type: 'string', description: '' }]
  215. vfUpdateNode(selectedNode.value.id, { variables: newVars })
  216. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  217. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  218. }
  219. function removeVariable(index) {
  220. const newVars = (selectedData.value?.variables || []).filter((_, i) => i !== index)
  221. vfUpdateNode(selectedNode.value.id, { variables: newVars })
  222. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  223. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  224. }
  225. // ========== 通用数据更新 ==========
  226. function onFieldChange(field, value) {
  227. if (!selectedNode.value) return
  228. const update = { [field]: value }
  229. // 特殊处理:智能体选择时同步名称和 IO 字段
  230. if (field === 'agentId') {
  231. const opt = agentOptions.value.find(o => o.value === value)
  232. update.agentName = opt ? opt.label : ''
  233. const skill = skillStore.skills?.find(s => s.folderName === value)
  234. update.inputs = skill?.inputs || []
  235. update.outputs = skill?.outputs || []
  236. }
  237. // 特殊处理:技能选择时同步名称和 IO 字段
  238. if (field === 'skillId') {
  239. const opt = agentOptions.value.find(o => o.value === value)
  240. update.skillName = opt ? opt.label : ''
  241. const skill = skillStore.skills?.find(s => s.folderName === value)
  242. update.inputs = skill?.inputs || []
  243. update.outputs = skill?.outputs || []
  244. }
  245. vfUpdateNode(selectedNode.value.id, update)
  246. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  247. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  248. }
  249. function onConditionChange(index, field, value) {
  250. if (!selectedData.value?.conditions) return
  251. const newConditions = selectedData.value.conditions.map((c, i) =>
  252. i === index ? { ...c, [field]: value } : c
  253. )
  254. vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
  255. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  256. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  257. }
  258. // ========== 输出节点字段操作 ==========
  259. function addOutputField() {
  260. if (!selectedData.value) return
  261. const newFields = [...(selectedData.value.fields || []), { name: '', label: '', type: 'string', description: '' }]
  262. vfUpdateNode(selectedNode.value.id, { fields: newFields })
  263. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  264. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  265. }
  266. function removeOutputField(index) {
  267. const newFields = (selectedData.value?.fields || []).filter((_, i) => i !== index)
  268. vfUpdateNode(selectedNode.value.id, { fields: newFields })
  269. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  270. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  271. }
  272. // ========== LLM 节点 IO 字段操作 ==========
  273. function addLlmField(field) {
  274. if (!selectedData.value) return
  275. const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
  276. vfUpdateNode(selectedNode.value.id, { [field]: list })
  277. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  278. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  279. }
  280. function removeLlmField(field, index) {
  281. const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
  282. vfUpdateNode(selectedNode.value.id, { [field]: list })
  283. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  284. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  285. }
  286. function syncLlmFieldsFromTemplate(field) {
  287. if (!selectedData.value) return
  288. const extracted = extractTemplateVariables(
  289. (selectedData.value.systemPrompt || '') + '\n' + (selectedData.value.userPrompt || '')
  290. )
  291. const existing = selectedData.value[field] || []
  292. const existingNames = new Set(existing.map(f => f.name))
  293. const merged = [...existing]
  294. for (const name of extracted) {
  295. if (!existingNames.has(name)) {
  296. merged.push({ name, label: name, type: 'string', description: '' })
  297. }
  298. }
  299. // 移除不在模板中的字段(仅移除无 label 描述的自动提取字段)
  300. const final = merged.filter(f => extracted.includes(f.name) || f.label !== f.name || existingNames.has(f.name))
  301. vfUpdateNode(selectedNode.value.id, { [field]: final })
  302. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  303. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  304. }
  305. // ========== 智能体节点 IO 字段操作 ==========
  306. function addAgentField(field) {
  307. if (!selectedData.value) return
  308. const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
  309. vfUpdateNode(selectedNode.value.id, { [field]: list })
  310. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  311. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  312. }
  313. function removeAgentField(field, index) {
  314. const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
  315. vfUpdateNode(selectedNode.value.id, { [field]: list })
  316. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  317. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  318. }
  319. // ========== 条件分支节点输入变量操作 ==========
  320. function addConditionInput() {
  321. if (!selectedData.value) return
  322. const list = [...(selectedData.value.inputs || []), { name: '', label: '', type: 'string', description: '' }]
  323. vfUpdateNode(selectedNode.value.id, { inputs: list })
  324. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  325. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  326. }
  327. function removeConditionInput(index) {
  328. const list = (selectedData.value?.inputs || []).filter((_, i) => i !== index)
  329. vfUpdateNode(selectedNode.value.id, { inputs: list })
  330. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  331. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  332. }
  333. // ========== 边状态文案 ==========
  334. function edgeStatusText(status) {
  335. return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
  336. }
  337. // ========== 保存 ==========
  338. async function handleSave() {
  339. if (!workflowName.value.trim()) {
  340. message.warning('请输入工作流名称')
  341. return
  342. }
  343. saving.value = true
  344. try {
  345. const graphData = JSON.stringify({
  346. nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
  347. edges: getEdges.value.map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
  348. })
  349. if (workflowId) {
  350. await wfStore.save(workflowId, {
  351. name: workflowName.value,
  352. description: workflowDesc.value,
  353. graphData
  354. })
  355. message.success('工作流已保存')
  356. } else {
  357. const created = await wfStore.create(workflowName.value, workflowDesc.value)
  358. await wfStore.save(created.id, {
  359. name: workflowName.value,
  360. description: workflowDesc.value,
  361. graphData
  362. })
  363. message.success('工作流已创建')
  364. router.replace(`/orchestration/edit/${created.id}`)
  365. }
  366. } catch (e) {
  367. message.error(e.response?.data?.message || '保存失败')
  368. } finally {
  369. saving.value = false
  370. }
  371. }
  372. function goBack() {
  373. router.push('/orchestration')
  374. }
  375. // ========== 工作流运行 ==========
  376. function openRunDialog() {
  377. // 重置表单
  378. Object.keys(formData).forEach(k => delete formData[k])
  379. inputFields.value.forEach(f => { formData[f.name] = '' })
  380. showRunDialog.value = true
  381. }
  382. function cancelRun() {
  383. showRunDialog.value = false
  384. }
  385. async function startRun() {
  386. // 收集填写的输入值
  387. const inputs = {}
  388. inputFields.value.forEach(f => {
  389. if (formData[f.name]) inputs[f.name] = formData[f.name]
  390. })
  391. showRunDialog.value = false
  392. running.value = true
  393. showResult.value = false
  394. workflowResult.value = null
  395. Object.keys(nodeStatuses).forEach(k => delete nodeStatuses[k])
  396. nodeResults.splice(0, nodeResults.length)
  397. // 重置节点样式
  398. getNodes.value.forEach(n => { n.class = '' })
  399. try {
  400. const response = await runWorkflow(workflowId, inputs)
  401. if (!response.ok) throw new Error(`HTTP ${response.status}`)
  402. const reader = response.body.getReader()
  403. const decoder = new TextDecoder()
  404. let buffer = ''
  405. while (true) {
  406. const { done, value } = await reader.read()
  407. if (done) break
  408. buffer += decoder.decode(value, { stream: true })
  409. const parts = buffer.split('\n\n')
  410. buffer = parts.pop()
  411. for (const part of parts) {
  412. const lines = part.split('\n')
  413. let data = null
  414. for (const line of lines) {
  415. if (line.startsWith('data:')) {
  416. try { data = JSON.parse(line.substring(5).trim()) } catch { /* skip */ }
  417. }
  418. }
  419. if (data) handleSSEEvent(data)
  420. }
  421. }
  422. } catch (e) {
  423. message.error('工作流执行失败: ' + e.message)
  424. } finally {
  425. running.value = false
  426. }
  427. }
  428. function handleSSEEvent(event) {
  429. if (event.type === 'node_status' && event.nodeId) {
  430. const status = event.status?.toLowerCase() || 'running'
  431. nodeStatuses[event.nodeId] = status
  432. const node = getNodes.value.find(n => n.id === event.nodeId)
  433. if (node) node.class = `node-status-${status}`
  434. // 收集节点结果
  435. const info = nodeTypeInfo[node?.type] || { color: '#666', typeLabel: '未知' }
  436. const existing = nodeResults.find(r => r.nodeId === event.nodeId)
  437. const update = {
  438. nodeId: event.nodeId,
  439. type: node?.type,
  440. label: node?.data?.label || '节点',
  441. status,
  442. output: event.output || (existing?.output),
  443. color: info.color,
  444. typeLabel: info.typeLabel
  445. }
  446. if (existing) {
  447. Object.assign(existing, update)
  448. } else {
  449. nodeResults.push(update)
  450. }
  451. // 自动切换到结果 Tab
  452. if (activeTab.value !== 'result') activeTab.value = 'result'
  453. } else if (event.type === 'workflow_complete') {
  454. workflowResult.value = event.output
  455. showResult.value = true
  456. message.success('工作流执行完成')
  457. } else if (event.type === 'workflow_error') {
  458. message.error('执行失败: ' + (event.error || '未知错误'))
  459. }
  460. }
  461. function closeResult() {
  462. showResult.value = false
  463. nodeResults.splice(0, nodeResults.length)
  464. Object.keys(nodeStatuses).forEach(k => delete nodeStatuses[k])
  465. getNodes.value.forEach(n => { n.class = '' })
  466. activeTab.value = 'props'
  467. }
  468. function statusText(s) {
  469. return { running: '执行中', success: '成功', failed: '失败', skipped: '跳过' }[s] || s
  470. }
  471. // ========== 初始化 ==========
  472. onMounted(async () => {
  473. skillStore.fetchSkills()
  474. if (workflowId) {
  475. try {
  476. const wf = await wfStore.fetchOne(workflowId)
  477. workflowName.value = wf.name
  478. workflowDesc.value = wf.description || ''
  479. if (wf.graphData) {
  480. try {
  481. const parsed = JSON.parse(wf.graphData)
  482. addNodes((parsed.nodes || []).map(n => {
  483. const defaults = getDefaultData(n.type)
  484. return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
  485. }))
  486. const edgeData = (parsed.edges || []).map(e => ({
  487. ...e,
  488. type: 'bezier',
  489. animated: true,
  490. style: defaultEdgeStyle,
  491. data: e.data || {}
  492. }))
  493. if (edgeData.length) addEdges(edgeData)
  494. // 重新推断所有边的 IO 映射(保留已有的手动映射)
  495. for (const node of getNodes.value) {
  496. applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
  497. }
  498. } catch { /* ignore */ }
  499. }
  500. } catch {
  501. message.error('工作流不存在')
  502. goBack()
  503. }
  504. }
  505. })
  506. </script>
  507. <template>
  508. <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
  509. <!-- 顶部工具栏 -->
  510. <div class="editor-toolbar">
  511. <div class="toolbar-left">
  512. <button class="icon-btn" title="返回列表" @click="goBack">
  513. <n-icon size="18"><ArrowBackOutline /></n-icon>
  514. </button>
  515. <n-input
  516. v-model:value="workflowName"
  517. placeholder="工作流名称"
  518. style="width: 220px"
  519. size="small"
  520. />
  521. </div>
  522. <div class="toolbar-right">
  523. <button
  524. class="run-btn"
  525. :class="{ 'is-running': running }"
  526. @click="openRunDialog"
  527. :disabled="!canRun"
  528. >
  529. <n-icon size="16"><PlayOutline /></n-icon>
  530. <span>{{ running ? '执行中...' : '运行' }}</span>
  531. </button>
  532. <button class="primary-btn" @click="handleSave" :disabled="saving">
  533. <n-icon size="16"><SaveOutline /></n-icon>
  534. <span>{{ saving ? '保存中...' : '完成' }}</span>
  535. </button>
  536. </div>
  537. </div>
  538. <div class="editor-body">
  539. <!-- 左侧节点工具栏 -->
  540. <div class="sidebar-nodes">
  541. <div class="sidebar-title">节点</div>
  542. <div
  543. v-for="nt in nodeTypes"
  544. :key="nt.type"
  545. class="node-type-item"
  546. draggable="true"
  547. @dragstart="onDragStart($event, nt.type)"
  548. >
  549. <div class="nt-icon" :style="{ background: nt.color }">
  550. <n-icon size="14"><component :is="nt.icon" /></n-icon>
  551. </div>
  552. <span class="nt-label">{{ nt.label }}</span>
  553. </div>
  554. </div>
  555. <!-- 中间画布 -->
  556. <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
  557. <VueFlow
  558. :default-viewport="{ zoom: 1, x: 0, y: 0 }"
  559. :min-zoom="0.3"
  560. :max-zoom="2"
  561. fit-view-on-init
  562. :elements-selectable="true"
  563. @node-click="onNodeClick"
  564. @pane-click="onPaneClick"
  565. @edge-click="onEdgeClick"
  566. :snap-to-grid="true"
  567. :snap-grid="[20, 20]"
  568. >
  569. <template #node-userInput="nodeProps"><InputNode :data="nodeProps.data" /></template>
  570. <template #node-llm="nodeProps"><LLMNode :data="nodeProps.data" /></template>
  571. <template #node-agent="nodeProps"><AgentNode :data="nodeProps.data" /></template>
  572. <template #node-skill="nodeProps"><SkillNode :data="nodeProps.data" /></template>
  573. <template #node-output="nodeProps"><OutputNode :data="nodeProps.data" /></template>
  574. <template #node-condition="nodeProps"><ConditionNode :data="nodeProps.data" /></template>
  575. <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
  576. </VueFlow>
  577. </div>
  578. <!-- 右侧面板 - Tab 页 -->
  579. <div class="sidebar-right">
  580. <div class="sidebar-tabs">
  581. <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
  582. <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
  583. 运行结果
  584. <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
  585. </button>
  586. </div>
  587. <div class="sidebar-tab-content">
  588. <!-- 节点属性 Tab -->
  589. <div v-show="activeTab === 'props'" class="tab-pane">
  590. <template v-if="selectedData">
  591. <!-- 用户输入节点 -->
  592. <template v-if="selectedNode?.type === 'userInput'">
  593. <div class="prop-section">
  594. <div class="prop-header">
  595. <span class="prop-label">输入变量</span>
  596. <button class="text-btn" @click="addVariable">+ 添加</button>
  597. </div>
  598. <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
  599. <div class="var-form-row">
  600. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  601. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  602. </div>
  603. <div class="var-form-row">
  604. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  605. <n-input :value="v.description" placeholder="描述" size="small" @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  606. </div>
  607. <button class="text-btn danger" @click="removeVariable(i)">删除</button>
  608. </div>
  609. <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
  610. </div>
  611. </template>
  612. <!-- 大模型处理节点 -->
  613. <template v-if="selectedNode?.type === 'llm'">
  614. <div class="prop-section">
  615. <label class="prop-label">模型</label>
  616. <n-select
  617. :value="selectedData?.model"
  618. :options="modelOptions"
  619. placeholder="选择模型"
  620. size="small"
  621. @update:value="v => onFieldChange('model', v)"
  622. />
  623. </div>
  624. <div class="prop-section">
  625. <label class="prop-label">系统提示词</label>
  626. <n-input
  627. :value="selectedData?.systemPrompt"
  628. type="textarea" :rows="3"
  629. placeholder="输入系统提示词"
  630. size="small"
  631. @update:value="v => onFieldChange('systemPrompt', v)"
  632. />
  633. </div>
  634. <div class="prop-section">
  635. <label class="prop-label">用户提示词</label>
  636. <n-input
  637. :value="selectedData?.userPrompt"
  638. type="textarea" :rows="3"
  639. placeholder="输入用户提示词,可用 {{变量名}} 引用变量"
  640. size="small"
  641. @update:value="v => onFieldChange('userPrompt', v)"
  642. />
  643. </div>
  644. <!-- 输入变量 -->
  645. <div class="prop-section">
  646. <div class="prop-header">
  647. <span class="prop-label">输入变量</span>
  648. <div style="display:flex;gap:4px">
  649. <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
  650. <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
  651. </div>
  652. </div>
  653. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  654. <div class="var-form-row">
  655. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  656. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  657. </div>
  658. <div class="var-form-row">
  659. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  660. <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
  661. </div>
  662. </div>
  663. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
  664. </div>
  665. <!-- 输出变量 -->
  666. <div class="prop-section">
  667. <div class="prop-header">
  668. <span class="prop-label">输出变量</span>
  669. <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
  670. </div>
  671. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  672. <div class="var-form-row">
  673. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  674. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  675. </div>
  676. <div class="var-form-row">
  677. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  678. <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
  679. </div>
  680. </div>
  681. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
  682. </div>
  683. </template>
  684. <!-- 智能体节点 -->
  685. <template v-if="selectedNode?.type === 'agent'">
  686. <div class="prop-section">
  687. <label class="prop-label">选择智能体</label>
  688. <n-select
  689. :value="selectedData?.agentId"
  690. :options="agentOptions"
  691. placeholder="选择技能"
  692. size="small"
  693. filterable
  694. @update:value="v => onFieldChange('agentId', v)"
  695. />
  696. </div>
  697. <!-- 输入变量 -->
  698. <div class="prop-section">
  699. <div class="prop-header">
  700. <span class="prop-label">输入变量</span>
  701. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  702. </div>
  703. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  704. <div class="var-form-row">
  705. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  706. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  707. </div>
  708. <div class="var-form-row">
  709. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  710. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  711. </div>
  712. </div>
  713. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  714. </div>
  715. <!-- 输出变量 -->
  716. <div class="prop-section">
  717. <div class="prop-header">
  718. <span class="prop-label">输出变量</span>
  719. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  720. </div>
  721. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  722. <div class="var-form-row">
  723. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  724. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  725. </div>
  726. <div class="var-form-row">
  727. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  728. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  729. </div>
  730. </div>
  731. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  732. </div>
  733. </template>
  734. <!-- 技能节点 -->
  735. <template v-if="selectedNode?.type === 'skill'">
  736. <div class="prop-section">
  737. <label class="prop-label">选择技能</label>
  738. <n-select
  739. :value="selectedData?.skillId"
  740. :options="agentOptions"
  741. placeholder="选择技能"
  742. size="small"
  743. filterable
  744. @update:value="v => onFieldChange('skillId', v)"
  745. />
  746. </div>
  747. <!-- 输入变量 -->
  748. <div class="prop-section">
  749. <div class="prop-header">
  750. <span class="prop-label">输入变量</span>
  751. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  752. </div>
  753. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  754. <div class="var-form-row">
  755. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  756. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  757. </div>
  758. <div class="var-form-row">
  759. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  760. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  761. </div>
  762. </div>
  763. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  764. </div>
  765. <!-- 输出变量 -->
  766. <div class="prop-section">
  767. <div class="prop-header">
  768. <span class="prop-label">输出变量</span>
  769. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  770. </div>
  771. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  772. <div class="var-form-row">
  773. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  774. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  775. </div>
  776. <div class="var-form-row">
  777. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  778. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  779. </div>
  780. </div>
  781. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  782. </div>
  783. </template>
  784. <!-- 条件分支节点 -->
  785. <template v-if="selectedNode?.type === 'condition'">
  786. <div class="prop-section">
  787. <div class="prop-header">
  788. <span class="prop-label">条件分支</span>
  789. <button class="text-btn" @click="addElif">+ ELIF</button>
  790. </div>
  791. <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
  792. <div class="cond-form-row">
  793. <span class="cond-tag">{{ c.type }}</span>
  794. <n-input
  795. :value="c.expression"
  796. placeholder="自然语言条件,可用 {{变量名}}"
  797. size="small"
  798. @update:value="v => onConditionChange(i, 'expression', v)"
  799. />
  800. <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
  801. </div>
  802. </div>
  803. </div>
  804. <div class="prop-section">
  805. <div class="prop-header">
  806. <span class="prop-label">接收变量</span>
  807. <button class="text-btn" @click="addConditionInput">+ 添加</button>
  808. </div>
  809. <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
  810. <div class="var-form-row">
  811. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  812. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  813. </div>
  814. <div class="var-form-row">
  815. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  816. <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
  817. </div>
  818. </div>
  819. <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  820. <div class="passthrough-hint">所有接收变量将透传至下游节点</div>
  821. </div>
  822. </template>
  823. <!-- 输出节点 -->
  824. <template v-if="selectedNode?.type === 'output'">
  825. <div class="prop-section">
  826. <div class="prop-header">
  827. <span class="prop-label">接收字段</span>
  828. <button class="text-btn" @click="addOutputField">+ 添加</button>
  829. </div>
  830. <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
  831. <div class="var-form-row">
  832. <n-input :value="f.name" placeholder="字段名" size="small" @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  833. <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  834. </div>
  835. <div class="var-form-row">
  836. <n-input :value="f.label" placeholder="中文名" size="small" @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  837. <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
  838. </div>
  839. </div>
  840. <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  841. </div>
  842. </template>
  843. </template>
  844. <!-- 边映射信息 -->
  845. <template v-else-if="selectedEdge?.data">
  846. <div class="prop-section">
  847. <div class="prop-header">
  848. <span class="prop-label">连接映射</span>
  849. <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
  850. {{ edgeStatusText(selectedEdge.data.status) }}
  851. </span>
  852. </div>
  853. <!-- 已映射字段 -->
  854. <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
  855. <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField" class="mapping-item mapped">
  856. <span class="mapping-field">{{ m.sourceField }}</span>
  857. <span class="mapping-arrow">→</span>
  858. <span class="mapping-field">{{ m.targetField }}</span>
  859. </div>
  860. </div>
  861. <!-- 类型不匹配 -->
  862. <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
  863. <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name" class="mapping-item mismatch">
  864. <span class="mapping-field">{{ tm.source.name }}</span>
  865. <span class="mapping-type">({{ tm.source.type }})</span>
  866. <span class="mapping-arrow">→</span>
  867. <span class="mapping-field">{{ tm.target.name }}</span>
  868. <span class="mapping-type">({{ tm.target.type }})</span>
  869. </div>
  870. </div>
  871. <!-- 未匹配的目标输入 -->
  872. <div v-if="selectedEdge.data.unmatchedTarget?.length">
  873. <div class="prop-label" style="margin-top:8px">未匹配输入</div>
  874. <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
  875. <span class="mapping-field">{{ f.name }}</span>
  876. <span class="mapping-type">({{ f.type }})</span>
  877. </div>
  878. </div>
  879. <!-- 多余的源输出 -->
  880. <div v-if="selectedEdge.data.unmatchedSource?.length">
  881. <div class="prop-label" style="margin-top:8px">多余输出</div>
  882. <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
  883. <span class="mapping-field">{{ f.name }}</span>
  884. <span class="mapping-type">({{ f.type }})</span>
  885. </div>
  886. </div>
  887. <div v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)" class="empty-hint">
  888. 无需映射
  889. </div>
  890. </div>
  891. </template>
  892. <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
  893. </div>
  894. <!-- 运行结果 Tab -->
  895. <div v-show="activeTab === 'result'" class="tab-pane">
  896. <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
  897. <div
  898. v-for="r in nodeResults"
  899. :key="r.nodeId"
  900. class="node-result-card"
  901. :style="{ borderLeftColor: r.color }"
  902. >
  903. <div class="nrc-header">
  904. <div class="nrc-icon" :style="{ background: r.color }">
  905. <n-icon size="12"><component :is="nodeTypeInfo[r.type]?.icon" /></n-icon>
  906. </div>
  907. <div class="nrc-info">
  908. <span class="nrc-label">{{ r.label }}</span>
  909. <span class="nrc-type">{{ r.typeLabel }}</span>
  910. </div>
  911. <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
  912. </div>
  913. <div class="nrc-vars" v-if="r.output">
  914. <div class="nrc-var" v-for="(value, key) in r.output" :key="key">
  915. <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
  916. <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) }}</span>
  917. </div>
  918. </div>
  919. </div>
  920. <div v-if="showResult && workflowResult" class="nrc-clear-row">
  921. <button class="text-btn danger" @click="closeResult">清除结果</button>
  922. </div>
  923. </div>
  924. </div>
  925. </div>
  926. </div>
  927. <!-- 运行对话框 -->
  928. <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
  929. <div class="run-dialog">
  930. <div class="dialog-header">
  931. <span class="dialog-title">运行工作流</span>
  932. <button class="icon-btn" @click="cancelRun"><n-icon size="16"><CloseOutline /></n-icon></button>
  933. </div>
  934. <div class="dialog-body">
  935. <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
  936. <div v-for="f in inputFields" :key="f.name" class="run-field">
  937. <label class="run-field-label">{{ f.label || f.name }}</label>
  938. <n-input
  939. v-model:value="formData[f.name]"
  940. :placeholder="f.description || f.name"
  941. :type="f.type === 'number' ? 'text' : 'text'"
  942. size="small"
  943. />
  944. </div>
  945. </div>
  946. <div class="dialog-footer">
  947. <button class="cancel-btn" @click="cancelRun">取消</button>
  948. <button class="primary-btn" @click="startRun">
  949. <n-icon size="14"><PlayOutline /></n-icon>
  950. <span>开始执行</span>
  951. </button>
  952. </div>
  953. </div>
  954. </div>
  955. </div>
  956. </template>
  957. <style scoped>
  958. .workflow-editor {
  959. height: calc(100vh - var(--header-height));
  960. display: flex;
  961. flex-direction: column;
  962. margin: -24px -32px 0;
  963. background: #13111a;
  964. }
  965. .editor-toolbar {
  966. display: flex;
  967. align-items: center;
  968. justify-content: space-between;
  969. padding: 10px 16px;
  970. background: var(--bg-card, #1a1a2e);
  971. border-bottom: 1px solid rgba(255,255,255,0.08);
  972. flex-shrink: 0;
  973. }
  974. .toolbar-left, .toolbar-right {
  975. display: flex;
  976. align-items: center;
  977. gap: 10px;
  978. }
  979. .editor-body {
  980. flex: 1;
  981. display: flex;
  982. overflow: hidden;
  983. }
  984. /* 左侧节点栏 */
  985. .sidebar-nodes {
  986. width: 180px;
  987. background: var(--bg-card, #1a1a2e);
  988. border-right: 1px solid rgba(255,255,255,0.08);
  989. padding: 12px;
  990. display: flex;
  991. flex-direction: column;
  992. gap: 6px;
  993. flex-shrink: 0;
  994. }
  995. .sidebar-title {
  996. font-size: 12px;
  997. font-weight: 600;
  998. color: var(--text-secondary, #999);
  999. text-transform: uppercase;
  1000. letter-spacing: 1px;
  1001. margin-bottom: 6px;
  1002. }
  1003. .node-type-item {
  1004. display: flex;
  1005. align-items: center;
  1006. gap: 8px;
  1007. padding: 8px 10px;
  1008. border-radius: 8px;
  1009. cursor: grab;
  1010. transition: background 0.15s;
  1011. border: 1px solid rgba(255,255,255,0.06);
  1012. }
  1013. .node-type-item:hover {
  1014. background: rgba(255,255,255,0.06);
  1015. }
  1016. .node-type-item:active {
  1017. cursor: grabbing;
  1018. }
  1019. .nt-icon {
  1020. width: 28px;
  1021. height: 28px;
  1022. display: flex;
  1023. align-items: center;
  1024. justify-content: center;
  1025. border-radius: 6px;
  1026. color: #fff;
  1027. flex-shrink: 0;
  1028. }
  1029. .nt-label {
  1030. font-size: 13px;
  1031. color: var(--text-primary, #e0e0e0);
  1032. }
  1033. /* 中间画布 */
  1034. .canvas-area {
  1035. flex: 1;
  1036. background: #0f0d17;
  1037. }
  1038. /* 覆盖 VueFlow 默认主题的节点白色背景 */
  1039. .canvas-area :deep(.vue-flow__node) {
  1040. background: transparent !important;
  1041. padding: 0 !important;
  1042. border: none !important;
  1043. box-shadow: none !important;
  1044. }
  1045. /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
  1046. .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
  1047. stroke: #facc15 !important;
  1048. stroke-width: 3 !important;
  1049. }
  1050. .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
  1051. stroke: #facc15 !important;
  1052. }
  1053. .canvas-area :deep(.vue-flow__edge) {
  1054. cursor: pointer;
  1055. }
  1056. /* 右侧面板 - Tab 页 */
  1057. .sidebar-right {
  1058. width: 300px;
  1059. background: var(--bg-card, #1a1a2e);
  1060. border-left: 1px solid rgba(255,255,255,0.08);
  1061. display: flex;
  1062. flex-direction: column;
  1063. flex-shrink: 0;
  1064. overflow: hidden;
  1065. }
  1066. .sidebar-tabs {
  1067. display: flex;
  1068. border-bottom: 1px solid rgba(255,255,255,0.08);
  1069. flex-shrink: 0;
  1070. }
  1071. .tab-btn {
  1072. flex: 1;
  1073. padding: 10px 0;
  1074. border: none;
  1075. background: transparent;
  1076. color: var(--text-secondary, #999);
  1077. font-size: 13px;
  1078. cursor: pointer;
  1079. transition: all 0.15s;
  1080. position: relative;
  1081. display: flex;
  1082. align-items: center;
  1083. justify-content: center;
  1084. gap: 4px;
  1085. }
  1086. .tab-btn:hover { color: var(--text-primary, #e0e0e0); }
  1087. .tab-btn.active {
  1088. color: var(--text-primary, #e0e0e0);
  1089. font-weight: 600;
  1090. }
  1091. .tab-btn.active::after {
  1092. content: '';
  1093. position: absolute;
  1094. bottom: 0;
  1095. left: 20%;
  1096. right: 20%;
  1097. height: 2px;
  1098. background: var(--color-primary, #2563EB);
  1099. border-radius: 1px;
  1100. }
  1101. .result-badge {
  1102. display: inline-flex;
  1103. align-items: center;
  1104. justify-content: center;
  1105. min-width: 16px;
  1106. height: 16px;
  1107. padding: 0 4px;
  1108. border-radius: 8px;
  1109. background: var(--color-primary, #2563EB);
  1110. color: #fff;
  1111. font-size: 10px;
  1112. font-weight: 600;
  1113. }
  1114. .sidebar-tab-content {
  1115. flex: 1;
  1116. overflow-y: auto;
  1117. }
  1118. .tab-pane {
  1119. padding: 12px;
  1120. }
  1121. .empty-props-hint {
  1122. color: var(--text-tertiary, #555);
  1123. font-size: 13px;
  1124. text-align: center;
  1125. padding: 40px 0;
  1126. }
  1127. /* 节点结果卡片 */
  1128. .node-result-card {
  1129. background: rgba(255,255,255,0.03);
  1130. border-radius: 8px;
  1131. border-left: 3px solid #666;
  1132. margin-bottom: 10px;
  1133. overflow: hidden;
  1134. }
  1135. .nrc-header {
  1136. display: flex;
  1137. align-items: center;
  1138. gap: 8px;
  1139. padding: 8px 10px;
  1140. border-bottom: 1px solid rgba(255,255,255,0.04);
  1141. }
  1142. .nrc-icon {
  1143. width: 22px;
  1144. height: 22px;
  1145. display: flex;
  1146. align-items: center;
  1147. justify-content: center;
  1148. border-radius: 5px;
  1149. color: #fff;
  1150. flex-shrink: 0;
  1151. }
  1152. .nrc-info {
  1153. flex: 1;
  1154. min-width: 0;
  1155. display: flex;
  1156. flex-direction: column;
  1157. gap: 1px;
  1158. }
  1159. .nrc-label {
  1160. font-size: 12px;
  1161. font-weight: 600;
  1162. color: var(--text-primary, #e0e0e0);
  1163. white-space: nowrap;
  1164. overflow: hidden;
  1165. text-overflow: ellipsis;
  1166. }
  1167. .nrc-type {
  1168. font-size: 10px;
  1169. color: var(--text-tertiary, #666);
  1170. }
  1171. .nrc-status {
  1172. font-size: 10px;
  1173. padding: 2px 6px;
  1174. border-radius: 4px;
  1175. font-weight: 600;
  1176. flex-shrink: 0;
  1177. }
  1178. .nrc-status.status-running { background: rgba(99,102,241,0.15); color: #818cf8; }
  1179. .nrc-status.status-success { background: rgba(16,185,129,0.15); color: #34d399; }
  1180. .nrc-status.status-failed { background: rgba(239,68,68,0.15); color: #f87171; }
  1181. .nrc-status.status-skipped { background: rgba(107,114,128,0.15); color: #9ca3af; }
  1182. .nrc-vars {
  1183. padding: 6px 10px;
  1184. }
  1185. .nrc-var {
  1186. display: flex;
  1187. align-items: baseline;
  1188. gap: 6px;
  1189. padding: 3px 0;
  1190. border-bottom: 1px solid rgba(255,255,255,0.02);
  1191. }
  1192. .nrc-var:last-child { border-bottom: none; }
  1193. .nrc-var-key {
  1194. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  1195. font-size: 11px;
  1196. font-weight: 600;
  1197. flex-shrink: 0;
  1198. }
  1199. .nrc-var-value {
  1200. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  1201. font-size: 11px;
  1202. color: var(--text-secondary, #aaa);
  1203. white-space: pre-wrap;
  1204. word-break: break-all;
  1205. max-height: 120px;
  1206. overflow-y: auto;
  1207. }
  1208. .nrc-clear-row {
  1209. text-align: center;
  1210. padding: 10px 0 4px;
  1211. border-top: 1px solid rgba(255,255,255,0.06);
  1212. margin-top: 8px;
  1213. }
  1214. .prop-section {
  1215. margin-bottom: 14px;
  1216. }
  1217. .prop-header {
  1218. display: flex;
  1219. align-items: center;
  1220. justify-content: space-between;
  1221. margin-bottom: 6px;
  1222. }
  1223. .prop-label {
  1224. display: block;
  1225. font-size: 12px;
  1226. font-weight: 500;
  1227. color: var(--text-secondary, #999);
  1228. margin-bottom: 6px;
  1229. }
  1230. .var-form {
  1231. background: rgba(255,255,255,0.03);
  1232. border-radius: 6px;
  1233. padding: 8px;
  1234. margin-bottom: 8px;
  1235. }
  1236. .var-form-row {
  1237. display: flex;
  1238. gap: 6px;
  1239. margin-bottom: 6px;
  1240. }
  1241. .cond-form {
  1242. margin-bottom: 8px;
  1243. }
  1244. .cond-form-row {
  1245. display: flex;
  1246. align-items: center;
  1247. gap: 6px;
  1248. }
  1249. .cond-tag {
  1250. font-size: 11px;
  1251. padding: 2px 8px;
  1252. border-radius: 4px;
  1253. background: rgba(249,115,22,0.15);
  1254. color: #F97316;
  1255. flex-shrink: 0;
  1256. font-weight: 600;
  1257. }
  1258. .text-btn {
  1259. background: none;
  1260. border: none;
  1261. color: var(--color-accent, #2563EB);
  1262. font-size: 12px;
  1263. cursor: pointer;
  1264. padding: 2px 4px;
  1265. }
  1266. .text-btn:hover { text-decoration: underline; }
  1267. .text-btn.danger { color: #EF4444; }
  1268. .empty-hint {
  1269. font-size: 12px;
  1270. color: var(--text-tertiary, #555);
  1271. font-style: italic;
  1272. }
  1273. .passthrough-hint {
  1274. font-size: 11px;
  1275. color: var(--text-tertiary, #555);
  1276. margin-top: 6px;
  1277. padding: 4px 8px;
  1278. background: rgba(249,115,22,0.08);
  1279. border-radius: 4px;
  1280. color: #fb923c;
  1281. }
  1282. /* 通用按钮 */
  1283. .icon-btn {
  1284. width: 32px;
  1285. height: 32px;
  1286. display: flex;
  1287. align-items: center;
  1288. justify-content: center;
  1289. border-radius: 6px;
  1290. border: 1px solid rgba(255,255,255,0.12);
  1291. background: transparent;
  1292. color: var(--text-secondary, #999);
  1293. cursor: pointer;
  1294. transition: all 0.15s;
  1295. }
  1296. .icon-btn:hover {
  1297. background: rgba(255,255,255,0.08);
  1298. color: #fff;
  1299. }
  1300. .primary-btn {
  1301. display: flex;
  1302. align-items: center;
  1303. gap: 6px;
  1304. padding: 7px 16px;
  1305. background: var(--color-primary, #2563EB);
  1306. color: #fff;
  1307. border: none;
  1308. border-radius: 6px;
  1309. font-size: 13px;
  1310. cursor: pointer;
  1311. transition: all 0.15s;
  1312. }
  1313. .primary-btn:hover { filter: brightness(1.15); }
  1314. .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  1315. /* 运行按钮 */
  1316. .run-btn {
  1317. display: flex;
  1318. align-items: center;
  1319. gap: 6px;
  1320. padding: 7px 16px;
  1321. background: #10B981;
  1322. color: #fff;
  1323. border: none;
  1324. border-radius: 6px;
  1325. font-size: 13px;
  1326. cursor: pointer;
  1327. transition: all 0.15s;
  1328. }
  1329. .run-btn:hover { filter: brightness(1.15); }
  1330. .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  1331. .run-btn.is-running {
  1332. background: #6366f1;
  1333. animation: pulse-btn 1.5s infinite;
  1334. }
  1335. @keyframes pulse-btn {
  1336. 0%, 100% { opacity: 1; }
  1337. 50% { opacity: 0.7; }
  1338. }
  1339. /* 节点执行状态 */
  1340. .canvas-area :deep(.vue-flow__node.node-status-running) {
  1341. animation: node-pulse 1.5s infinite;
  1342. }
  1343. .canvas-area :deep(.vue-flow__node.node-status-success) {
  1344. filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
  1345. }
  1346. .canvas-area :deep(.vue-flow__node.node-status-failed) {
  1347. filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
  1348. }
  1349. .canvas-area :deep(.vue-flow__node.node-status-skipped) {
  1350. opacity: 0.4;
  1351. }
  1352. @keyframes node-pulse {
  1353. 0%, 100% { filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5)); }
  1354. 50% { filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9)); }
  1355. }
  1356. /* 运行对话框 */
  1357. .dialog-overlay {
  1358. position: fixed;
  1359. inset: 0;
  1360. background: rgba(0,0,0,0.75);
  1361. display: flex;
  1362. align-items: center;
  1363. justify-content: center;
  1364. z-index: 100;
  1365. }
  1366. .run-dialog {
  1367. background: var(--bg-card, #1a1a2e);
  1368. border-radius: 12px;
  1369. width: 400px;
  1370. max-height: 80vh;
  1371. display: flex;
  1372. flex-direction: column;
  1373. border: 1px solid rgba(255,255,255,0.1);
  1374. }
  1375. .dialog-header {
  1376. display: flex;
  1377. align-items: center;
  1378. justify-content: space-between;
  1379. padding: 14px 18px;
  1380. border-bottom: 1px solid rgba(255,255,255,0.08);
  1381. }
  1382. .dialog-title { font-size: 15px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
  1383. .dialog-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
  1384. .dialog-footer {
  1385. display: flex;
  1386. justify-content: flex-end;
  1387. gap: 10px;
  1388. padding: 12px 18px;
  1389. border-top: 1px solid rgba(255,255,255,0.08);
  1390. }
  1391. .cancel-btn {
  1392. padding: 7px 16px;
  1393. border: 1px solid rgba(255,255,255,0.15);
  1394. background: transparent;
  1395. color: var(--text-secondary, #999);
  1396. border-radius: 6px;
  1397. cursor: pointer;
  1398. font-size: 13px;
  1399. }
  1400. .cancel-btn:hover { background: rgba(255,255,255,0.06); }
  1401. .run-field { margin-bottom: 12px; }
  1402. .run-field-label {
  1403. display: block;
  1404. font-size: 12px;
  1405. color: var(--text-secondary, #999);
  1406. margin-bottom: 4px;
  1407. }
  1408. /* 边映射状态 */
  1409. .edge-status-badge {
  1410. font-size: 11px;
  1411. padding: 2px 8px;
  1412. border-radius: 4px;
  1413. font-weight: 600;
  1414. }
  1415. .edge-status-badge.es-ok { background: rgba(34,197,94,0.15); color: #4ade80; }
  1416. .edge-status-badge.es-unmapped { background: rgba(107,114,128,0.15); color: #9ca3af; }
  1417. .edge-status-badge.es-partial { background: rgba(245,158,11,0.15); color: #fbbf24; }
  1418. .edge-status-badge.es-mismatch { background: rgba(239,68,68,0.15); color: #f87171; }
  1419. .mapping-list {
  1420. margin-top: 6px;
  1421. }
  1422. .mapping-item {
  1423. display: flex;
  1424. align-items: center;
  1425. gap: 4px;
  1426. padding: 4px 8px;
  1427. border-radius: 4px;
  1428. margin-bottom: 3px;
  1429. font-size: 12px;
  1430. font-family: 'Cascadia Code', 'Fira Code', monospace;
  1431. }
  1432. .mapping-item.mapped { background: rgba(34,197,94,0.08); }
  1433. .mapping-item.mismatch { background: rgba(239,68,68,0.08); }
  1434. .mapping-item.unmapped { background: rgba(245,158,11,0.08); }
  1435. .mapping-item.extra { background: rgba(107,114,128,0.06); }
  1436. .mapping-field {
  1437. color: var(--text-primary, #e0e0e0);
  1438. font-weight: 500;
  1439. }
  1440. .mapping-arrow {
  1441. color: var(--text-tertiary, #666);
  1442. }
  1443. .mapping-type {
  1444. color: var(--text-tertiary, #666);
  1445. font-size: 10px;
  1446. }
  1447. </style>