WorkflowEditor.vue 89 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433
  1. <script setup>
  2. import { ref, computed, reactive, onMounted, markRaw, nextTick } 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, ColorWandOutline, LinkOutline, TrashOutline, OpenOutline,
  13. TimeOutline
  14. } from '@vicons/ionicons5'
  15. import { useWorkflowStore } from '../../stores/workflow'
  16. import { useSkillStore } from '../../stores/skill'
  17. import { runWorkflow, autoAssociate } from '../../api/workflow'
  18. import { getAgentTemplate } from '../../api/agentTemplate'
  19. import { getModelList } from '../../api/model'
  20. import InputNode from '../../components/workflow/nodes/InputNode.vue'
  21. import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
  22. import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
  23. import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
  24. import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
  25. import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
  26. import SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
  27. import VirtualGroupNode from '../../components/workflow/nodes/VirtualGroupNode.vue'
  28. import CodeEditor from '../../components/skill/CodeEditor.vue'
  29. import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES } from '../../utils/ioInference'
  30. import { useVersionHistory } from '../../composables/useVersionHistory'
  31. import { useWorkflowRunner } from '../../composables/useWorkflowRunner'
  32. import { useNodeFields } from '../../composables/useNodeFields'
  33. import '@vue-flow/core/dist/style.css'
  34. import '@vue-flow/core/dist/theme-default.css'
  35. const route = useRoute()
  36. const router = useRouter()
  37. const wfStore = useWorkflowStore()
  38. const skillStore = useSkillStore()
  39. const message = useMessage()
  40. const workflowId = computed(() => route.params.id ? Number(route.params.id) : null)
  41. const workflowName = ref('')
  42. const workflowDesc = ref('')
  43. const saving = ref(false)
  44. // ========== SmartAction 弹出编辑器 ==========
  45. const showPromptEditor = ref(false)
  46. const promptEditValue = ref('')
  47. // ========== Vue Flow ==========
  48. const {
  49. onConnect, addEdges, addNodes, removeNodes, removeEdges,
  50. updateNodeData: vfUpdateNode,
  51. project, vueFlowRef, getNodes, getEdges, fitView, onInit,
  52. onNodeDragStart, onNodeDrag, onNodeDragStop
  53. } = useVueFlow()
  54. const selectedNode = ref(null)
  55. // 从 userInput 节点提取输入字段定义
  56. const inputFields = computed(() => {
  57. const fields = []
  58. for (const node of getNodes.value) {
  59. if (node.type === 'userInput' && node.data?.variables) {
  60. for (const v of node.data.variables) {
  61. if (v.name) fields.push(v)
  62. }
  63. }
  64. }
  65. return fields
  66. })
  67. const formData = reactive({})
  68. const canRun = computed(() => !!workflowId.value && !running.value && getNodes.value.length > 0)
  69. // ========== 自动关联 ==========
  70. const associating = ref(false)
  71. const hasAssociations = ref(false)
  72. // ========== 版本历史 ==========
  73. const {
  74. showVersionPanel, versions, versionsLoading, selectedVersion, versionContent,
  75. editingMessage, editingMessageText,
  76. toggleVersionPanel, handleVersionSelect, handleRollback,
  77. startEditMessage, saveMessage, cancelEditMessage,
  78. formatTime, formatVersionJson
  79. } = useVersionHistory()
  80. // ========== 工作流运行状态 ==========
  81. const {
  82. running, showRunDialog, showResult, workflowResult,
  83. nodeStatuses, activeTab, nodeResults, expandedLogs, expandedContext, expandedThinking,
  84. lastRunId, lastRunWorkflowId,
  85. startRun, openRunDialog, cancelRun, closeResult,
  86. statusText, logTypeIcon, logTypeLabel, formatTimestamp, toggleLogs,
  87. toggleContext, toggleThinking, formatContextValue
  88. } = useWorkflowRunner({
  89. getNodes,
  90. nodeTypeInfo: {
  91. userInput: { color: '#10B981', typeLabel: '用户输入' },
  92. llm: { color: '#8B5CF6', typeLabel: '大模型' },
  93. skill: { color: '#06B6D4', typeLabel: '技能' },
  94. agent: { color: '#F59E0B', typeLabel: '智能体' },
  95. smartAction: { color: '#EC4899', typeLabel: '智能操作' },
  96. condition: { color: '#F97316', typeLabel: '条件' },
  97. output: { color: '#EF4444', typeLabel: '输出' }
  98. },
  99. workflowIdRef: workflowId
  100. })
  101. const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
  102. const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
  103. onConnect((params) => {
  104. const sourceNode = getNodes.value.find(n => n.id === params.source)
  105. const targetNode = getNodes.value.find(n => n.id === params.target)
  106. const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode, getNodes.value, getEdges.value) : null
  107. addEdges([{
  108. ...params,
  109. type: 'bezier',
  110. animated: true,
  111. style: inferred?.style || defaultEdgeStyle,
  112. data: inferred?.data || {}
  113. }])
  114. })
  115. // ========== 自动关联虚线组拖动联动 ==========
  116. // 虚线框(virtualGroup)拖动时,同步移动其内部所有技能子节点
  117. // 使用绝对画布坐标而非 parentId 机制(parentId 依赖 dimensions 异步测量,不可靠)
  118. const dragStartPositions = new Map()
  119. onNodeDragStart(({ node }) => {
  120. if (node?.type === 'virtualGroup') {
  121. dragStartPositions.set(node.id, { ...node.position })
  122. }
  123. })
  124. onNodeDrag(({ node }) => {
  125. if (node?.type !== 'virtualGroup') return
  126. const start = dragStartPositions.get(node.id)
  127. if (!start) return
  128. const dx = node.position.x - start.x
  129. const dy = node.position.y - start.y
  130. if (dx === 0 && dy === 0) return
  131. // 联动所有归属该虚框的技能节点(通过 data.virtualGroupId 标记)
  132. getNodes.value.forEach(n => {
  133. if (n.data?.virtualGroupId === node.id) {
  134. n.position = { x: n.position.x + dx, y: n.position.y + dy }
  135. }
  136. })
  137. dragStartPositions.set(node.id, { ...node.position })
  138. })
  139. onNodeDragStop(() => {
  140. dragStartPositions.clear()
  141. })
  142. // 批量更新边的映射数据和样式
  143. function applyEdgeMappingUpdates(updates) {
  144. for (const upd of updates) {
  145. const edge = getEdges.value.find(e => e.id === upd.id)
  146. if (edge) {
  147. edge.data = { ...edge.data, ...upd.data }
  148. edge.style = upd.style
  149. }
  150. }
  151. }
  152. // ========== 节点类型定义 ==========
  153. const nodeTypes = [
  154. { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
  155. { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
  156. { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
  157. { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
  158. { type: 'smartAction', label: '智能操作', icon: markRaw(ColorWandOutline), color: '#EC4899' },
  159. { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
  160. { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
  161. ]
  162. const nodeTypeInfo = {
  163. userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
  164. llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
  165. skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
  166. agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
  167. smartAction: { color: '#EC4899', typeLabel: '智能操作', icon: markRaw(ColorWandOutline) },
  168. condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
  169. output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
  170. }
  171. function getDefaultData(type) {
  172. switch (type) {
  173. case 'userInput': return { label: '用户输入', variables: [] }
  174. case 'llm': return { label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
  175. case 'agent': return { label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
  176. case 'skill': return { label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
  177. case 'smartAction': return { label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
  178. case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
  179. case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
  180. default: return { label: '节点' }
  181. }
  182. }
  183. let nodeIdCounter = 0
  184. function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
  185. // ========== 拖拽到画布 ==========
  186. function onDragOver(e) {
  187. e.preventDefault()
  188. e.dataTransfer.dropEffect = 'move'
  189. }
  190. function onDrop(e) {
  191. const type = e.dataTransfer.getData('application/vueflow')
  192. if (!type) return
  193. const bounds = vueFlowRef.value.getBoundingClientRect()
  194. const position = project({
  195. x: e.clientX - bounds.left,
  196. y: e.clientY - bounds.top
  197. })
  198. const id = genId()
  199. addNodes([{
  200. id,
  201. type,
  202. position,
  203. data: getDefaultData(type)
  204. }])
  205. }
  206. function onDragStart(e, type) {
  207. e.dataTransfer.setData('application/vueflow', type)
  208. e.dataTransfer.effectAllowed = 'move'
  209. }
  210. // ========== 节点选中 ==========
  211. function onNodeClick({ node }) {
  212. selectedNode.value = node
  213. }
  214. function onPaneClick() {
  215. selectedNode.value = null
  216. selectedEdgeId.value = null
  217. contextMenu.value = null
  218. }
  219. // ========== 节点右键菜单 ==========
  220. const contextMenu = ref(null) // { x, y, nodeId }
  221. function onNodeContextMenu({ event, node }) {
  222. event.preventDefault()
  223. contextMenu.value = { x: event.clientX, y: event.clientY, nodeId: node.id }
  224. }
  225. function closeContextMenu() {
  226. contextMenu.value = null
  227. }
  228. function deleteNodeById(nodeId) {
  229. const affectedNodeIds = new Set()
  230. for (const edge of getEdges.value) {
  231. if (edge.source === nodeId || edge.target === nodeId) {
  232. affectedNodeIds.add(edge.source)
  233. affectedNodeIds.add(edge.target)
  234. }
  235. }
  236. removeNodes(nodeId)
  237. if (selectedNode.value?.id === nodeId) {
  238. selectedNode.value = null
  239. }
  240. closeContextMenu()
  241. for (const id of affectedNodeIds) {
  242. if (id !== nodeId) {
  243. applyEdgeMappingUpdates(refreshMappingsForNode(id, getNodes.value, getEdges.value))
  244. }
  245. }
  246. }
  247. // ========== 连接线选中与删除 ==========
  248. const selectedEdgeId = ref(null)
  249. function onEdgeClick({ edge }) {
  250. selectedEdgeId.value = edge.id
  251. selectedNode.value = null
  252. }
  253. function deleteSelectedEdge() {
  254. if (!selectedEdgeId.value) return
  255. const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
  256. const sourceId = edge?.source
  257. const targetId = edge?.target
  258. removeEdges(selectedEdgeId.value)
  259. selectedEdgeId.value = null
  260. if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
  261. if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
  262. }
  263. function onKeyDown(e) {
  264. if (e.key === 'Delete' || e.key === 'Backspace') {
  265. const tag = e.target?.tagName
  266. if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
  267. if (selectedNode.value) {
  268. deleteNodeById(selectedNode.value.id)
  269. } else {
  270. deleteSelectedEdge()
  271. }
  272. }
  273. }
  274. const selectedData = computed(() => selectedNode.value?.data || null)
  275. const selectedEdge = computed(() => {
  276. if (!selectedEdgeId.value) return null
  277. return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
  278. })
  279. // ========== 智能体选项 ==========
  280. const agentOptions = computed(() =>
  281. (skillStore.skills || []).map(s => ({
  282. label: s.translatedName || s.name || s.folderName,
  283. value: s.folderName
  284. }))
  285. )
  286. // ========== 数据库模型选项 ==========
  287. const dbModels = ref([])
  288. const defaultModelId = ref(null)
  289. const modelOptions = computed(() =>
  290. dbModels.value.map(m => ({
  291. label: `${m.name} (${m.modelName})`,
  292. value: m.id
  293. }))
  294. )
  295. async function fetchDbModels() {
  296. try {
  297. const res = await getModelList()
  298. dbModels.value = res.data || []
  299. const defaultModel = dbModels.value.find(m => m.isDefault)
  300. defaultModelId.value = defaultModel?.id || null
  301. } catch (e) {
  302. message.error('加载模型列表失败')
  303. }
  304. }
  305. // 失败策略选项
  306. const failStrategyOptions = [
  307. { label: '终止工作流', value: 'abort' },
  308. { label: '跳过继续', value: 'skip' }
  309. ]
  310. // ========== 节点字段操作(来自 composable) ==========
  311. const {
  312. onFieldChange, onModelChange,
  313. addListField, removeListField,
  314. addVariable, removeVariable,
  315. addOutputField, removeOutputField,
  316. addElif, removeElif, onConditionChange,
  317. syncLlmFieldsFromTemplate
  318. } = useNodeFields({
  319. selectedNode,
  320. selectedData,
  321. vfUpdateNode,
  322. getNodes: () => getNodes,
  323. getEdges: () => getEdges,
  324. applyEdgeMappingUpdates,
  325. skillStore,
  326. agentOptions
  327. })
  328. // 兼容原有命名(template 中使用)
  329. const addLlmField = addListField
  330. const removeLlmField = removeListField
  331. const addAgentField = addListField
  332. const removeAgentField = removeListField
  333. const addConditionInput = () => addListField('inputs')
  334. const removeConditionInput = removeListField.bind(null, 'inputs')
  335. // ========== 边状态文案 ==========
  336. function edgeStatusText(status) {
  337. return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
  338. }
  339. // ========== SmartAction 弹出编辑器 ==========
  340. function openPromptEditor() {
  341. promptEditValue.value = selectedData.value?.actionPrompt || ''
  342. showPromptEditor.value = true
  343. }
  344. function confirmPromptEdit() {
  345. onFieldChange('actionPrompt', promptEditValue.value)
  346. showPromptEditor.value = false
  347. }
  348. // ========== 自动关联 ==========
  349. async function handleAutoAssociate() {
  350. if (associating.value) return
  351. clearAssociations()
  352. associating.value = true
  353. try {
  354. const graphData = JSON.stringify({
  355. nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
  356. edges: getEdges.value.filter(e => !e.data?.virtual).map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
  357. })
  358. const res = await autoAssociate(graphData)
  359. const associations = res.data || []
  360. if (associations.length === 0) {
  361. message.info('未发现隐式关联的技能')
  362. return
  363. }
  364. const groups = {}
  365. for (const a of associations) {
  366. if (!groups[a.sourceNodeId]) groups[a.sourceNodeId] = []
  367. const list = groups[a.sourceNodeId]
  368. if (!list.some(x => x.folderName === a.skillFolderName)) {
  369. list.push({ folderName: a.skillFolderName, skillName: a.skillName || a.skillFolderName })
  370. }
  371. }
  372. const skillNodeMap = {}
  373. const GROUP_WIDTH = 240
  374. const GROUP_PADDING_X = 12 // 左右内边距
  375. const GROUP_PADDING_TOP = 30 // 顶部预留 label 高度
  376. const GROUP_PADDING_BOTTOM = 14 // 底部内边距
  377. const SKILL_HEIGHT = 96 // 单个技能节点占位高度(含余量)
  378. const SKILL_GAP = 12
  379. const GAP_TO_ORIGIN = 120
  380. const ts = Date.now()
  381. const groupNodes = [] // 虚框父节点
  382. const skillNodes = [] // 技能子节点
  383. const edgesToAdd = []
  384. for (const [sourceNodeId, items] of Object.entries(groups)) {
  385. const sourceNode = getNodes.value.find(n => n.id === sourceNodeId)
  386. if (!sourceNode) continue
  387. // 源节点实际宽度(Vue Flow 渲染后测量),fallback 估计值 200
  388. const sourceWidth = sourceNode.dimensions?.width || sourceNode.width || 200
  389. const groupHeight = GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM
  390. + items.length * SKILL_HEIGHT
  391. + (items.length - 1) * SKILL_GAP
  392. // 水平居中:源节点中心 X - 虚框宽度 / 2
  393. const groupX = sourceNode.position.x + sourceWidth / 2 - GROUP_WIDTH / 2
  394. const groupY = sourceNode.position.y + GAP_TO_ORIGIN
  395. const groupId = 'vg-' + sourceNodeId + '-' + ts
  396. groupNodes.push({
  397. id: groupId,
  398. type: 'virtualGroup',
  399. position: { x: groupX, y: groupY },
  400. data: {
  401. virtual: true,
  402. width: GROUP_WIDTH,
  403. height: groupHeight,
  404. label: items.length + ' 个关联技能'
  405. },
  406. style: { width: GROUP_WIDTH + 'px', height: groupHeight + 'px' },
  407. class: 'virtual-group-node',
  408. draggable: true, // 允许整体拖动
  409. selectable: false
  410. })
  411. items.forEach((item, idx) => {
  412. if (skillNodeMap[item.folderName]) return
  413. const existing = getNodes.value.find(n => n.type === 'skill' && n.data?.skillId === item.folderName)
  414. if (existing) {
  415. skillNodeMap[item.folderName] = existing.id
  416. return
  417. }
  418. const skillId = 'virtual-' + item.folderName + '-' + ts
  419. // 绝对画布坐标:父节点位置 + 相对偏移。
  420. // 不使用 parentId/extent(依赖 dimensions 异步测量,未完成时子节点会跑到画布左上角)。
  421. // 改为通过 data.virtualGroupId 标记归属,onNodeDrag 时手动联动子节点位置。
  422. const skillAbsX = groupX + GROUP_PADDING_X
  423. const skillAbsY = groupY + GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP)
  424. skillNodes.push({
  425. id: skillId,
  426. type: 'skill',
  427. position: { x: skillAbsX, y: skillAbsY },
  428. data: {
  429. label: item.skillName,
  430. skillId: item.folderName,
  431. skillName: item.skillName,
  432. virtual: true,
  433. virtualGroupId: groupId // 标记归属,拖动联动时按此查找
  434. },
  435. class: 'virtual-node',
  436. draggable: false, // 子节点不单独拖动,事件穿透到父节点实现整体拖动
  437. selectable: false,
  438. connectable: false
  439. })
  440. skillNodeMap[item.folderName] = skillId
  441. })
  442. edgesToAdd.push({
  443. id: 've-' + groupId,
  444. source: groupId,
  445. sourceHandle: 'top',
  446. target: sourceNodeId,
  447. targetHandle: 'bottom-target',
  448. type: 'straight',
  449. animated: false,
  450. style: { stroke: '#818cf8', strokeWidth: 1.5, strokeDasharray: '6 4' },
  451. class: 'virtual-group-edge',
  452. data: { virtual: true }
  453. })
  454. }
  455. // 一次性添加所有节点(父+子)与边。子节点使用绝对坐标,无需等待 dimensions 测量。
  456. const allNodes = [...groupNodes, ...skillNodes]
  457. if (allNodes.length) addNodes(allNodes)
  458. if (edgesToAdd.length) addEdges(edgesToAdd)
  459. hasAssociations.value = true
  460. const totalSkills = Object.keys(skillNodeMap).length
  461. message.success(`发现 ${totalSkills} 个隐式关联技能(${Object.keys(groups).length} 组)`)
  462. } catch (e) {
  463. message.error(e.response?.data?.message || '关联分析失败')
  464. } finally {
  465. associating.value = false
  466. }
  467. }
  468. function clearAssociations() {
  469. const virtualNodes = getNodes.value.filter(n => n.data?.virtual)
  470. const virtualEdges = getEdges.value.filter(e => e.data?.virtual)
  471. if (virtualEdges.length) removeEdges(virtualEdges.map(e => e.id))
  472. if (virtualNodes.length) removeNodes(virtualNodes.map(n => n.id))
  473. hasAssociations.value = false
  474. }
  475. // ========== 保存 ==========
  476. async function handleSave() {
  477. if (!workflowName.value.trim()) {
  478. message.warning('请输入工作流名称')
  479. return
  480. }
  481. saving.value = true
  482. try {
  483. const graphData = JSON.stringify({
  484. nodes: getNodes.value.filter(n => !n.data?.virtual).map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
  485. edges: getEdges.value.filter(e => !e.data?.virtual).map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
  486. })
  487. if (workflowId.value) {
  488. await wfStore.save(workflowId.value, {
  489. name: workflowName.value,
  490. description: workflowDesc.value,
  491. graphData
  492. })
  493. message.success('工作流已保存')
  494. } else {
  495. const created = await wfStore.create(workflowName.value, workflowDesc.value)
  496. await wfStore.save(created.id, {
  497. name: workflowName.value,
  498. description: workflowDesc.value,
  499. graphData
  500. })
  501. message.success('工作流已创建')
  502. router.replace(`/orchestration/edit/${created.id}`)
  503. }
  504. } catch (e) {
  505. message.error(e.response?.data?.message || '保存失败')
  506. } finally {
  507. saving.value = false
  508. }
  509. }
  510. function goBack() {
  511. router.push('/orchestration')
  512. }
  513. // ========== 文件选择(运行时上传) ==========
  514. function onFileSelect(e, isDir = false) {
  515. const files = Array.from(e.target.files || [])
  516. files.forEach(f => {
  517. const relativePath = isDir ? f.webkitRelativePath : f.name
  518. uploadFiles.value.push({ file: f, relativePath })
  519. })
  520. e.target.value = ''
  521. }
  522. // 文件上传列表(运行对话框使用)
  523. const uploadFiles = ref([])
  524. // 启动运行(包装 composable,处理上传文件 + 输入字段)
  525. async function handleStartRun() {
  526. const inputs = {}
  527. inputFields.value.forEach(f => {
  528. if (formData[f.name]) inputs[f.name] = formData[f.name]
  529. })
  530. await startRun(workflowId.value, inputs, uploadFiles.value)
  531. uploadFiles.value = []
  532. }
  533. function handleOpenRunDialog() {
  534. openRunDialog(inputFields, formData)
  535. uploadFiles.value = []
  536. }
  537. // 回滚应用到画布(供 useVersionHistory 调用)
  538. async function applyVersionToCanvas(wf) {
  539. if (!wf) return
  540. workflowName.value = wf.name || ''
  541. workflowDesc.value = wf.description || ''
  542. if (wf.graphData) {
  543. const data = JSON.parse(wf.graphData)
  544. addNodes(data.nodes || [])
  545. addEdges((data.edges || []).map(e => ({
  546. ...e,
  547. type: 'bezier',
  548. animated: true,
  549. style: getEdgeStyle(e.data?.status),
  550. })))
  551. for (const node of getNodes.value) {
  552. applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
  553. }
  554. }
  555. }
  556. // 包装版本历史的方法以适配本组件 API
  557. async function handleRollbackWrap(v) {
  558. await handleRollback(workflowId.value, v, applyVersionToCanvas)
  559. }
  560. async function handleVersionSelectWrap(v) {
  561. await handleVersionSelect(workflowId.value, v)
  562. }
  563. async function toggleVersionPanelWrap() {
  564. await toggleVersionPanel(workflowId.value)
  565. }
  566. async function saveMessageWrap(v) {
  567. await saveMessage(workflowId.value, v)
  568. }
  569. // ========== 初始化 ==========
  570. onMounted(async () => {
  571. skillStore.fetchSkills()
  572. fetchDbModels()
  573. if (workflowId.value) {
  574. // 编辑已有工作流
  575. try {
  576. const wf = await wfStore.fetchOne(workflowId.value)
  577. workflowName.value = wf.name
  578. workflowDesc.value = wf.description || ''
  579. if (wf.graphData) {
  580. applyGraphData(wf.graphData)
  581. }
  582. } catch {
  583. message.error('工作流不存在')
  584. goBack()
  585. }
  586. } else if (route.query.templateId) {
  587. // 从模板创建:加载模板 graphData 作为初始内容
  588. try {
  589. const tplId = Number(route.query.templateId)
  590. const { data: tpl } = await getAgentTemplate(tplId)
  591. if (tpl) {
  592. // 用模板名作为工作流名前缀,便于识别
  593. if (tpl.name) workflowName.value = `${tpl.name}-工作流`
  594. if (tpl.description) workflowDesc.value = tpl.description
  595. if (tpl.graphData) {
  596. applyGraphData(tpl.graphData)
  597. message.info(`已加载模板「${tpl.name}」`)
  598. }
  599. }
  600. } catch (e) {
  601. /* 全局拦截器已处理 */
  602. message.warning('模板加载失败,将创建空白工作流')
  603. }
  604. }
  605. await nextTick()
  606. fitView({ padding: 0.5 })
  607. })
  608. // 将 graphData(JSON 字符串)应用到画布
  609. function applyGraphData(graphData) {
  610. try {
  611. const parsed = typeof graphData === 'string' ? JSON.parse(graphData) : graphData
  612. addNodes((parsed.nodes || []).map(n => {
  613. const defaults = getDefaultData(n.type)
  614. return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
  615. }))
  616. const edgeData = (parsed.edges || []).map(e => ({
  617. ...e,
  618. type: 'bezier',
  619. animated: true,
  620. style: defaultEdgeStyle,
  621. data: e.data || {}
  622. }))
  623. if (edgeData.length) addEdges(edgeData)
  624. for (const node of getNodes.value) {
  625. applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
  626. }
  627. } catch { /* ignore */ }
  628. }
  629. </script>
  630. <template>
  631. <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
  632. <!-- 顶部工具栏 -->
  633. <div class="editor-toolbar">
  634. <div class="toolbar-left">
  635. <button class="icon-btn" title="返回列表" @click="goBack">
  636. <n-icon size="18"><ArrowBackOutline /></n-icon>
  637. </button>
  638. <n-input
  639. v-model:value="workflowName"
  640. placeholder="工作流名称"
  641. style="width: 220px"
  642. size="small"
  643. />
  644. </div>
  645. <div class="toolbar-right">
  646. <button
  647. :class="['version-btn', { active: showVersionPanel }]"
  648. @click="toggleVersionPanelWrap"
  649. :disabled="!workflowId"
  650. >
  651. <n-icon size="16"><TimeOutline /></n-icon>
  652. <span>版本历史</span>
  653. </button>
  654. <button
  655. class="associate-btn"
  656. :class="{ active: hasAssociations }"
  657. @click="handleAutoAssociate"
  658. :disabled="associating"
  659. >
  660. <n-icon size="16"><LinkOutline /></n-icon>
  661. <span>{{ associating ? '分析中...' : hasAssociations ? '重新关联' : '自动关联' }}</span>
  662. </button>
  663. <button v-if="hasAssociations" class="clear-assoc-btn" @click="clearAssociations" title="清除关联">
  664. <n-icon size="14"><TrashOutline /></n-icon>
  665. </button>
  666. <button
  667. class="run-btn"
  668. :class="{ 'is-running': running }"
  669. @click="handleOpenRunDialog"
  670. :disabled="!canRun"
  671. >
  672. <n-icon size="16"><PlayOutline /></n-icon>
  673. <span>{{ running ? '执行中...' : '运行' }}</span>
  674. </button>
  675. <button class="primary-btn" @click="handleSave" :disabled="saving">
  676. <n-icon size="16"><SaveOutline /></n-icon>
  677. <span>{{ saving ? '保存中...' : '完成' }}</span>
  678. </button>
  679. </div>
  680. </div>
  681. <div class="editor-body">
  682. <!-- 左侧节点工具栏 -->
  683. <div class="sidebar-nodes">
  684. <div class="sidebar-title">节点</div>
  685. <div
  686. v-for="nt in nodeTypes"
  687. :key="nt.type"
  688. class="node-type-item"
  689. draggable="true"
  690. @dragstart="onDragStart($event, nt.type)"
  691. >
  692. <div class="nt-icon" :style="{ background: nt.color }">
  693. <n-icon size="14"><component :is="nt.icon" /></n-icon>
  694. </div>
  695. <span class="nt-label">{{ nt.label }}</span>
  696. </div>
  697. </div>
  698. <!-- 中间画布 -->
  699. <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
  700. <VueFlow
  701. :default-viewport="{ zoom: 1, x: 0, y: 0 }"
  702. :min-zoom="0.1"
  703. :max-zoom="2"
  704. :elements-selectable="true"
  705. @node-click="onNodeClick"
  706. @node-contextmenu="onNodeContextMenu"
  707. @pane-click="onPaneClick"
  708. @edge-click="onEdgeClick"
  709. :snap-to-grid="true"
  710. :snap-grid="[20, 20]"
  711. >
  712. <template #node-userInput="nodeProps"><InputNode :data="nodeProps.data" /></template>
  713. <template #node-llm="nodeProps"><LLMNode :data="nodeProps.data" /></template>
  714. <template #node-agent="nodeProps"><AgentNode :data="nodeProps.data" /></template>
  715. <template #node-skill="nodeProps"><SkillNode :data="nodeProps.data" /></template>
  716. <template #node-output="nodeProps"><OutputNode :data="nodeProps.data" /></template>
  717. <template #node-condition="nodeProps"><ConditionNode :data="nodeProps.data" /></template>
  718. <template #node-smartAction="nodeProps"><SmartActionNode :data="nodeProps.data" /></template>
  719. <template #node-virtualGroup="nodeProps"><VirtualGroupNode :data="nodeProps.data" :style="{ width: nodeProps.data.width + 'px', height: nodeProps.data.height + 'px' }" /></template>
  720. <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
  721. </VueFlow>
  722. </div>
  723. <!-- 右侧面板 - Tab 页 -->
  724. <div class="sidebar-right">
  725. <div class="sidebar-tabs">
  726. <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
  727. <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
  728. 运行结果
  729. <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
  730. </button>
  731. </div>
  732. <div class="sidebar-tab-content">
  733. <!-- 节点属性 Tab -->
  734. <div v-show="activeTab === 'props'" class="tab-pane">
  735. <template v-if="selectedData">
  736. <!-- 用户输入节点 -->
  737. <template v-if="selectedNode?.type === 'userInput'">
  738. <div class="prop-section">
  739. <div class="prop-header">
  740. <span class="prop-label">输入变量</span>
  741. <button class="text-btn" @click="addVariable">+ 添加</button>
  742. </div>
  743. <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
  744. <div class="var-form-row">
  745. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  746. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  747. </div>
  748. <div class="var-form-row">
  749. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  750. <n-input :value="v.description" placeholder="描述" size="small" @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  751. </div>
  752. <button class="text-btn danger" @click="removeVariable(i)">删除</button>
  753. </div>
  754. <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
  755. </div>
  756. </template>
  757. <!-- 大模型处理节点 -->
  758. <template v-if="selectedNode?.type === 'llm'">
  759. <div class="prop-section">
  760. <label class="prop-label">模型</label>
  761. <n-select
  762. :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  763. :options="modelOptions"
  764. placeholder="选择模型(默认使用默认模型)"
  765. size="small"
  766. clearable
  767. @update:value="v => onModelChange(v, dbModels)"
  768. />
  769. </div>
  770. <div class="prop-section">
  771. <label class="prop-label">系统提示词</label>
  772. <n-input
  773. :value="selectedData?.systemPrompt"
  774. type="textarea" :rows="3"
  775. placeholder="输入系统提示词"
  776. size="small"
  777. @update:value="v => onFieldChange('systemPrompt', v)"
  778. />
  779. </div>
  780. <div class="prop-section">
  781. <label class="prop-label">用户提示词</label>
  782. <n-input
  783. :value="selectedData?.userPrompt"
  784. type="textarea" :rows="3"
  785. placeholder="输入用户提示词,可用 {{变量名}} 引用变量"
  786. size="small"
  787. @update:value="v => onFieldChange('userPrompt', v)"
  788. />
  789. </div>
  790. <!-- 前置数据 -->
  791. <div class="prop-section">
  792. <div class="prop-header">
  793. <span class="prop-label">前置数据</span>
  794. <div style="display:flex;gap:4px">
  795. <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
  796. <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
  797. </div>
  798. </div>
  799. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  800. <div class="var-form-row">
  801. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  802. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  803. </div>
  804. <div class="var-form-row">
  805. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  806. <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
  807. </div>
  808. </div>
  809. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
  810. </div>
  811. <!-- 输出变量 -->
  812. <div class="prop-section">
  813. <div class="prop-header">
  814. <span class="prop-label">输出变量</span>
  815. <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
  816. </div>
  817. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  818. <div class="var-form-row">
  819. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  820. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  821. </div>
  822. <div class="var-form-row">
  823. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  824. <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
  825. </div>
  826. </div>
  827. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
  828. </div>
  829. <!-- 失败策略 -->
  830. <div class="prop-section">
  831. <label class="prop-label">失败策略</label>
  832. <n-select
  833. :value="selectedData?.failStrategy || 'abort'"
  834. :options="failStrategyOptions"
  835. size="small"
  836. @update:value="v => onFieldChange('failStrategy', v)"
  837. />
  838. </div>
  839. </template>
  840. <!-- 智能体节点 -->
  841. <template v-if="selectedNode?.type === 'agent'">
  842. <div class="prop-section">
  843. <label class="prop-label">选择智能体</label>
  844. <n-select
  845. :value="selectedData?.agentId"
  846. :options="agentOptions"
  847. placeholder="选择技能"
  848. size="small"
  849. filterable
  850. @update:value="v => onFieldChange('agentId', v)"
  851. />
  852. </div>
  853. <div class="prop-section">
  854. <label class="prop-label">模型</label>
  855. <n-select
  856. :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  857. :options="modelOptions"
  858. placeholder="默认使用默认模型"
  859. size="small"
  860. clearable
  861. @update:value="v => onModelChange(v, dbModels)"
  862. />
  863. </div>
  864. <!-- 前置数据 -->
  865. <div class="prop-section">
  866. <div class="prop-header">
  867. <span class="prop-label">前置数据</span>
  868. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  869. </div>
  870. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  871. <div class="var-form-row">
  872. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  873. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  874. </div>
  875. <div class="var-form-row">
  876. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  877. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  878. </div>
  879. </div>
  880. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  881. </div>
  882. <!-- 输出变量 -->
  883. <div class="prop-section">
  884. <div class="prop-header">
  885. <span class="prop-label">输出变量</span>
  886. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  887. </div>
  888. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  889. <div class="var-form-row">
  890. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  891. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  892. </div>
  893. <div class="var-form-row">
  894. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  895. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  896. </div>
  897. </div>
  898. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  899. </div>
  900. <!-- 失败策略 -->
  901. <div class="prop-section">
  902. <label class="prop-label">失败策略</label>
  903. <n-select
  904. :value="selectedData?.failStrategy || 'abort'"
  905. :options="failStrategyOptions"
  906. size="small"
  907. @update:value="v => onFieldChange('failStrategy', v)"
  908. />
  909. </div>
  910. </template>
  911. <!-- 技能节点 -->
  912. <template v-if="selectedNode?.type === 'skill'">
  913. <div class="prop-section">
  914. <label class="prop-label">选择技能</label>
  915. <n-select
  916. :value="selectedData?.skillId"
  917. :options="agentOptions"
  918. placeholder="选择技能"
  919. size="small"
  920. filterable
  921. @update:value="v => onFieldChange('skillId', v)"
  922. />
  923. </div>
  924. <div class="prop-section">
  925. <label class="prop-label">模型</label>
  926. <n-select
  927. :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  928. :options="modelOptions"
  929. placeholder="默认使用默认模型"
  930. size="small"
  931. clearable
  932. @update:value="v => onModelChange(v, dbModels)"
  933. />
  934. </div>
  935. <!-- 前置数据 -->
  936. <div class="prop-section">
  937. <div class="prop-header">
  938. <span class="prop-label">前置数据</span>
  939. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  940. </div>
  941. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  942. <div class="var-form-row">
  943. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  944. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  945. </div>
  946. <div class="var-form-row">
  947. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  948. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  949. </div>
  950. </div>
  951. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  952. </div>
  953. <!-- 输出变量 -->
  954. <div class="prop-section">
  955. <div class="prop-header">
  956. <span class="prop-label">输出变量</span>
  957. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  958. </div>
  959. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  960. <div class="var-form-row">
  961. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  962. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  963. </div>
  964. <div class="var-form-row">
  965. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  966. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  967. </div>
  968. </div>
  969. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  970. </div>
  971. <!-- 失败策略 -->
  972. <div class="prop-section">
  973. <label class="prop-label">失败策略</label>
  974. <n-select
  975. :value="selectedData?.failStrategy || 'abort'"
  976. :options="failStrategyOptions"
  977. size="small"
  978. @update:value="v => onFieldChange('failStrategy', v)"
  979. />
  980. </div>
  981. </template>
  982. <!-- 智能操作节点 -->
  983. <template v-if="selectedNode?.type === 'smartAction'">
  984. <div class="prop-section">
  985. <div class="prop-header">
  986. <span class="prop-label">操作要求</span>
  987. <button class="text-btn" @click="openPromptEditor" title="在编辑器中打开">
  988. <n-icon size="12"><OpenOutline /></n-icon> 展开
  989. </button>
  990. </div>
  991. <n-input
  992. :value="selectedData?.actionPrompt"
  993. type="textarea" :rows="5"
  994. placeholder="描述你希望 AI 执行的操作..."
  995. size="small"
  996. @update:value="v => onFieldChange('actionPrompt', v)"
  997. />
  998. </div>
  999. <div class="prop-section">
  1000. <label class="prop-label">模型</label>
  1001. <n-select
  1002. :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  1003. :options="modelOptions"
  1004. placeholder="默认使用默认模型"
  1005. size="small"
  1006. clearable
  1007. @update:value="v => onModelChange(v, dbModels)"
  1008. />
  1009. </div>
  1010. <!-- 前置数据 -->
  1011. <div class="prop-section">
  1012. <div class="prop-header">
  1013. <span class="prop-label">前置数据</span>
  1014. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  1015. </div>
  1016. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
  1017. <div class="var-form-row">
  1018. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1019. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1020. </div>
  1021. <div class="var-form-row">
  1022. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1023. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  1024. </div>
  1025. </div>
  1026. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
  1027. </div>
  1028. <!-- 输出变量 -->
  1029. <div class="prop-section">
  1030. <div class="prop-header">
  1031. <span class="prop-label">输出变量</span>
  1032. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  1033. </div>
  1034. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
  1035. <div class="var-form-row">
  1036. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1037. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1038. </div>
  1039. <div class="var-form-row">
  1040. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1041. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  1042. </div>
  1043. </div>
  1044. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
  1045. </div>
  1046. <!-- 失败策略 -->
  1047. <div class="prop-section">
  1048. <label class="prop-label">失败策略</label>
  1049. <n-select
  1050. :value="selectedData?.failStrategy || 'abort'"
  1051. :options="failStrategyOptions"
  1052. size="small"
  1053. @update:value="v => onFieldChange('failStrategy', v)"
  1054. />
  1055. </div>
  1056. </template>
  1057. <!-- 条件分支节点 -->
  1058. <template v-if="selectedNode?.type === 'condition'">
  1059. <div class="prop-section">
  1060. <div class="prop-header">
  1061. <span class="prop-label">条件分支</span>
  1062. <button class="text-btn" @click="addElif">+ ELIF</button>
  1063. </div>
  1064. <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
  1065. <div class="cond-form-row">
  1066. <span class="cond-tag">{{ c.type }}</span>
  1067. <n-input
  1068. :value="c.expression"
  1069. placeholder="自然语言条件,可用 {{变量名}}"
  1070. size="small"
  1071. @update:value="v => onConditionChange(i, 'expression', v)"
  1072. />
  1073. <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
  1074. </div>
  1075. </div>
  1076. </div>
  1077. <div class="prop-section">
  1078. <div class="prop-header">
  1079. <span class="prop-label">前置数据</span>
  1080. <button class="text-btn" @click="addConditionInput">+ 添加</button>
  1081. </div>
  1082. <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
  1083. <div class="var-form-row">
  1084. <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1085. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1086. </div>
  1087. <div class="var-form-row">
  1088. <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1089. <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
  1090. </div>
  1091. </div>
  1092. <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  1093. <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
  1094. </div>
  1095. </template>
  1096. <!-- 输出节点 -->
  1097. <template v-if="selectedNode?.type === 'output'">
  1098. <div class="prop-section">
  1099. <div class="prop-header">
  1100. <span class="prop-label">接收字段</span>
  1101. <button class="text-btn" @click="addOutputField">+ 添加</button>
  1102. </div>
  1103. <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
  1104. <div class="var-form-row">
  1105. <n-input :value="f.name" placeholder="字段名" size="small" @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  1106. <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  1107. </div>
  1108. <div class="var-form-row">
  1109. <n-input :value="f.label" placeholder="中文名" size="small" @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  1110. <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
  1111. </div>
  1112. </div>
  1113. <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  1114. </div>
  1115. </template>
  1116. </template>
  1117. <!-- 边映射信息 -->
  1118. <template v-else-if="selectedEdge?.data">
  1119. <div class="prop-section">
  1120. <div class="prop-header">
  1121. <span class="prop-label">连接映射</span>
  1122. <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
  1123. {{ edgeStatusText(selectedEdge.data.status) }}
  1124. </span>
  1125. </div>
  1126. <!-- 已映射字段 -->
  1127. <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
  1128. <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField" class="mapping-item mapped">
  1129. <span class="mapping-field">{{ m.sourceField }}</span>
  1130. <span class="mapping-arrow">→</span>
  1131. <span class="mapping-field">{{ m.targetField }}</span>
  1132. </div>
  1133. </div>
  1134. <!-- 类型不匹配 -->
  1135. <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
  1136. <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name" class="mapping-item mismatch">
  1137. <span class="mapping-field">{{ tm.source.name }}</span>
  1138. <span class="mapping-type">({{ tm.source.type }})</span>
  1139. <span class="mapping-arrow">→</span>
  1140. <span class="mapping-field">{{ tm.target.name }}</span>
  1141. <span class="mapping-type">({{ tm.target.type }})</span>
  1142. </div>
  1143. </div>
  1144. <!-- 未匹配的目标前置数据 -->
  1145. <div v-if="selectedEdge.data.unmatchedTarget?.length">
  1146. <div class="prop-label" style="margin-top:8px">未匹配前置数据</div>
  1147. <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
  1148. <span class="mapping-field">{{ f.name }}</span>
  1149. <span class="mapping-type">({{ f.type }})</span>
  1150. </div>
  1151. </div>
  1152. <!-- 多余的源输出 -->
  1153. <div v-if="selectedEdge.data.unmatchedSource?.length">
  1154. <div class="prop-label" style="margin-top:8px">多余输出</div>
  1155. <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
  1156. <span class="mapping-field">{{ f.name }}</span>
  1157. <span class="mapping-type">({{ f.type }})</span>
  1158. </div>
  1159. </div>
  1160. <div v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)" class="empty-hint">
  1161. 无需映射
  1162. </div>
  1163. </div>
  1164. </template>
  1165. <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
  1166. </div>
  1167. <!-- 运行结果 Tab -->
  1168. <div v-show="activeTab === 'result'" class="tab-pane">
  1169. <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
  1170. <div
  1171. v-for="r in nodeResults"
  1172. :key="r.nodeId"
  1173. class="node-result-card"
  1174. :style="{ borderLeftColor: r.color }"
  1175. >
  1176. <div class="nrc-header" @click="toggleLogs(r.nodeId)" style="cursor: pointer">
  1177. <div class="nrc-icon" :style="{ background: r.color }">
  1178. <n-icon size="12"><component :is="nodeTypeInfo[r.type]?.icon" /></n-icon>
  1179. </div>
  1180. <div class="nrc-info">
  1181. <span class="nrc-label">{{ r.label }}</span>
  1182. <span class="nrc-type">{{ r.typeLabel }}</span>
  1183. </div>
  1184. <span v-if="r.logs && r.logs.length" class="nrc-log-count">{{ r.logs.length }} 条日志</span>
  1185. <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
  1186. <span v-if="r.logs && r.logs.length" class="nrc-expand" :class="{ expanded: expandedLogs[r.nodeId] }">▶</span>
  1187. </div>
  1188. <!-- 流式思考过程(实时累加,节点完成后保留为历史)
  1189. 折叠时仅隐藏 body,streamingThinking 仍持续累加,展开后可继续输出 -->
  1190. <div v-if="r.streamingThinking" class="nrc-thinking" :class="{ live: r.status === 'running' }">
  1191. <div class="nrc-thinking-header" @click="toggleThinking(r.nodeId)" style="cursor: pointer">
  1192. <span class="nrc-thinking-tag">💭 思考过程</span>
  1193. <span v-if="r.status === 'running'" class="nrc-thinking-live">实时输出中…</span>
  1194. <span v-else class="nrc-thinking-static">{{ r.streamingThinking.length }} 字</span>
  1195. <span class="nrc-thinking-expand" :class="{ expanded: expandedThinking[r.nodeId] !== false }">▶</span>
  1196. </div>
  1197. <pre v-show="expandedThinking[r.nodeId] !== false" class="nrc-thinking-body">{{ r.streamingThinking }}</pre>
  1198. </div>
  1199. <div class="nrc-vars" v-if="r.output">
  1200. <div class="nrc-var" v-for="(value, key) in r.output" :key="key">
  1201. <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
  1202. <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value) }}</span>
  1203. </div>
  1204. </div>
  1205. <!-- 完整上下文快照(节点执行后的全部 variables,debug 视图) -->
  1206. <div v-if="r.contextSnapshot" class="nrc-ctx-toggle" @click="toggleContext(r.nodeId)">
  1207. <span class="nrc-ctx-icon" :class="{ expanded: expandedContext[r.nodeId] }">▶</span>
  1208. <span class="nrc-ctx-label">查看完整上下文({{ Object.keys(r.contextSnapshot).length }} 个变量)</span>
  1209. </div>
  1210. <div v-if="expandedContext[r.nodeId] && r.contextSnapshot" class="nrc-ctx-panel">
  1211. <div class="nrc-ctx-var" v-for="(value, key) in r.contextSnapshot" :key="key">
  1212. <span class="nrc-ctx-key">{{ key }}</span>
  1213. <span class="nrc-ctx-value">{{ formatContextValue(value) }}</span>
  1214. </div>
  1215. </div>
  1216. <!-- 执行日志 -->
  1217. <div v-if="expandedLogs[r.nodeId] && r.logs && r.logs.length" class="nrc-logs">
  1218. <div class="nrc-log" v-for="(log, idx) in r.logs" :key="idx" :class="'log-type-' + (log.type || 'info').toLowerCase()">
  1219. <span class="nrc-log-icon">{{ logTypeIcon(log.type) }}</span>
  1220. <span class="nrc-log-time">{{ formatTimestamp(log.timestamp) }}</span>
  1221. <span class="nrc-log-tag">{{ logTypeLabel(log.type) }}</span>
  1222. <span class="nrc-log-msg">{{ log.message }}</span>
  1223. <div v-if="log.detail" class="nrc-log-detail">{{ log.detail }}</div>
  1224. </div>
  1225. </div>
  1226. </div>
  1227. <div v-if="showResult && workflowResult" class="nrc-clear-row">
  1228. <a v-if="lastRunId && lastRunWorkflowId"
  1229. :href="`/api/workflows/${lastRunWorkflowId}/runs/${lastRunId}/download`"
  1230. class="text-btn" style="text-decoration:none; color: var(--primary)">下载工作目录</a>
  1231. <button class="text-btn danger" @click="closeResult">清除结果</button>
  1232. </div>
  1233. </div>
  1234. </div>
  1235. </div>
  1236. </div>
  1237. <!-- 运行对话框 -->
  1238. <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
  1239. <div class="run-dialog">
  1240. <div class="dialog-header">
  1241. <span class="dialog-title">运行工作流</span>
  1242. <button class="icon-btn" @click="cancelRun"><n-icon size="16"><CloseOutline /></n-icon></button>
  1243. </div>
  1244. <div class="dialog-body">
  1245. <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
  1246. <div v-for="f in inputFields" :key="f.name" class="run-field">
  1247. <label class="run-field-label">{{ f.label || f.name }}</label>
  1248. <n-input
  1249. v-model:value="formData[f.name]"
  1250. :placeholder="f.description || f.name"
  1251. :type="f.type === 'number' ? 'text' : 'text'"
  1252. size="small"
  1253. />
  1254. </div>
  1255. <div class="run-upload">
  1256. <label class="run-field-label">上传文件到工作目录</label>
  1257. <div class="upload-btns">
  1258. <div class="upload-area" @click="$refs.fileInput.click()">
  1259. <input ref="fileInput" type="file" multiple style="display:none" @change="onFileSelect($event, false)" />
  1260. <span class="upload-hint">选择文件</span>
  1261. </div>
  1262. <div class="upload-area" @click="$refs.dirInput.click()">
  1263. <input ref="dirInput" type="file" webkitdirectory style="display:none" @change="onFileSelect($event, true)" />
  1264. <span class="upload-hint">选择目录</span>
  1265. </div>
  1266. </div>
  1267. <div v-if="uploadFiles.length" class="upload-list">
  1268. <div v-for="(f, i) in uploadFiles" :key="i" class="upload-item">
  1269. <span class="upload-name">{{ f.relativePath }}</span>
  1270. <button class="upload-remove" @click="uploadFiles.splice(i, 1)">×</button>
  1271. </div>
  1272. </div>
  1273. </div>
  1274. </div>
  1275. <div class="dialog-footer">
  1276. <button class="cancel-btn" @click="cancelRun">取消</button>
  1277. <button class="primary-btn" @click="handleStartRun">
  1278. <n-icon size="14"><PlayOutline /></n-icon>
  1279. <span>开始执行</span>
  1280. </button>
  1281. </div>
  1282. </div>
  1283. </div>
  1284. <!-- 操作要求编辑器弹出框 -->
  1285. <div class="dialog-overlay" v-if="showPromptEditor" @click.self="showPromptEditor = false">
  1286. <div class="prompt-editor-dialog">
  1287. <div class="dialog-header">
  1288. <span class="dialog-title">编辑操作要求</span>
  1289. <button class="icon-btn" @click="showPromptEditor = false"><n-icon size="16"><CloseOutline /></n-icon></button>
  1290. </div>
  1291. <div class="prompt-editor-body">
  1292. <CodeEditor v-model="promptEditValue" language="markdown" height="100%" :minimap="false" />
  1293. </div>
  1294. <div class="dialog-footer">
  1295. <button class="cancel-btn" @click="showPromptEditor = false">取消</button>
  1296. <button class="primary-btn" @click="confirmPromptEdit">确认</button>
  1297. </div>
  1298. </div>
  1299. </div>
  1300. <!-- 版本历史面板 -->
  1301. <transition name="slide-right">
  1302. <div v-if="showVersionPanel" class="version-panel">
  1303. <div class="vp-header">
  1304. <h3 class="vp-title">版本历史</h3>
  1305. <button class="icon-btn" @click="toggleVersionPanelWrap"><n-icon size="16"><CloseOutline /></n-icon></button>
  1306. </div>
  1307. <div class="vp-list">
  1308. <n-spin v-if="versionsLoading" size="small" />
  1309. <div v-else-if="!versions.length" class="vp-empty">暂无版本(保存后自动创建)</div>
  1310. <div v-else class="version-items">
  1311. <div
  1312. v-for="v in versions"
  1313. :key="v.number"
  1314. class="version-item"
  1315. :class="{ active: selectedVersion?.number === v.number }"
  1316. >
  1317. <div class="vi-main" @click="handleVersionSelectWrap(v)">
  1318. <div class="vi-row">
  1319. <span class="vi-version">v{{ v.number }}<span v-if="v.current" class="vi-current">当前</span></span>
  1320. <span class="vi-time">{{ formatTime(v.createdAt) }}</span>
  1321. </div>
  1322. <div v-if="editingMessage !== v.number" class="vi-message" @dblclick="startEditMessage(v)">
  1323. {{ v.message || '双击编辑备注' }}
  1324. </div>
  1325. <div v-else class="vi-message-edit">
  1326. <n-input v-model:value="editingMessageText" size="tiny" placeholder="版本备注"
  1327. @keyup.enter="saveMessageWrap(v)" @keyup.escape="cancelEditMessage" />
  1328. <n-button size="tiny" type="primary" @click="saveMessageWrap(v)">✓</n-button>
  1329. <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
  1330. </div>
  1331. </div>
  1332. <div class="vi-actions">
  1333. <button v-if="!v.current" class="vi-action-btn rollback-btn"
  1334. @click.stop="handleRollbackWrap(v)" title="回滚到此版本">↩</button>
  1335. </div>
  1336. </div>
  1337. </div>
  1338. </div>
  1339. <div v-if="selectedVersion" class="vp-content">
  1340. <div class="vp-content-header">
  1341. <span>v{{ selectedVersion.number }} 的数据</span>
  1342. </div>
  1343. <pre class="version-json">{{ formatVersionJson(versionContent) }}</pre>
  1344. </div>
  1345. </div>
  1346. </transition>
  1347. <!-- 右键菜单 -->
  1348. <Teleport to="body">
  1349. <div
  1350. v-if="contextMenu"
  1351. class="context-menu-overlay"
  1352. @click="closeContextMenu"
  1353. @contextmenu.prevent="closeContextMenu"
  1354. >
  1355. <div class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
  1356. <button class="ctx-item ctx-danger" @click="deleteNodeById(contextMenu.nodeId)">
  1357. 删除节点
  1358. </button>
  1359. </div>
  1360. </div>
  1361. </Teleport>
  1362. </div>
  1363. </template>
  1364. <style scoped>
  1365. .workflow-editor {
  1366. height: calc(100vh - var(--header-height));
  1367. display: flex;
  1368. flex-direction: column;
  1369. margin: -24px -32px 0;
  1370. background: #13111a;
  1371. }
  1372. .editor-toolbar {
  1373. display: flex;
  1374. align-items: center;
  1375. justify-content: space-between;
  1376. padding: 10px 16px;
  1377. background: var(--bg-card, #1a1a2e);
  1378. border-bottom: 1px solid rgba(255,255,255,0.08);
  1379. flex-shrink: 0;
  1380. }
  1381. .toolbar-left, .toolbar-right {
  1382. display: flex;
  1383. align-items: center;
  1384. gap: 10px;
  1385. }
  1386. .editor-body {
  1387. flex: 1;
  1388. display: flex;
  1389. overflow: hidden;
  1390. }
  1391. /* 左侧节点栏 */
  1392. .sidebar-nodes {
  1393. width: 180px;
  1394. background: var(--bg-card, #1a1a2e);
  1395. border-right: 1px solid rgba(255,255,255,0.08);
  1396. padding: 12px;
  1397. display: flex;
  1398. flex-direction: column;
  1399. gap: 6px;
  1400. flex-shrink: 0;
  1401. }
  1402. .sidebar-title {
  1403. font-size: 12px;
  1404. font-weight: 600;
  1405. color: var(--text-secondary, #999);
  1406. text-transform: uppercase;
  1407. letter-spacing: 1px;
  1408. margin-bottom: 6px;
  1409. }
  1410. .node-type-item {
  1411. display: flex;
  1412. align-items: center;
  1413. gap: 8px;
  1414. padding: 8px 10px;
  1415. border-radius: 8px;
  1416. cursor: grab;
  1417. transition: background 0.15s;
  1418. border: 1px solid rgba(255,255,255,0.06);
  1419. }
  1420. .node-type-item:hover {
  1421. background: rgba(255,255,255,0.06);
  1422. }
  1423. .node-type-item:active {
  1424. cursor: grabbing;
  1425. }
  1426. .nt-icon {
  1427. width: 28px;
  1428. height: 28px;
  1429. display: flex;
  1430. align-items: center;
  1431. justify-content: center;
  1432. border-radius: 6px;
  1433. color: #fff;
  1434. flex-shrink: 0;
  1435. }
  1436. .nt-label {
  1437. font-size: 13px;
  1438. color: var(--text-primary, #e0e0e0);
  1439. }
  1440. /* 中间画布 */
  1441. .canvas-area {
  1442. flex: 1;
  1443. background: #0f0d17;
  1444. }
  1445. /* 覆盖 VueFlow 默认主题的节点白色背景 */
  1446. .canvas-area :deep(.vue-flow__node) {
  1447. background: transparent !important;
  1448. padding: 0 !important;
  1449. border: none !important;
  1450. box-shadow: none !important;
  1451. }
  1452. /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
  1453. .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
  1454. stroke: #facc15 !important;
  1455. stroke-width: 3 !important;
  1456. }
  1457. .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
  1458. stroke: #facc15 !important;
  1459. }
  1460. .canvas-area :deep(.vue-flow__edge) {
  1461. cursor: pointer;
  1462. }
  1463. /* 右侧面板 - Tab 页 */
  1464. .sidebar-right {
  1465. width: 300px;
  1466. background: var(--bg-card, #1a1a2e);
  1467. border-left: 1px solid rgba(255,255,255,0.08);
  1468. display: flex;
  1469. flex-direction: column;
  1470. flex-shrink: 0;
  1471. overflow: hidden;
  1472. }
  1473. .sidebar-tabs {
  1474. display: flex;
  1475. border-bottom: 1px solid rgba(255,255,255,0.08);
  1476. flex-shrink: 0;
  1477. }
  1478. .tab-btn {
  1479. flex: 1;
  1480. padding: 10px 0;
  1481. border: none;
  1482. background: transparent;
  1483. color: var(--text-secondary, #999);
  1484. font-size: 13px;
  1485. cursor: pointer;
  1486. transition: all 0.15s;
  1487. position: relative;
  1488. display: flex;
  1489. align-items: center;
  1490. justify-content: center;
  1491. gap: 4px;
  1492. }
  1493. .tab-btn:hover { color: var(--text-primary, #e0e0e0); }
  1494. .tab-btn.active {
  1495. color: var(--text-primary, #e0e0e0);
  1496. font-weight: 600;
  1497. }
  1498. .tab-btn.active::after {
  1499. content: '';
  1500. position: absolute;
  1501. bottom: 0;
  1502. left: 20%;
  1503. right: 20%;
  1504. height: 2px;
  1505. background: var(--color-primary, #2563EB);
  1506. border-radius: 1px;
  1507. }
  1508. .result-badge {
  1509. display: inline-flex;
  1510. align-items: center;
  1511. justify-content: center;
  1512. min-width: 16px;
  1513. height: 16px;
  1514. padding: 0 4px;
  1515. border-radius: 8px;
  1516. background: var(--color-primary, #2563EB);
  1517. color: #fff;
  1518. font-size: 10px;
  1519. font-weight: 600;
  1520. }
  1521. .sidebar-tab-content {
  1522. flex: 1;
  1523. overflow-y: auto;
  1524. }
  1525. .tab-pane {
  1526. padding: 12px;
  1527. }
  1528. .empty-props-hint {
  1529. color: var(--text-tertiary, #555);
  1530. font-size: 13px;
  1531. text-align: center;
  1532. padding: 40px 0;
  1533. }
  1534. /* 节点结果卡片 */
  1535. .node-result-card {
  1536. background: rgba(255,255,255,0.03);
  1537. border-radius: 8px;
  1538. border-left: 3px solid #666;
  1539. margin-bottom: 10px;
  1540. overflow: hidden;
  1541. }
  1542. .nrc-header {
  1543. display: flex;
  1544. align-items: center;
  1545. gap: 8px;
  1546. padding: 8px 10px;
  1547. border-bottom: 1px solid rgba(255,255,255,0.04);
  1548. }
  1549. .nrc-icon {
  1550. width: 22px;
  1551. height: 22px;
  1552. display: flex;
  1553. align-items: center;
  1554. justify-content: center;
  1555. border-radius: 5px;
  1556. color: #fff;
  1557. flex-shrink: 0;
  1558. }
  1559. .nrc-info {
  1560. flex: 1;
  1561. min-width: 0;
  1562. display: flex;
  1563. flex-direction: column;
  1564. gap: 1px;
  1565. }
  1566. .nrc-label {
  1567. font-size: 12px;
  1568. font-weight: 600;
  1569. color: var(--text-primary, #e0e0e0);
  1570. white-space: nowrap;
  1571. overflow: hidden;
  1572. text-overflow: ellipsis;
  1573. }
  1574. .nrc-type {
  1575. font-size: 10px;
  1576. color: var(--text-tertiary, #666);
  1577. }
  1578. .nrc-status {
  1579. font-size: 10px;
  1580. padding: 2px 6px;
  1581. border-radius: 4px;
  1582. font-weight: 600;
  1583. flex-shrink: 0;
  1584. }
  1585. .nrc-status.status-running { background: rgba(99,102,241,0.15); color: #818cf8; }
  1586. .nrc-status.status-success { background: rgba(16,185,129,0.15); color: #34d399; }
  1587. .nrc-status.status-failed { background: rgba(239,68,68,0.15); color: #f87171; }
  1588. .nrc-status.status-skipped { background: rgba(107,114,128,0.15); color: #9ca3af; }
  1589. .nrc-status.status-no_run { background: rgba(107,114,128,0.10); color: #6b7280; }
  1590. .nrc-vars {
  1591. padding: 6px 10px;
  1592. }
  1593. /* 流式思考过程区块 */
  1594. .nrc-thinking {
  1595. margin: 6px 10px;
  1596. border-radius: 6px;
  1597. background: rgba(168, 85, 247, 0.05);
  1598. border: 1px solid rgba(168, 85, 247, 0.2);
  1599. overflow: hidden;
  1600. }
  1601. .nrc-thinking.live {
  1602. border-color: rgba(168, 85, 247, 0.5);
  1603. box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15);
  1604. }
  1605. .nrc-thinking-header {
  1606. display: flex;
  1607. align-items: center;
  1608. gap: 8px;
  1609. padding: 5px 10px;
  1610. font-size: 11px;
  1611. background: rgba(168, 85, 247, 0.08);
  1612. }
  1613. .nrc-thinking-tag { color: #c084fc; font-weight: 600; }
  1614. .nrc-thinking-live {
  1615. color: #ec4899;
  1616. font-size: 10px;
  1617. animation: nrc-pulse 1.2s ease-in-out infinite;
  1618. }
  1619. .nrc-thinking-static { color: #888; font-size: 10px; }
  1620. .nrc-thinking-expand {
  1621. margin-left: auto;
  1622. font-size: 10px;
  1623. color: var(--text-tertiary, #666);
  1624. transition: transform 0.2s;
  1625. flex-shrink: 0;
  1626. }
  1627. .nrc-thinking-expand.expanded {
  1628. transform: rotate(90deg);
  1629. }
  1630. .nrc-thinking-body {
  1631. margin: 0;
  1632. padding: 8px 12px;
  1633. max-height: 320px;
  1634. overflow: auto;
  1635. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
  1636. font-size: 11px;
  1637. line-height: 1.6;
  1638. color: var(--text-secondary, #b0b0b0);
  1639. white-space: pre-wrap;
  1640. word-break: break-word;
  1641. }
  1642. @keyframes nrc-pulse {
  1643. 0%, 100% { opacity: 0.55; }
  1644. 50% { opacity: 1; }
  1645. }
  1646. .nrc-var {
  1647. display: flex;
  1648. flex-direction: column;
  1649. gap: 2px;
  1650. padding: 4px 0;
  1651. border-bottom: 1px solid rgba(255,255,255,0.02);
  1652. }
  1653. .nrc-var:last-child { border-bottom: none; }
  1654. .nrc-var-key {
  1655. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  1656. font-size: 11px;
  1657. font-weight: 600;
  1658. }
  1659. .nrc-var-value {
  1660. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  1661. font-size: 11px;
  1662. color: var(--text-secondary, #aaa);
  1663. white-space: pre-wrap;
  1664. word-break: break-all;
  1665. max-height: 120px;
  1666. overflow-y: auto;
  1667. }
  1668. /* 完整上下文快照折叠区(debug 视图) */
  1669. .nrc-ctx-toggle {
  1670. display: flex;
  1671. align-items: center;
  1672. gap: 6px;
  1673. padding: 6px 10px;
  1674. cursor: pointer;
  1675. border-top: 1px solid rgba(255,255,255,0.04);
  1676. font-size: 11px;
  1677. color: var(--text-secondary, #aaa);
  1678. user-select: none;
  1679. }
  1680. .nrc-ctx-toggle:hover { background: rgba(99,102,241,0.05); }
  1681. .nrc-ctx-icon {
  1682. font-size: 9px;
  1683. color: var(--text-tertiary, #888);
  1684. transition: transform 0.2s;
  1685. }
  1686. .nrc-ctx-icon.expanded { transform: rotate(90deg); }
  1687. .nrc-ctx-label { font-weight: 500; }
  1688. .nrc-ctx-panel {
  1689. padding: 6px 10px;
  1690. border-top: 1px solid rgba(255,255,255,0.04);
  1691. max-height: 280px;
  1692. overflow-y: auto;
  1693. background: rgba(0,0,0,0.15);
  1694. }
  1695. .nrc-ctx-var {
  1696. display: flex;
  1697. flex-direction: column;
  1698. gap: 2px;
  1699. padding: 4px 0;
  1700. border-bottom: 1px solid rgba(255,255,255,0.02);
  1701. }
  1702. .nrc-ctx-var:last-child { border-bottom: none; }
  1703. .nrc-ctx-key {
  1704. font-family: 'Cascadia Code', 'Fira Code', monospace;
  1705. font-size: 11px;
  1706. font-weight: 600;
  1707. color: #818cf8;
  1708. }
  1709. .nrc-ctx-value {
  1710. font-family: 'Cascadia Code', 'Fira Code', monospace;
  1711. font-size: 11px;
  1712. color: var(--text-secondary, #ccc);
  1713. white-space: pre-wrap;
  1714. word-break: break-all;
  1715. max-height: 80px;
  1716. overflow-y: auto;
  1717. }
  1718. .nrc-clear-row {
  1719. text-align: center;
  1720. padding: 10px 0 4px;
  1721. border-top: 1px solid rgba(255,255,255,0.06);
  1722. margin-top: 8px;
  1723. }
  1724. .nrc-log-count {
  1725. font-size: 10px;
  1726. padding: 2px 6px;
  1727. border-radius: 4px;
  1728. background: rgba(99,102,241,0.1);
  1729. color: #818cf8;
  1730. flex-shrink: 0;
  1731. }
  1732. .nrc-expand {
  1733. font-size: 10px;
  1734. color: var(--text-tertiary, #666);
  1735. transition: transform 0.2s;
  1736. flex-shrink: 0;
  1737. }
  1738. .nrc-expand.expanded {
  1739. transform: rotate(90deg);
  1740. }
  1741. .nrc-logs {
  1742. border-top: 1px solid rgba(255,255,255,0.04);
  1743. padding: 6px 10px;
  1744. max-height: 400px;
  1745. overflow-y: auto;
  1746. }
  1747. .nrc-log {
  1748. padding: 4px 0;
  1749. border-bottom: 1px solid rgba(255,255,255,0.02);
  1750. font-size: 11px;
  1751. line-height: 1.5;
  1752. }
  1753. .nrc-log:last-child { border-bottom: none; }
  1754. .nrc-log-icon {
  1755. margin-right: 4px;
  1756. }
  1757. .nrc-log-time {
  1758. font-family: 'Cascadia Code', 'Fira Code', monospace;
  1759. font-size: 10px;
  1760. color: var(--text-tertiary, #555);
  1761. margin-right: 6px;
  1762. }
  1763. .nrc-log-tag {
  1764. font-size: 10px;
  1765. padding: 1px 4px;
  1766. border-radius: 3px;
  1767. margin-right: 6px;
  1768. font-weight: 600;
  1769. flex-shrink: 0;
  1770. }
  1771. .log-type-tool_call .nrc-log-tag { background: rgba(59,130,246,0.15); color: #60a5fa; }
  1772. .log-type-tool_result .nrc-log-tag { background: rgba(16,185,129,0.15); color: #34d399; }
  1773. .log-type-thinking .nrc-log-tag { background: rgba(168,85,247,0.15); color: #c084fc; }
  1774. .log-type-error .nrc-log-tag { background: rgba(239,68,68,0.15); color: #f87171; }
  1775. .log-type-info .nrc-log-tag { background: rgba(107,114,128,0.15); color: #9ca3af; }
  1776. .nrc-log-msg {
  1777. color: var(--text-secondary, #ccc);
  1778. word-break: break-all;
  1779. }
  1780. .nrc-log-detail {
  1781. margin-top: 3px;
  1782. margin-left: 22px;
  1783. font-family: 'Cascadia Code', 'Fira Code', monospace;
  1784. font-size: 10px;
  1785. color: var(--text-tertiary, #888);
  1786. background: rgba(0,0,0,0.2);
  1787. padding: 4px 6px;
  1788. border-radius: 4px;
  1789. white-space: pre-wrap;
  1790. word-break: break-all;
  1791. max-height: 100px;
  1792. overflow-y: auto;
  1793. }
  1794. .prop-section {
  1795. margin-bottom: 14px;
  1796. }
  1797. .prop-header {
  1798. display: flex;
  1799. align-items: center;
  1800. justify-content: space-between;
  1801. margin-bottom: 6px;
  1802. }
  1803. .prop-label {
  1804. display: block;
  1805. font-size: 12px;
  1806. font-weight: 500;
  1807. color: var(--text-secondary, #999);
  1808. margin-bottom: 6px;
  1809. }
  1810. .var-form {
  1811. background: rgba(255,255,255,0.03);
  1812. border-radius: 6px;
  1813. padding: 8px;
  1814. margin-bottom: 8px;
  1815. }
  1816. .var-form-row {
  1817. display: flex;
  1818. gap: 6px;
  1819. margin-bottom: 6px;
  1820. }
  1821. .cond-form {
  1822. margin-bottom: 8px;
  1823. }
  1824. .cond-form-row {
  1825. display: flex;
  1826. align-items: center;
  1827. gap: 6px;
  1828. }
  1829. .cond-tag {
  1830. font-size: 11px;
  1831. padding: 2px 8px;
  1832. border-radius: 4px;
  1833. background: rgba(249,115,22,0.15);
  1834. color: #F97316;
  1835. flex-shrink: 0;
  1836. font-weight: 600;
  1837. }
  1838. .text-btn {
  1839. background: none;
  1840. border: none;
  1841. color: var(--color-accent, #2563EB);
  1842. font-size: 12px;
  1843. cursor: pointer;
  1844. padding: 2px 4px;
  1845. }
  1846. .text-btn:hover { text-decoration: underline; }
  1847. .text-btn.danger { color: #EF4444; }
  1848. .empty-hint {
  1849. font-size: 12px;
  1850. color: var(--text-tertiary, #555);
  1851. font-style: italic;
  1852. }
  1853. .passthrough-hint {
  1854. font-size: 11px;
  1855. color: var(--text-tertiary, #555);
  1856. margin-top: 6px;
  1857. padding: 4px 8px;
  1858. background: rgba(249,115,22,0.08);
  1859. border-radius: 4px;
  1860. color: #fb923c;
  1861. }
  1862. /* 通用按钮 */
  1863. .icon-btn {
  1864. width: 32px;
  1865. height: 32px;
  1866. display: flex;
  1867. align-items: center;
  1868. justify-content: center;
  1869. border-radius: 6px;
  1870. border: 1px solid rgba(255,255,255,0.12);
  1871. background: transparent;
  1872. color: var(--text-secondary, #999);
  1873. cursor: pointer;
  1874. transition: all 0.15s;
  1875. }
  1876. .icon-btn:hover {
  1877. background: rgba(255,255,255,0.08);
  1878. color: #fff;
  1879. }
  1880. .primary-btn {
  1881. display: flex;
  1882. align-items: center;
  1883. gap: 6px;
  1884. padding: 7px 16px;
  1885. background: var(--color-primary, #2563EB);
  1886. color: #fff;
  1887. border: none;
  1888. border-radius: 6px;
  1889. font-size: 13px;
  1890. cursor: pointer;
  1891. transition: all 0.15s;
  1892. }
  1893. .primary-btn:hover { filter: brightness(1.15); }
  1894. .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  1895. /* 自动关联按钮 */
  1896. .associate-btn {
  1897. display: flex;
  1898. align-items: center;
  1899. gap: 6px;
  1900. padding: 7px 16px;
  1901. background: rgba(129, 140, 248, 0.12);
  1902. color: #818cf8;
  1903. border: 1px solid rgba(129, 140, 248, 0.25);
  1904. border-radius: 6px;
  1905. font-size: 13px;
  1906. cursor: pointer;
  1907. transition: all 0.15s;
  1908. }
  1909. .associate-btn:hover { background: rgba(129, 140, 248, 0.2); border-color: rgba(129, 140, 248, 0.4); }
  1910. .associate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  1911. .associate-btn.active { background: rgba(129, 140, 248, 0.18); border-color: rgba(129, 140, 248, 0.35); }
  1912. .clear-assoc-btn {
  1913. display: flex;
  1914. align-items: center;
  1915. justify-content: center;
  1916. width: 30px;
  1917. height: 30px;
  1918. background: transparent;
  1919. color: var(--text-tertiary);
  1920. border: 1px solid var(--border-color);
  1921. border-radius: 6px;
  1922. cursor: pointer;
  1923. transition: all 0.15s;
  1924. }
  1925. .clear-assoc-btn:hover { color: #f87171; border-color: rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); }
  1926. /* 虚拟节点(自动关联产生的技能节点) */
  1927. .canvas-area :deep(.vue-flow__node.virtual-node) {
  1928. opacity: 0.75;
  1929. pointer-events: none;
  1930. }
  1931. .canvas-area :deep(.vue-flow__node.virtual-node .base-node) {
  1932. /* 内容也穿透:鼠标按下技能节点时事件落到下层虚框,由父节点接管拖拽实现整体移动 */
  1933. pointer-events: none;
  1934. border-style: dashed !important;
  1935. border-color: #818cf8 !important;
  1936. background: rgba(129, 140, 248, 0.06) !important;
  1937. }
  1938. .canvas-area :deep(.vue-flow__node.virtual-node .base-node:hover) {
  1939. border-color: #a5b4fc !important;
  1940. }
  1941. /* 虚线分组容器节点:必须保留 pointer-events 以接收拖动事件实现整体拖动 */
  1942. .canvas-area :deep(.vue-flow__node.virtual-group-node) {
  1943. pointer-events: all !important;
  1944. }
  1945. .canvas-area :deep(.vue-flow__node.virtual-group-node .vue-flow__handle) {
  1946. pointer-events: none !important;
  1947. opacity: 0 !important;
  1948. width: 1px !important;
  1949. height: 1px !important;
  1950. min-width: 1px !important;
  1951. min-height: 1px !important;
  1952. background: transparent !important;
  1953. border: none !important;
  1954. }
  1955. /* 虚线分组边 */
  1956. .canvas-area :deep(.vue-flow__edge.virtual-group-edge .vue-flow__edge-path) {
  1957. stroke: #818cf8;
  1958. stroke-width: 1.5;
  1959. stroke-dasharray: 5 4;
  1960. opacity: 0.7;
  1961. }
  1962. /* 运行按钮 */
  1963. .run-btn {
  1964. display: flex;
  1965. align-items: center;
  1966. gap: 6px;
  1967. padding: 7px 16px;
  1968. background: #10B981;
  1969. color: #fff;
  1970. border: none;
  1971. border-radius: 6px;
  1972. font-size: 13px;
  1973. cursor: pointer;
  1974. transition: all 0.15s;
  1975. }
  1976. .run-btn:hover { filter: brightness(1.15); }
  1977. .run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  1978. .run-btn.is-running {
  1979. background: #6366f1;
  1980. animation: pulse-btn 1.5s infinite;
  1981. }
  1982. @keyframes pulse-btn {
  1983. 0%, 100% { opacity: 1; }
  1984. 50% { opacity: 0.7; }
  1985. }
  1986. /* 节点执行状态 */
  1987. .canvas-area :deep(.vue-flow__node.node-status-running) {
  1988. animation: node-pulse 1.5s infinite;
  1989. }
  1990. .canvas-area :deep(.vue-flow__node.node-status-success) {
  1991. filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
  1992. }
  1993. .canvas-area :deep(.vue-flow__node.node-status-failed) {
  1994. filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
  1995. }
  1996. .canvas-area :deep(.vue-flow__node.node-status-skipped) {
  1997. opacity: 0.4;
  1998. }
  1999. @keyframes node-pulse {
  2000. 0%, 100% { filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5)); }
  2001. 50% { filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9)); }
  2002. }
  2003. /* 运行对话框 */
  2004. .dialog-overlay {
  2005. position: fixed;
  2006. inset: 0;
  2007. background: rgba(0,0,0,0.75);
  2008. display: flex;
  2009. align-items: center;
  2010. justify-content: center;
  2011. z-index: 100;
  2012. }
  2013. .run-dialog {
  2014. background: var(--bg-card, #1a1a2e);
  2015. border-radius: 12px;
  2016. width: 400px;
  2017. max-height: 80vh;
  2018. display: flex;
  2019. flex-direction: column;
  2020. border: 1px solid rgba(255,255,255,0.1);
  2021. }
  2022. .prompt-editor-dialog {
  2023. background: var(--bg-card, #1a1a2e);
  2024. border-radius: 12px;
  2025. width: 80vw;
  2026. max-width: 960px;
  2027. height: 75vh;
  2028. display: flex;
  2029. flex-direction: column;
  2030. border: 1px solid rgba(255,255,255,0.1);
  2031. }
  2032. .prompt-editor-body {
  2033. flex: 1;
  2034. min-height: 0;
  2035. overflow: hidden;
  2036. }
  2037. .dialog-header {
  2038. display: flex;
  2039. align-items: center;
  2040. justify-content: space-between;
  2041. padding: 14px 18px;
  2042. border-bottom: 1px solid rgba(255,255,255,0.08);
  2043. }
  2044. .dialog-title { font-size: 15px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
  2045. .dialog-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
  2046. .dialog-footer {
  2047. display: flex;
  2048. justify-content: flex-end;
  2049. gap: 10px;
  2050. padding: 12px 18px;
  2051. border-top: 1px solid rgba(255,255,255,0.08);
  2052. }
  2053. .cancel-btn {
  2054. padding: 7px 16px;
  2055. border: 1px solid rgba(255,255,255,0.15);
  2056. background: transparent;
  2057. color: var(--text-secondary, #999);
  2058. border-radius: 6px;
  2059. cursor: pointer;
  2060. font-size: 13px;
  2061. }
  2062. .cancel-btn:hover { background: rgba(255,255,255,0.06); }
  2063. .run-field { margin-bottom: 12px; }
  2064. .run-field-label {
  2065. display: block;
  2066. font-size: 12px;
  2067. color: var(--text-secondary, #999);
  2068. margin-bottom: 4px;
  2069. }
  2070. /* 文件上传区域 */
  2071. .run-upload { margin-top: 16px; }
  2072. .upload-btns { display: flex; gap: 8px; }
  2073. .upload-area {
  2074. flex: 1;
  2075. border: 1px dashed rgba(255,255,255,0.2);
  2076. border-radius: 6px;
  2077. padding: 12px;
  2078. text-align: center;
  2079. cursor: pointer;
  2080. transition: border-color 0.2s;
  2081. }
  2082. .upload-area:hover { border-color: var(--primary); }
  2083. .upload-hint { font-size: 12px; color: var(--text-secondary, #999); }
  2084. .upload-list { margin-top: 8px; }
  2085. .upload-item {
  2086. display: flex;
  2087. align-items: center;
  2088. justify-content: space-between;
  2089. padding: 4px 8px;
  2090. font-size: 12px;
  2091. color: var(--text-primary, #eee);
  2092. background: rgba(255,255,255,0.05);
  2093. border-radius: 4px;
  2094. margin-bottom: 4px;
  2095. }
  2096. .upload-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  2097. .upload-remove {
  2098. background: none;
  2099. border: none;
  2100. color: var(--text-secondary, #999);
  2101. cursor: pointer;
  2102. font-size: 16px;
  2103. padding: 0 4px;
  2104. line-height: 1;
  2105. }
  2106. .upload-remove:hover { color: #f87171; }
  2107. /* 边映射状态 */
  2108. .edge-status-badge {
  2109. font-size: 11px;
  2110. padding: 2px 8px;
  2111. border-radius: 4px;
  2112. font-weight: 600;
  2113. }
  2114. .edge-status-badge.es-ok { background: rgba(34,197,94,0.15); color: #4ade80; }
  2115. .edge-status-badge.es-unmapped { background: rgba(107,114,128,0.15); color: #9ca3af; }
  2116. .edge-status-badge.es-partial { background: rgba(245,158,11,0.15); color: #fbbf24; }
  2117. .edge-status-badge.es-mismatch { background: rgba(239,68,68,0.15); color: #f87171; }
  2118. .mapping-list {
  2119. margin-top: 6px;
  2120. }
  2121. .mapping-item {
  2122. display: flex;
  2123. align-items: center;
  2124. gap: 4px;
  2125. padding: 4px 8px;
  2126. border-radius: 4px;
  2127. margin-bottom: 3px;
  2128. font-size: 12px;
  2129. font-family: 'Cascadia Code', 'Fira Code', monospace;
  2130. }
  2131. .mapping-item.mapped { background: rgba(34,197,94,0.08); }
  2132. .mapping-item.mismatch { background: rgba(239,68,68,0.08); }
  2133. .mapping-item.unmapped { background: rgba(245,158,11,0.08); }
  2134. .mapping-item.extra { background: rgba(107,114,128,0.06); }
  2135. .mapping-field {
  2136. color: var(--text-primary, #e0e0e0);
  2137. font-weight: 500;
  2138. }
  2139. .mapping-arrow {
  2140. color: var(--text-tertiary, #666);
  2141. }
  2142. .mapping-type {
  2143. color: var(--text-tertiary, #666);
  2144. font-size: 10px;
  2145. }
  2146. /* 右键菜单 */
  2147. .context-menu-overlay {
  2148. position: fixed;
  2149. inset: 0;
  2150. z-index: 200;
  2151. }
  2152. .context-menu {
  2153. position: fixed;
  2154. background: #1e1b2e;
  2155. border: 1px solid rgba(255, 255, 255, 0.12);
  2156. border-radius: 8px;
  2157. padding: 4px;
  2158. min-width: 140px;
  2159. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  2160. }
  2161. .ctx-item {
  2162. display: flex;
  2163. align-items: center;
  2164. gap: 8px;
  2165. width: 100%;
  2166. padding: 8px 12px;
  2167. border: none;
  2168. border-radius: 6px;
  2169. background: transparent;
  2170. color: var(--text-primary, #e0e0e0);
  2171. font-size: 13px;
  2172. cursor: pointer;
  2173. transition: background 0.12s;
  2174. text-align: left;
  2175. }
  2176. .ctx-item:hover {
  2177. background: rgba(255, 255, 255, 0.08);
  2178. }
  2179. .ctx-item.ctx-danger {
  2180. color: #f87171;
  2181. }
  2182. .ctx-item.ctx-danger:hover {
  2183. background: rgba(239, 68, 68, 0.15);
  2184. }
  2185. /* 版本历史面板 */
  2186. .version-panel {
  2187. position: fixed;
  2188. right: 0;
  2189. top: 0;
  2190. bottom: 0;
  2191. width: 380px;
  2192. background: var(--bg-secondary, #fff);
  2193. border-left: 1px solid var(--border-color, #e5e7eb);
  2194. display: flex;
  2195. flex-direction: column;
  2196. z-index: 100;
  2197. box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
  2198. }
  2199. .version-panel .vp-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color, #e5e7eb); }
  2200. .version-panel .vp-title { font-size: 15px; font-weight: 600; margin: 0; }
  2201. .version-panel .vp-list { max-height: 300px; overflow-y: auto; padding: 8px; }
  2202. .version-panel .vp-empty { text-align: center; color: var(--text-tertiary, #999); padding: 24px 0; font-size: 13px; }
  2203. .version-panel .version-items { display: flex; flex-direction: column; gap: 4px; }
  2204. .version-panel .version-item { display: flex; align-items: stretch; border-radius: 6px; border: 1px solid transparent; cursor: pointer; transition: all 0.15s; }
  2205. .version-panel .version-item:hover { background: rgba(37, 99, 235, 0.06); }
  2206. .version-panel .version-item.active { background: rgba(37, 99, 235, 0.1); border-color: rgba(37, 99, 235, 0.3); }
  2207. .version-panel .vi-main { flex: 1; padding: 8px 10px; min-width: 0; }
  2208. .version-panel .vi-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
  2209. .version-panel .vi-version { font-size: 13px; font-weight: 600; color: var(--color-accent, #2563eb); }
  2210. .version-panel .vi-current { font-size: 10px; font-weight: 500; background: rgba(34, 197, 94, 0.15); color: #22c55e; padding: 0 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle; }
  2211. .version-panel .vi-time { font-size: 11px; color: var(--text-tertiary, #999); }
  2212. .version-panel .vi-message { font-size: 12px; color: var(--text-secondary, #666); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  2213. .version-panel .vi-message-edit { display: flex; gap: 4px; margin-top: 4px; align-items: center; }
  2214. .version-panel .vi-actions { display: flex; flex-direction: column; justify-content: center; gap: 2px; padding: 4px 6px; opacity: 0; transition: opacity 0.15s; }
  2215. .version-panel .version-item:hover .vi-actions { opacity: 1; }
  2216. .vi-action-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; background: transparent; transition: background 0.15s; }
  2217. .rollback-btn { color: var(--color-danger, #ef4444); }
  2218. .rollback-btn:hover { background: rgba(239, 68, 68, 0.15); }
  2219. .version-panel .vp-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; border-top: 1px solid var(--border-color, #e5e7eb); }
  2220. .version-panel .vp-content-header { padding: 8px 16px; font-size: 13px; font-weight: 500; color: var(--text-secondary, #666); border-bottom: 1px solid var(--border-color, #e5e7eb); flex-shrink: 0; }
  2221. .version-json { flex: 1; overflow: auto; padding: 12px 16px; margin: 0; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; color: var(--text-secondary, #666); font-family: 'Consolas', 'Monaco', monospace; background: transparent; }
  2222. /* 版本按钮 */
  2223. .version-btn {
  2224. display: inline-flex; align-items: center; gap: 4px; padding: 5px 10px;
  2225. border: 1px solid var(--border-color, #e5e7eb); border-radius: 6px;
  2226. background: var(--bg-secondary, #fff); color: var(--text-secondary, #666);
  2227. cursor: pointer; font-size: 13px; transition: all 0.2s;
  2228. }
  2229. .version-btn:hover:not(:disabled) { border-color: var(--color-accent, #2563eb); color: var(--color-accent, #2563eb); }
  2230. .version-btn.active { background: var(--color-accent, #2563eb); color: #fff; border-color: var(--color-accent, #2563eb); }
  2231. .version-btn:disabled { opacity: 0.4; cursor: not-allowed; }
  2232. /* 右侧滑入动画 */
  2233. .slide-right-enter-active, .slide-right-leave-active { transition: transform 0.25s ease; }
  2234. .slide-right-enter-from, .slide-right-leave-to { transform: translateX(100%); }
  2235. </style>