| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594 |
- <script setup>
- import { ref, computed, reactive, onMounted, markRaw } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { VueFlow, useVueFlow } from '@vue-flow/core'
- import { Background } from '@vue-flow/background'
- import {
- NInput, NSelect, NButton, NIcon, useMessage
- } from 'naive-ui'
- import {
- ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
- SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
- PlayOutline, CloseOutline
- } from '@vicons/ionicons5'
- import { useWorkflowStore } from '../../stores/workflow'
- import { useSkillStore } from '../../stores/skill'
- import { runWorkflow } from '../../api/workflow'
- import InputNode from '../../components/workflow/nodes/InputNode.vue'
- import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
- import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
- import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
- import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
- import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
- import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES, extractTemplateVariables } from '../../utils/ioInference'
- import '@vue-flow/core/dist/style.css'
- import '@vue-flow/core/dist/theme-default.css'
- const route = useRoute()
- const router = useRouter()
- const wfStore = useWorkflowStore()
- const skillStore = useSkillStore()
- const message = useMessage()
- const workflowId = route.params.id ? Number(route.params.id) : null
- const workflowName = ref('')
- const workflowDesc = ref('')
- const saving = ref(false)
- // ========== 工作流运行状态 ==========
- const running = ref(false)
- const showRunDialog = ref(false)
- const showResult = ref(false)
- const workflowResult = ref(null)
- const nodeStatuses = reactive({}) // nodeId → 'running' | 'success' | 'failed' | 'skipped'
- const activeTab = ref('props') // 'props' | 'result'
- const nodeResults = reactive([]) // [{ nodeId, type, label, status, output, color, typeLabel }]
- // 从 userInput 节点提取输入字段定义
- const inputFields = computed(() => {
- const fields = []
- for (const node of getNodes.value) {
- if (node.type === 'userInput' && node.data?.variables) {
- for (const v of node.data.variables) {
- if (v.name) fields.push(v)
- }
- }
- }
- return fields
- })
- const formData = reactive({})
- const canRun = computed(() => !!workflowId && !running.value && getNodes.value.length > 0)
- // ========== Vue Flow ==========
- const {
- onConnect, addEdges, addNodes, removeEdges,
- updateNodeData: vfUpdateNode,
- project, vueFlowRef, getNodes, getEdges
- } = useVueFlow()
- const selectedNode = ref(null)
- const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
- const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
- onConnect((params) => {
- const sourceNode = getNodes.value.find(n => n.id === params.source)
- const targetNode = getNodes.value.find(n => n.id === params.target)
- const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode) : null
- addEdges([{
- ...params,
- type: 'bezier',
- animated: true,
- style: inferred?.style || defaultEdgeStyle,
- data: inferred?.data || {}
- }])
- })
- // 批量更新边的映射数据和样式
- function applyEdgeMappingUpdates(updates) {
- for (const upd of updates) {
- const edge = getEdges.value.find(e => e.id === upd.id)
- if (edge) {
- edge.data = { ...edge.data, ...upd.data }
- edge.style = upd.style
- }
- }
- }
- // ========== 节点类型定义 ==========
- const nodeTypes = [
- { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
- { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
- { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
- { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
- { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
- { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
- ]
- const nodeTypeInfo = {
- userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
- llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
- skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
- agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
- condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
- output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
- }
- function getDefaultData(type) {
- switch (type) {
- case 'userInput': return { label: '用户输入', variables: [] }
- case 'llm': return { label: '大模型处理', model: '', systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }] }
- case 'agent': return { label: '智能体', agentId: '', agentName: '' }
- case 'skill': return { label: '技能', skillId: '', skillName: '' }
- case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
- case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
- default: return { label: '节点' }
- }
- }
- let nodeIdCounter = 0
- function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
- // ========== 拖拽到画布 ==========
- function onDragOver(e) {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
- function onDrop(e) {
- const type = e.dataTransfer.getData('application/vueflow')
- if (!type) return
- const bounds = vueFlowRef.value.getBoundingClientRect()
- const position = project({
- x: e.clientX - bounds.left,
- y: e.clientY - bounds.top
- })
- const id = genId()
- addNodes([{
- id,
- type,
- position,
- data: getDefaultData(type)
- }])
- }
- function onDragStart(e, type) {
- e.dataTransfer.setData('application/vueflow', type)
- e.dataTransfer.effectAllowed = 'move'
- }
- // ========== 节点选中 ==========
- function onNodeClick({ node }) {
- selectedNode.value = node
- }
- function onPaneClick() {
- selectedNode.value = null
- selectedEdgeId.value = null
- }
- // ========== 连接线选中与删除 ==========
- const selectedEdgeId = ref(null)
- function onEdgeClick({ edge }) {
- selectedEdgeId.value = edge.id
- selectedNode.value = null
- }
- function deleteSelectedEdge() {
- if (!selectedEdgeId.value) return
- const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
- const sourceId = edge?.source
- const targetId = edge?.target
- removeEdges(selectedEdgeId.value)
- selectedEdgeId.value = null
- if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
- if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
- }
- function onKeyDown(e) {
- if (e.key === 'Delete' || e.key === 'Backspace') {
- // 避免在输入框内触发删除
- const tag = e.target?.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
- deleteSelectedEdge()
- }
- }
- const selectedData = computed(() => selectedNode.value?.data || null)
- const selectedEdge = computed(() => {
- if (!selectedEdgeId.value) return null
- return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
- })
- // ========== 智能体选项 ==========
- const agentOptions = computed(() =>
- (skillStore.skills || []).map(s => ({
- label: s.translatedName || s.name || s.folderName,
- value: s.folderName
- }))
- )
- const modelOptions = [
- { label: 'GPT-4o', value: 'gpt-4o' },
- { label: 'GPT-4o-mini', value: 'gpt-4o-mini' },
- { label: 'Claude 3.5 Sonnet', value: 'claude-3.5-sonnet' },
- { label: 'Claude 3 Opus', value: 'claude-3-opus' },
- { label: 'DeepSeek V3', value: 'deepseek-v3' },
- { label: 'GLM-4', value: 'glm-4' }
- ]
- // ========== 条件分支操作 ==========
- function addElif() {
- if (!selectedData.value?.conditions) return
- const newConditions = [...selectedData.value.conditions, { type: 'ELIF', expression: '' }]
- vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
- // 同步 selectedNode 引用
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeElif(index) {
- if (!selectedData.value?.conditions) return
- const newConditions = selectedData.value.conditions.filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 变量操作 ==========
- function addVariable() {
- if (!selectedData.value) return
- const newVars = [...(selectedData.value.variables || []), { name: '', label: '', type: 'string', description: '' }]
- vfUpdateNode(selectedNode.value.id, { variables: newVars })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeVariable(index) {
- const newVars = (selectedData.value?.variables || []).filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { variables: newVars })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 通用数据更新 ==========
- function onFieldChange(field, value) {
- if (!selectedNode.value) return
- const update = { [field]: value }
- // 特殊处理:智能体选择时同步名称和 IO 字段
- if (field === 'agentId') {
- const opt = agentOptions.value.find(o => o.value === value)
- update.agentName = opt ? opt.label : ''
- const skill = skillStore.skills?.find(s => s.folderName === value)
- update.inputs = skill?.inputs || []
- update.outputs = skill?.outputs || []
- }
- // 特殊处理:技能选择时同步名称和 IO 字段
- if (field === 'skillId') {
- const opt = agentOptions.value.find(o => o.value === value)
- update.skillName = opt ? opt.label : ''
- const skill = skillStore.skills?.find(s => s.folderName === value)
- update.inputs = skill?.inputs || []
- update.outputs = skill?.outputs || []
- }
- vfUpdateNode(selectedNode.value.id, update)
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function onConditionChange(index, field, value) {
- if (!selectedData.value?.conditions) return
- const newConditions = selectedData.value.conditions.map((c, i) =>
- i === index ? { ...c, [field]: value } : c
- )
- vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 输出节点字段操作 ==========
- function addOutputField() {
- if (!selectedData.value) return
- const newFields = [...(selectedData.value.fields || []), { name: '', label: '', type: 'string', description: '' }]
- vfUpdateNode(selectedNode.value.id, { fields: newFields })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeOutputField(index) {
- const newFields = (selectedData.value?.fields || []).filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { fields: newFields })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== LLM 节点 IO 字段操作 ==========
- function addLlmField(field) {
- if (!selectedData.value) return
- const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
- vfUpdateNode(selectedNode.value.id, { [field]: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeLlmField(field, index) {
- const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { [field]: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function syncLlmFieldsFromTemplate(field) {
- if (!selectedData.value) return
- const extracted = extractTemplateVariables(
- (selectedData.value.systemPrompt || '') + '\n' + (selectedData.value.userPrompt || '')
- )
- const existing = selectedData.value[field] || []
- const existingNames = new Set(existing.map(f => f.name))
- const merged = [...existing]
- for (const name of extracted) {
- if (!existingNames.has(name)) {
- merged.push({ name, label: name, type: 'string', description: '' })
- }
- }
- // 移除不在模板中的字段(仅移除无 label 描述的自动提取字段)
- const final = merged.filter(f => extracted.includes(f.name) || f.label !== f.name || existingNames.has(f.name))
- vfUpdateNode(selectedNode.value.id, { [field]: final })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 智能体节点 IO 字段操作 ==========
- function addAgentField(field) {
- if (!selectedData.value) return
- const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
- vfUpdateNode(selectedNode.value.id, { [field]: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeAgentField(field, index) {
- const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { [field]: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 条件分支节点输入变量操作 ==========
- function addConditionInput() {
- if (!selectedData.value) return
- const list = [...(selectedData.value.inputs || []), { name: '', label: '', type: 'string', description: '' }]
- vfUpdateNode(selectedNode.value.id, { inputs: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- function removeConditionInput(index) {
- const list = (selectedData.value?.inputs || []).filter((_, i) => i !== index)
- vfUpdateNode(selectedNode.value.id, { inputs: list })
- selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
- applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
- }
- // ========== 边状态文案 ==========
- function edgeStatusText(status) {
- return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
- }
- // ========== 保存 ==========
- async function handleSave() {
- if (!workflowName.value.trim()) {
- message.warning('请输入工作流名称')
- return
- }
- saving.value = true
- try {
- const graphData = JSON.stringify({
- nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
- edges: getEdges.value.map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
- })
- if (workflowId) {
- await wfStore.save(workflowId, {
- name: workflowName.value,
- description: workflowDesc.value,
- graphData
- })
- message.success('工作流已保存')
- } else {
- const created = await wfStore.create(workflowName.value, workflowDesc.value)
- await wfStore.save(created.id, {
- name: workflowName.value,
- description: workflowDesc.value,
- graphData
- })
- message.success('工作流已创建')
- router.replace(`/orchestration/edit/${created.id}`)
- }
- } catch (e) {
- message.error(e.response?.data?.message || '保存失败')
- } finally {
- saving.value = false
- }
- }
- function goBack() {
- router.push('/orchestration')
- }
- // ========== 工作流运行 ==========
- function openRunDialog() {
- // 重置表单
- Object.keys(formData).forEach(k => delete formData[k])
- inputFields.value.forEach(f => { formData[f.name] = '' })
- showRunDialog.value = true
- }
- function cancelRun() {
- showRunDialog.value = false
- }
- async function startRun() {
- // 收集填写的输入值
- const inputs = {}
- inputFields.value.forEach(f => {
- if (formData[f.name]) inputs[f.name] = formData[f.name]
- })
- showRunDialog.value = false
- running.value = true
- showResult.value = false
- workflowResult.value = null
- Object.keys(nodeStatuses).forEach(k => delete nodeStatuses[k])
- nodeResults.splice(0, nodeResults.length)
- // 重置节点样式
- getNodes.value.forEach(n => { n.class = '' })
- try {
- const response = await runWorkflow(workflowId, inputs)
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- const parts = buffer.split('\n\n')
- buffer = parts.pop()
- for (const part of parts) {
- const lines = part.split('\n')
- let data = null
- for (const line of lines) {
- if (line.startsWith('data:')) {
- try { data = JSON.parse(line.substring(5).trim()) } catch { /* skip */ }
- }
- }
- if (data) handleSSEEvent(data)
- }
- }
- } catch (e) {
- message.error('工作流执行失败: ' + e.message)
- } finally {
- running.value = false
- }
- }
- function handleSSEEvent(event) {
- if (event.type === 'node_status' && event.nodeId) {
- const status = event.status?.toLowerCase() || 'running'
- nodeStatuses[event.nodeId] = status
- const node = getNodes.value.find(n => n.id === event.nodeId)
- if (node) node.class = `node-status-${status}`
- // 收集节点结果
- const info = nodeTypeInfo[node?.type] || { color: '#666', typeLabel: '未知' }
- const existing = nodeResults.find(r => r.nodeId === event.nodeId)
- const update = {
- nodeId: event.nodeId,
- type: node?.type,
- label: node?.data?.label || '节点',
- status,
- output: event.output || (existing?.output),
- color: info.color,
- typeLabel: info.typeLabel
- }
- if (existing) {
- Object.assign(existing, update)
- } else {
- nodeResults.push(update)
- }
- // 自动切换到结果 Tab
- if (activeTab.value !== 'result') activeTab.value = 'result'
- } else if (event.type === 'workflow_complete') {
- workflowResult.value = event.output
- showResult.value = true
- message.success('工作流执行完成')
- } else if (event.type === 'workflow_error') {
- message.error('执行失败: ' + (event.error || '未知错误'))
- }
- }
- function closeResult() {
- showResult.value = false
- nodeResults.splice(0, nodeResults.length)
- Object.keys(nodeStatuses).forEach(k => delete nodeStatuses[k])
- getNodes.value.forEach(n => { n.class = '' })
- activeTab.value = 'props'
- }
- function statusText(s) {
- return { running: '执行中', success: '成功', failed: '失败', skipped: '跳过' }[s] || s
- }
- // ========== 初始化 ==========
- onMounted(async () => {
- skillStore.fetchSkills()
- if (workflowId) {
- try {
- const wf = await wfStore.fetchOne(workflowId)
- workflowName.value = wf.name
- workflowDesc.value = wf.description || ''
- if (wf.graphData) {
- try {
- const parsed = JSON.parse(wf.graphData)
- addNodes((parsed.nodes || []).map(n => {
- const defaults = getDefaultData(n.type)
- return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
- }))
- const edgeData = (parsed.edges || []).map(e => ({
- ...e,
- type: 'bezier',
- animated: true,
- style: defaultEdgeStyle,
- data: e.data || {}
- }))
- if (edgeData.length) addEdges(edgeData)
- // 重新推断所有边的 IO 映射(保留已有的手动映射)
- for (const node of getNodes.value) {
- applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
- }
- } catch { /* ignore */ }
- }
- } catch {
- message.error('工作流不存在')
- goBack()
- }
- }
- })
- </script>
- <template>
- <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
- <!-- 顶部工具栏 -->
- <div class="editor-toolbar">
- <div class="toolbar-left">
- <button class="icon-btn" title="返回列表" @click="goBack">
- <n-icon size="18"><ArrowBackOutline /></n-icon>
- </button>
- <n-input
- v-model:value="workflowName"
- placeholder="工作流名称"
- style="width: 220px"
- size="small"
- />
- </div>
- <div class="toolbar-right">
- <button
- class="run-btn"
- :class="{ 'is-running': running }"
- @click="openRunDialog"
- :disabled="!canRun"
- >
- <n-icon size="16"><PlayOutline /></n-icon>
- <span>{{ running ? '执行中...' : '运行' }}</span>
- </button>
- <button class="primary-btn" @click="handleSave" :disabled="saving">
- <n-icon size="16"><SaveOutline /></n-icon>
- <span>{{ saving ? '保存中...' : '完成' }}</span>
- </button>
- </div>
- </div>
- <div class="editor-body">
- <!-- 左侧节点工具栏 -->
- <div class="sidebar-nodes">
- <div class="sidebar-title">节点</div>
- <div
- v-for="nt in nodeTypes"
- :key="nt.type"
- class="node-type-item"
- draggable="true"
- @dragstart="onDragStart($event, nt.type)"
- >
- <div class="nt-icon" :style="{ background: nt.color }">
- <n-icon size="14"><component :is="nt.icon" /></n-icon>
- </div>
- <span class="nt-label">{{ nt.label }}</span>
- </div>
- </div>
- <!-- 中间画布 -->
- <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
- <VueFlow
- :default-viewport="{ zoom: 1, x: 0, y: 0 }"
- :min-zoom="0.3"
- :max-zoom="2"
- fit-view-on-init
- :elements-selectable="true"
- @node-click="onNodeClick"
- @pane-click="onPaneClick"
- @edge-click="onEdgeClick"
- :snap-to-grid="true"
- :snap-grid="[20, 20]"
- >
- <template #node-userInput="nodeProps"><InputNode :data="nodeProps.data" /></template>
- <template #node-llm="nodeProps"><LLMNode :data="nodeProps.data" /></template>
- <template #node-agent="nodeProps"><AgentNode :data="nodeProps.data" /></template>
- <template #node-skill="nodeProps"><SkillNode :data="nodeProps.data" /></template>
- <template #node-output="nodeProps"><OutputNode :data="nodeProps.data" /></template>
- <template #node-condition="nodeProps"><ConditionNode :data="nodeProps.data" /></template>
- <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
- </VueFlow>
- </div>
- <!-- 右侧面板 - Tab 页 -->
- <div class="sidebar-right">
- <div class="sidebar-tabs">
- <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
- <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
- 运行结果
- <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
- </button>
- </div>
- <div class="sidebar-tab-content">
- <!-- 节点属性 Tab -->
- <div v-show="activeTab === 'props'" class="tab-pane">
- <template v-if="selectedData">
- <!-- 用户输入节点 -->
- <template v-if="selectedNode?.type === 'userInput'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <button class="text-btn" @click="addVariable">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <div class="var-form-row">
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.description" placeholder="描述" size="small" @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <button class="text-btn danger" @click="removeVariable(i)">删除</button>
- </div>
- <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
- </div>
- </template>
- <!-- 大模型处理节点 -->
- <template v-if="selectedNode?.type === 'llm'">
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select
- :value="selectedData?.model"
- :options="modelOptions"
- placeholder="选择模型"
- size="small"
- @update:value="v => onFieldChange('model', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">系统提示词</label>
- <n-input
- :value="selectedData?.systemPrompt"
- type="textarea" :rows="3"
- placeholder="输入系统提示词"
- size="small"
- @update:value="v => onFieldChange('systemPrompt', v)"
- />
- </div>
- <div class="prop-section">
- <label class="prop-label">用户提示词</label>
- <n-input
- :value="selectedData?.userPrompt"
- type="textarea" :rows="3"
- placeholder="输入用户提示词,可用 {{变量名}} 引用变量"
- size="small"
- @update:value="v => onFieldChange('userPrompt', v)"
- />
- </div>
- <!-- 输入变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <div style="display:flex;gap:4px">
- <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
- <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
- </div>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
- </div>
- </template>
- <!-- 智能体节点 -->
- <template v-if="selectedNode?.type === 'agent'">
- <div class="prop-section">
- <label class="prop-label">选择智能体</label>
- <n-select
- :value="selectedData?.agentId"
- :options="agentOptions"
- placeholder="选择技能"
- size="small"
- filterable
- @update:value="v => onFieldChange('agentId', v)"
- />
- </div>
- <!-- 输入变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- </template>
- <!-- 技能节点 -->
- <template v-if="selectedNode?.type === 'skill'">
- <div class="prop-section">
- <label class="prop-label">选择技能</label>
- <n-select
- :value="selectedData?.skillId"
- :options="agentOptions"
- placeholder="选择技能"
- size="small"
- filterable
- @update:value="v => onFieldChange('skillId', v)"
- />
- </div>
- <!-- 输入变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- </template>
- <!-- 条件分支节点 -->
- <template v-if="selectedNode?.type === 'condition'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">条件分支</span>
- <button class="text-btn" @click="addElif">+ ELIF</button>
- </div>
- <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
- <div class="cond-form-row">
- <span class="cond-tag">{{ c.type }}</span>
- <n-input
- :value="c.expression"
- placeholder="自然语言条件,可用 {{变量名}}"
- size="small"
- @update:value="v => onConditionChange(i, 'expression', v)"
- />
- <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
- </div>
- </div>
- </div>
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">接收变量</span>
- <button class="text-btn" @click="addConditionInput">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- <div class="passthrough-hint">所有接收变量将透传至下游节点</div>
- </div>
- </template>
- <!-- 输出节点 -->
- <template v-if="selectedNode?.type === 'output'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">接收字段</span>
- <button class="text-btn" @click="addOutputField">+ 添加</button>
- </div>
- <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="f.name" placeholder="字段名" size="small" @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="f.label" placeholder="中文名" size="small" @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- </div>
- </template>
- </template>
- <!-- 边映射信息 -->
- <template v-else-if="selectedEdge?.data">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">连接映射</span>
- <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
- {{ edgeStatusText(selectedEdge.data.status) }}
- </span>
- </div>
- <!-- 已映射字段 -->
- <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
- <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField" class="mapping-item mapped">
- <span class="mapping-field">{{ m.sourceField }}</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ m.targetField }}</span>
- </div>
- </div>
- <!-- 类型不匹配 -->
- <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
- <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name" class="mapping-item mismatch">
- <span class="mapping-field">{{ tm.source.name }}</span>
- <span class="mapping-type">({{ tm.source.type }})</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ tm.target.name }}</span>
- <span class="mapping-type">({{ tm.target.type }})</span>
- </div>
- </div>
- <!-- 未匹配的目标输入 -->
- <div v-if="selectedEdge.data.unmatchedTarget?.length">
- <div class="prop-label" style="margin-top:8px">未匹配输入</div>
- <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <!-- 多余的源输出 -->
- <div v-if="selectedEdge.data.unmatchedSource?.length">
- <div class="prop-label" style="margin-top:8px">多余输出</div>
- <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <div v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)" class="empty-hint">
- 无需映射
- </div>
- </div>
- </template>
- <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
- </div>
- <!-- 运行结果 Tab -->
- <div v-show="activeTab === 'result'" class="tab-pane">
- <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
- <div
- v-for="r in nodeResults"
- :key="r.nodeId"
- class="node-result-card"
- :style="{ borderLeftColor: r.color }"
- >
- <div class="nrc-header">
- <div class="nrc-icon" :style="{ background: r.color }">
- <n-icon size="12"><component :is="nodeTypeInfo[r.type]?.icon" /></n-icon>
- </div>
- <div class="nrc-info">
- <span class="nrc-label">{{ r.label }}</span>
- <span class="nrc-type">{{ r.typeLabel }}</span>
- </div>
- <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
- </div>
- <div class="nrc-vars" v-if="r.output">
- <div class="nrc-var" v-for="(value, key) in r.output" :key="key">
- <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
- <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) }}</span>
- </div>
- </div>
- </div>
- <div v-if="showResult && workflowResult" class="nrc-clear-row">
- <button class="text-btn danger" @click="closeResult">清除结果</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 运行对话框 -->
- <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
- <div class="run-dialog">
- <div class="dialog-header">
- <span class="dialog-title">运行工作流</span>
- <button class="icon-btn" @click="cancelRun"><n-icon size="16"><CloseOutline /></n-icon></button>
- </div>
- <div class="dialog-body">
- <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
- <div v-for="f in inputFields" :key="f.name" class="run-field">
- <label class="run-field-label">{{ f.label || f.name }}</label>
- <n-input
- v-model:value="formData[f.name]"
- :placeholder="f.description || f.name"
- :type="f.type === 'number' ? 'text' : 'text'"
- size="small"
- />
- </div>
- </div>
- <div class="dialog-footer">
- <button class="cancel-btn" @click="cancelRun">取消</button>
- <button class="primary-btn" @click="startRun">
- <n-icon size="14"><PlayOutline /></n-icon>
- <span>开始执行</span>
- </button>
- </div>
- </div>
- </div>
- </div>
- </template>
- <style scoped>
- .workflow-editor {
- height: calc(100vh - var(--header-height));
- display: flex;
- flex-direction: column;
- margin: -24px -32px 0;
- background: #13111a;
- }
- .editor-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- background: var(--bg-card, #1a1a2e);
- border-bottom: 1px solid rgba(255,255,255,0.08);
- flex-shrink: 0;
- }
- .toolbar-left, .toolbar-right {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- /* 左侧节点栏 */
- .sidebar-nodes {
- width: 180px;
- background: var(--bg-card, #1a1a2e);
- border-right: 1px solid rgba(255,255,255,0.08);
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex-shrink: 0;
- }
- .sidebar-title {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary, #999);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin-bottom: 6px;
- }
- .node-type-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-radius: 8px;
- cursor: grab;
- transition: background 0.15s;
- border: 1px solid rgba(255,255,255,0.06);
- }
- .node-type-item:hover {
- background: rgba(255,255,255,0.06);
- }
- .node-type-item:active {
- cursor: grabbing;
- }
- .nt-icon {
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- color: #fff;
- flex-shrink: 0;
- }
- .nt-label {
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
- }
- /* 中间画布 */
- .canvas-area {
- flex: 1;
- background: #0f0d17;
- }
- /* 覆盖 VueFlow 默认主题的节点白色背景 */
- .canvas-area :deep(.vue-flow__node) {
- background: transparent !important;
- padding: 0 !important;
- border: none !important;
- box-shadow: none !important;
- }
- /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
- .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
- stroke: #facc15 !important;
- stroke-width: 3 !important;
- }
- .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
- stroke: #facc15 !important;
- }
- .canvas-area :deep(.vue-flow__edge) {
- cursor: pointer;
- }
- /* 右侧面板 - Tab 页 */
- .sidebar-right {
- width: 300px;
- background: var(--bg-card, #1a1a2e);
- border-left: 1px solid rgba(255,255,255,0.08);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- overflow: hidden;
- }
- .sidebar-tabs {
- display: flex;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- flex-shrink: 0;
- }
- .tab-btn {
- flex: 1;
- padding: 10px 0;
- border: none;
- background: transparent;
- color: var(--text-secondary, #999);
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 4px;
- }
- .tab-btn:hover { color: var(--text-primary, #e0e0e0); }
- .tab-btn.active {
- color: var(--text-primary, #e0e0e0);
- font-weight: 600;
- }
- .tab-btn.active::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 20%;
- right: 20%;
- height: 2px;
- background: var(--color-primary, #2563EB);
- border-radius: 1px;
- }
- .result-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 16px;
- height: 16px;
- padding: 0 4px;
- border-radius: 8px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- font-size: 10px;
- font-weight: 600;
- }
- .sidebar-tab-content {
- flex: 1;
- overflow-y: auto;
- }
- .tab-pane {
- padding: 12px;
- }
- .empty-props-hint {
- color: var(--text-tertiary, #555);
- font-size: 13px;
- text-align: center;
- padding: 40px 0;
- }
- /* 节点结果卡片 */
- .node-result-card {
- background: rgba(255,255,255,0.03);
- border-radius: 8px;
- border-left: 3px solid #666;
- margin-bottom: 10px;
- overflow: hidden;
- }
- .nrc-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid rgba(255,255,255,0.04);
- }
- .nrc-icon {
- width: 22px;
- height: 22px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 5px;
- color: #fff;
- flex-shrink: 0;
- }
- .nrc-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 1px;
- }
- .nrc-label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .nrc-type {
- font-size: 10px;
- color: var(--text-tertiary, #666);
- }
- .nrc-status {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .nrc-status.status-running { background: rgba(99,102,241,0.15); color: #818cf8; }
- .nrc-status.status-success { background: rgba(16,185,129,0.15); color: #34d399; }
- .nrc-status.status-failed { background: rgba(239,68,68,0.15); color: #f87171; }
- .nrc-status.status-skipped { background: rgba(107,114,128,0.15); color: #9ca3af; }
- .nrc-vars {
- padding: 6px 10px;
- }
- .nrc-var {
- display: flex;
- align-items: baseline;
- gap: 6px;
- padding: 3px 0;
- border-bottom: 1px solid rgba(255,255,255,0.02);
- }
- .nrc-var:last-child { border-bottom: none; }
- .nrc-var-key {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .nrc-var-value {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 120px;
- overflow-y: auto;
- }
- .nrc-clear-row {
- text-align: center;
- padding: 10px 0 4px;
- border-top: 1px solid rgba(255,255,255,0.06);
- margin-top: 8px;
- }
- .prop-section {
- margin-bottom: 14px;
- }
- .prop-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 6px;
- }
- .prop-label {
- display: block;
- font-size: 12px;
- font-weight: 500;
- color: var(--text-secondary, #999);
- margin-bottom: 6px;
- }
- .var-form {
- background: rgba(255,255,255,0.03);
- border-radius: 6px;
- padding: 8px;
- margin-bottom: 8px;
- }
- .var-form-row {
- display: flex;
- gap: 6px;
- margin-bottom: 6px;
- }
- .cond-form {
- margin-bottom: 8px;
- }
- .cond-form-row {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .cond-tag {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- background: rgba(249,115,22,0.15);
- color: #F97316;
- flex-shrink: 0;
- font-weight: 600;
- }
- .text-btn {
- background: none;
- border: none;
- color: var(--color-accent, #2563EB);
- font-size: 12px;
- cursor: pointer;
- padding: 2px 4px;
- }
- .text-btn:hover { text-decoration: underline; }
- .text-btn.danger { color: #EF4444; }
- .empty-hint {
- font-size: 12px;
- color: var(--text-tertiary, #555);
- font-style: italic;
- }
- .passthrough-hint {
- font-size: 11px;
- color: var(--text-tertiary, #555);
- margin-top: 6px;
- padding: 4px 8px;
- background: rgba(249,115,22,0.08);
- border-radius: 4px;
- color: #fb923c;
- }
- /* 通用按钮 */
- .icon-btn {
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- border: 1px solid rgba(255,255,255,0.12);
- background: transparent;
- color: var(--text-secondary, #999);
- cursor: pointer;
- transition: all 0.15s;
- }
- .icon-btn:hover {
- background: rgba(255,255,255,0.08);
- color: #fff;
- }
- .primary-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .primary-btn:hover { filter: brightness(1.15); }
- .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
- /* 运行按钮 */
- .run-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: #10B981;
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .run-btn:hover { filter: brightness(1.15); }
- .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
- .run-btn.is-running {
- background: #6366f1;
- animation: pulse-btn 1.5s infinite;
- }
- @keyframes pulse-btn {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
- }
- /* 节点执行状态 */
- .canvas-area :deep(.vue-flow__node.node-status-running) {
- animation: node-pulse 1.5s infinite;
- }
- .canvas-area :deep(.vue-flow__node.node-status-success) {
- filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
- }
- .canvas-area :deep(.vue-flow__node.node-status-failed) {
- filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
- }
- .canvas-area :deep(.vue-flow__node.node-status-skipped) {
- opacity: 0.4;
- }
- @keyframes node-pulse {
- 0%, 100% { filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5)); }
- 50% { filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9)); }
- }
- /* 运行对话框 */
- .dialog-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.75);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- }
- .run-dialog {
- background: var(--bg-card, #1a1a2e);
- border-radius: 12px;
- width: 400px;
- max-height: 80vh;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255,255,255,0.1);
- }
- .dialog-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- }
- .dialog-title { font-size: 15px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
- .dialog-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
- .dialog-footer {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding: 12px 18px;
- border-top: 1px solid rgba(255,255,255,0.08);
- }
- .cancel-btn {
- padding: 7px 16px;
- border: 1px solid rgba(255,255,255,0.15);
- background: transparent;
- color: var(--text-secondary, #999);
- border-radius: 6px;
- cursor: pointer;
- font-size: 13px;
- }
- .cancel-btn:hover { background: rgba(255,255,255,0.06); }
- .run-field { margin-bottom: 12px; }
- .run-field-label {
- display: block;
- font-size: 12px;
- color: var(--text-secondary, #999);
- margin-bottom: 4px;
- }
- /* 边映射状态 */
- .edge-status-badge {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- font-weight: 600;
- }
- .edge-status-badge.es-ok { background: rgba(34,197,94,0.15); color: #4ade80; }
- .edge-status-badge.es-unmapped { background: rgba(107,114,128,0.15); color: #9ca3af; }
- .edge-status-badge.es-partial { background: rgba(245,158,11,0.15); color: #fbbf24; }
- .edge-status-badge.es-mismatch { background: rgba(239,68,68,0.15); color: #f87171; }
- .mapping-list {
- margin-top: 6px;
- }
- .mapping-item {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- border-radius: 4px;
- margin-bottom: 3px;
- font-size: 12px;
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- }
- .mapping-item.mapped { background: rgba(34,197,94,0.08); }
- .mapping-item.mismatch { background: rgba(239,68,68,0.08); }
- .mapping-item.unmapped { background: rgba(245,158,11,0.08); }
- .mapping-item.extra { background: rgba(107,114,128,0.06); }
- .mapping-field {
- color: var(--text-primary, #e0e0e0);
- font-weight: 500;
- }
- .mapping-arrow {
- color: var(--text-tertiary, #666);
- }
- .mapping-type {
- color: var(--text-tertiary, #666);
- font-size: 10px;
- }
- </style>
|