WorkflowEditor.vue 142 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127
  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, NCheckbox, NModal, 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, LibraryOutline, DownloadOutline, CloudUploadOutline
  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 { getDataSources } from '../../api/datasource'
  21. import { getGraphSources } from '../../api/graphsource'
  22. import { getKbDocuments } from '../../api/knowledge'
  23. import { listKnowledgeBases } from '../../api/rag'
  24. import { normalizeKebabCase, isValidKebabCase } from '../../utils/workflowName'
  25. import InputNode from '../../components/workflow/nodes/InputNode.vue'
  26. import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
  27. import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
  28. import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
  29. import KnowledgeRetrievalNode from '../../components/workflow/nodes/KnowledgeRetrievalNode.vue'
  30. import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
  31. import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
  32. import SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
  33. import VirtualGroupNode from '../../components/workflow/nodes/VirtualGroupNode.vue'
  34. import CodeEditor from '../../components/skill/CodeEditor.vue'
  35. import { inferNewEdge, refreshMappingsForNode, getNodeOutputs, getNodeInputs, getReachablePredecessors, getReachableSuccessors, IO_TYPES } from '../../utils/ioInference'
  36. import { useVersionHistory } from '../../composables/useVersionHistory'
  37. import { useWorkflowRunner, unwrapNodeOutput, nodeOutputStatus, nodeOutputMessage } from '../../composables/useWorkflowRunner'
  38. import { useNodeFields } from '../../composables/useNodeFields'
  39. import '@vue-flow/core/dist/style.css'
  40. import '@vue-flow/core/dist/theme-default.css'
  41. const route = useRoute()
  42. const router = useRouter()
  43. const wfStore = useWorkflowStore()
  44. const skillStore = useSkillStore()
  45. const message = useMessage()
  46. // 路由参数 name(kebab-case);旧数字 id 作为字符串同样可被后端解析
  47. const routeName = computed(() => route.params.name || null)
  48. // 工作流 form 字段:name (kebab-case 唯一标识) / displayName (中文显示名) / description
  49. const form = ref({
  50. name: '',
  51. displayName: '',
  52. description: ''
  53. })
  54. const saving = ref(false)
  55. // 名称 5 态机(参考 SkillEdit.vue):
  56. // empty / invalid / will-rename / will-normalize / valid
  57. const nameState = computed(() => {
  58. const raw = form.value.name || ''
  59. if (!raw.trim()) return { status: 'empty', normalized: '', changed: false }
  60. const normalized = normalizeKebabCase(raw)
  61. const valid = isValidKebabCase(normalized)
  62. if (!valid) return { status: 'invalid', normalized, changed: false }
  63. // 编辑态:规范化后与路由 name 不同 → 保存时触发重命名
  64. if (routeName.value && normalized !== routeName.value) {
  65. return { status: 'will-rename', normalized, changed: true }
  66. }
  67. // raw 不规范但规范化后等于当前 name → 仅规范化输入
  68. if (normalized !== raw) return { status: 'will-normalize', normalized, changed: true }
  69. return { status: 'valid', normalized, changed: false }
  70. })
  71. // 兼容字段(保留旧变量名以减少 template 改动):workflowName = displayName
  72. const workflowName = computed({
  73. get: () => form.value.displayName,
  74. set: v => { form.value.displayName = v }
  75. })
  76. const workflowDesc = computed({
  77. get: () => form.value.description,
  78. set: v => { form.value.description = v }
  79. })
  80. // ========== SmartAction 弹出编辑器 ==========
  81. const showPromptEditor = ref(false)
  82. const promptEditValue = ref('')
  83. // ========== Vue Flow ==========
  84. const {
  85. onConnect, addEdges, addNodes, removeNodes, removeEdges,
  86. updateNodeData: vfUpdateNode,
  87. project, vueFlowRef, getNodes, getEdges, setNodes, setEdges, fitView, onInit,
  88. onNodeDrag, onNodeDragStop
  89. } = useVueFlow()
  90. const selectedNode = ref(null)
  91. // 从 userInput 节点提取输入字段定义
  92. const inputFields = computed(() => {
  93. const fields = []
  94. for (const node of getNodes.value) {
  95. if (node.type === 'userInput' && node.data?.variables) {
  96. for (const v of node.data.variables) {
  97. if (v.name) fields.push({ ...v, nodeName: node.data?.nodeName || '输入节点' })
  98. }
  99. }
  100. }
  101. return fields
  102. })
  103. const formData = reactive({})
  104. const canRun = computed(() => !!routeName.value && !running.value && getNodes.value.length > 0)
  105. // ========== 自动关联 ==========
  106. const associating = ref(false)
  107. const hasAssociations = ref(false)
  108. // ========== 版本历史 ==========
  109. const {
  110. showVersionPanel, versions, versionsLoading, selectedVersion, versionContent,
  111. editingMessage, editingMessageText,
  112. toggleVersionPanel, handleVersionSelect, handleRollback,
  113. startEditMessage, saveMessage, cancelEditMessage,
  114. formatTime, formatVersionJson
  115. } = useVersionHistory()
  116. // ========== 工作流运行状态 ==========
  117. const {
  118. running, showRunDialog, showResult, workflowResult,
  119. nodeStatuses, activeTab, nodeResults, expandedLogs, expandedContext, expandedThinking,
  120. lastRunId, lastRunWorkflowId,
  121. startRun, openRunDialog, cancelRun, closeResult,
  122. statusText, logTypeIcon, logTypeLabel, formatTimestamp, toggleLogs,
  123. toggleContext, toggleThinking, formatContextValue
  124. } = useWorkflowRunner({
  125. getNodes,
  126. nodeTypeInfo: {
  127. userInput: { color: '#10B981', typeLabel: '用户输入' },
  128. llm: { color: '#8B5CF6', typeLabel: '大模型' },
  129. skill: { color: '#06B6D4', typeLabel: '技能' },
  130. agent: { color: '#F59E0B', typeLabel: '智能体' },
  131. smartAction: { color: '#EC4899', typeLabel: '智能操作' },
  132. condition: { color: '#F97316', typeLabel: '条件' },
  133. knowledgeRetrieval: { color: '#10B981', typeLabel: '知识库检索' },
  134. output: { color: '#EF4444', typeLabel: '输出' }
  135. },
  136. workflowIdRef: routeName
  137. })
  138. const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
  139. const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
  140. // 运行结果高亮:点击画布节点后滚动到对应结果卡片并短暂高亮
  141. const highlightedNodeId = ref(null)
  142. let highlightTimer = null
  143. function scrollToNodeResult(nodeId) {
  144. if (activeTab.value !== 'result' || nodeResults.length === 0) return
  145. const el = document.getElementById('node-result-' + nodeId)
  146. if (!el) return
  147. el.scrollIntoView({ behavior: 'smooth', block: 'center' })
  148. highlightedNodeId.value = nodeId
  149. if (highlightTimer) clearTimeout(highlightTimer)
  150. highlightTimer = setTimeout(() => { highlightedNodeId.value = null }, 2000)
  151. }
  152. onConnect((params) => {
  153. const sourceNode = getNodes.value.find(n => n.id === params.source)
  154. const targetNode = getNodes.value.find(n => n.id === params.target)
  155. const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode, getNodes.value, getEdges.value) : null
  156. addEdges([{
  157. ...params,
  158. type: 'bezier',
  159. animated: true,
  160. // 视觉上统一灰色,io 推断状态仅写入 data 供属性面板展示(不再影响线条颜色)
  161. style: defaultEdgeStyle,
  162. data: inferred?.data || {}
  163. }])
  164. })
  165. // 自适应更新虚线框与其源节点的连线端点:
  166. // 虚线框在源节点上方时,连线从虚线框底边中点连到源节点顶边中点;
  167. // 虚线框在源节点下方时,连线从虚线框顶边中点连到源节点底边中点。
  168. function updateVirtualGroupEdge(edge) {
  169. if (!edge?.data?.virtual) return
  170. const group = getNodes.value.find(n => n.id === edge.source)
  171. const source = getNodes.value.find(n => n.id === edge.target)
  172. if (!group || !source) return
  173. const groupH = group.dimensions?.height || group.height || 0
  174. const sourceH = source.dimensions?.height || source.height || 0
  175. const groupCenterY = group.position.y + groupH / 2
  176. const sourceCenterY = source.position.y + sourceH / 2
  177. if (groupCenterY < sourceCenterY) {
  178. // 虚线框在源节点上方
  179. edge.sourceHandle = 'bottom'
  180. edge.targetHandle = 'top-target'
  181. } else {
  182. // 虚线框在源节点下方
  183. edge.sourceHandle = 'top'
  184. edge.targetHandle = 'bottom-target'
  185. }
  186. }
  187. let virtualEdgeDragInProgress = false
  188. onNodeDrag(({ node }) => {
  189. if (node?.type !== 'virtualGroup') return
  190. if (virtualEdgeDragInProgress) return
  191. virtualEdgeDragInProgress = true
  192. requestAnimationFrame(() => {
  193. virtualEdgeDragInProgress = false
  194. const virtualEdges = getEdges.value.filter(e => e.data?.virtual && e.source === node.id)
  195. for (const edge of virtualEdges) {
  196. const prevSourceHandle = edge.sourceHandle
  197. const prevTargetHandle = edge.targetHandle
  198. updateVirtualGroupEdge(edge)
  199. if (edge.sourceHandle !== prevSourceHandle || edge.targetHandle !== prevTargetHandle) {
  200. // 通过替换 edge 对象触发 Vue Flow 重新计算路径
  201. getEdges.value = getEdges.value.map(e => (e.id === edge.id ? { ...edge } : e))
  202. }
  203. }
  204. })
  205. })
  206. onNodeDragStop(() => {
  207. virtualEdgeDragInProgress = false
  208. })
  209. // 批量更新边的映射数据(仅写入 data,不再改变 style;视觉统一灰色,避免不同映射状态造成颜色差异)
  210. function applyEdgeMappingUpdates(updates) {
  211. for (const upd of updates) {
  212. const edge = getEdges.value.find(e => e.id === upd.id)
  213. if (edge) {
  214. edge.data = { ...edge.data, ...upd.data }
  215. }
  216. }
  217. }
  218. // ========== 节点类型定义 ==========
  219. const nodeTypes = [
  220. { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
  221. { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
  222. { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
  223. { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
  224. { type: 'smartAction', label: '智能操作', icon: markRaw(ColorWandOutline), color: '#EC4899' },
  225. { type: 'knowledgeRetrieval', label: '知识库检索', icon: markRaw(LibraryOutline), color: '#10B981' },
  226. { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
  227. { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
  228. ]
  229. const nodeTypeInfo = {
  230. userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
  231. llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
  232. skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
  233. agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
  234. smartAction: { color: '#EC4899', typeLabel: '智能操作', icon: markRaw(ColorWandOutline) },
  235. knowledgeRetrieval: { color: '#10B981', typeLabel: '知识库检索', icon: markRaw(LibraryOutline) },
  236. condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
  237. output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
  238. }
  239. function getDefaultData(type) {
  240. switch (type) {
  241. case 'userInput': return { nodeName: '', label: '用户输入', variables: [] }
  242. case 'llm': return { nodeName: '', label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
  243. case 'agent': return { nodeName: '', label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
  244. case 'skill': return { nodeName: '', label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
  245. case 'smartAction': return { nodeName: '', label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
  246. case 'knowledgeRetrieval': return {
  247. nodeName: '',
  248. label: '知识库检索',
  249. source: 'document',
  250. query: '',
  251. allDocuments: true,
  252. documentIds: [],
  253. allDatasources: false,
  254. datasourceIds: [],
  255. allGraphSources: false,
  256. graphSourceIds: [],
  257. knowledgeBaseId: null,
  258. topK: 5,
  259. inputs: [],
  260. outputs: [
  261. { name: 'evidences', label: '检索证据列表', type: 'array', description: '命中的知识片段集合', mapping: null },
  262. { name: 'evidenceCount', label: '证据数量', type: 'number', description: '命中证据条数', mapping: null },
  263. { name: 'sourceType', label: '来源类型', type: 'string', description: 'DOCUMENT / STRUCTURED_DATA / GRAPH / HYBRID', mapping: null },
  264. { name: 'diagnostics', label: '诊断信息', type: 'object', description: '检索过程的诊断信息', mapping: null }
  265. ]
  266. }
  267. case 'condition': return { nodeName: '', label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
  268. case 'output': return { nodeName: '', label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
  269. default: return { nodeName: '', label: '节点' }
  270. }
  271. }
  272. let nodeIdCounter = 0
  273. function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
  274. // ========== 拖拽到画布 ==========
  275. function onDragOver(e) {
  276. e.preventDefault()
  277. e.dataTransfer.dropEffect = 'move'
  278. }
  279. function onDrop(e) {
  280. const type = e.dataTransfer.getData('application/vueflow')
  281. if (!type) return
  282. const bounds = vueFlowRef.value.getBoundingClientRect()
  283. const position = project({
  284. x: e.clientX - bounds.left,
  285. y: e.clientY - bounds.top
  286. })
  287. const id = genId()
  288. addNodes([{
  289. id,
  290. type,
  291. position,
  292. data: getDefaultData(type)
  293. }])
  294. }
  295. function onDragStart(e, type) {
  296. e.dataTransfer.setData('application/vueflow', type)
  297. e.dataTransfer.effectAllowed = 'move'
  298. }
  299. // ========== 节点选中 ==========
  300. function onNodeClick({ node }) {
  301. selectedNode.value = node
  302. scrollToNodeResult(node.id)
  303. }
  304. function onPaneClick() {
  305. selectedNode.value = null
  306. selectedEdgeId.value = null
  307. contextMenu.value = null
  308. }
  309. // ========== 节点右键菜单 ==========
  310. const contextMenu = ref(null) // { x, y, nodeId }
  311. function onNodeContextMenu({ event, node }) {
  312. event.preventDefault()
  313. contextMenu.value = { x: event.clientX, y: event.clientY, nodeId: node.id }
  314. }
  315. function closeContextMenu() {
  316. contextMenu.value = null
  317. }
  318. function deleteNodeById(nodeId) {
  319. const affectedNodeIds = new Set()
  320. for (const edge of getEdges.value) {
  321. if (edge.source === nodeId || edge.target === nodeId) {
  322. affectedNodeIds.add(edge.source)
  323. affectedNodeIds.add(edge.target)
  324. }
  325. }
  326. removeNodes(nodeId)
  327. // 节点删除后,其后继节点中指向该节点的显式关联回退为自动推断
  328. revertSuccessorMappings(nodeId)
  329. if (selectedNode.value?.id === nodeId) {
  330. selectedNode.value = null
  331. }
  332. closeContextMenu()
  333. for (const id of affectedNodeIds) {
  334. if (id !== nodeId) {
  335. applyEdgeMappingUpdates(refreshMappingsForNode(id, getNodes.value, getEdges.value))
  336. }
  337. }
  338. }
  339. // ========== 连接线选中与删除 ==========
  340. const selectedEdgeId = ref(null)
  341. function onEdgeClick({ edge }) {
  342. selectedEdgeId.value = edge.id
  343. selectedNode.value = null
  344. }
  345. function deleteSelectedEdge() {
  346. if (!selectedEdgeId.value) return
  347. const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
  348. const sourceId = edge?.source
  349. const targetId = edge?.target
  350. removeEdges(selectedEdgeId.value)
  351. selectedEdgeId.value = null
  352. if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
  353. if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
  354. }
  355. function onKeyDown(e) {
  356. if (e.key === 'Delete' || e.key === 'Backspace') {
  357. const tag = e.target?.tagName
  358. if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
  359. if (selectedNode.value) {
  360. deleteNodeById(selectedNode.value.id)
  361. } else {
  362. deleteSelectedEdge()
  363. }
  364. }
  365. }
  366. const selectedData = computed(() => selectedNode.value?.data || null)
  367. const selectedEdge = computed(() => {
  368. if (!selectedEdgeId.value) return null
  369. return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
  370. })
  371. // ========== 输入变量关联:列出所有可达前驱节点的输出字段供选择 ==========
  372. // 关联范围:不仅限直接上游,而是通过边反向 BFS 找出画布中所有可达的前序节点
  373. const inputMappingOptions = computed(() => {
  374. if (!selectedNode.value) return []
  375. const predecessors = getReachablePredecessors(selectedNode.value.id, getNodes.value, getEdges.value)
  376. const opts = []
  377. for (const src of predecessors) {
  378. const outputs = getNodeOutputs(src)
  379. for (const out of outputs) {
  380. const value = JSON.stringify({ nodeId: src.id, field: out.name })
  381. opts.push({
  382. label: `${src.data?.nodeName || src.data?.label || src.id}.${out.name}`,
  383. value,
  384. nodeId: src.id,
  385. field: out.name
  386. })
  387. }
  388. }
  389. return opts
  390. })
  391. function currentMappingValue(input) {
  392. if (!input?.mapping?.sourceNodeId) return null
  393. return JSON.stringify({ nodeId: input.mapping.sourceNodeId, field: input.mapping.sourceField })
  394. }
  395. function updateInputMapping(input, value) {
  396. if (!value) {
  397. input.mapping = null
  398. } else {
  399. const parsed = JSON.parse(value)
  400. input.mapping = { sourceNodeId: parsed.nodeId, sourceField: parsed.field, sourcePath: null, autoMapped: false }
  401. }
  402. onFieldChange('inputs', [...selectedData.value.inputs])
  403. }
  404. function clearInputMapping(input) {
  405. input.mapping = null
  406. onFieldChange('inputs', [...selectedData.value.inputs])
  407. }
  408. // ========== 字段引入对话框:从前驱/后继节点引入字段 ==========
  409. const importDialog = reactive({
  410. open: false,
  411. mode: null, // 'predecessor' | 'successor'
  412. candidates: []
  413. })
  414. // 前驱输出候选项:用于"前置数据"面板的引入按钮
  415. const predecessorOutputCandidates = computed(() => {
  416. if (!selectedNode.value) return []
  417. const predecessors = getReachablePredecessors(selectedNode.value.id, getNodes.value, getEdges.value)
  418. const result = []
  419. for (const src of predecessors) {
  420. for (const out of getNodeOutputs(src)) {
  421. result.push({
  422. nodeId: src.id,
  423. nodeName: src.data?.nodeName || src.data?.label || src.id,
  424. fieldName: out.name,
  425. fieldLabel: out.label || out.name,
  426. fieldType: out.type || 'string',
  427. fieldDescription: out.description || ''
  428. })
  429. }
  430. }
  431. return result
  432. })
  433. // 后继输入候选项:用于"输出变量"面板的引入按钮
  434. const successorInputCandidates = computed(() => {
  435. if (!selectedNode.value) return []
  436. const successors = getReachableSuccessors(selectedNode.value.id, getNodes.value, getEdges.value)
  437. const result = []
  438. for (const dst of successors) {
  439. for (const inp of getNodeInputs(dst)) {
  440. result.push({
  441. nodeId: dst.id,
  442. nodeName: dst.data?.nodeName || dst.data?.label || dst.id,
  443. fieldName: inp.name,
  444. fieldLabel: inp.label || inp.name,
  445. fieldType: inp.type || 'string',
  446. fieldDescription: inp.description || ''
  447. })
  448. }
  449. }
  450. return result
  451. })
  452. function openImportDialog(mode) {
  453. importDialog.mode = mode
  454. importDialog.candidates = mode === 'predecessor' ? predecessorOutputCandidates.value : successorInputCandidates.value
  455. importDialog.open = true
  456. }
  457. // 确认引入:从前驱输出导入到 inputs(普通节点)或 fields(输出节点),
  458. // 或从后继输入导入到 outputs(普通节点)或 variables(输入节点)
  459. function confirmImportField(candidate) {
  460. if (!selectedNode.value) return
  461. const nodeType = selectedNode.value.type
  462. if (importDialog.mode === 'predecessor') {
  463. // 输出节点:引入到 fields(接收字段仍作为业务输出);其他节点:引入到 inputs(含 mapping)
  464. const targetField = nodeType === 'output' ? 'fields' : 'inputs'
  465. const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
  466. if (exists) {
  467. message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
  468. return
  469. }
  470. const newField = {
  471. name: candidate.fieldName,
  472. label: candidate.fieldLabel || candidate.fieldName,
  473. type: candidate.fieldType || 'string',
  474. description: candidate.fieldDescription || '',
  475. mapping: {
  476. sourceNodeId: candidate.nodeId,
  477. sourceField: candidate.fieldName,
  478. sourcePath: null,
  479. autoMapped: false
  480. }
  481. }
  482. const updated = [...(selectedData.value?.[targetField] || []), newField]
  483. vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
  484. } else if (importDialog.mode === 'successor') {
  485. // 输入节点:引入到 variables(输入变量仍对业务暴露);其他节点:引入到 outputs
  486. const targetField = nodeType === 'userInput' ? 'variables' : 'outputs'
  487. const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
  488. if (exists) {
  489. message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
  490. return
  491. }
  492. const newField = {
  493. name: candidate.fieldName,
  494. label: candidate.fieldLabel || candidate.fieldName,
  495. type: candidate.fieldType || 'string',
  496. description: candidate.fieldDescription || ''
  497. }
  498. const updated = [...(selectedData.value?.[targetField] || []), newField]
  499. vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
  500. // 同步设置后继节点对应 input 的 mapping,指向本节点该输出字段
  501. // (userInput 的 variables 即为其对后继的输出,见 ioInference.getNodeOutputs)
  502. const successorNode = getNodes.value.find(n => n.id === candidate.nodeId)
  503. if (successorNode) {
  504. const succInputs = (successorNode.data?.inputs || []).map(inp => {
  505. if (inp.name === candidate.fieldName) {
  506. return {
  507. ...inp,
  508. mapping: {
  509. sourceNodeId: selectedNode.value.id,
  510. sourceField: candidate.fieldName,
  511. sourcePath: null,
  512. autoMapped: false
  513. }
  514. }
  515. }
  516. return inp
  517. })
  518. vfUpdateNode(candidate.nodeId, { inputs: succInputs })
  519. }
  520. }
  521. // 刷新当前节点关联的边映射,并同步 selectedNode 引用
  522. applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  523. selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  524. importDialog.open = false
  525. }
  526. // ========== 节点名称编辑(含重名校验) ==========
  527. function updateNodeName(value) {
  528. const trimmed = (value || '').trim()
  529. if (trimmed && selectedNode.value) {
  530. const duplicate = getNodes.value.find(n =>
  531. n.id !== selectedNode.value.id &&
  532. n.data?.nodeName === trimmed
  533. )
  534. if (duplicate) {
  535. message.warning(`节点名称「${trimmed}」已被其他节点使用`)
  536. return
  537. }
  538. }
  539. onFieldChange('nodeName', trimmed)
  540. }
  541. // 节点内字段列表名称唯一性校验
  542. const FIELD_LIST_LABELS = {
  543. variables: '输入变量',
  544. inputs: '前置数据',
  545. outputs: '输出变量',
  546. fields: '接收字段'
  547. }
  548. function updateFieldName(item, index, listKey, newName) {
  549. const list = selectedData.value?.[listKey] || []
  550. const trimmed = (newName || '').trim()
  551. if (trimmed && list.some((f, i) => i !== index && f.name === trimmed)) {
  552. message.warning(`变量名「${trimmed}」在${FIELD_LIST_LABELS[listKey] || listKey}中已存在`)
  553. }
  554. item.name = newName
  555. onFieldChange(listKey, [...list])
  556. }
  557. // ========== 智能体选项 ==========
  558. const agentOptions = computed(() =>
  559. (skillStore.skills || []).map(s => ({
  560. label: s.displayName || s.translatedDisplayName || s.name || s.folderName,
  561. value: s.folderName
  562. }))
  563. )
  564. // ========== 数据库模型选项 ==========
  565. const dbModels = ref([])
  566. const defaultModelId = ref(null)
  567. const modelOptions = computed(() =>
  568. dbModels.value.map(m => ({
  569. label: `${m.name} (${m.modelName})`,
  570. value: m.id
  571. }))
  572. )
  573. async function fetchDbModels() {
  574. try {
  575. const res = await getModelList()
  576. dbModels.value = res.data || []
  577. const defaultModel = dbModels.value.find(m => m.isDefault)
  578. defaultModelId.value = defaultModel?.id || null
  579. } catch (e) {
  580. message.error('加载模型列表失败')
  581. }
  582. }
  583. // 失败策略选项
  584. const failStrategyOptions = [
  585. { label: '终止工作流', value: 'abort' },
  586. { label: '跳过继续', value: 'skip' }
  587. ]
  588. // ========== 知识库检索节点:下拉/复选框数据源 ==========
  589. const krSourceOptions = [
  590. { label: '文档数据库', value: 'document' },
  591. { label: '结构化数据库', value: 'structured' },
  592. { label: '知识图谱库', value: 'graph' },
  593. { label: '混合检索', value: 'hybrid' }
  594. ]
  595. const krDocuments = ref([])
  596. const krDocumentsLoading = ref(false)
  597. const krDatasources = ref([])
  598. const krDatasourcesLoading = ref(false)
  599. const krGraphSources = ref([])
  600. const krGraphSourcesLoading = ref(false)
  601. const krKnowledgeBases = ref([])
  602. const krKnowledgeBasesLoading = ref(false)
  603. const krDocumentOptions = computed(() => krDocuments.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
  604. const krDatasourceOptions = computed(() => krDatasources.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
  605. const krGraphSourceOptions = computed(() => krGraphSources.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
  606. const krKnowledgeBaseOptions = computed(() => krKnowledgeBases.value.map(kb => ({ label: kb.name || `#${kb.id}`, value: kb.id })))
  607. async function loadKrDocuments() {
  608. if (krDocuments.value.length || krDocumentsLoading.value) return
  609. krDocumentsLoading.value = true
  610. try {
  611. const res = await getKbDocuments({ page: 1, size: 1000 })
  612. krDocuments.value = res.data?.items || []
  613. } catch (e) {
  614. message.error('加载文档列表失败')
  615. } finally {
  616. krDocumentsLoading.value = false
  617. }
  618. }
  619. async function loadKrDatasources() {
  620. if (krDatasources.value.length || krDatasourcesLoading.value) return
  621. krDatasourcesLoading.value = true
  622. try {
  623. const res = await getDataSources()
  624. krDatasources.value = res.data || []
  625. } catch (e) {
  626. message.error('加载结构化数据源失败')
  627. } finally {
  628. krDatasourcesLoading.value = false
  629. }
  630. }
  631. async function loadKrGraphSources() {
  632. if (krGraphSources.value.length || krGraphSourcesLoading.value) return
  633. krGraphSourcesLoading.value = true
  634. try {
  635. const res = await getGraphSources()
  636. krGraphSources.value = res.data || []
  637. } catch (e) {
  638. message.error('加载图谱数据源失败')
  639. } finally {
  640. krGraphSourcesLoading.value = false
  641. }
  642. }
  643. async function loadKrKnowledgeBases() {
  644. if (krKnowledgeBases.value.length || krKnowledgeBasesLoading.value) return
  645. krKnowledgeBasesLoading.value = true
  646. try {
  647. krKnowledgeBases.value = await listKnowledgeBases() || []
  648. } catch (e) {
  649. message.error('加载知识库列表失败')
  650. } finally {
  651. krKnowledgeBasesLoading.value = false
  652. }
  653. }
  654. function toggleKrListField(field, value) {
  655. if (!selectedData.value) return
  656. const list = new Set(selectedData.value[field] || [])
  657. if (list.has(value)) list.delete(value)
  658. else list.add(value)
  659. onFieldChange(field, [...list])
  660. }
  661. // ========== 节点字段操作(来自 composable) ==========
  662. const {
  663. onFieldChange, onModelChange,
  664. addListField, removeListField,
  665. addVariable, removeVariable,
  666. addOutputField, removeOutputField,
  667. addElif, removeElif, onConditionChange,
  668. revertSuccessorMappings
  669. } = useNodeFields({
  670. selectedNode,
  671. selectedData,
  672. vfUpdateNode,
  673. getNodes: () => getNodes,
  674. getEdges: () => getEdges,
  675. applyEdgeMappingUpdates,
  676. skillStore,
  677. agentOptions
  678. })
  679. // 兼容原有命名(template 中使用)
  680. const addLlmField = addListField
  681. const removeLlmField = removeListField
  682. const addAgentField = addListField
  683. const removeAgentField = removeListField
  684. const addConditionInput = () => addListField('inputs')
  685. const removeConditionInput = removeListField.bind(null, 'inputs')
  686. // ========== 边状态文案 ==========
  687. function edgeStatusText(status) {
  688. return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
  689. }
  690. // ========== SmartAction 弹出编辑器 ==========
  691. function openPromptEditor() {
  692. promptEditValue.value = selectedData.value?.actionPrompt || ''
  693. showPromptEditor.value = true
  694. }
  695. function confirmPromptEdit() {
  696. onFieldChange('actionPrompt', promptEditValue.value)
  697. showPromptEditor.value = false
  698. }
  699. // ========== 自动关联 ==========
  700. async function handleAutoAssociate() {
  701. if (associating.value) return
  702. clearAssociations()
  703. associating.value = true
  704. try {
  705. const graphData = JSON.stringify({
  706. nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
  707. 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 }))
  708. })
  709. const res = await autoAssociate(graphData)
  710. const associations = res.data || []
  711. if (associations.length === 0) {
  712. message.info('未发现隐式关联的技能')
  713. return
  714. }
  715. const groups = {}
  716. for (const a of associations) {
  717. if (!groups[a.sourceNodeId]) groups[a.sourceNodeId] = []
  718. const list = groups[a.sourceNodeId]
  719. if (!list.some(x => x.folderName === a.skillFolderName)) {
  720. list.push({ folderName: a.skillFolderName, skillName: a.skillName || a.skillFolderName })
  721. }
  722. }
  723. const skillNodeMap = {}
  724. const GROUP_WIDTH = 240
  725. const GROUP_PADDING_X = 12 // 左右内边距
  726. const GROUP_PADDING_TOP = 30 // 顶部预留 label 高度
  727. const GROUP_PADDING_BOTTOM = 14 // 底部内边距
  728. const SKILL_HEIGHT = 96 // 单个技能节点占位高度(含余量)
  729. const SKILL_GAP = 12
  730. const GAP_TO_ORIGIN = 120
  731. const ts = Date.now()
  732. const groupNodes = [] // 虚框父节点
  733. const skillNodes = [] // 技能子节点
  734. const edgesToAdd = []
  735. const groupMap = {} // sourceNodeId -> groupId
  736. for (const [sourceNodeId, items] of Object.entries(groups)) {
  737. const sourceNode = getNodes.value.find(n => n.id === sourceNodeId)
  738. if (!sourceNode) continue
  739. // 源节点实际宽度(Vue Flow 渲染后测量),fallback 估计值 200
  740. const sourceWidth = sourceNode.dimensions?.width || sourceNode.width || 200
  741. const groupHeight = GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM
  742. + items.length * SKILL_HEIGHT
  743. + (items.length - 1) * SKILL_GAP
  744. // 水平居中:源节点中心 X - 虚框宽度 / 2
  745. const groupX = sourceNode.position.x + sourceWidth / 2 - GROUP_WIDTH / 2
  746. const groupY = sourceNode.position.y + GAP_TO_ORIGIN
  747. const groupId = 'vg-' + sourceNodeId + '-' + ts
  748. groupMap[sourceNodeId] = groupId
  749. groupNodes.push({
  750. id: groupId,
  751. type: 'virtualGroup',
  752. position: { x: groupX, y: groupY },
  753. width: GROUP_WIDTH,
  754. height: groupHeight,
  755. zIndex: 0,
  756. data: {
  757. virtual: true,
  758. width: GROUP_WIDTH,
  759. height: groupHeight,
  760. label: items.length + ' 个关联技能'
  761. },
  762. style: { width: GROUP_WIDTH + 'px', height: groupHeight + 'px' },
  763. class: 'virtual-group-node',
  764. draggable: true, // 允许整体拖动
  765. selectable: false
  766. })
  767. }
  768. // 先加入父节点并等待其进入 Vue Flow 商店、完成尺寸测量,再添加子节点。
  769. // 之前失败的原因是 parentNode 依赖父节点 dimensions,同步批量添加时尺寸尚未就绪。
  770. if (groupNodes.length) addNodes(groupNodes)
  771. await nextTick()
  772. for (const [sourceNodeId, items] of Object.entries(groups)) {
  773. const groupId = groupMap[sourceNodeId]
  774. if (!groupId) continue
  775. items.forEach((item, idx) => {
  776. if (skillNodeMap[item.folderName]) return
  777. const existing = getNodes.value.find(n => n.type === 'skill' && n.data?.skillId === item.folderName)
  778. if (existing) {
  779. skillNodeMap[item.folderName] = existing.id
  780. return
  781. }
  782. const skillId = 'virtual-' + item.folderName + '-' + ts
  783. // 使用 Vue Flow 原生 parentNode 分组:子节点坐标相对于父节点,
  784. // extent: 'parent' 保证子节点随父节点一起移动且不会脱离虚线框。
  785. skillNodes.push({
  786. id: skillId,
  787. type: 'skill',
  788. parentNode: groupId,
  789. extent: 'parent',
  790. position: { x: GROUP_PADDING_X, y: GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP) },
  791. style: { width: (GROUP_WIDTH - GROUP_PADDING_X * 2) + 'px' },
  792. data: {
  793. label: item.skillName,
  794. skillId: item.folderName,
  795. skillName: item.skillName,
  796. virtual: true
  797. },
  798. class: 'virtual-node',
  799. draggable: false, // 子节点不单独拖动
  800. selectable: false,
  801. connectable: false,
  802. zIndex: 2
  803. })
  804. skillNodeMap[item.folderName] = skillId
  805. })
  806. const edge = {
  807. id: 've-' + groupId,
  808. source: groupId,
  809. sourceHandle: 'top',
  810. target: sourceNodeId,
  811. targetHandle: 'bottom-target',
  812. type: 'straight',
  813. animated: false,
  814. style: { stroke: '#818cf8', strokeWidth: 1.5, strokeDasharray: '6 4' },
  815. class: 'virtual-group-edge',
  816. data: { virtual: true }
  817. }
  818. updateVirtualGroupEdge(edge)
  819. edgesToAdd.push(edge)
  820. }
  821. if (skillNodes.length) addNodes(skillNodes)
  822. if (edgesToAdd.length) addEdges(edgesToAdd)
  823. hasAssociations.value = true
  824. const totalSkills = Object.keys(skillNodeMap).length
  825. message.success(`发现 ${totalSkills} 个隐式关联技能(${Object.keys(groups).length} 组)`)
  826. } catch (e) {
  827. message.error(e.response?.data?.message || '关联分析失败')
  828. } finally {
  829. associating.value = false
  830. }
  831. }
  832. function clearAssociations() {
  833. const virtualNodes = getNodes.value.filter(n => n.data?.virtual)
  834. const virtualEdges = getEdges.value.filter(e => e.data?.virtual)
  835. if (virtualEdges.length) removeEdges(virtualEdges.map(e => e.id))
  836. if (virtualNodes.length) removeNodes(virtualNodes.map(n => n.id))
  837. hasAssociations.value = false
  838. }
  839. // ========== 保存前图结构校验 ==========
  840. function validateGraphData() {
  841. const nodes = getNodes.value.filter(n => !n.data?.virtual)
  842. const edges = getEdges.value.filter(e => !e.data?.virtual)
  843. const inputNodes = nodes.filter(n => n.type === 'userInput')
  844. const outputNodes = nodes.filter(n => n.type === 'output')
  845. if (!inputNodes.length) {
  846. return { valid: false, message: '工作流必须包含至少一个用户输入节点' }
  847. }
  848. if (!outputNodes.length) {
  849. return { valid: false, message: '工作流必须包含至少一个输出节点' }
  850. }
  851. const outgoing = {}
  852. edges.forEach(e => {
  853. if (!outgoing[e.source]) outgoing[e.source] = []
  854. outgoing[e.source].push(e.target)
  855. })
  856. function computeReachable(start) {
  857. const visited = new Set()
  858. const stack = [start]
  859. while (stack.length) {
  860. const current = stack.pop()
  861. if (visited.has(current)) continue
  862. visited.add(current)
  863. for (const target of outgoing[current] || []) {
  864. if (!visited.has(target)) stack.push(target)
  865. }
  866. }
  867. return visited
  868. }
  869. let hasConnectedPath = false
  870. for (const input of inputNodes) {
  871. const reachable = computeReachable(input.id)
  872. for (const output of outputNodes) {
  873. if (!reachable.has(output.id)) continue
  874. const direct = outgoing[input.id]?.includes(output.id)
  875. if (direct) {
  876. const candidates = (outgoing[input.id] || []).filter(nextId => nextId !== output.id)
  877. const stack = [...candidates]
  878. const visited = new Set()
  879. while (stack.length) {
  880. const current = stack.pop()
  881. if (visited.has(current)) continue
  882. visited.add(current)
  883. if (current === output.id) {
  884. hasConnectedPath = true
  885. break
  886. }
  887. for (const target of outgoing[current] || []) {
  888. if (!visited.has(target)) stack.push(target)
  889. }
  890. }
  891. } else {
  892. hasConnectedPath = true
  893. }
  894. if (hasConnectedPath) break
  895. }
  896. if (hasConnectedPath) break
  897. }
  898. if (!hasConnectedPath) {
  899. return { valid: false, message: '用户输入节点与输出节点必须通过其他节点连接起来' }
  900. }
  901. return { valid: true }
  902. }
  903. // 校验各节点内部变量/字段名是否重复
  904. function validateDuplicateFieldNames() {
  905. const listConfigs = [
  906. { key: 'variables', label: '输入变量' },
  907. { key: 'inputs', label: '前置数据' },
  908. { key: 'outputs', label: '输出变量' },
  909. { key: 'fields', label: '接收字段' }
  910. ]
  911. const duplicates = []
  912. for (const node of getNodes.value.filter(n => !n.data?.virtual)) {
  913. const d = node.data || {}
  914. const nodeLabel = d.nodeName || d.label || node.id
  915. for (const { key, label } of listConfigs) {
  916. const items = d[key] || []
  917. const seen = new Map()
  918. const dupNames = new Set()
  919. for (const item of items) {
  920. const name = (item.name || '').trim()
  921. if (!name) continue
  922. if (seen.has(name)) {
  923. dupNames.add(name)
  924. } else {
  925. seen.set(name, true)
  926. }
  927. }
  928. if (dupNames.size) {
  929. duplicates.push({ nodeLabel, listLabel: label, names: [...dupNames] })
  930. }
  931. }
  932. }
  933. return duplicates
  934. }
  935. // ========== 保存 ==========
  936. async function handleSave() {
  937. // 校验 displayName(必填)
  938. if (!form.value.displayName.trim()) {
  939. message.warning('请输入工作流中文名称')
  940. return
  941. }
  942. // 校验 name(kebab-case)
  943. if (nameState.value.status === 'invalid') {
  944. message.error('名称规范化后仍非法(需含至少一个字母或数字)')
  945. return
  946. }
  947. if (nameState.value.status === 'empty') {
  948. message.warning('请输入工作流名称(kebab-case)')
  949. return
  950. }
  951. // 应用规范化(如用户输入大写/空格,保存时使用 normalized)
  952. const finalName = nameState.value.normalized
  953. const validation = validateGraphData()
  954. if (!validation.valid) {
  955. message.warning(validation.message)
  956. return
  957. }
  958. const duplicates = validateDuplicateFieldNames()
  959. if (duplicates.length) {
  960. const lines = duplicates.map(d => `${d.nodeLabel}(${d.listLabel}):${d.names.join('、')}`)
  961. message.warning('以下节点存在重名变量,请修改后再保存:\n' + lines.join('\n'))
  962. return
  963. }
  964. saving.value = true
  965. try {
  966. const graphData = JSON.stringify({
  967. nodes: getNodes.value.filter(n => !n.data?.virtual).map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
  968. 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 }))
  969. })
  970. if (routeName.value) {
  971. // 编辑态:用规范化后的 name 作为 URL 标识(若变化则后端会重命名)
  972. const updated = await wfStore.save(routeName.value, {
  973. name: finalName,
  974. displayName: form.value.displayName,
  975. description: form.value.description,
  976. graphData
  977. })
  978. message.success('工作流已保存')
  979. // 若 name 发生变化,跳转到新 URL
  980. if (updated?.name && updated.name !== routeName.value) {
  981. router.replace(`/orchestration/edit/${updated.name}`)
  982. }
  983. } else {
  984. // 新建:先 create 再 save 提交 graphData
  985. const created = await wfStore.create({
  986. name: finalName,
  987. displayName: form.value.displayName,
  988. description: form.value.description
  989. })
  990. await wfStore.save(created.name, {
  991. name: finalName,
  992. displayName: form.value.displayName,
  993. description: form.value.description,
  994. graphData
  995. })
  996. message.success('工作流已创建')
  997. router.replace(`/orchestration/edit/${created.name}`)
  998. }
  999. } catch (e) {
  1000. message.error(e.response?.data?.message || '保存失败')
  1001. } finally {
  1002. saving.value = false
  1003. }
  1004. }
  1005. function goBack() {
  1006. router.push('/orchestration')
  1007. }
  1008. // ========== 运行对话框变量导出/导入 ==========
  1009. async function exportRunVariables() {
  1010. const data = {}
  1011. for (const f of inputFields.value) {
  1012. const value = formData[f.name]
  1013. if (value !== undefined && value !== null && String(value).trim() !== '') {
  1014. data[f.name] = value
  1015. }
  1016. }
  1017. if (!Object.keys(data).length) {
  1018. message.warning('当前没有已输入的变量可导出')
  1019. return
  1020. }
  1021. const workflowName = form.value.displayName || form.value.name || 'workflow'
  1022. const nodeName = inputFields.value[0]?.nodeName || 'input'
  1023. const fileName = `${workflowName}-${nodeName}-输入变量.json`
  1024. const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
  1025. // 优先使用 File System Access API,强制弹出保存位置选择框
  1026. if (typeof window !== 'undefined' && window.showSaveFilePicker) {
  1027. try {
  1028. const handle = await window.showSaveFilePicker({
  1029. suggestedName: fileName,
  1030. types: [{
  1031. description: 'JSON 文件',
  1032. accept: { 'application/json': ['.json'] }
  1033. }]
  1034. })
  1035. const writable = await handle.createWritable()
  1036. await writable.write(blob)
  1037. await writable.close()
  1038. message.success('变量已导出')
  1039. } catch (error) {
  1040. if (error.name === 'AbortError') {
  1041. // 用户取消选择,静默处理
  1042. return
  1043. }
  1044. message.error(`导出失败:${error.message}`)
  1045. }
  1046. return
  1047. }
  1048. // 不支持 File System Access API 的浏览器回退到默认下载
  1049. const url = URL.createObjectURL(blob)
  1050. const a = document.createElement('a')
  1051. a.href = url
  1052. a.download = fileName
  1053. document.body.appendChild(a)
  1054. a.click()
  1055. document.body.removeChild(a)
  1056. URL.revokeObjectURL(url)
  1057. message.success('变量已导出(当前浏览器不支持选择保存位置)')
  1058. }
  1059. function onImportVariablesClick() {
  1060. const input = document.createElement('input')
  1061. input.type = 'file'
  1062. input.accept = 'application/json,.json'
  1063. input.onchange = e => importRunVariables(e.target.files?.[0])
  1064. input.click()
  1065. }
  1066. async function importRunVariables(file) {
  1067. if (!file) return
  1068. try {
  1069. const text = await file.text()
  1070. const data = JSON.parse(text)
  1071. if (!data || typeof data !== 'object') {
  1072. message.error('导入文件格式错误,应为 JSON 对象')
  1073. return
  1074. }
  1075. let count = 0
  1076. for (const f of inputFields.value) {
  1077. if (Object.prototype.hasOwnProperty.call(data, f.name)) {
  1078. formData[f.name] = data[f.name]
  1079. count++
  1080. }
  1081. }
  1082. message.success(`已导入 ${count} 个变量`)
  1083. } catch (error) {
  1084. message.error(`导入失败:${error.message}`)
  1085. }
  1086. }
  1087. // ========== 文件选择(运行时上传) ==========
  1088. function onFileSelect(e, isDir = false) {
  1089. const files = Array.from(e.target.files || [])
  1090. files.forEach(f => {
  1091. const relativePath = isDir ? f.webkitRelativePath : f.name
  1092. uploadFiles.value.push({ file: f, relativePath })
  1093. })
  1094. e.target.value = ''
  1095. }
  1096. // 文件上传列表(运行对话框使用)
  1097. const uploadFiles = ref([])
  1098. function handleOpenRunDialog() {
  1099. openRunDialog(inputFields, formData)
  1100. uploadFiles.value = []
  1101. }
  1102. // 启动运行(包装 composable,处理上传文件 + 输入字段)
  1103. async function handleStartRun() {
  1104. const inputs = {}
  1105. inputFields.value.forEach(f => {
  1106. if (formData[f.name]) inputs[f.name] = formData[f.name]
  1107. })
  1108. await startRun(routeName.value, inputs, uploadFiles.value)
  1109. uploadFiles.value = []
  1110. }
  1111. // 回滚应用到画布(供 useVersionHistory 调用)
  1112. async function applyVersionToCanvas(wf) {
  1113. if (!wf) return
  1114. // 回滚仅恢复 graphData + description;name/displayName 保持当前以避免与 URL 错位
  1115. workflowDesc.value = wf.description || ''
  1116. if (wf.graphData) {
  1117. const data = JSON.parse(wf.graphData)
  1118. setNodes((data.nodes || []).map(n => ({ ...n, data: { ...getDefaultData(n.type), ...n.data } })))
  1119. setEdges((data.edges || []).map(e => ({
  1120. ...e,
  1121. type: 'bezier',
  1122. animated: true,
  1123. style: defaultEdgeStyle,
  1124. data: e.data || {}
  1125. })))
  1126. await nextTick()
  1127. for (const node of getNodes.value) {
  1128. applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
  1129. }
  1130. fitView({ padding: 0.5 })
  1131. }
  1132. }
  1133. // 包装版本历史的方法以适配本组件 API
  1134. async function handleRollbackWrap(v) {
  1135. await handleRollback(routeName.value, v, applyVersionToCanvas)
  1136. }
  1137. async function handleVersionSelectWrap(v) {
  1138. await handleVersionSelect(routeName.value, v)
  1139. }
  1140. async function toggleVersionPanelWrap() {
  1141. await toggleVersionPanel(routeName.value)
  1142. }
  1143. async function saveMessageWrap(v) {
  1144. await saveMessage(routeName.value, v)
  1145. }
  1146. // ========== 初始化 ==========
  1147. onMounted(async () => {
  1148. skillStore.fetchSkills()
  1149. fetchDbModels()
  1150. // 预加载知识库检索节点所需的资源列表(失败不阻塞主流程)
  1151. loadKrDocuments()
  1152. loadKrDatasources()
  1153. loadKrGraphSources()
  1154. loadKrKnowledgeBases()
  1155. if (routeName.value) {
  1156. // 编辑已有工作流(routeName 为 kebab-case name 或旧数字 id 字符串)
  1157. try {
  1158. const wf = await wfStore.fetchOne(routeName.value)
  1159. // 后端迁移后:wf.name 为 kebab-case 唯一标识,wf.displayName 为中文显示名
  1160. form.value.name = wf.name || ''
  1161. form.value.displayName = wf.displayName || wf.name || ''
  1162. form.value.description = wf.description || ''
  1163. if (wf.graphData) {
  1164. applyGraphData(wf.graphData)
  1165. }
  1166. } catch {
  1167. message.error('工作流不存在')
  1168. goBack()
  1169. }
  1170. } else if (route.query.templateId) {
  1171. // 从模板创建:加载模板 graphData 作为初始内容
  1172. try {
  1173. const tplId = Number(route.query.templateId)
  1174. const { data: tpl } = await getAgentTemplate(tplId)
  1175. if (tpl) {
  1176. // 用模板名作为 displayName 初始值(name 留空,由用户填写并规范化)
  1177. if (tpl.name) form.value.displayName = `${tpl.name}-工作流`
  1178. if (tpl.description) form.value.description = tpl.description
  1179. if (tpl.graphData) {
  1180. applyGraphData(tpl.graphData)
  1181. message.info(`已加载模板「${tpl.name}」`)
  1182. }
  1183. }
  1184. } catch (e) {
  1185. /* 全局拦截器已处理 */
  1186. message.warning('模板加载失败,将创建空白工作流')
  1187. }
  1188. }
  1189. await nextTick()
  1190. fitView({ padding: 0.5 })
  1191. })
  1192. // 将 graphData(JSON 字符串)应用到画布
  1193. function applyGraphData(graphData) {
  1194. try {
  1195. const parsed = typeof graphData === 'string' ? JSON.parse(graphData) : graphData
  1196. addNodes((parsed.nodes || []).map(n => {
  1197. const defaults = getDefaultData(n.type)
  1198. return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
  1199. }))
  1200. const edgeData = (parsed.edges || []).map(e => ({
  1201. ...e,
  1202. type: 'bezier',
  1203. animated: true,
  1204. style: defaultEdgeStyle,
  1205. data: e.data || {}
  1206. }))
  1207. if (edgeData.length) addEdges(edgeData)
  1208. for (const node of getNodes.value) {
  1209. applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
  1210. }
  1211. } catch { /* ignore */ }
  1212. }
  1213. </script>
  1214. <template>
  1215. <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
  1216. <!-- 顶部工具栏 -->
  1217. <div class="editor-toolbar">
  1218. <div class="toolbar-left">
  1219. <button class="icon-btn" title="返回列表" @click="goBack">
  1220. <n-icon size="18">
  1221. <ArrowBackOutline />
  1222. </n-icon>
  1223. </button>
  1224. <div class="toolbar-form">
  1225. <div class="wf-field wf-field-show">
  1226. <label class="wf-label">
  1227. 智能体展示名称
  1228. <span class="label-hint">(可包含中文)</span>
  1229. </label>
  1230. <n-input v-model:value="form.displayName" placeholder="如:军事分析助手" style="width: 200px" size="small"
  1231. :maxlength="100" />
  1232. </div>
  1233. <div class="wf-field wf-field-name">
  1234. <label class="wf-label">
  1235. 智能体名称
  1236. <span class="label-hint">(kebab-case,唯一标识,禁用大写/空格/下划线/中文)</span>
  1237. </label>
  1238. <n-input v-model:value="form.name" placeholder="如:military-analyze-agent" size="small"
  1239. :maxlength="64"
  1240. :status="nameState.status === 'invalid' ? 'error' : nameState.status === 'will-rename' || nameState.status === 'will-normalize' ? 'warning' : 'success'" />
  1241. </div>
  1242. <div class="wf-field wf-field-grow">
  1243. <label class="wf-label">智能体描述</label>
  1244. <n-input v-model:value="form.description" placeholder="请输入智能体描述" size="small"
  1245. :maxlength="500" />
  1246. </div>
  1247. <div class="wf-name-hint">
  1248. <span v-if="nameState.status === 'will-rename'" class="hint-warn">
  1249. ⚠ 保存时将重命名:<code>{{ routeName }}</code> → <code>{{ nameState.normalized }}</code>
  1250. </span>
  1251. <span v-else-if="nameState.status === 'will-normalize'" class="hint-warn">
  1252. 将规范化为:<code>{{ nameState.normalized }}</code>
  1253. </span>
  1254. <span v-else-if="nameState.status === 'invalid'" class="hint-error">
  1255. 规范化后仍非法(需含字母或数字)
  1256. </span>
  1257. <span v-else-if="nameState.status === 'valid'" class="hint-ok">
  1258. ✓ 合法 kebab-case
  1259. </span>
  1260. </div>
  1261. </div>
  1262. </div>
  1263. <div class="toolbar-right">
  1264. <button :class="['version-btn', { active: showVersionPanel }]" @click="toggleVersionPanelWrap"
  1265. :disabled="!routeName">
  1266. <n-icon size="16">
  1267. <TimeOutline />
  1268. </n-icon>
  1269. <span>版本历史</span>
  1270. </button>
  1271. <button class="associate-btn" :class="{ active: hasAssociations }" @click="handleAutoAssociate"
  1272. :disabled="associating">
  1273. <n-icon size="16">
  1274. <LinkOutline />
  1275. </n-icon>
  1276. <span>{{ associating ? '分析中...' : hasAssociations ? '重新关联' : '自动关联' }}</span>
  1277. </button>
  1278. <button v-if="hasAssociations" class="clear-assoc-btn" @click="clearAssociations" title="清除关联">
  1279. <n-icon size="14">
  1280. <TrashOutline />
  1281. </n-icon>
  1282. </button>
  1283. <button class="run-btn" :class="{ 'is-running': running }" @click="handleOpenRunDialog" :disabled="!canRun">
  1284. <n-icon size="16">
  1285. <PlayOutline />
  1286. </n-icon>
  1287. <span>{{ running ? '执行中...' : '运行' }}</span>
  1288. </button>
  1289. <button class="primary-btn" @click="handleSave" :disabled="saving">
  1290. <n-icon size="16">
  1291. <SaveOutline />
  1292. </n-icon>
  1293. <span>{{ saving ? '保存中...' : '完成' }}</span>
  1294. </button>
  1295. </div>
  1296. </div>
  1297. <div class="editor-body">
  1298. <!-- 左侧节点工具栏 -->
  1299. <div class="sidebar-nodes">
  1300. <div class="sidebar-title">节点</div>
  1301. <div v-for="nt in nodeTypes" :key="nt.type" class="node-type-item" draggable="true"
  1302. @dragstart="onDragStart($event, nt.type)">
  1303. <div class="nt-icon" :style="{ background: nt.color }">
  1304. <n-icon size="14">
  1305. <component :is="nt.icon" />
  1306. </n-icon>
  1307. </div>
  1308. <span class="nt-label">{{ nt.label }}</span>
  1309. </div>
  1310. </div>
  1311. <!-- 中间画布 -->
  1312. <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
  1313. <VueFlow :default-viewport="{ zoom: 1, x: 0, y: 0 }" :min-zoom="0.1" :max-zoom="2" :elements-selectable="true"
  1314. :select-nodes-on-drag="false" @node-click="onNodeClick" @node-contextmenu="onNodeContextMenu"
  1315. @pane-click="onPaneClick" @edge-click="onEdgeClick" :snap-to-grid="true" :snap-grid="[20, 20]">
  1316. <template #node-userInput="nodeProps">
  1317. <InputNode :data="nodeProps.data" />
  1318. </template>
  1319. <template #node-llm="nodeProps">
  1320. <LLMNode :data="nodeProps.data" />
  1321. </template>
  1322. <template #node-agent="nodeProps">
  1323. <AgentNode :data="nodeProps.data" />
  1324. </template>
  1325. <template #node-skill="nodeProps">
  1326. <SkillNode :data="nodeProps.data" />
  1327. </template>
  1328. <template #node-knowledgeRetrieval="nodeProps">
  1329. <KnowledgeRetrievalNode :data="nodeProps.data" />
  1330. </template>
  1331. <template #node-output="nodeProps">
  1332. <OutputNode :data="nodeProps.data" />
  1333. </template>
  1334. <template #node-condition="nodeProps">
  1335. <ConditionNode :data="nodeProps.data" />
  1336. </template>
  1337. <template #node-smartAction="nodeProps">
  1338. <SmartActionNode :data="nodeProps.data" />
  1339. </template>
  1340. <template #node-virtualGroup="nodeProps">
  1341. <VirtualGroupNode :data="nodeProps.data"
  1342. :style="{ width: nodeProps.data.width + 'px', height: nodeProps.data.height + 'px' }" />
  1343. </template>
  1344. <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
  1345. </VueFlow>
  1346. </div>
  1347. <!-- 右侧面板 - Tab 页 -->
  1348. <div class="sidebar-right">
  1349. <div class="sidebar-tabs">
  1350. <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
  1351. <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
  1352. 运行结果
  1353. <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
  1354. </button>
  1355. </div>
  1356. <div class="sidebar-tab-content">
  1357. <!-- 节点属性 Tab -->
  1358. <div v-show="activeTab === 'props'" class="tab-pane">
  1359. <template v-if="selectedData">
  1360. <!-- 节点名称(可选,画布内唯一) -->
  1361. <div class="prop-section">
  1362. <label class="prop-label">节点名称</label>
  1363. <n-input :value="selectedData?.nodeName || ''" placeholder="可选,留空则仅显示节点类型" size="small"
  1364. @update:value="v => updateNodeName(v)" />
  1365. </div>
  1366. <!-- 用户输入节点 -->
  1367. <template v-if="selectedNode?.type === 'userInput'">
  1368. <div class="prop-section">
  1369. <div class="prop-header">
  1370. <span class="prop-label">输入变量</span>
  1371. <div style="display:flex;gap:4px">
  1372. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1373. <button class="text-btn" @click="addVariable">+ 添加</button>
  1374. </div>
  1375. </div>
  1376. <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
  1377. <div class="var-form-row">
  1378. <n-input :value="v.name" placeholder="变量名" size="small"
  1379. @update:value="nv => { updateFieldName(v, i, 'variables', nv) }" />
  1380. <n-input :value="v.label" placeholder="中文名" size="small"
  1381. @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  1382. </div>
  1383. <div class="var-form-row">
  1384. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1385. @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  1386. <n-input :value="v.description" placeholder="描述" size="small"
  1387. @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
  1388. </div>
  1389. <button class="text-btn danger" @click="removeVariable(i)">删除</button>
  1390. </div>
  1391. <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
  1392. </div>
  1393. </template>
  1394. <!-- 大模型处理节点 -->
  1395. <template v-if="selectedNode?.type === 'llm'">
  1396. <div class="prop-section">
  1397. <label class="prop-label">模型</label>
  1398. <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  1399. :options="modelOptions" placeholder="选择模型(默认使用默认模型)" size="small" clearable
  1400. @update:value="v => onModelChange(v, dbModels)" />
  1401. </div>
  1402. <div class="prop-section">
  1403. <label class="prop-label">系统提示词</label>
  1404. <n-input :value="selectedData?.systemPrompt" type="textarea" :rows="3" placeholder="输入系统提示词"
  1405. size="small" @update:value="v => onFieldChange('systemPrompt', v)" />
  1406. </div>
  1407. <div class="prop-section">
  1408. <label class="prop-label">用户提示词</label>
  1409. <n-input :value="selectedData?.userPrompt" type="textarea" :rows="3"
  1410. placeholder="输入用户提示词,可用 {{变量名}} 引用变量" size="small"
  1411. @update:value="v => onFieldChange('userPrompt', v)" />
  1412. </div>
  1413. <!-- 前置数据 -->
  1414. <div class="prop-section">
  1415. <div class="prop-header">
  1416. <span class="prop-label">前置数据</span>
  1417. <div style="display:flex;gap:4px">
  1418. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1419. <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
  1420. </div>
  1421. </div>
  1422. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
  1423. <div class="var-form-row">
  1424. <n-input :value="v.name" placeholder="变量名" size="small"
  1425. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1426. <n-input :value="v.label" placeholder="中文名" size="small"
  1427. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1428. </div>
  1429. <div class="var-form-row">
  1430. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1431. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1432. <n-input :value="v.description" placeholder="描述" size="small"
  1433. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1434. </div>
  1435. <div class="var-form-row mapping-row">
  1436. <n-select :value="currentMappingValue(v)"
  1437. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1438. clearable placeholder="关联方式:自动推断" style="flex:1"
  1439. @update:value="nv => updateInputMapping(v, nv)" />
  1440. <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
  1441. </div>
  1442. </div>
  1443. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
  1444. </div>
  1445. <!-- 输出变量 -->
  1446. <div class="prop-section">
  1447. <div class="prop-header">
  1448. <span class="prop-label">输出变量</span>
  1449. <div style="display:flex;gap:4px">
  1450. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1451. <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
  1452. </div>
  1453. </div>
  1454. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
  1455. <div class="var-form-row">
  1456. <n-input :value="v.name" placeholder="变量名" size="small"
  1457. @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
  1458. <n-input :value="v.label" placeholder="中文名" size="small"
  1459. @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1460. </div>
  1461. <div class="var-form-row">
  1462. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1463. @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1464. <n-input :value="v.description" placeholder="描述" size="small"
  1465. @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1466. </div>
  1467. <div class="var-form-row" style="justify-content:flex-end">
  1468. <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
  1469. </div>
  1470. </div>
  1471. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
  1472. </div>
  1473. <!-- 重试配置(仅声明 outputs 时生效;未配置时使用全局默认) -->
  1474. <details class="retry-config">
  1475. <summary class="prop-label">重试配置</summary>
  1476. <div class="retry-config-body">
  1477. <div class="var-form-row">
  1478. <span class="retry-field-label">最大重试次数</span>
  1479. <n-input-number :value="selectedData?.maxRetries" :min="1" :max="10" size="small"
  1480. placeholder="使用全局默认" clearable
  1481. @update:value="v => onFieldChange('maxRetries', v == null ? undefined : Number(v))" />
  1482. </div>
  1483. <div class="retry-checkbox-group">
  1484. <label class="retry-checkbox-row">
  1485. <n-checkbox :checked="selectedData?.retryOnParseError ?? false"
  1486. @update:checked="v => onFieldChange('retryOnParseError', v)" />
  1487. <span>JSON 解析失败时重试</span>
  1488. </label>
  1489. <label class="retry-checkbox-row">
  1490. <n-checkbox :checked="selectedData?.retryOnMissingRequired ?? false"
  1491. @update:checked="v => onFieldChange('retryOnMissingRequired', v)" />
  1492. <span>缺少必填字段时重试</span>
  1493. </label>
  1494. <label class="retry-checkbox-row">
  1495. <n-checkbox :checked="selectedData?.retryOnTypeMismatch ?? false"
  1496. @update:checked="v => onFieldChange('retryOnTypeMismatch', v)" />
  1497. <span>类型不匹配时重试</span>
  1498. </label>
  1499. </div>
  1500. <div class="retry-hint">未勾选时使用全局默认;声明 outputs 后此项才生效</div>
  1501. </div>
  1502. </details>
  1503. <!-- 失败策略 -->
  1504. <div class="prop-section">
  1505. <label class="prop-label">失败策略</label>
  1506. <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
  1507. @update:value="v => onFieldChange('failStrategy', v)" />
  1508. </div>
  1509. </template>
  1510. <!-- 智能体节点 -->
  1511. <template v-if="selectedNode?.type === 'agent'">
  1512. <div class="prop-section">
  1513. <label class="prop-label">选择智能体</label>
  1514. <n-select :value="selectedData?.agentId" :options="agentOptions" placeholder="选择技能" size="small"
  1515. filterable @update:value="v => onFieldChange('agentId', v)" />
  1516. </div>
  1517. <div class="prop-section">
  1518. <label class="prop-label">模型</label>
  1519. <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  1520. :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
  1521. @update:value="v => onModelChange(v, dbModels)" />
  1522. </div>
  1523. <!-- 前置数据 -->
  1524. <div class="prop-section">
  1525. <div class="prop-header">
  1526. <span class="prop-label">前置数据</span>
  1527. <div style="display:flex;gap:4px">
  1528. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1529. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  1530. </div>
  1531. </div>
  1532. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
  1533. <div class="var-form-row">
  1534. <n-input :value="v.name" placeholder="变量名" size="small"
  1535. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1536. <n-input :value="v.label" placeholder="中文名" size="small"
  1537. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1538. </div>
  1539. <div class="var-form-row">
  1540. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1541. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1542. <n-input :value="v.description" placeholder="描述" size="small"
  1543. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1544. </div>
  1545. <div class="var-form-row mapping-row">
  1546. <n-select :value="currentMappingValue(v)"
  1547. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1548. clearable placeholder="关联方式:自动推断" style="flex:1"
  1549. @update:value="nv => updateInputMapping(v, nv)" />
  1550. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  1551. </div>
  1552. </div>
  1553. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  1554. </div>
  1555. <!-- 输出变量 -->
  1556. <div class="prop-section">
  1557. <div class="prop-header">
  1558. <span class="prop-label">输出变量</span>
  1559. <div style="display:flex;gap:4px">
  1560. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1561. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  1562. </div>
  1563. </div>
  1564. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
  1565. <div class="var-form-row">
  1566. <n-input :value="v.name" placeholder="变量名" size="small"
  1567. @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
  1568. <n-input :value="v.label" placeholder="中文名" size="small"
  1569. @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1570. </div>
  1571. <div class="var-form-row">
  1572. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1573. @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1574. <n-input :value="v.description" placeholder="描述" size="small"
  1575. @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1576. </div>
  1577. <div class="var-form-row" style="justify-content:flex-end">
  1578. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  1579. </div>
  1580. </div>
  1581. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  1582. </div>
  1583. <!-- 失败策略 -->
  1584. <div class="prop-section">
  1585. <label class="prop-label">失败策略</label>
  1586. <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
  1587. @update:value="v => onFieldChange('failStrategy', v)" />
  1588. </div>
  1589. </template>
  1590. <!-- 技能节点 -->
  1591. <template v-if="selectedNode?.type === 'skill'">
  1592. <div class="prop-section">
  1593. <label class="prop-label">选择技能</label>
  1594. <n-select :value="selectedData?.skillId" :options="agentOptions" placeholder="选择技能" size="small"
  1595. filterable @update:value="v => onFieldChange('skillId', v)" />
  1596. </div>
  1597. <div class="prop-section">
  1598. <label class="prop-label">模型</label>
  1599. <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  1600. :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
  1601. @update:value="v => onModelChange(v, dbModels)" />
  1602. </div>
  1603. <!-- 前置数据 -->
  1604. <div class="prop-section">
  1605. <div class="prop-header">
  1606. <span class="prop-label">前置数据</span>
  1607. <div style="display:flex;gap:4px">
  1608. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1609. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  1610. </div>
  1611. </div>
  1612. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
  1613. <div class="var-form-row">
  1614. <n-input :value="v.name" placeholder="变量名" size="small"
  1615. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1616. <n-input :value="v.label" placeholder="中文名" size="small"
  1617. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1618. </div>
  1619. <div class="var-form-row">
  1620. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1621. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1622. <n-input :value="v.description" placeholder="描述" size="small"
  1623. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1624. </div>
  1625. <div class="var-form-row mapping-row">
  1626. <n-select :value="currentMappingValue(v)"
  1627. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1628. clearable placeholder="关联方式:自动推断" style="flex:1"
  1629. @update:value="nv => updateInputMapping(v, nv)" />
  1630. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  1631. </div>
  1632. </div>
  1633. <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
  1634. </div>
  1635. <!-- 输出变量 -->
  1636. <div class="prop-section">
  1637. <div class="prop-header">
  1638. <span class="prop-label">输出变量</span>
  1639. <div style="display:flex;gap:4px">
  1640. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1641. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  1642. </div>
  1643. </div>
  1644. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
  1645. <div class="var-form-row">
  1646. <n-input :value="v.name" placeholder="变量名" size="small"
  1647. @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
  1648. <n-input :value="v.label" placeholder="中文名" size="small"
  1649. @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1650. </div>
  1651. <div class="var-form-row">
  1652. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1653. @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1654. <n-input :value="v.description" placeholder="描述" size="small"
  1655. @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1656. </div>
  1657. <div class="var-form-row" style="justify-content:flex-end">
  1658. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  1659. </div>
  1660. </div>
  1661. <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
  1662. </div>
  1663. <!-- 失败策略 -->
  1664. <div class="prop-section">
  1665. <label class="prop-label">失败策略</label>
  1666. <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
  1667. @update:value="v => onFieldChange('failStrategy', v)" />
  1668. </div>
  1669. </template>
  1670. <!-- 智能操作节点 -->
  1671. <template v-if="selectedNode?.type === 'smartAction'">
  1672. <div class="prop-section">
  1673. <div class="prop-header">
  1674. <span class="prop-label">操作要求</span>
  1675. <button class="text-btn" @click="openPromptEditor" title="在编辑器中打开">
  1676. <n-icon size="12">
  1677. <OpenOutline />
  1678. </n-icon> 展开
  1679. </button>
  1680. </div>
  1681. <n-input :value="selectedData?.actionPrompt" type="textarea" :rows="5" placeholder="描述你希望 AI 执行的操作..."
  1682. size="small" @update:value="v => onFieldChange('actionPrompt', v)" />
  1683. </div>
  1684. <div class="prop-section">
  1685. <label class="prop-label">模型</label>
  1686. <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
  1687. :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
  1688. @update:value="v => onModelChange(v, dbModels)" />
  1689. </div>
  1690. <!-- 前置数据 -->
  1691. <div class="prop-section">
  1692. <div class="prop-header">
  1693. <span class="prop-label">前置数据</span>
  1694. <div style="display:flex;gap:4px">
  1695. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1696. <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
  1697. </div>
  1698. </div>
  1699. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
  1700. <div class="var-form-row">
  1701. <n-input :value="v.name" placeholder="变量名" size="small"
  1702. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1703. <n-input :value="v.label" placeholder="中文名" size="small"
  1704. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1705. </div>
  1706. <div class="var-form-row">
  1707. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1708. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1709. <n-input :value="v.description" placeholder="描述" size="small"
  1710. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1711. </div>
  1712. <div class="var-form-row mapping-row">
  1713. <n-select :value="currentMappingValue(v)"
  1714. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1715. clearable placeholder="关联方式:自动推断" style="flex:1"
  1716. @update:value="nv => updateInputMapping(v, nv)" />
  1717. <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
  1718. </div>
  1719. </div>
  1720. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
  1721. </div>
  1722. <!-- 输出变量 -->
  1723. <div class="prop-section">
  1724. <div class="prop-header">
  1725. <span class="prop-label">输出变量</span>
  1726. <div style="display:flex;gap:4px">
  1727. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1728. <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
  1729. </div>
  1730. </div>
  1731. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
  1732. <div class="var-form-row">
  1733. <n-input :value="v.name" placeholder="变量名" size="small"
  1734. @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
  1735. <n-input :value="v.label" placeholder="中文名" size="small"
  1736. @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1737. </div>
  1738. <div class="var-form-row">
  1739. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1740. @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1741. <n-input :value="v.description" placeholder="描述" size="small"
  1742. @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1743. </div>
  1744. <div class="var-form-row" style="justify-content:flex-end">
  1745. <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
  1746. </div>
  1747. </div>
  1748. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
  1749. </div>
  1750. <!-- 重试配置(仅声明 outputs 时生效;未配置时使用全局默认) -->
  1751. <details class="retry-config">
  1752. <summary class="prop-label">重试配置</summary>
  1753. <div class="retry-config-body">
  1754. <div class="var-form-row">
  1755. <span class="retry-field-label">最大重试次数</span>
  1756. <n-input-number :value="selectedData?.maxRetries" :min="1" :max="10" size="small"
  1757. placeholder="使用全局默认" clearable
  1758. @update:value="v => onFieldChange('maxRetries', v == null ? undefined : Number(v))" />
  1759. </div>
  1760. <div class="retry-checkbox-group">
  1761. <label class="retry-checkbox-row">
  1762. <n-checkbox :checked="selectedData?.retryOnParseError ?? false"
  1763. @update:checked="v => onFieldChange('retryOnParseError', v)" />
  1764. <span>JSON 解析失败时重试</span>
  1765. </label>
  1766. <label class="retry-checkbox-row">
  1767. <n-checkbox :checked="selectedData?.retryOnMissingRequired ?? false"
  1768. @update:checked="v => onFieldChange('retryOnMissingRequired', v)" />
  1769. <span>缺少必填字段时重试</span>
  1770. </label>
  1771. <label class="retry-checkbox-row">
  1772. <n-checkbox :checked="selectedData?.retryOnTypeMismatch ?? false"
  1773. @update:checked="v => onFieldChange('retryOnTypeMismatch', v)" />
  1774. <span>类型不匹配时重试</span>
  1775. </label>
  1776. </div>
  1777. <div class="retry-hint">未勾选时使用全局默认;声明 outputs 后此项才生效</div>
  1778. </div>
  1779. </details>
  1780. <!-- 失败策略 -->
  1781. <div class="prop-section">
  1782. <label class="prop-label">失败策略</label>
  1783. <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
  1784. @update:value="v => onFieldChange('failStrategy', v)" />
  1785. </div>
  1786. </template>
  1787. <!-- 知识库检索节点 -->
  1788. <template v-if="selectedNode?.type === 'knowledgeRetrieval'">
  1789. <div class="prop-section">
  1790. <label class="prop-label">检索来源</label>
  1791. <n-select :value="selectedData?.source || 'document'" :options="krSourceOptions" size="small"
  1792. @update:value="v => onFieldChange('source', v)" />
  1793. </div>
  1794. <div class="prop-section">
  1795. <label class="prop-label">检索语句</label>
  1796. <n-input :value="selectedData?.query" type="textarea" :rows="3" placeholder="输入检索语句,可用 {{变量名}} 引用上游变量"
  1797. size="small" @update:value="v => onFieldChange('query', v)" />
  1798. </div>
  1799. <!-- 文档数据库 -->
  1800. <template v-if="(selectedData?.source || 'document') === 'document'">
  1801. <div class="prop-section">
  1802. <label class="kr-checkbox-row">
  1803. <n-checkbox :checked="!!selectedData?.allDocuments"
  1804. @update:checked="v => onFieldChange('allDocuments', v)" />
  1805. <span>在所有文档中检索</span>
  1806. </label>
  1807. <div v-if="!selectedData?.allDocuments" class="kr-list">
  1808. <div v-if="krDocumentsLoading" class="empty-hint">加载中...</div>
  1809. <div v-else-if="!krDocumentOptions.length" class="empty-hint">暂无文档</div>
  1810. <label v-for="opt in krDocumentOptions" :key="opt.value" class="kr-checkbox-row">
  1811. <n-checkbox :checked="(selectedData?.documentIds || []).includes(opt.value)"
  1812. @update:checked="() => toggleKrListField('documentIds', opt.value)" />
  1813. <span>{{ opt.label }}</span>
  1814. </label>
  1815. </div>
  1816. </div>
  1817. <div class="prop-section">
  1818. <label class="prop-label">TopK(命中条数)</label>
  1819. <n-input :value="String(selectedData?.topK ?? 5)" type="text" size="small"
  1820. @update:value="v => onFieldChange('topK', Math.max(1, parseInt(v) || 5))" />
  1821. </div>
  1822. </template>
  1823. <!-- 结构化数据库 -->
  1824. <template v-if="selectedData?.source === 'structured'">
  1825. <div class="prop-section">
  1826. <label class="kr-checkbox-row">
  1827. <n-checkbox :checked="!!selectedData?.allDatasources"
  1828. @update:checked="v => onFieldChange('allDatasources', v)" />
  1829. <span>在所有数据源中检索</span>
  1830. </label>
  1831. <div v-if="!selectedData?.allDatasources" class="kr-list">
  1832. <div v-if="krDatasourcesLoading" class="empty-hint">加载中...</div>
  1833. <div v-else-if="!krDatasourceOptions.length" class="empty-hint">暂无数据源</div>
  1834. <label v-for="opt in krDatasourceOptions" :key="opt.value" class="kr-checkbox-row">
  1835. <n-checkbox :checked="(selectedData?.datasourceIds || []).includes(opt.value)"
  1836. @update:checked="() => toggleKrListField('datasourceIds', opt.value)" />
  1837. <span>{{ opt.label }}</span>
  1838. </label>
  1839. </div>
  1840. </div>
  1841. </template>
  1842. <!-- 知识图谱库 -->
  1843. <template v-if="selectedData?.source === 'graph'">
  1844. <div class="prop-section">
  1845. <label class="kr-checkbox-row">
  1846. <n-checkbox :checked="!!selectedData?.allGraphSources"
  1847. @update:checked="v => onFieldChange('allGraphSources', v)" />
  1848. <span>在所有数据源中检索</span>
  1849. </label>
  1850. <div v-if="!selectedData?.allGraphSources" class="kr-list">
  1851. <div v-if="krGraphSourcesLoading" class="empty-hint">加载中...</div>
  1852. <div v-else-if="!krGraphSourceOptions.length" class="empty-hint">暂无图谱数据源</div>
  1853. <label v-for="opt in krGraphSourceOptions" :key="opt.value" class="kr-checkbox-row">
  1854. <n-checkbox :checked="(selectedData?.graphSourceIds || []).includes(opt.value)"
  1855. @update:checked="() => toggleKrListField('graphSourceIds', opt.value)" />
  1856. <span>{{ opt.label }}</span>
  1857. </label>
  1858. </div>
  1859. </div>
  1860. </template>
  1861. <!-- 混合检索 -->
  1862. <template v-if="selectedData?.source === 'hybrid'">
  1863. <div class="prop-section">
  1864. <label class="prop-label">知识库</label>
  1865. <div v-if="krKnowledgeBasesLoading" class="empty-hint">加载中...</div>
  1866. <n-select :value="selectedData?.knowledgeBaseId" :options="krKnowledgeBaseOptions"
  1867. placeholder="选择知识库" size="small" @update:value="v => onFieldChange('knowledgeBaseId', v)" />
  1868. <div v-if="!krKnowledgeBaseOptions.length && !krKnowledgeBasesLoading" class="empty-hint">暂无知识库
  1869. </div>
  1870. </div>
  1871. </template>
  1872. <!-- 前置数据 -->
  1873. <div class="prop-section">
  1874. <div class="prop-header">
  1875. <span class="prop-label">前置数据</span>
  1876. <div style="display:flex;gap:4px">
  1877. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1878. <button class="text-btn" @click="addListField('inputs')">+ 添加</button>
  1879. </div>
  1880. </div>
  1881. <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
  1882. <div class="var-form-row">
  1883. <n-input :value="v.name" placeholder="变量名" size="small"
  1884. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1885. <n-input :value="v.label" placeholder="中文名" size="small"
  1886. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1887. </div>
  1888. <div class="var-form-row">
  1889. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1890. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1891. <n-input :value="v.description" placeholder="描述" size="small"
  1892. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1893. </div>
  1894. <div class="var-form-row mapping-row">
  1895. <n-select :value="currentMappingValue(v)"
  1896. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1897. clearable placeholder="关联方式:自动推断" style="flex:1"
  1898. @update:value="nv => updateInputMapping(v, nv)" />
  1899. <button class="text-btn danger" @click="removeListField('inputs', i)">删除</button>
  1900. </div>
  1901. </div>
  1902. <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
  1903. </div>
  1904. <!-- 输出变量 -->
  1905. <div class="prop-section">
  1906. <div class="prop-header">
  1907. <span class="prop-label">输出变量</span>
  1908. <div style="display:flex;gap:4px">
  1909. <button class="text-btn" @click="openImportDialog('successor')">引入</button>
  1910. <button class="text-btn" @click="addListField('outputs')">+ 添加</button>
  1911. </div>
  1912. </div>
  1913. <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
  1914. <div class="var-form-row">
  1915. <n-input :value="v.name" placeholder="变量名" size="small"
  1916. @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
  1917. <n-input :value="v.label" placeholder="中文名" size="small"
  1918. @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1919. </div>
  1920. <div class="var-form-row">
  1921. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1922. @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1923. <n-input :value="v.description" placeholder="描述" size="small"
  1924. @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
  1925. </div>
  1926. <div class="var-form-row" style="justify-content:flex-end">
  1927. <button class="text-btn danger" @click="removeListField('outputs', i)">删除</button>
  1928. </div>
  1929. </div>
  1930. <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 evidences / evidenceCount / sourceType / diagnostics</div>
  1931. </div>
  1932. </template>
  1933. <!-- 条件分支节点 -->
  1934. <template v-if="selectedNode?.type === 'condition'">
  1935. <div class="prop-section">
  1936. <div class="prop-header">
  1937. <span class="prop-label">条件分支</span>
  1938. <button class="text-btn" @click="addElif">+ ELIF</button>
  1939. </div>
  1940. <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
  1941. <div class="cond-form-row">
  1942. <span class="cond-tag">{{ c.type }}</span>
  1943. <n-input :value="c.expression" placeholder="自然语言条件,可用 {{变量名}}" size="small"
  1944. @update:value="v => onConditionChange(i, 'expression', v)" />
  1945. <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
  1946. </div>
  1947. </div>
  1948. </div>
  1949. <div class="prop-section">
  1950. <div class="prop-header">
  1951. <span class="prop-label">前置数据</span>
  1952. <div style="display:flex;gap:4px">
  1953. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1954. <button class="text-btn" @click="addConditionInput">+ 添加</button>
  1955. </div>
  1956. </div>
  1957. <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
  1958. <div class="var-form-row">
  1959. <n-input :value="v.name" placeholder="变量名" size="small"
  1960. @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
  1961. <n-input :value="v.label" placeholder="中文名" size="small"
  1962. @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1963. </div>
  1964. <div class="var-form-row">
  1965. <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
  1966. @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1967. <n-input :value="v.description" placeholder="描述" size="small"
  1968. @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
  1969. </div>
  1970. <div class="var-form-row mapping-row">
  1971. <n-select :value="currentMappingValue(v)"
  1972. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  1973. clearable placeholder="关联方式:自动推断" style="flex:1"
  1974. @update:value="nv => updateInputMapping(v, nv)" />
  1975. <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
  1976. </div>
  1977. </div>
  1978. <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  1979. <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
  1980. </div>
  1981. </template>
  1982. <!-- 输出节点 -->
  1983. <template v-if="selectedNode?.type === 'output'">
  1984. <div class="prop-section">
  1985. <div class="prop-header">
  1986. <span class="prop-label">接收字段</span>
  1987. <div style="display:flex;gap:4px">
  1988. <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
  1989. <button class="text-btn" @click="addOutputField">+ 添加</button>
  1990. </div>
  1991. </div>
  1992. <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
  1993. <div class="var-form-row">
  1994. <n-input :value="f.name" placeholder="字段名" size="small"
  1995. @update:value="nv => { updateFieldName(f, i, 'fields', nv) }" />
  1996. <n-input :value="f.label" placeholder="中文名" size="small"
  1997. @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  1998. </div>
  1999. <div class="var-form-row">
  2000. <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px"
  2001. @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  2002. <n-input :value="f.description" placeholder="描述" size="small"
  2003. @update:value="nv => { f.description = nv; onFieldChange('fields', [...selectedData.fields]) }" />
  2004. </div>
  2005. <div class="var-form-row mapping-row">
  2006. <n-select :value="currentMappingValue(f)"
  2007. :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
  2008. clearable placeholder="关联方式:自动推断" style="flex:1"
  2009. @update:value="nv => updateInputMapping(f, nv)" />
  2010. <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
  2011. </div>
  2012. </div>
  2013. <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
  2014. </div>
  2015. </template>
  2016. </template>
  2017. <!-- 边映射信息 -->
  2018. <template v-else-if="selectedEdge?.data">
  2019. <div class="prop-section">
  2020. <div class="prop-header">
  2021. <span class="prop-label">连接映射</span>
  2022. <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
  2023. {{ edgeStatusText(selectedEdge.data.status) }}
  2024. </span>
  2025. </div>
  2026. <!-- 已映射字段 -->
  2027. <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
  2028. <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField"
  2029. class="mapping-item mapped">
  2030. <span class="mapping-field">{{ m.sourceField }}</span>
  2031. <span class="mapping-arrow">→</span>
  2032. <span class="mapping-field">{{ m.targetField }}</span>
  2033. </div>
  2034. </div>
  2035. <!-- 类型不匹配 -->
  2036. <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
  2037. <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name"
  2038. class="mapping-item mismatch">
  2039. <span class="mapping-field">{{ tm.source.name }}</span>
  2040. <span class="mapping-type">({{ tm.source.type }})</span>
  2041. <span class="mapping-arrow">→</span>
  2042. <span class="mapping-field">{{ tm.target.name }}</span>
  2043. <span class="mapping-type">({{ tm.target.type }})</span>
  2044. </div>
  2045. </div>
  2046. <!-- 未匹配的目标前置数据 -->
  2047. <div v-if="selectedEdge.data.unmatchedTarget?.length">
  2048. <div class="prop-label" style="margin-top:8px">未匹配前置数据</div>
  2049. <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
  2050. <span class="mapping-field">{{ f.name }}</span>
  2051. <span class="mapping-type">({{ f.type }})</span>
  2052. </div>
  2053. </div>
  2054. <!-- 多余的源输出 -->
  2055. <div v-if="selectedEdge.data.unmatchedSource?.length">
  2056. <div class="prop-label" style="margin-top:8px">多余输出</div>
  2057. <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
  2058. <span class="mapping-field">{{ f.name }}</span>
  2059. <span class="mapping-type">({{ f.type }})</span>
  2060. </div>
  2061. </div>
  2062. <div
  2063. v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)"
  2064. class="empty-hint">
  2065. 无需映射
  2066. </div>
  2067. </div>
  2068. </template>
  2069. <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
  2070. </div>
  2071. <!-- 运行结果 Tab -->
  2072. <div v-show="activeTab === 'result'" class="tab-pane">
  2073. <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
  2074. <div v-for="r in nodeResults" :key="r.nodeId" :id="'node-result-' + r.nodeId" class="node-result-card"
  2075. :class="{ highlighted: highlightedNodeId === r.nodeId }" :style="{ borderLeftColor: r.color }">
  2076. <div class="nrc-header" @click="toggleLogs(r.nodeId)" style="cursor: pointer">
  2077. <div class="nrc-icon" :style="{ background: r.color }">
  2078. <n-icon size="12">
  2079. <component :is="nodeTypeInfo[r.type]?.icon" />
  2080. </n-icon>
  2081. </div>
  2082. <div class="nrc-info">
  2083. <span class="nrc-label">{{ r.label }}</span>
  2084. <span class="nrc-type">{{ r.typeLabel }}</span>
  2085. </div>
  2086. <span v-if="r.logs && r.logs.length" class="nrc-log-count">{{ r.logs.length }} 条日志</span>
  2087. <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
  2088. <span v-if="r.logs && r.logs.length" class="nrc-expand"
  2089. :class="{ expanded: expandedLogs[r.nodeId] }">▶</span>
  2090. </div>
  2091. <!-- 流式思考过程(实时累加,节点完成后保留为历史)
  2092. 折叠时仅隐藏 body,streamingThinking 仍持续累加,展开后可继续输出 -->
  2093. <div v-if="r.streamingThinking" class="nrc-thinking" :class="{ live: r.status === 'running' }">
  2094. <div class="nrc-thinking-header" @click="toggleThinking(r.nodeId)" style="cursor: pointer">
  2095. <span class="nrc-thinking-tag">💭 思考过程</span>
  2096. <span v-if="r.status === 'running'" class="nrc-thinking-live">实时输出中…</span>
  2097. <span v-else class="nrc-thinking-static">{{ r.streamingThinking.length }} 字</span>
  2098. <span class="nrc-thinking-expand" :class="{ expanded: expandedThinking[r.nodeId] !== false }">▶</span>
  2099. </div>
  2100. <pre v-show="expandedThinking[r.nodeId] !== false"
  2101. class="nrc-thinking-body">{{ r.streamingThinking }}</pre>
  2102. </div>
  2103. <div class="nrc-vars" v-if="unwrapNodeOutput(r.output) && Object.keys(unwrapNodeOutput(r.output)).length">
  2104. <!-- envelope 失败(status=400)时显示红色徽章 + message -->
  2105. <div v-if="nodeOutputStatus(r.output) === 400" class="nrc-env-fail">
  2106. <span class="nrc-env-badge failed">失败</span>
  2107. <span class="nrc-env-msg">{{ nodeOutputMessage(r.output) }}</span>
  2108. </div>
  2109. <div class="nrc-var" v-for="(value, key) in unwrapNodeOutput(r.output)" :key="key">
  2110. <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
  2111. <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) :
  2112. String(value)
  2113. }}</span>
  2114. </div>
  2115. </div>
  2116. <!-- 完整上下文快照(节点执行后的全部 variables,debug 视图) -->
  2117. <div v-if="r.contextSnapshot" class="nrc-ctx-toggle" @click="toggleContext(r.nodeId)">
  2118. <span class="nrc-ctx-icon" :class="{ expanded: expandedContext[r.nodeId] }">▶</span>
  2119. <span class="nrc-ctx-label">查看完整上下文({{ Object.keys(r.contextSnapshot).length }} 个变量)</span>
  2120. </div>
  2121. <div v-if="expandedContext[r.nodeId] && r.contextSnapshot" class="nrc-ctx-panel">
  2122. <div class="nrc-ctx-var" v-for="(value, key) in r.contextSnapshot" :key="key">
  2123. <span class="nrc-ctx-key">{{ key }}</span>
  2124. <span class="nrc-ctx-value">{{ formatContextValue(value) }}</span>
  2125. </div>
  2126. </div>
  2127. <!-- 执行日志 -->
  2128. <div v-if="expandedLogs[r.nodeId] && r.logs && r.logs.length" class="nrc-logs">
  2129. <div class="nrc-log" v-for="(log, idx) in r.logs" :key="idx"
  2130. :class="'log-type-' + (log.type || 'info').toLowerCase()">
  2131. <span class="nrc-log-icon">{{ logTypeIcon(log.type) }}</span>
  2132. <span class="nrc-log-time">{{ formatTimestamp(log.timestamp) }}</span>
  2133. <span class="nrc-log-tag">{{ logTypeLabel(log.type) }}</span>
  2134. <span class="nrc-log-msg">{{ log.message }}</span>
  2135. <div v-if="log.detail" class="nrc-log-detail">{{ log.detail }}</div>
  2136. </div>
  2137. </div>
  2138. </div>
  2139. <div v-if="showResult && workflowResult" class="nrc-clear-row">
  2140. <a v-if="lastRunId && lastRunWorkflowId"
  2141. :href="`/api/workflows/${lastRunWorkflowId}/runs/${lastRunId}/download`" class="text-btn"
  2142. style="text-decoration:none; color: var(--primary)">下载工作目录</a>
  2143. <button class="text-btn danger" @click="closeResult">清除结果</button>
  2144. </div>
  2145. </div>
  2146. </div>
  2147. </div>
  2148. </div>
  2149. <!-- 运行对话框 -->
  2150. <div class="dialog-overlay" v-if="showRunDialog">
  2151. <div class="run-dialog">
  2152. <div class="dialog-header">
  2153. <span class="dialog-title">运行工作流</span>
  2154. <button class="icon-btn" @click="cancelRun"><n-icon size="16">
  2155. <CloseOutline />
  2156. </n-icon></button>
  2157. </div>
  2158. <div class="dialog-body">
  2159. <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
  2160. <div class="run-actions" v-if="inputFields.length">
  2161. <button class="text-btn" @click="exportRunVariables">
  2162. <n-icon size="12"><DownloadOutline /></n-icon> 变量导出
  2163. </button>
  2164. <button class="text-btn" @click="onImportVariablesClick">
  2165. <n-icon size="12"><CloudUploadOutline /></n-icon> 变量导入
  2166. </button>
  2167. </div>
  2168. <div v-for="f in inputFields" :key="f.name" class="run-field">
  2169. <label class="run-field-label">{{ f.label || f.name }}</label>
  2170. <n-input v-model:value="formData[f.name]" :placeholder="f.description || f.name"
  2171. :type="f.type === 'number' ? 'text' : 'text'" size="small" />
  2172. </div>
  2173. <div class="run-upload">
  2174. <label class="run-field-label">上传文件到工作目录</label>
  2175. <div class="upload-btns">
  2176. <div class="upload-area" @click="$refs.fileInput.click()">
  2177. <input ref="fileInput" type="file" multiple style="display:none"
  2178. @change="onFileSelect($event, false)" />
  2179. <span class="upload-hint">选择文件</span>
  2180. </div>
  2181. <div class="upload-area" @click="$refs.dirInput.click()">
  2182. <input ref="dirInput" type="file" webkitdirectory style="display:none"
  2183. @change="onFileSelect($event, true)" />
  2184. <span class="upload-hint">选择目录</span>
  2185. </div>
  2186. </div>
  2187. <div v-if="uploadFiles.length" class="upload-list">
  2188. <div v-for="(f, i) in uploadFiles" :key="i" class="upload-item">
  2189. <span class="upload-name">{{ f.relativePath }}</span>
  2190. <button class="upload-remove" @click="uploadFiles.splice(i, 1)">×</button>
  2191. </div>
  2192. </div>
  2193. </div>
  2194. </div>
  2195. <div class="dialog-footer">
  2196. <button class="cancel-btn" @click="cancelRun">取消</button>
  2197. <button class="primary-btn" @click="handleStartRun">
  2198. <n-icon size="14">
  2199. <PlayOutline />
  2200. </n-icon>
  2201. <span>开始执行</span>
  2202. </button>
  2203. </div>
  2204. </div>
  2205. </div>
  2206. <!-- 操作要求编辑器弹出框 -->
  2207. <div class="dialog-overlay" v-if="showPromptEditor" @click.self="showPromptEditor = false">
  2208. <div class="prompt-editor-dialog">
  2209. <div class="dialog-header">
  2210. <span class="dialog-title">编辑操作要求</span>
  2211. <button class="icon-btn" @click="showPromptEditor = false"><n-icon size="16">
  2212. <CloseOutline />
  2213. </n-icon></button>
  2214. </div>
  2215. <div class="prompt-editor-body">
  2216. <CodeEditor v-model="promptEditValue" language="markdown" height="100%" :minimap="false" />
  2217. </div>
  2218. <div class="dialog-footer">
  2219. <button class="cancel-btn" @click="showPromptEditor = false">取消</button>
  2220. <button class="primary-btn" @click="confirmPromptEdit">确认</button>
  2221. </div>
  2222. </div>
  2223. </div>
  2224. <!-- 版本历史面板 -->
  2225. <transition name="slide-right">
  2226. <div v-if="showVersionPanel" class="version-panel">
  2227. <div class="vp-header">
  2228. <h3 class="vp-title">版本历史</h3>
  2229. <button class="icon-btn" @click="toggleVersionPanelWrap"><n-icon size="16">
  2230. <CloseOutline />
  2231. </n-icon></button>
  2232. </div>
  2233. <div class="vp-list">
  2234. <n-spin v-if="versionsLoading" size="small" />
  2235. <div v-else-if="!versions.length" class="vp-empty">暂无版本(保存后自动创建)</div>
  2236. <div v-else class="version-items">
  2237. <div v-for="v in versions" :key="v.number" class="version-item"
  2238. :class="{ active: selectedVersion?.number === v.number }">
  2239. <div class="vi-main" @click="handleVersionSelectWrap(v)">
  2240. <div class="vi-row">
  2241. <span class="vi-version">v{{ v.number }}<span v-if="v.current" class="vi-current">当前</span></span>
  2242. <span class="vi-time">{{ formatTime(v.createdAt) }}</span>
  2243. </div>
  2244. <div v-if="editingMessage !== v.number" class="vi-message" @dblclick="startEditMessage(v)">
  2245. {{ v.message || '双击编辑备注' }}
  2246. </div>
  2247. <div v-else class="vi-message-edit">
  2248. <n-input v-model:value="editingMessageText" size="tiny" placeholder="版本备注"
  2249. @keyup.enter="saveMessageWrap(v)" @keyup.escape="cancelEditMessage" />
  2250. <n-button size="tiny" type="primary" @click="saveMessageWrap(v)">✓</n-button>
  2251. <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
  2252. </div>
  2253. </div>
  2254. <div class="vi-actions">
  2255. <button v-if="!v.current" class="vi-action-btn rollback-btn" @click.stop="handleRollbackWrap(v)"
  2256. title="回滚到此版本">↩</button>
  2257. </div>
  2258. </div>
  2259. </div>
  2260. </div>
  2261. <div v-if="selectedVersion" class="vp-content">
  2262. <div class="vp-content-header">
  2263. <span>v{{ selectedVersion.number }} 的数据</span>
  2264. </div>
  2265. <pre class="version-json">{{ formatVersionJson(versionContent) }}</pre>
  2266. </div>
  2267. </div>
  2268. </transition>
  2269. <!-- 右键菜单 -->
  2270. <Teleport to="body">
  2271. <div v-if="contextMenu" class="context-menu-overlay" @click="closeContextMenu"
  2272. @contextmenu.prevent="closeContextMenu">
  2273. <div class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
  2274. <button class="ctx-item ctx-danger" @click="deleteNodeById(contextMenu.nodeId)">
  2275. 删除节点
  2276. </button>
  2277. </div>
  2278. </div>
  2279. </Teleport>
  2280. <!-- 字段引入对话框:从前驱输出或后继输入中引入字段 -->
  2281. <n-modal v-model:show="importDialog.open" preset="card" :mask-closable="false" style="max-width:560px"
  2282. :title="importDialog.mode === 'predecessor' ? '从前驱节点引入字段' : '向后继节点引入字段'">
  2283. <div class="import-candidate-list">
  2284. <div v-if="!importDialog.candidates.length" class="empty-hint">暂无可引入字段</div>
  2285. <div v-for="(c, i) in importDialog.candidates" :key="i" class="import-candidate"
  2286. @click="confirmImportField(c)">
  2287. <div class="candidate-main">
  2288. <span class="candidate-node">{{ c.nodeName }}</span>
  2289. <span class="candidate-sep">.</span>
  2290. <span class="candidate-field">{{ c.fieldName }}</span>
  2291. </div>
  2292. <div class="candidate-meta">
  2293. <span class="candidate-type">{{ c.fieldType }}</span>
  2294. <span v-if="c.fieldLabel && c.fieldLabel !== c.fieldName" class="candidate-label">{{ c.fieldLabel }}</span>
  2295. </div>
  2296. <div v-if="c.fieldDescription" class="candidate-desc">{{ c.fieldDescription }}</div>
  2297. </div>
  2298. </div>
  2299. </n-modal>
  2300. </div>
  2301. </template>
  2302. <style scoped>
  2303. .workflow-editor {
  2304. height: calc(100vh - var(--header-height));
  2305. display: flex;
  2306. flex-direction: column;
  2307. margin: -28px -32px;
  2308. background: #13111a;
  2309. }
  2310. .editor-toolbar {
  2311. display: flex;
  2312. align-items: center;
  2313. justify-content: space-between;
  2314. padding: 10px 16px;
  2315. background: var(--bg-card, #1a1a2e);
  2316. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  2317. flex-shrink: 0;
  2318. }
  2319. .toolbar-left,
  2320. .toolbar-right {
  2321. display: flex;
  2322. align-items: center;
  2323. gap: 10px;
  2324. }
  2325. /* 让左侧工具栏占满 toolbar 中间空间,使描述输入框可扩展到版本历史按钮左侧 */
  2326. .toolbar-left {
  2327. flex: 1;
  2328. min-width: 0;
  2329. }
  2330. /* 名称 5 态机提示 */
  2331. .name-status-hint {
  2332. font-size: 11px;
  2333. white-space: nowrap;
  2334. max-width: 220px;
  2335. overflow: hidden;
  2336. text-overflow: ellipsis;
  2337. }
  2338. .name-status-hint .hint-warn {
  2339. color: #e0a526;
  2340. }
  2341. .name-status-hint .hint-error {
  2342. color: #ef4444;
  2343. }
  2344. .name-status-hint .hint-ok {
  2345. color: #22c55e;
  2346. }
  2347. /* 工具栏表单:grid 布局让名称提示跨名称、描述两列延伸到描述字段右边界 */
  2348. .toolbar-form {
  2349. display: grid;
  2350. grid-template-columns: auto auto 1fr;
  2351. column-gap: 14px;
  2352. row-gap: 4px;
  2353. align-items: start;
  2354. flex: 1;
  2355. min-width: 0;
  2356. }
  2357. .wf-field {
  2358. display: flex;
  2359. flex-direction: column;
  2360. gap: 4px;
  2361. min-width: 0;
  2362. }
  2363. /* 让 wf-field 内的 n-input 拉伸到 field 宽度(默认 inline-flex 不会自动占满) */
  2364. .wf-field :deep(.n-input) {
  2365. width: 100%;
  2366. }
  2367. /* 三列定位:展示名称 / 名称 / 描述(描述占满剩余空间) */
  2368. .wf-field-show {
  2369. grid-column: 1;
  2370. grid-row: 1;
  2371. }
  2372. .wf-field-name {
  2373. grid-column: 2;
  2374. grid-row: 1;
  2375. }
  2376. .wf-field.wf-field-grow {
  2377. grid-column: 3;
  2378. grid-row: 1;
  2379. min-width: 0;
  2380. }
  2381. .wf-label {
  2382. font-size: 12px;
  2383. font-weight: 500;
  2384. color: var(--text-secondary, #aaa);
  2385. line-height: 1.2;
  2386. }
  2387. .wf-label .label-hint {
  2388. font-size: 11px;
  2389. font-weight: 400;
  2390. color: var(--text-tertiary, #888);
  2391. }
  2392. .wf-name-hint {
  2393. grid-column: 2 / -1;
  2394. grid-row: 2;
  2395. font-size: 11px;
  2396. min-height: 14px;
  2397. line-height: 1.2;
  2398. white-space: nowrap;
  2399. overflow: hidden;
  2400. text-overflow: ellipsis;
  2401. }
  2402. .wf-name-hint code {
  2403. background: rgba(255, 255, 255, 0.06);
  2404. padding: 1px 5px;
  2405. border-radius: 3px;
  2406. font-family: 'Consolas', 'Monaco', monospace;
  2407. font-size: 10px;
  2408. }
  2409. .wf-name-hint .hint-warn {
  2410. color: #e0a526;
  2411. }
  2412. .wf-name-hint .hint-error {
  2413. color: #ef4444;
  2414. }
  2415. .wf-name-hint .hint-ok {
  2416. color: #22c55e;
  2417. }
  2418. .editor-body {
  2419. flex: 1;
  2420. display: flex;
  2421. overflow: hidden;
  2422. }
  2423. /* 左侧节点栏 */
  2424. .sidebar-nodes {
  2425. width: 180px;
  2426. background: var(--bg-card, #1a1a2e);
  2427. border-right: 1px solid rgba(255, 255, 255, 0.08);
  2428. padding: 12px;
  2429. display: flex;
  2430. flex-direction: column;
  2431. gap: 6px;
  2432. flex-shrink: 0;
  2433. }
  2434. .sidebar-title {
  2435. font-size: 12px;
  2436. font-weight: 600;
  2437. color: var(--text-secondary, #999);
  2438. text-transform: uppercase;
  2439. letter-spacing: 1px;
  2440. margin-bottom: 6px;
  2441. }
  2442. .node-type-item {
  2443. display: flex;
  2444. align-items: center;
  2445. gap: 8px;
  2446. padding: 8px 10px;
  2447. border-radius: 8px;
  2448. cursor: grab;
  2449. transition: background 0.15s;
  2450. border: 1px solid rgba(255, 255, 255, 0.06);
  2451. }
  2452. .node-type-item:hover {
  2453. background: rgba(255, 255, 255, 0.06);
  2454. }
  2455. .node-type-item:active {
  2456. cursor: grabbing;
  2457. }
  2458. .nt-icon {
  2459. width: 28px;
  2460. height: 28px;
  2461. display: flex;
  2462. align-items: center;
  2463. justify-content: center;
  2464. border-radius: 6px;
  2465. color: #fff;
  2466. flex-shrink: 0;
  2467. }
  2468. .nt-label {
  2469. font-size: 13px;
  2470. color: var(--text-primary, #e0e0e0);
  2471. }
  2472. /* 中间画布 */
  2473. .canvas-area {
  2474. flex: 1;
  2475. background: #0f0d17;
  2476. }
  2477. /* 覆盖 VueFlow 默认主题的节点白色背景 */
  2478. .canvas-area :deep(.vue-flow__node) {
  2479. background: transparent !important;
  2480. padding: 0 !important;
  2481. border: none !important;
  2482. box-shadow: none !important;
  2483. }
  2484. /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
  2485. .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
  2486. stroke: #facc15 !important;
  2487. stroke-width: 3 !important;
  2488. }
  2489. .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
  2490. stroke: #facc15 !important;
  2491. }
  2492. .canvas-area :deep(.vue-flow__edge) {
  2493. cursor: pointer;
  2494. }
  2495. /* 右侧面板 - Tab 页 */
  2496. .sidebar-right {
  2497. width: 300px;
  2498. background: var(--bg-card, #1a1a2e);
  2499. border-left: 1px solid rgba(255, 255, 255, 0.08);
  2500. display: flex;
  2501. flex-direction: column;
  2502. flex-shrink: 0;
  2503. overflow: hidden;
  2504. }
  2505. .sidebar-tabs {
  2506. display: flex;
  2507. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  2508. flex-shrink: 0;
  2509. }
  2510. .tab-btn {
  2511. flex: 1;
  2512. padding: 10px 0;
  2513. border: none;
  2514. background: transparent;
  2515. color: var(--text-secondary, #999);
  2516. font-size: 13px;
  2517. cursor: pointer;
  2518. transition: all 0.15s;
  2519. position: relative;
  2520. display: flex;
  2521. align-items: center;
  2522. justify-content: center;
  2523. gap: 4px;
  2524. }
  2525. .tab-btn:hover {
  2526. color: var(--text-primary, #e0e0e0);
  2527. }
  2528. .tab-btn.active {
  2529. color: var(--text-primary, #e0e0e0);
  2530. font-weight: 600;
  2531. }
  2532. .tab-btn.active::after {
  2533. content: '';
  2534. position: absolute;
  2535. bottom: 0;
  2536. left: 20%;
  2537. right: 20%;
  2538. height: 2px;
  2539. background: var(--color-primary, #2563EB);
  2540. border-radius: 1px;
  2541. }
  2542. .result-badge {
  2543. display: inline-flex;
  2544. align-items: center;
  2545. justify-content: center;
  2546. min-width: 16px;
  2547. height: 16px;
  2548. padding: 0 4px;
  2549. border-radius: 8px;
  2550. background: var(--color-primary, #2563EB);
  2551. color: #fff;
  2552. font-size: 10px;
  2553. font-weight: 600;
  2554. }
  2555. .sidebar-tab-content {
  2556. flex: 1;
  2557. overflow-y: auto;
  2558. }
  2559. .tab-pane {
  2560. padding: 12px;
  2561. }
  2562. .empty-props-hint {
  2563. color: var(--text-tertiary, #555);
  2564. font-size: 13px;
  2565. text-align: center;
  2566. padding: 40px 0;
  2567. }
  2568. /* 节点结果卡片 */
  2569. .node-result-card {
  2570. background: rgba(255, 255, 255, 0.03);
  2571. border-radius: 8px;
  2572. border-left: 3px solid #666;
  2573. margin-bottom: 10px;
  2574. overflow: hidden;
  2575. transition: background 0.3s, box-shadow 0.3s;
  2576. }
  2577. .node-result-card.highlighted {
  2578. animation: highlight-pulse 2s ease;
  2579. }
  2580. @keyframes highlight-pulse {
  2581. 0% {
  2582. background: rgba(129, 140, 248, 0.18);
  2583. box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.45);
  2584. }
  2585. 70% {
  2586. background: rgba(129, 140, 248, 0.08);
  2587. box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.2);
  2588. }
  2589. 100% {
  2590. background: rgba(255, 255, 255, 0.03);
  2591. box-shadow: none;
  2592. }
  2593. }
  2594. .nrc-header {
  2595. display: flex;
  2596. align-items: center;
  2597. gap: 8px;
  2598. padding: 8px 10px;
  2599. border-bottom: 1px solid rgba(255, 255, 255, 0.04);
  2600. }
  2601. .nrc-icon {
  2602. width: 22px;
  2603. height: 22px;
  2604. display: flex;
  2605. align-items: center;
  2606. justify-content: center;
  2607. border-radius: 5px;
  2608. color: #fff;
  2609. flex-shrink: 0;
  2610. }
  2611. .nrc-info {
  2612. flex: 1;
  2613. min-width: 0;
  2614. display: flex;
  2615. flex-direction: column;
  2616. gap: 1px;
  2617. }
  2618. .nrc-label {
  2619. font-size: 12px;
  2620. font-weight: 600;
  2621. color: var(--text-primary, #e0e0e0);
  2622. white-space: nowrap;
  2623. overflow: hidden;
  2624. text-overflow: ellipsis;
  2625. }
  2626. .nrc-type {
  2627. font-size: 10px;
  2628. color: var(--text-tertiary, #666);
  2629. }
  2630. .nrc-status {
  2631. font-size: 10px;
  2632. padding: 2px 6px;
  2633. border-radius: 4px;
  2634. font-weight: 600;
  2635. flex-shrink: 0;
  2636. }
  2637. .nrc-status.status-running {
  2638. background: rgba(99, 102, 241, 0.15);
  2639. color: #818cf8;
  2640. }
  2641. .nrc-status.status-success {
  2642. background: rgba(16, 185, 129, 0.15);
  2643. color: #34d399;
  2644. }
  2645. .nrc-status.status-failed {
  2646. background: rgba(239, 68, 68, 0.15);
  2647. color: #f87171;
  2648. }
  2649. .nrc-status.status-skipped {
  2650. background: rgba(107, 114, 128, 0.15);
  2651. color: #9ca3af;
  2652. }
  2653. .nrc-status.status-no_run {
  2654. background: rgba(107, 114, 128, 0.10);
  2655. color: #6b7280;
  2656. }
  2657. .nrc-vars {
  2658. padding: 6px 10px;
  2659. }
  2660. /* envelope 失败提示条:status=400 时显示在变量列表顶部 */
  2661. .nrc-env-fail {
  2662. display: flex;
  2663. align-items: center;
  2664. gap: 6px;
  2665. padding: 4px 8px;
  2666. margin-bottom: 6px;
  2667. background: rgba(239, 68, 68, 0.08);
  2668. border-left: 2px solid #ef4444;
  2669. border-radius: 3px;
  2670. font-size: 11px;
  2671. }
  2672. .nrc-env-badge {
  2673. font-weight: 600;
  2674. padding: 1px 6px;
  2675. border-radius: 3px;
  2676. flex-shrink: 0;
  2677. }
  2678. .nrc-env-badge.failed {
  2679. background: rgba(239, 68, 68, 0.15);
  2680. color: #f87171;
  2681. }
  2682. .nrc-env-msg {
  2683. color: #f87171;
  2684. word-break: break-all;
  2685. }
  2686. /* 流式思考过程区块 */
  2687. .nrc-thinking {
  2688. margin: 6px 10px;
  2689. border-radius: 6px;
  2690. background: rgba(168, 85, 247, 0.05);
  2691. border: 1px solid rgba(168, 85, 247, 0.2);
  2692. overflow: hidden;
  2693. }
  2694. .nrc-thinking.live {
  2695. border-color: rgba(168, 85, 247, 0.5);
  2696. box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15);
  2697. }
  2698. .nrc-thinking-header {
  2699. display: flex;
  2700. align-items: center;
  2701. gap: 8px;
  2702. padding: 5px 10px;
  2703. font-size: 11px;
  2704. background: rgba(168, 85, 247, 0.08);
  2705. }
  2706. .nrc-thinking-tag {
  2707. color: #c084fc;
  2708. font-weight: 600;
  2709. }
  2710. .nrc-thinking-live {
  2711. color: #ec4899;
  2712. font-size: 10px;
  2713. animation: nrc-pulse 1.2s ease-in-out infinite;
  2714. }
  2715. .nrc-thinking-static {
  2716. color: #888;
  2717. font-size: 10px;
  2718. }
  2719. .nrc-thinking-expand {
  2720. margin-left: auto;
  2721. font-size: 10px;
  2722. color: var(--text-tertiary, #666);
  2723. transition: transform 0.2s;
  2724. flex-shrink: 0;
  2725. }
  2726. .nrc-thinking-expand.expanded {
  2727. transform: rotate(90deg);
  2728. }
  2729. .nrc-thinking-body {
  2730. margin: 0;
  2731. padding: 8px 12px;
  2732. max-height: 320px;
  2733. overflow: auto;
  2734. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
  2735. font-size: 11px;
  2736. line-height: 1.6;
  2737. color: var(--text-secondary, #b0b0b0);
  2738. white-space: pre-wrap;
  2739. word-break: break-word;
  2740. }
  2741. @keyframes nrc-pulse {
  2742. 0%,
  2743. 100% {
  2744. opacity: 0.55;
  2745. }
  2746. 50% {
  2747. opacity: 1;
  2748. }
  2749. }
  2750. .nrc-var {
  2751. display: flex;
  2752. flex-direction: column;
  2753. gap: 2px;
  2754. padding: 4px 0;
  2755. border-bottom: 1px solid rgba(255, 255, 255, 0.02);
  2756. }
  2757. .nrc-var:last-child {
  2758. border-bottom: none;
  2759. }
  2760. .nrc-var-key {
  2761. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  2762. font-size: 11px;
  2763. font-weight: 600;
  2764. }
  2765. .nrc-var-value {
  2766. font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
  2767. font-size: 11px;
  2768. color: var(--text-secondary, #aaa);
  2769. white-space: pre-wrap;
  2770. word-break: break-all;
  2771. max-height: 120px;
  2772. overflow-y: auto;
  2773. }
  2774. /* 完整上下文快照折叠区(debug 视图) */
  2775. .nrc-ctx-toggle {
  2776. display: flex;
  2777. align-items: center;
  2778. gap: 6px;
  2779. padding: 6px 10px;
  2780. cursor: pointer;
  2781. border-top: 1px solid rgba(255, 255, 255, 0.04);
  2782. font-size: 11px;
  2783. color: var(--text-secondary, #aaa);
  2784. user-select: none;
  2785. }
  2786. .nrc-ctx-toggle:hover {
  2787. background: rgba(99, 102, 241, 0.05);
  2788. }
  2789. .nrc-ctx-icon {
  2790. font-size: 9px;
  2791. color: var(--text-tertiary, #888);
  2792. transition: transform 0.2s;
  2793. }
  2794. .nrc-ctx-icon.expanded {
  2795. transform: rotate(90deg);
  2796. }
  2797. .nrc-ctx-label {
  2798. font-weight: 500;
  2799. }
  2800. .nrc-ctx-panel {
  2801. padding: 6px 10px;
  2802. border-top: 1px solid rgba(255, 255, 255, 0.04);
  2803. max-height: 280px;
  2804. overflow-y: auto;
  2805. background: rgba(0, 0, 0, 0.15);
  2806. }
  2807. .nrc-ctx-var {
  2808. display: flex;
  2809. flex-direction: column;
  2810. gap: 2px;
  2811. padding: 4px 0;
  2812. border-bottom: 1px solid rgba(255, 255, 255, 0.02);
  2813. }
  2814. .nrc-ctx-var:last-child {
  2815. border-bottom: none;
  2816. }
  2817. .nrc-ctx-key {
  2818. font-family: 'Cascadia Code', 'Fira Code', monospace;
  2819. font-size: 11px;
  2820. font-weight: 600;
  2821. color: #818cf8;
  2822. }
  2823. .nrc-ctx-value {
  2824. font-family: 'Cascadia Code', 'Fira Code', monospace;
  2825. font-size: 11px;
  2826. color: var(--text-secondary, #ccc);
  2827. white-space: pre-wrap;
  2828. word-break: break-all;
  2829. max-height: 80px;
  2830. overflow-y: auto;
  2831. }
  2832. .nrc-clear-row {
  2833. text-align: center;
  2834. padding: 10px 0 4px;
  2835. border-top: 1px solid rgba(255, 255, 255, 0.06);
  2836. margin-top: 8px;
  2837. }
  2838. .nrc-log-count {
  2839. font-size: 10px;
  2840. padding: 2px 6px;
  2841. border-radius: 4px;
  2842. background: rgba(99, 102, 241, 0.1);
  2843. color: #818cf8;
  2844. flex-shrink: 0;
  2845. }
  2846. .nrc-expand {
  2847. font-size: 10px;
  2848. color: var(--text-tertiary, #666);
  2849. transition: transform 0.2s;
  2850. flex-shrink: 0;
  2851. }
  2852. .nrc-expand.expanded {
  2853. transform: rotate(90deg);
  2854. }
  2855. .nrc-logs {
  2856. border-top: 1px solid rgba(255, 255, 255, 0.04);
  2857. padding: 6px 10px;
  2858. max-height: 400px;
  2859. overflow-y: auto;
  2860. }
  2861. .nrc-log {
  2862. padding: 4px 0;
  2863. border-bottom: 1px solid rgba(255, 255, 255, 0.02);
  2864. font-size: 11px;
  2865. line-height: 1.5;
  2866. }
  2867. .nrc-log:last-child {
  2868. border-bottom: none;
  2869. }
  2870. .nrc-log-icon {
  2871. margin-right: 4px;
  2872. }
  2873. .nrc-log-time {
  2874. font-family: 'Cascadia Code', 'Fira Code', monospace;
  2875. font-size: 10px;
  2876. color: var(--text-tertiary, #555);
  2877. margin-right: 6px;
  2878. }
  2879. .nrc-log-tag {
  2880. font-size: 10px;
  2881. padding: 1px 4px;
  2882. border-radius: 3px;
  2883. margin-right: 6px;
  2884. font-weight: 600;
  2885. flex-shrink: 0;
  2886. }
  2887. .log-type-tool_call .nrc-log-tag {
  2888. background: rgba(59, 130, 246, 0.15);
  2889. color: #60a5fa;
  2890. }
  2891. .log-type-tool_result .nrc-log-tag {
  2892. background: rgba(16, 185, 129, 0.15);
  2893. color: #34d399;
  2894. }
  2895. .log-type-thinking .nrc-log-tag {
  2896. background: rgba(168, 85, 247, 0.15);
  2897. color: #c084fc;
  2898. }
  2899. .log-type-error .nrc-log-tag {
  2900. background: rgba(239, 68, 68, 0.15);
  2901. color: #f87171;
  2902. }
  2903. .log-type-info .nrc-log-tag {
  2904. background: rgba(107, 114, 128, 0.15);
  2905. color: #9ca3af;
  2906. }
  2907. .nrc-log-msg {
  2908. color: var(--text-secondary, #ccc);
  2909. word-break: break-all;
  2910. }
  2911. .nrc-log-detail {
  2912. margin-top: 3px;
  2913. margin-left: 22px;
  2914. font-family: 'Cascadia Code', 'Fira Code', monospace;
  2915. font-size: 10px;
  2916. color: var(--text-tertiary, #888);
  2917. background: rgba(0, 0, 0, 0.2);
  2918. padding: 4px 6px;
  2919. border-radius: 4px;
  2920. white-space: pre-wrap;
  2921. word-break: break-all;
  2922. max-height: 100px;
  2923. overflow-y: auto;
  2924. }
  2925. .prop-section {
  2926. margin-bottom: 14px;
  2927. }
  2928. .prop-header {
  2929. display: flex;
  2930. align-items: center;
  2931. justify-content: space-between;
  2932. margin-bottom: 6px;
  2933. }
  2934. .prop-label {
  2935. display: block;
  2936. font-size: 12px;
  2937. font-weight: 500;
  2938. color: var(--text-secondary, #999);
  2939. margin-bottom: 6px;
  2940. }
  2941. .var-form {
  2942. background: rgba(255, 255, 255, 0.03);
  2943. border-radius: 6px;
  2944. padding: 8px;
  2945. margin-bottom: 8px;
  2946. }
  2947. .var-form-row {
  2948. display: flex;
  2949. gap: 6px;
  2950. margin-bottom: 6px;
  2951. }
  2952. /* 关联方式行:作为前置数据的一个属性,紧跟字段定义 */
  2953. .var-form-row.mapping-row {
  2954. margin-top: 2px;
  2955. padding-top: 6px;
  2956. border-top: 1px dashed rgba(255, 255, 255, 0.06);
  2957. }
  2958. .cond-form {
  2959. margin-bottom: 8px;
  2960. }
  2961. .cond-form-row {
  2962. display: flex;
  2963. align-items: center;
  2964. gap: 6px;
  2965. }
  2966. .cond-tag {
  2967. font-size: 11px;
  2968. padding: 2px 8px;
  2969. border-radius: 4px;
  2970. background: rgba(249, 115, 22, 0.15);
  2971. color: #F97316;
  2972. flex-shrink: 0;
  2973. font-weight: 600;
  2974. }
  2975. .text-btn {
  2976. background: none;
  2977. border: none;
  2978. color: var(--color-accent, #2563EB);
  2979. font-size: 12px;
  2980. cursor: pointer;
  2981. padding: 2px 4px;
  2982. }
  2983. .text-btn:hover {
  2984. text-decoration: underline;
  2985. }
  2986. .text-btn.danger {
  2987. color: #EF4444;
  2988. }
  2989. .empty-hint {
  2990. font-size: 12px;
  2991. color: var(--text-tertiary, #555);
  2992. font-style: italic;
  2993. }
  2994. /* 引入对话框候选列表 */
  2995. .import-candidate-list {
  2996. max-height: 360px;
  2997. overflow-y: auto;
  2998. display: flex;
  2999. flex-direction: column;
  3000. gap: 6px;
  3001. }
  3002. .import-candidate {
  3003. padding: 8px 10px;
  3004. border-radius: 6px;
  3005. background: rgba(255, 255, 255, 0.04);
  3006. border: 1px solid rgba(255, 255, 255, 0.06);
  3007. cursor: pointer;
  3008. transition: background 0.15s, border-color 0.15s;
  3009. }
  3010. .import-candidate:hover {
  3011. background: rgba(255, 255, 255, 0.08);
  3012. border-color: rgba(255, 255, 255, 0.14);
  3013. }
  3014. .import-candidate .candidate-main {
  3015. font-size: 13px;
  3016. color: var(--text-primary, #eee);
  3017. margin-bottom: 2px;
  3018. }
  3019. .import-candidate .candidate-node {
  3020. color: var(--text-secondary, #aaa);
  3021. }
  3022. .import-candidate .candidate-sep {
  3023. color: var(--text-tertiary, #666);
  3024. margin: 0 2px;
  3025. }
  3026. .import-candidate .candidate-field {
  3027. font-family: 'Consolas', 'Monaco', monospace;
  3028. font-weight: 500;
  3029. }
  3030. .import-candidate .candidate-meta {
  3031. display: flex;
  3032. gap: 8px;
  3033. align-items: center;
  3034. font-size: 11px;
  3035. margin-bottom: 2px;
  3036. }
  3037. .import-candidate .candidate-type {
  3038. padding: 1px 6px;
  3039. border-radius: 3px;
  3040. background: rgba(255, 255, 255, 0.06);
  3041. color: var(--text-secondary, #aaa);
  3042. }
  3043. .import-candidate .candidate-label {
  3044. color: var(--text-tertiary, #888);
  3045. }
  3046. .import-candidate .candidate-desc {
  3047. font-size: 11px;
  3048. color: var(--text-tertiary, #777);
  3049. line-height: 1.4;
  3050. }
  3051. .kr-checkbox-row {
  3052. display: flex;
  3053. align-items: center;
  3054. gap: 6px;
  3055. font-size: 12px;
  3056. cursor: pointer;
  3057. margin-bottom: 4px;
  3058. }
  3059. /* 重试配置折叠面板 */
  3060. .retry-config {
  3061. margin: 8px 0 12px;
  3062. padding: 6px 10px;
  3063. background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
  3064. border-radius: 4px;
  3065. border-left: 2px solid #8b5cf6;
  3066. }
  3067. .retry-config > summary {
  3068. cursor: pointer;
  3069. list-style: none;
  3070. user-select: none;
  3071. font-size: 12px;
  3072. font-weight: 500;
  3073. }
  3074. .retry-config > summary::-webkit-details-marker {
  3075. display: none;
  3076. }
  3077. .retry-config > summary::before {
  3078. content: "▶";
  3079. display: inline-block;
  3080. margin-right: 6px;
  3081. font-size: 10px;
  3082. transition: transform 0.15s;
  3083. }
  3084. .retry-config[open] > summary::before {
  3085. transform: rotate(90deg);
  3086. }
  3087. .retry-config-body {
  3088. margin-top: 8px;
  3089. display: flex;
  3090. flex-direction: column;
  3091. gap: 8px;
  3092. }
  3093. .retry-field-label {
  3094. font-size: 11px;
  3095. color: var(--text-secondary, #666);
  3096. flex-shrink: 0;
  3097. min-width: 80px;
  3098. }
  3099. .retry-checkbox-group {
  3100. display: flex;
  3101. flex-direction: column;
  3102. gap: 4px;
  3103. }
  3104. .retry-checkbox-row {
  3105. display: flex;
  3106. align-items: center;
  3107. gap: 6px;
  3108. font-size: 11px;
  3109. cursor: pointer;
  3110. }
  3111. .retry-hint {
  3112. font-size: 10px;
  3113. color: var(--text-tertiary, #888);
  3114. margin-top: 2px;
  3115. }
  3116. .kr-list {
  3117. margin-top: 6px;
  3118. max-height: 240px;
  3119. overflow-y: auto;
  3120. padding-right: 4px;
  3121. }
  3122. .passthrough-hint {
  3123. font-size: 11px;
  3124. color: var(--text-tertiary, #555);
  3125. margin-top: 6px;
  3126. padding: 4px 8px;
  3127. background: rgba(249, 115, 22, 0.08);
  3128. border-radius: 4px;
  3129. color: #fb923c;
  3130. }
  3131. /* 通用按钮 */
  3132. .icon-btn {
  3133. width: 32px;
  3134. height: 32px;
  3135. display: flex;
  3136. align-items: center;
  3137. justify-content: center;
  3138. border-radius: 6px;
  3139. border: 1px solid rgba(255, 255, 255, 0.12);
  3140. background: transparent;
  3141. color: var(--text-secondary, #999);
  3142. cursor: pointer;
  3143. transition: all 0.15s;
  3144. }
  3145. .icon-btn:hover {
  3146. background: rgba(255, 255, 255, 0.08);
  3147. color: #fff;
  3148. }
  3149. .primary-btn {
  3150. display: flex;
  3151. align-items: center;
  3152. gap: 6px;
  3153. padding: 7px 16px;
  3154. background: var(--color-primary, #2563EB);
  3155. color: #fff;
  3156. border: none;
  3157. border-radius: 6px;
  3158. font-size: 13px;
  3159. cursor: pointer;
  3160. transition: all 0.15s;
  3161. }
  3162. .primary-btn:hover {
  3163. filter: brightness(1.15);
  3164. }
  3165. .primary-btn:disabled {
  3166. opacity: 0.5;
  3167. cursor: not-allowed;
  3168. }
  3169. /* 自动关联按钮 */
  3170. .associate-btn {
  3171. display: flex;
  3172. align-items: center;
  3173. gap: 6px;
  3174. padding: 7px 16px;
  3175. background: rgba(129, 140, 248, 0.12);
  3176. color: #818cf8;
  3177. border: 1px solid rgba(129, 140, 248, 0.25);
  3178. border-radius: 6px;
  3179. font-size: 13px;
  3180. cursor: pointer;
  3181. transition: all 0.15s;
  3182. }
  3183. .associate-btn:hover {
  3184. background: rgba(129, 140, 248, 0.2);
  3185. border-color: rgba(129, 140, 248, 0.4);
  3186. }
  3187. .associate-btn:disabled {
  3188. opacity: 0.5;
  3189. cursor: not-allowed;
  3190. }
  3191. .associate-btn.active {
  3192. background: rgba(129, 140, 248, 0.18);
  3193. border-color: rgba(129, 140, 248, 0.35);
  3194. }
  3195. .clear-assoc-btn {
  3196. display: flex;
  3197. align-items: center;
  3198. justify-content: center;
  3199. width: 30px;
  3200. height: 30px;
  3201. background: transparent;
  3202. color: var(--text-tertiary);
  3203. border: 1px solid var(--border-color);
  3204. border-radius: 6px;
  3205. cursor: pointer;
  3206. transition: all 0.15s;
  3207. }
  3208. .clear-assoc-btn:hover {
  3209. color: #f87171;
  3210. border-color: rgba(239, 68, 68, 0.3);
  3211. background: rgba(239, 68, 68, 0.08);
  3212. }
  3213. /* 虚拟节点(自动关联产生的技能节点) */
  3214. .canvas-area :deep(.vue-flow__node.virtual-node) {
  3215. opacity: 0.75;
  3216. pointer-events: none;
  3217. }
  3218. .canvas-area :deep(.vue-flow__node.virtual-node .base-node) {
  3219. /* 内容也穿透:鼠标按下技能节点时事件落到下层虚框,由父节点接管拖拽实现整体移动 */
  3220. pointer-events: none;
  3221. border-style: dashed !important;
  3222. border-color: #818cf8 !important;
  3223. background: rgba(129, 140, 248, 0.06) !important;
  3224. }
  3225. .canvas-area :deep(.vue-flow__node.virtual-node .base-node:hover) {
  3226. border-color: #a5b4fc !important;
  3227. }
  3228. /* 虚线分组容器节点:必须保留 pointer-events 以接收拖动事件实现整体拖动 */
  3229. .canvas-area :deep(.vue-flow__node.virtual-group-node) {
  3230. pointer-events: all !important;
  3231. }
  3232. .canvas-area :deep(.vue-flow__node.virtual-group-node .vue-flow__handle) {
  3233. pointer-events: none !important;
  3234. opacity: 0 !important;
  3235. width: 1px !important;
  3236. height: 1px !important;
  3237. min-width: 1px !important;
  3238. min-height: 1px !important;
  3239. background: transparent !important;
  3240. border: none !important;
  3241. }
  3242. /* 虚线分组边 */
  3243. .canvas-area :deep(.vue-flow__edge.virtual-group-edge .vue-flow__edge-path) {
  3244. stroke: #818cf8;
  3245. stroke-width: 1.5;
  3246. stroke-dasharray: 5 4;
  3247. opacity: 0.7;
  3248. }
  3249. /* 运行按钮 */
  3250. .run-btn {
  3251. display: flex;
  3252. align-items: center;
  3253. gap: 6px;
  3254. padding: 7px 16px;
  3255. background: #10B981;
  3256. color: #fff;
  3257. border: none;
  3258. border-radius: 6px;
  3259. font-size: 13px;
  3260. cursor: pointer;
  3261. transition: all 0.15s;
  3262. }
  3263. .run-btn:hover {
  3264. filter: brightness(1.15);
  3265. }
  3266. .run-btn:disabled {
  3267. opacity: 0.5;
  3268. cursor: not-allowed;
  3269. }
  3270. .run-btn.is-running {
  3271. background: #6366f1;
  3272. animation: pulse-btn 1.5s infinite;
  3273. }
  3274. @keyframes pulse-btn {
  3275. 0%,
  3276. 100% {
  3277. opacity: 1;
  3278. }
  3279. 50% {
  3280. opacity: 0.7;
  3281. }
  3282. }
  3283. /* 节点执行状态 */
  3284. .canvas-area :deep(.vue-flow__node.node-status-running) {
  3285. animation: node-pulse 1.5s infinite;
  3286. }
  3287. .canvas-area :deep(.vue-flow__node.node-status-success) {
  3288. filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
  3289. }
  3290. .canvas-area :deep(.vue-flow__node.node-status-failed) {
  3291. filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
  3292. }
  3293. .canvas-area :deep(.vue-flow__node.node-status-skipped) {
  3294. opacity: 0.4;
  3295. }
  3296. @keyframes node-pulse {
  3297. 0%,
  3298. 100% {
  3299. filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5));
  3300. }
  3301. 50% {
  3302. filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9));
  3303. }
  3304. }
  3305. /* 运行对话框 */
  3306. .dialog-overlay {
  3307. position: fixed;
  3308. inset: 0;
  3309. background: rgba(0, 0, 0, 0.75);
  3310. display: flex;
  3311. align-items: center;
  3312. justify-content: center;
  3313. z-index: 100;
  3314. }
  3315. .run-dialog {
  3316. background: var(--bg-card, #1a1a2e);
  3317. border-radius: 12px;
  3318. width: 400px;
  3319. max-height: 80vh;
  3320. display: flex;
  3321. flex-direction: column;
  3322. border: 1px solid rgba(255, 255, 255, 0.1);
  3323. }
  3324. .prompt-editor-dialog {
  3325. background: var(--bg-card, #1a1a2e);
  3326. border-radius: 12px;
  3327. width: 80vw;
  3328. max-width: 960px;
  3329. height: 75vh;
  3330. display: flex;
  3331. flex-direction: column;
  3332. border: 1px solid rgba(255, 255, 255, 0.1);
  3333. }
  3334. .prompt-editor-body {
  3335. flex: 1;
  3336. min-height: 0;
  3337. overflow: hidden;
  3338. }
  3339. .dialog-header {
  3340. display: flex;
  3341. align-items: center;
  3342. justify-content: space-between;
  3343. padding: 14px 18px;
  3344. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  3345. }
  3346. .dialog-title {
  3347. font-size: 15px;
  3348. font-weight: 600;
  3349. color: var(--text-primary, #e0e0e0);
  3350. }
  3351. .dialog-body {
  3352. padding: 16px 18px;
  3353. overflow-y: auto;
  3354. flex: 1;
  3355. }
  3356. .dialog-footer {
  3357. display: flex;
  3358. justify-content: flex-end;
  3359. gap: 10px;
  3360. padding: 12px 18px;
  3361. border-top: 1px solid rgba(255, 255, 255, 0.08);
  3362. }
  3363. .cancel-btn {
  3364. padding: 7px 16px;
  3365. border: 1px solid rgba(255, 255, 255, 0.15);
  3366. background: transparent;
  3367. color: var(--text-secondary, #999);
  3368. border-radius: 6px;
  3369. cursor: pointer;
  3370. font-size: 13px;
  3371. }
  3372. .cancel-btn:hover {
  3373. background: rgba(255, 255, 255, 0.06);
  3374. }
  3375. .run-field {
  3376. margin-bottom: 12px;
  3377. }
  3378. .run-field-label {
  3379. display: block;
  3380. font-size: 12px;
  3381. color: var(--text-secondary, #999);
  3382. margin-bottom: 4px;
  3383. }
  3384. /* 运行对话框变量导出/导入按钮 */
  3385. .run-actions {
  3386. display: flex;
  3387. gap: 10px;
  3388. margin-bottom: 12px;
  3389. }
  3390. .run-actions .text-btn {
  3391. display: inline-flex;
  3392. align-items: center;
  3393. gap: 4px;
  3394. }
  3395. /* 文件上传区域 */
  3396. .run-upload {
  3397. margin-top: 16px;
  3398. }
  3399. .upload-btns {
  3400. display: flex;
  3401. gap: 8px;
  3402. }
  3403. .upload-area {
  3404. flex: 1;
  3405. border: 1px dashed rgba(255, 255, 255, 0.2);
  3406. border-radius: 6px;
  3407. padding: 12px;
  3408. text-align: center;
  3409. cursor: pointer;
  3410. transition: border-color 0.2s;
  3411. }
  3412. .upload-area:hover {
  3413. border-color: var(--primary);
  3414. }
  3415. .upload-hint {
  3416. font-size: 12px;
  3417. color: var(--text-secondary, #999);
  3418. }
  3419. .upload-list {
  3420. margin-top: 8px;
  3421. }
  3422. .upload-item {
  3423. display: flex;
  3424. align-items: center;
  3425. justify-content: space-between;
  3426. padding: 4px 8px;
  3427. font-size: 12px;
  3428. color: var(--text-primary, #eee);
  3429. background: rgba(255, 255, 255, 0.05);
  3430. border-radius: 4px;
  3431. margin-bottom: 4px;
  3432. }
  3433. .upload-name {
  3434. flex: 1;
  3435. overflow: hidden;
  3436. text-overflow: ellipsis;
  3437. white-space: nowrap;
  3438. }
  3439. .upload-remove {
  3440. background: none;
  3441. border: none;
  3442. color: var(--text-secondary, #999);
  3443. cursor: pointer;
  3444. font-size: 16px;
  3445. padding: 0 4px;
  3446. line-height: 1;
  3447. }
  3448. .upload-remove:hover {
  3449. color: #f87171;
  3450. }
  3451. /* 边映射状态 */
  3452. .edge-status-badge {
  3453. font-size: 11px;
  3454. padding: 2px 8px;
  3455. border-radius: 4px;
  3456. font-weight: 600;
  3457. }
  3458. .edge-status-badge.es-ok {
  3459. background: rgba(34, 197, 94, 0.15);
  3460. color: #4ade80;
  3461. }
  3462. .edge-status-badge.es-unmapped {
  3463. background: rgba(107, 114, 128, 0.15);
  3464. color: #9ca3af;
  3465. }
  3466. .edge-status-badge.es-partial {
  3467. background: rgba(245, 158, 11, 0.15);
  3468. color: #fbbf24;
  3469. }
  3470. .edge-status-badge.es-mismatch {
  3471. background: rgba(239, 68, 68, 0.15);
  3472. color: #f87171;
  3473. }
  3474. .mapping-list {
  3475. margin-top: 6px;
  3476. }
  3477. .mapping-item {
  3478. display: flex;
  3479. align-items: center;
  3480. gap: 4px;
  3481. padding: 4px 8px;
  3482. border-radius: 4px;
  3483. margin-bottom: 3px;
  3484. font-size: 12px;
  3485. font-family: 'Cascadia Code', 'Fira Code', monospace;
  3486. }
  3487. .mapping-item.mapped {
  3488. background: rgba(34, 197, 94, 0.08);
  3489. }
  3490. .mapping-item.mismatch {
  3491. background: rgba(239, 68, 68, 0.08);
  3492. }
  3493. .mapping-item.unmapped {
  3494. background: rgba(245, 158, 11, 0.08);
  3495. }
  3496. .mapping-item.extra {
  3497. background: rgba(107, 114, 128, 0.06);
  3498. }
  3499. .mapping-field {
  3500. color: var(--text-primary, #e0e0e0);
  3501. font-weight: 500;
  3502. }
  3503. .mapping-arrow {
  3504. color: var(--text-tertiary, #666);
  3505. }
  3506. .mapping-type {
  3507. color: var(--text-tertiary, #666);
  3508. font-size: 10px;
  3509. }
  3510. /* 右键菜单 */
  3511. .context-menu-overlay {
  3512. position: fixed;
  3513. inset: 0;
  3514. z-index: 200;
  3515. }
  3516. .context-menu {
  3517. position: fixed;
  3518. background: #1e1b2e;
  3519. border: 1px solid rgba(255, 255, 255, 0.12);
  3520. border-radius: 8px;
  3521. padding: 4px;
  3522. min-width: 140px;
  3523. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  3524. }
  3525. .ctx-item {
  3526. display: flex;
  3527. align-items: center;
  3528. gap: 8px;
  3529. width: 100%;
  3530. padding: 8px 12px;
  3531. border: none;
  3532. border-radius: 6px;
  3533. background: transparent;
  3534. color: var(--text-primary, #e0e0e0);
  3535. font-size: 13px;
  3536. cursor: pointer;
  3537. transition: background 0.12s;
  3538. text-align: left;
  3539. }
  3540. .ctx-item:hover {
  3541. background: rgba(255, 255, 255, 0.08);
  3542. }
  3543. .ctx-item.ctx-danger {
  3544. color: #f87171;
  3545. }
  3546. .ctx-item.ctx-danger:hover {
  3547. background: rgba(239, 68, 68, 0.15);
  3548. }
  3549. /* 版本历史面板 */
  3550. .version-panel {
  3551. position: fixed;
  3552. right: 0;
  3553. top: 0;
  3554. bottom: 0;
  3555. width: 380px;
  3556. background: var(--bg-secondary, #fff);
  3557. border-left: 1px solid var(--border-color, #e5e7eb);
  3558. display: flex;
  3559. flex-direction: column;
  3560. z-index: 100;
  3561. box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
  3562. }
  3563. .version-panel .vp-header {
  3564. display: flex;
  3565. align-items: center;
  3566. justify-content: space-between;
  3567. padding: 12px 16px;
  3568. border-bottom: 1px solid var(--border-color, #e5e7eb);
  3569. }
  3570. .version-panel .vp-title {
  3571. font-size: 15px;
  3572. font-weight: 600;
  3573. margin: 0;
  3574. }
  3575. .version-panel .vp-list {
  3576. max-height: 300px;
  3577. overflow-y: auto;
  3578. padding: 8px;
  3579. }
  3580. .version-panel .vp-empty {
  3581. text-align: center;
  3582. color: var(--text-tertiary, #999);
  3583. padding: 24px 0;
  3584. font-size: 13px;
  3585. }
  3586. .version-panel .version-items {
  3587. display: flex;
  3588. flex-direction: column;
  3589. gap: 4px;
  3590. }
  3591. .version-panel .version-item {
  3592. display: flex;
  3593. align-items: stretch;
  3594. border-radius: 6px;
  3595. border: 1px solid transparent;
  3596. cursor: pointer;
  3597. transition: all 0.15s;
  3598. }
  3599. .version-panel .version-item:hover {
  3600. background: rgba(37, 99, 235, 0.06);
  3601. }
  3602. .version-panel .version-item.active {
  3603. background: rgba(37, 99, 235, 0.1);
  3604. border-color: rgba(37, 99, 235, 0.3);
  3605. }
  3606. .version-panel .vi-main {
  3607. flex: 1;
  3608. padding: 8px 10px;
  3609. min-width: 0;
  3610. }
  3611. .version-panel .vi-row {
  3612. display: flex;
  3613. align-items: center;
  3614. justify-content: space-between;
  3615. gap: 8px;
  3616. }
  3617. .version-panel .vi-version {
  3618. font-size: 13px;
  3619. font-weight: 600;
  3620. color: var(--color-accent, #2563eb);
  3621. }
  3622. .version-panel .vi-current {
  3623. font-size: 10px;
  3624. font-weight: 500;
  3625. background: rgba(34, 197, 94, 0.15);
  3626. color: #22c55e;
  3627. padding: 0 4px;
  3628. border-radius: 3px;
  3629. margin-left: 4px;
  3630. vertical-align: middle;
  3631. }
  3632. .version-panel .vi-time {
  3633. font-size: 11px;
  3634. color: var(--text-tertiary, #999);
  3635. }
  3636. .version-panel .vi-message {
  3637. font-size: 12px;
  3638. color: var(--text-secondary, #666);
  3639. margin-top: 2px;
  3640. white-space: nowrap;
  3641. overflow: hidden;
  3642. text-overflow: ellipsis;
  3643. }
  3644. .version-panel .vi-message-edit {
  3645. display: flex;
  3646. gap: 4px;
  3647. margin-top: 4px;
  3648. align-items: center;
  3649. }
  3650. .version-panel .vi-actions {
  3651. display: flex;
  3652. flex-direction: column;
  3653. justify-content: center;
  3654. gap: 2px;
  3655. padding: 4px 6px;
  3656. opacity: 0;
  3657. transition: opacity 0.15s;
  3658. }
  3659. .version-panel .version-item:hover .vi-actions {
  3660. opacity: 1;
  3661. }
  3662. .vi-action-btn {
  3663. width: 24px;
  3664. height: 24px;
  3665. display: flex;
  3666. align-items: center;
  3667. justify-content: center;
  3668. border: none;
  3669. border-radius: 4px;
  3670. cursor: pointer;
  3671. font-size: 13px;
  3672. background: transparent;
  3673. transition: background 0.15s;
  3674. }
  3675. .rollback-btn {
  3676. color: var(--color-danger, #ef4444);
  3677. }
  3678. .rollback-btn:hover {
  3679. background: rgba(239, 68, 68, 0.15);
  3680. }
  3681. .version-panel .vp-content {
  3682. flex: 1;
  3683. display: flex;
  3684. flex-direction: column;
  3685. overflow: hidden;
  3686. border-top: 1px solid var(--border-color, #e5e7eb);
  3687. }
  3688. .version-panel .vp-content-header {
  3689. padding: 8px 16px;
  3690. font-size: 13px;
  3691. font-weight: 500;
  3692. color: var(--text-secondary, #666);
  3693. border-bottom: 1px solid var(--border-color, #e5e7eb);
  3694. flex-shrink: 0;
  3695. }
  3696. .version-json {
  3697. flex: 1;
  3698. overflow: auto;
  3699. padding: 12px 16px;
  3700. margin: 0;
  3701. font-size: 11px;
  3702. line-height: 1.5;
  3703. white-space: pre-wrap;
  3704. word-break: break-all;
  3705. color: var(--text-secondary, #666);
  3706. font-family: 'Consolas', 'Monaco', monospace;
  3707. background: transparent;
  3708. }
  3709. /* 版本按钮 */
  3710. .version-btn {
  3711. display: inline-flex;
  3712. align-items: center;
  3713. gap: 4px;
  3714. padding: 5px 10px;
  3715. border: 1px solid var(--border-color, #e5e7eb);
  3716. border-radius: 6px;
  3717. background: var(--bg-secondary, #fff);
  3718. color: var(--text-secondary, #666);
  3719. cursor: pointer;
  3720. font-size: 13px;
  3721. transition: all 0.2s;
  3722. }
  3723. .version-btn:hover:not(:disabled) {
  3724. border-color: var(--color-accent, #2563eb);
  3725. color: var(--color-accent, #2563eb);
  3726. }
  3727. .version-btn.active {
  3728. background: var(--color-accent, #2563eb);
  3729. color: #fff;
  3730. border-color: var(--color-accent, #2563eb);
  3731. }
  3732. .version-btn:disabled {
  3733. opacity: 0.4;
  3734. cursor: not-allowed;
  3735. }
  3736. /* 右侧滑入动画 */
  3737. .slide-right-enter-active,
  3738. .slide-right-leave-active {
  3739. transition: transform 0.25s ease;
  3740. }
  3741. .slide-right-enter-from,
  3742. .slide-right-leave-to {
  3743. transform: translateX(100%);
  3744. }
  3745. </style>