| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458 |
- <script setup>
- import { ref, computed, reactive, onMounted, markRaw, nextTick } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { VueFlow, useVueFlow } from '@vue-flow/core'
- import { Background } from '@vue-flow/background'
- import {
- NInput, NSelect, NButton, NIcon, NCheckbox, useMessage
- } from 'naive-ui'
- import {
- ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
- SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
- PlayOutline, CloseOutline, ColorWandOutline, LinkOutline, TrashOutline, OpenOutline,
- TimeOutline, LibraryOutline
- } from '@vicons/ionicons5'
- import { useWorkflowStore } from '../../stores/workflow'
- import { useSkillStore } from '../../stores/skill'
- import { runWorkflow, autoAssociate } from '../../api/workflow'
- import { getAgentTemplate } from '../../api/agentTemplate'
- import { getModelList } from '../../api/model'
- import { getDataSources } from '../../api/datasource'
- import { getGraphSources } from '../../api/graphsource'
- import { getKbDocuments } from '../../api/knowledge'
- import { listKnowledgeBases } from '../../api/rag'
- import { normalizeKebabCase, isValidKebabCase } from '../../utils/workflowName'
- import InputNode from '../../components/workflow/nodes/InputNode.vue'
- import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
- import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
- import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
- import KnowledgeRetrievalNode from '../../components/workflow/nodes/KnowledgeRetrievalNode.vue'
- import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
- import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
- import SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
- import VirtualGroupNode from '../../components/workflow/nodes/VirtualGroupNode.vue'
- import CodeEditor from '../../components/skill/CodeEditor.vue'
- import { inferNewEdge, refreshMappingsForNode, getNodeOutputs, getReachablePredecessors, IO_TYPES } from '../../utils/ioInference'
- import { useVersionHistory } from '../../composables/useVersionHistory'
- import { useWorkflowRunner } from '../../composables/useWorkflowRunner'
- import { useNodeFields } from '../../composables/useNodeFields'
- import '@vue-flow/core/dist/style.css'
- import '@vue-flow/core/dist/theme-default.css'
- const route = useRoute()
- const router = useRouter()
- const wfStore = useWorkflowStore()
- const skillStore = useSkillStore()
- const message = useMessage()
- // 路由参数 name(kebab-case);旧数字 id 作为字符串同样可被后端解析
- const routeName = computed(() => route.params.name || null)
- // 工作流 form 字段:name (kebab-case 唯一标识) / displayName (中文显示名) / description
- const form = ref({
- name: '',
- displayName: '',
- description: ''
- })
- const saving = ref(false)
- // 名称 5 态机(参考 SkillEdit.vue):
- // empty / invalid / will-rename / will-normalize / valid
- const nameState = computed(() => {
- const raw = form.value.name || ''
- if (!raw.trim()) return { status: 'empty', normalized: '', changed: false }
- const normalized = normalizeKebabCase(raw)
- const valid = isValidKebabCase(normalized)
- if (!valid) return { status: 'invalid', normalized, changed: false }
- // 编辑态:规范化后与路由 name 不同 → 保存时触发重命名
- if (routeName.value && normalized !== routeName.value) {
- return { status: 'will-rename', normalized, changed: true }
- }
- // raw 不规范但规范化后等于当前 name → 仅规范化输入
- if (normalized !== raw) return { status: 'will-normalize', normalized, changed: true }
- return { status: 'valid', normalized, changed: false }
- })
- // 兼容字段(保留旧变量名以减少 template 改动):workflowName = displayName
- const workflowName = computed({
- get: () => form.value.displayName,
- set: v => { form.value.displayName = v }
- })
- const workflowDesc = computed({
- get: () => form.value.description,
- set: v => { form.value.description = v }
- })
- // ========== SmartAction 弹出编辑器 ==========
- const showPromptEditor = ref(false)
- const promptEditValue = ref('')
- // ========== Vue Flow ==========
- const {
- onConnect, addEdges, addNodes, removeNodes, removeEdges,
- updateNodeData: vfUpdateNode,
- project, vueFlowRef, getNodes, getEdges, setNodes, setEdges, fitView, onInit,
- onNodeDrag, onNodeDragStop
- } = useVueFlow()
- const selectedNode = ref(null)
- // 从 userInput 节点提取输入字段定义
- const inputFields = computed(() => {
- const fields = []
- for (const node of getNodes.value) {
- if (node.type === 'userInput' && node.data?.variables) {
- for (const v of node.data.variables) {
- if (v.name) fields.push(v)
- }
- }
- }
- return fields
- })
- const formData = reactive({})
- const canRun = computed(() => !!routeName.value && !running.value && getNodes.value.length > 0)
- // ========== 自动关联 ==========
- const associating = ref(false)
- const hasAssociations = ref(false)
- // ========== 版本历史 ==========
- const {
- showVersionPanel, versions, versionsLoading, selectedVersion, versionContent,
- editingMessage, editingMessageText,
- toggleVersionPanel, handleVersionSelect, handleRollback,
- startEditMessage, saveMessage, cancelEditMessage,
- formatTime, formatVersionJson
- } = useVersionHistory()
- // ========== 工作流运行状态 ==========
- const {
- running, showRunDialog, showResult, workflowResult,
- nodeStatuses, activeTab, nodeResults, expandedLogs, expandedContext, expandedThinking,
- lastRunId, lastRunWorkflowId,
- startRun, openRunDialog, cancelRun, closeResult,
- statusText, logTypeIcon, logTypeLabel, formatTimestamp, toggleLogs,
- toggleContext, toggleThinking, formatContextValue
- } = useWorkflowRunner({
- getNodes,
- nodeTypeInfo: {
- userInput: { color: '#10B981', typeLabel: '用户输入' },
- llm: { color: '#8B5CF6', typeLabel: '大模型' },
- skill: { color: '#06B6D4', typeLabel: '技能' },
- agent: { color: '#F59E0B', typeLabel: '智能体' },
- smartAction: { color: '#EC4899', typeLabel: '智能操作' },
- condition: { color: '#F97316', typeLabel: '条件' },
- knowledgeRetrieval: { color: '#10B981', typeLabel: '知识库检索' },
- output: { color: '#EF4444', typeLabel: '输出' }
- },
- workflowIdRef: routeName
- })
- const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
- const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
- // 运行结果高亮:点击画布节点后滚动到对应结果卡片并短暂高亮
- const highlightedNodeId = ref(null)
- let highlightTimer = null
- function scrollToNodeResult(nodeId) {
- if (activeTab.value !== 'result' || nodeResults.length === 0) return
- const el = document.getElementById('node-result-' + nodeId)
- if (!el) return
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
- highlightedNodeId.value = nodeId
- if (highlightTimer) clearTimeout(highlightTimer)
- highlightTimer = setTimeout(() => { highlightedNodeId.value = null }, 2000)
- }
- onConnect((params) => {
- const sourceNode = getNodes.value.find(n => n.id === params.source)
- const targetNode = getNodes.value.find(n => n.id === params.target)
- const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode, getNodes.value, getEdges.value) : null
- addEdges([{
- ...params,
- type: 'bezier',
- animated: true,
- // 视觉上统一灰色,io 推断状态仅写入 data 供属性面板展示(不再影响线条颜色)
- style: defaultEdgeStyle,
- data: inferred?.data || {}
- }])
- })
- // 自适应更新虚线框与其源节点的连线端点:
- // 虚线框在源节点上方时,连线从虚线框底边中点连到源节点顶边中点;
- // 虚线框在源节点下方时,连线从虚线框顶边中点连到源节点底边中点。
- function updateVirtualGroupEdge(edge) {
- if (!edge?.data?.virtual) return
- const group = getNodes.value.find(n => n.id === edge.source)
- const source = getNodes.value.find(n => n.id === edge.target)
- if (!group || !source) return
- const groupH = group.dimensions?.height || group.height || 0
- const sourceH = source.dimensions?.height || source.height || 0
- const groupCenterY = group.position.y + groupH / 2
- const sourceCenterY = source.position.y + sourceH / 2
- if (groupCenterY < sourceCenterY) {
- // 虚线框在源节点上方
- edge.sourceHandle = 'bottom'
- edge.targetHandle = 'top-target'
- } else {
- // 虚线框在源节点下方
- edge.sourceHandle = 'top'
- edge.targetHandle = 'bottom-target'
- }
- }
- let virtualEdgeDragInProgress = false
- onNodeDrag(({ node }) => {
- if (node?.type !== 'virtualGroup') return
- if (virtualEdgeDragInProgress) return
- virtualEdgeDragInProgress = true
- requestAnimationFrame(() => {
- virtualEdgeDragInProgress = false
- const virtualEdges = getEdges.value.filter(e => e.data?.virtual && e.source === node.id)
- for (const edge of virtualEdges) {
- const prevSourceHandle = edge.sourceHandle
- const prevTargetHandle = edge.targetHandle
- updateVirtualGroupEdge(edge)
- if (edge.sourceHandle !== prevSourceHandle || edge.targetHandle !== prevTargetHandle) {
- // 通过替换 edge 对象触发 Vue Flow 重新计算路径
- getEdges.value = getEdges.value.map(e => (e.id === edge.id ? { ...edge } : e))
- }
- }
- })
- })
- onNodeDragStop(() => {
- virtualEdgeDragInProgress = false
- })
- // 批量更新边的映射数据(仅写入 data,不再改变 style;视觉统一灰色,避免不同映射状态造成颜色差异)
- function applyEdgeMappingUpdates(updates) {
- for (const upd of updates) {
- const edge = getEdges.value.find(e => e.id === upd.id)
- if (edge) {
- edge.data = { ...edge.data, ...upd.data }
- }
- }
- }
- // ========== 节点类型定义 ==========
- const nodeTypes = [
- { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
- { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
- { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
- { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
- { type: 'smartAction', label: '智能操作', icon: markRaw(ColorWandOutline), color: '#EC4899' },
- { type: 'knowledgeRetrieval', label: '知识库检索', icon: markRaw(LibraryOutline), color: '#10B981' },
- { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
- { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
- ]
- const nodeTypeInfo = {
- userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
- llm: { color: '#8B5CF6', typeLabel: '大模型', icon: markRaw(SparklesOutline) },
- skill: { color: '#06B6D4', typeLabel: '技能', icon: markRaw(FlaskOutline) },
- agent: { color: '#F59E0B', typeLabel: '智能体', icon: markRaw(RocketOutline) },
- smartAction: { color: '#EC4899', typeLabel: '智能操作', icon: markRaw(ColorWandOutline) },
- knowledgeRetrieval: { color: '#10B981', typeLabel: '知识库检索', icon: markRaw(LibraryOutline) },
- condition: { color: '#F97316', typeLabel: '条件', icon: markRaw(GitBranchOutline) },
- output: { color: '#EF4444', typeLabel: '输出', icon: markRaw(ExitOutline) }
- }
- function getDefaultData(type) {
- switch (type) {
- case 'userInput': return { nodeName: '', label: '用户输入', variables: [] }
- case 'llm': return { nodeName: '', label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
- case 'agent': return { nodeName: '', label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
- case 'skill': return { nodeName: '', label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
- case 'smartAction': return { nodeName: '', label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
- case 'knowledgeRetrieval': return {
- nodeName: '',
- label: '知识库检索',
- source: 'document',
- query: '',
- allDocuments: true,
- documentIds: [],
- allDatasources: false,
- datasourceIds: [],
- allGraphSources: false,
- graphSourceIds: [],
- knowledgeBaseId: null,
- topK: 5
- }
- case 'condition': return { nodeName: '', label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
- case 'output': return { nodeName: '', label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
- default: return { nodeName: '', label: '节点' }
- }
- }
- let nodeIdCounter = 0
- function genId() { return `node-${++nodeIdCounter}-${Date.now()}` }
- // ========== 拖拽到画布 ==========
- function onDragOver(e) {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
- function onDrop(e) {
- const type = e.dataTransfer.getData('application/vueflow')
- if (!type) return
- const bounds = vueFlowRef.value.getBoundingClientRect()
- const position = project({
- x: e.clientX - bounds.left,
- y: e.clientY - bounds.top
- })
- const id = genId()
- addNodes([{
- id,
- type,
- position,
- data: getDefaultData(type)
- }])
- }
- function onDragStart(e, type) {
- e.dataTransfer.setData('application/vueflow', type)
- e.dataTransfer.effectAllowed = 'move'
- }
- // ========== 节点选中 ==========
- function onNodeClick({ node }) {
- selectedNode.value = node
- scrollToNodeResult(node.id)
- }
- function onPaneClick() {
- selectedNode.value = null
- selectedEdgeId.value = null
- contextMenu.value = null
- }
- // ========== 节点右键菜单 ==========
- const contextMenu = ref(null) // { x, y, nodeId }
- function onNodeContextMenu({ event, node }) {
- event.preventDefault()
- contextMenu.value = { x: event.clientX, y: event.clientY, nodeId: node.id }
- }
- function closeContextMenu() {
- contextMenu.value = null
- }
- function deleteNodeById(nodeId) {
- const affectedNodeIds = new Set()
- for (const edge of getEdges.value) {
- if (edge.source === nodeId || edge.target === nodeId) {
- affectedNodeIds.add(edge.source)
- affectedNodeIds.add(edge.target)
- }
- }
- removeNodes(nodeId)
- if (selectedNode.value?.id === nodeId) {
- selectedNode.value = null
- }
- closeContextMenu()
- for (const id of affectedNodeIds) {
- if (id !== nodeId) {
- applyEdgeMappingUpdates(refreshMappingsForNode(id, getNodes.value, getEdges.value))
- }
- }
- }
- // ========== 连接线选中与删除 ==========
- const selectedEdgeId = ref(null)
- function onEdgeClick({ edge }) {
- selectedEdgeId.value = edge.id
- selectedNode.value = null
- }
- function deleteSelectedEdge() {
- if (!selectedEdgeId.value) return
- const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
- const sourceId = edge?.source
- const targetId = edge?.target
- removeEdges(selectedEdgeId.value)
- selectedEdgeId.value = null
- if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
- if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
- }
- function onKeyDown(e) {
- if (e.key === 'Delete' || e.key === 'Backspace') {
- const tag = e.target?.tagName
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
- if (selectedNode.value) {
- deleteNodeById(selectedNode.value.id)
- } else {
- deleteSelectedEdge()
- }
- }
- }
- const selectedData = computed(() => selectedNode.value?.data || null)
- const selectedEdge = computed(() => {
- if (!selectedEdgeId.value) return null
- return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
- })
- // ========== 输入变量关联:列出所有可达前驱节点的输出字段供选择 ==========
- // 关联范围:不仅限直接上游,而是通过边反向 BFS 找出画布中所有可达的前序节点
- const inputMappingOptions = computed(() => {
- if (!selectedNode.value) return []
- const predecessors = getReachablePredecessors(selectedNode.value.id, getNodes.value, getEdges.value)
- const opts = []
- for (const src of predecessors) {
- const outputs = getNodeOutputs(src)
- for (const out of outputs) {
- const value = JSON.stringify({ nodeId: src.id, field: out.name })
- opts.push({
- label: `${src.data?.nodeName || src.data?.label || src.id}.${out.name}`,
- value,
- nodeId: src.id,
- field: out.name
- })
- }
- }
- return opts
- })
- function currentMappingValue(input) {
- if (!input?.mapping?.sourceNodeId) return null
- return JSON.stringify({ nodeId: input.mapping.sourceNodeId, field: input.mapping.sourceField })
- }
- function updateInputMapping(input, value) {
- if (!value) {
- input.mapping = null
- } else {
- const parsed = JSON.parse(value)
- input.mapping = { sourceNodeId: parsed.nodeId, sourceField: parsed.field, sourcePath: null, autoMapped: false }
- }
- onFieldChange('inputs', [...selectedData.value.inputs])
- }
- function clearInputMapping(input) {
- input.mapping = null
- onFieldChange('inputs', [...selectedData.value.inputs])
- }
- // ========== 节点名称编辑(含重名校验) ==========
- function updateNodeName(value) {
- const trimmed = (value || '').trim()
- if (trimmed && selectedNode.value) {
- const duplicate = getNodes.value.find(n =>
- n.id !== selectedNode.value.id &&
- n.data?.nodeName === trimmed
- )
- if (duplicate) {
- message.warning(`节点名称「${trimmed}」已被其他节点使用`)
- return
- }
- }
- onFieldChange('nodeName', trimmed)
- }
- // ========== 智能体选项 ==========
- const agentOptions = computed(() =>
- (skillStore.skills || []).map(s => ({
- label: s.displayName || s.translatedDisplayName || s.name || s.folderName,
- value: s.folderName
- }))
- )
- // ========== 数据库模型选项 ==========
- const dbModels = ref([])
- const defaultModelId = ref(null)
- const modelOptions = computed(() =>
- dbModels.value.map(m => ({
- label: `${m.name} (${m.modelName})`,
- value: m.id
- }))
- )
- async function fetchDbModels() {
- try {
- const res = await getModelList()
- dbModels.value = res.data || []
- const defaultModel = dbModels.value.find(m => m.isDefault)
- defaultModelId.value = defaultModel?.id || null
- } catch (e) {
- message.error('加载模型列表失败')
- }
- }
- // 失败策略选项
- const failStrategyOptions = [
- { label: '终止工作流', value: 'abort' },
- { label: '跳过继续', value: 'skip' }
- ]
- // ========== 知识库检索节点:下拉/复选框数据源 ==========
- const krSourceOptions = [
- { label: '文档数据库', value: 'document' },
- { label: '结构化数据库', value: 'structured' },
- { label: '知识图谱库', value: 'graph' },
- { label: '混合检索', value: 'hybrid' }
- ]
- const krDocuments = ref([])
- const krDocumentsLoading = ref(false)
- const krDatasources = ref([])
- const krDatasourcesLoading = ref(false)
- const krGraphSources = ref([])
- const krGraphSourcesLoading = ref(false)
- const krKnowledgeBases = ref([])
- const krKnowledgeBasesLoading = ref(false)
- const krDocumentOptions = computed(() => krDocuments.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
- const krDatasourceOptions = computed(() => krDatasources.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
- const krGraphSourceOptions = computed(() => krGraphSources.value.map(d => ({ label: d.name || `#${d.id}`, value: String(d.id) })))
- const krKnowledgeBaseOptions = computed(() => krKnowledgeBases.value.map(kb => ({ label: kb.name || `#${kb.id}`, value: kb.id })))
- async function loadKrDocuments() {
- if (krDocuments.value.length || krDocumentsLoading.value) return
- krDocumentsLoading.value = true
- try {
- const res = await getKbDocuments({ page: 1, size: 1000 })
- krDocuments.value = res.data?.items || []
- } catch (e) {
- message.error('加载文档列表失败')
- } finally {
- krDocumentsLoading.value = false
- }
- }
- async function loadKrDatasources() {
- if (krDatasources.value.length || krDatasourcesLoading.value) return
- krDatasourcesLoading.value = true
- try {
- const res = await getDataSources()
- krDatasources.value = res.data || []
- } catch (e) {
- message.error('加载结构化数据源失败')
- } finally {
- krDatasourcesLoading.value = false
- }
- }
- async function loadKrGraphSources() {
- if (krGraphSources.value.length || krGraphSourcesLoading.value) return
- krGraphSourcesLoading.value = true
- try {
- const res = await getGraphSources()
- krGraphSources.value = res.data || []
- } catch (e) {
- message.error('加载图谱数据源失败')
- } finally {
- krGraphSourcesLoading.value = false
- }
- }
- async function loadKrKnowledgeBases() {
- if (krKnowledgeBases.value.length || krKnowledgeBasesLoading.value) return
- krKnowledgeBasesLoading.value = true
- try {
- krKnowledgeBases.value = await listKnowledgeBases() || []
- } catch (e) {
- message.error('加载知识库列表失败')
- } finally {
- krKnowledgeBasesLoading.value = false
- }
- }
- function toggleKrListField(field, value) {
- if (!selectedData.value) return
- const list = new Set(selectedData.value[field] || [])
- if (list.has(value)) list.delete(value)
- else list.add(value)
- onFieldChange(field, [...list])
- }
- // ========== 节点字段操作(来自 composable) ==========
- const {
- onFieldChange, onModelChange,
- addListField, removeListField,
- addVariable, removeVariable,
- addOutputField, removeOutputField,
- addElif, removeElif, onConditionChange,
- syncLlmFieldsFromTemplate
- } = useNodeFields({
- selectedNode,
- selectedData,
- vfUpdateNode,
- getNodes: () => getNodes,
- getEdges: () => getEdges,
- applyEdgeMappingUpdates,
- skillStore,
- agentOptions
- })
- // 兼容原有命名(template 中使用)
- const addLlmField = addListField
- const removeLlmField = removeListField
- const addAgentField = addListField
- const removeAgentField = removeListField
- const addConditionInput = () => addListField('inputs')
- const removeConditionInput = removeListField.bind(null, 'inputs')
- // ========== 边状态文案 ==========
- function edgeStatusText(status) {
- return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
- }
- // ========== SmartAction 弹出编辑器 ==========
- function openPromptEditor() {
- promptEditValue.value = selectedData.value?.actionPrompt || ''
- showPromptEditor.value = true
- }
- function confirmPromptEdit() {
- onFieldChange('actionPrompt', promptEditValue.value)
- showPromptEditor.value = false
- }
- // ========== 自动关联 ==========
- async function handleAutoAssociate() {
- if (associating.value) return
- clearAssociations()
- associating.value = true
- try {
- const graphData = JSON.stringify({
- nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
- edges: getEdges.value.filter(e => !e.data?.virtual).map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
- })
- const res = await autoAssociate(graphData)
- const associations = res.data || []
- if (associations.length === 0) {
- message.info('未发现隐式关联的技能')
- return
- }
- const groups = {}
- for (const a of associations) {
- if (!groups[a.sourceNodeId]) groups[a.sourceNodeId] = []
- const list = groups[a.sourceNodeId]
- if (!list.some(x => x.folderName === a.skillFolderName)) {
- list.push({ folderName: a.skillFolderName, skillName: a.skillName || a.skillFolderName })
- }
- }
- const skillNodeMap = {}
- const GROUP_WIDTH = 240
- const GROUP_PADDING_X = 12 // 左右内边距
- const GROUP_PADDING_TOP = 30 // 顶部预留 label 高度
- const GROUP_PADDING_BOTTOM = 14 // 底部内边距
- const SKILL_HEIGHT = 96 // 单个技能节点占位高度(含余量)
- const SKILL_GAP = 12
- const GAP_TO_ORIGIN = 120
- const ts = Date.now()
- const groupNodes = [] // 虚框父节点
- const skillNodes = [] // 技能子节点
- const edgesToAdd = []
- const groupMap = {} // sourceNodeId -> groupId
- for (const [sourceNodeId, items] of Object.entries(groups)) {
- const sourceNode = getNodes.value.find(n => n.id === sourceNodeId)
- if (!sourceNode) continue
- // 源节点实际宽度(Vue Flow 渲染后测量),fallback 估计值 200
- const sourceWidth = sourceNode.dimensions?.width || sourceNode.width || 200
- const groupHeight = GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM
- + items.length * SKILL_HEIGHT
- + (items.length - 1) * SKILL_GAP
- // 水平居中:源节点中心 X - 虚框宽度 / 2
- const groupX = sourceNode.position.x + sourceWidth / 2 - GROUP_WIDTH / 2
- const groupY = sourceNode.position.y + GAP_TO_ORIGIN
- const groupId = 'vg-' + sourceNodeId + '-' + ts
- groupMap[sourceNodeId] = groupId
- groupNodes.push({
- id: groupId,
- type: 'virtualGroup',
- position: { x: groupX, y: groupY },
- width: GROUP_WIDTH,
- height: groupHeight,
- zIndex: 0,
- data: {
- virtual: true,
- width: GROUP_WIDTH,
- height: groupHeight,
- label: items.length + ' 个关联技能'
- },
- style: { width: GROUP_WIDTH + 'px', height: groupHeight + 'px' },
- class: 'virtual-group-node',
- draggable: true, // 允许整体拖动
- selectable: false
- })
- }
- // 先加入父节点并等待其进入 Vue Flow 商店、完成尺寸测量,再添加子节点。
- // 之前失败的原因是 parentNode 依赖父节点 dimensions,同步批量添加时尺寸尚未就绪。
- if (groupNodes.length) addNodes(groupNodes)
- await nextTick()
- for (const [sourceNodeId, items] of Object.entries(groups)) {
- const groupId = groupMap[sourceNodeId]
- if (!groupId) continue
- items.forEach((item, idx) => {
- if (skillNodeMap[item.folderName]) return
- const existing = getNodes.value.find(n => n.type === 'skill' && n.data?.skillId === item.folderName)
- if (existing) {
- skillNodeMap[item.folderName] = existing.id
- return
- }
- const skillId = 'virtual-' + item.folderName + '-' + ts
- // 使用 Vue Flow 原生 parentNode 分组:子节点坐标相对于父节点,
- // extent: 'parent' 保证子节点随父节点一起移动且不会脱离虚线框。
- skillNodes.push({
- id: skillId,
- type: 'skill',
- parentNode: groupId,
- extent: 'parent',
- position: { x: GROUP_PADDING_X, y: GROUP_PADDING_TOP + idx * (SKILL_HEIGHT + SKILL_GAP) },
- style: { width: (GROUP_WIDTH - GROUP_PADDING_X * 2) + 'px' },
- data: {
- label: item.skillName,
- skillId: item.folderName,
- skillName: item.skillName,
- virtual: true
- },
- class: 'virtual-node',
- draggable: false, // 子节点不单独拖动
- selectable: false,
- connectable: false,
- zIndex: 2
- })
- skillNodeMap[item.folderName] = skillId
- })
- const edge = {
- id: 've-' + groupId,
- source: groupId,
- sourceHandle: 'top',
- target: sourceNodeId,
- targetHandle: 'bottom-target',
- type: 'straight',
- animated: false,
- style: { stroke: '#818cf8', strokeWidth: 1.5, strokeDasharray: '6 4' },
- class: 'virtual-group-edge',
- data: { virtual: true }
- }
- updateVirtualGroupEdge(edge)
- edgesToAdd.push(edge)
- }
- if (skillNodes.length) addNodes(skillNodes)
- if (edgesToAdd.length) addEdges(edgesToAdd)
- hasAssociations.value = true
- const totalSkills = Object.keys(skillNodeMap).length
- message.success(`发现 ${totalSkills} 个隐式关联技能(${Object.keys(groups).length} 组)`)
- } catch (e) {
- message.error(e.response?.data?.message || '关联分析失败')
- } finally {
- associating.value = false
- }
- }
- function clearAssociations() {
- const virtualNodes = getNodes.value.filter(n => n.data?.virtual)
- const virtualEdges = getEdges.value.filter(e => e.data?.virtual)
- if (virtualEdges.length) removeEdges(virtualEdges.map(e => e.id))
- if (virtualNodes.length) removeNodes(virtualNodes.map(n => n.id))
- hasAssociations.value = false
- }
- // ========== 保存前图结构校验 ==========
- function validateGraphData() {
- const nodes = getNodes.value.filter(n => !n.data?.virtual)
- const edges = getEdges.value.filter(e => !e.data?.virtual)
- const inputNodes = nodes.filter(n => n.type === 'userInput')
- const outputNodes = nodes.filter(n => n.type === 'output')
- if (!inputNodes.length) {
- return { valid: false, message: '工作流必须包含至少一个用户输入节点' }
- }
- if (!outputNodes.length) {
- return { valid: false, message: '工作流必须包含至少一个输出节点' }
- }
- const outgoing = {}
- edges.forEach(e => {
- if (!outgoing[e.source]) outgoing[e.source] = []
- outgoing[e.source].push(e.target)
- })
- function computeReachable(start) {
- const visited = new Set()
- const stack = [start]
- while (stack.length) {
- const current = stack.pop()
- if (visited.has(current)) continue
- visited.add(current)
- for (const target of outgoing[current] || []) {
- if (!visited.has(target)) stack.push(target)
- }
- }
- return visited
- }
- let hasConnectedPath = false
- for (const input of inputNodes) {
- const reachable = computeReachable(input.id)
- for (const output of outputNodes) {
- if (!reachable.has(output.id)) continue
- const direct = outgoing[input.id]?.includes(output.id)
- if (direct) {
- const candidates = (outgoing[input.id] || []).filter(nextId => nextId !== output.id)
- const stack = [...candidates]
- const visited = new Set()
- while (stack.length) {
- const current = stack.pop()
- if (visited.has(current)) continue
- visited.add(current)
- if (current === output.id) {
- hasConnectedPath = true
- break
- }
- for (const target of outgoing[current] || []) {
- if (!visited.has(target)) stack.push(target)
- }
- }
- } else {
- hasConnectedPath = true
- }
- if (hasConnectedPath) break
- }
- if (hasConnectedPath) break
- }
- if (!hasConnectedPath) {
- return { valid: false, message: '用户输入节点与输出节点必须通过其他节点连接起来' }
- }
- return { valid: true }
- }
- // ========== 保存 ==========
- async function handleSave() {
- // 校验 displayName(必填)
- if (!form.value.displayName.trim()) {
- message.warning('请输入工作流中文名称')
- return
- }
- // 校验 name(kebab-case)
- if (nameState.value.status === 'invalid') {
- message.error('名称规范化后仍非法(需含至少一个字母或数字)')
- return
- }
- if (nameState.value.status === 'empty') {
- message.warning('请输入工作流名称(kebab-case)')
- return
- }
- // 应用规范化(如用户输入大写/空格,保存时使用 normalized)
- const finalName = nameState.value.normalized
- const validation = validateGraphData()
- if (!validation.valid) {
- message.warning(validation.message)
- return
- }
- saving.value = true
- try {
- const graphData = JSON.stringify({
- nodes: getNodes.value.filter(n => !n.data?.virtual).map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
- 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 }))
- })
- if (routeName.value) {
- // 编辑态:用规范化后的 name 作为 URL 标识(若变化则后端会重命名)
- const updated = await wfStore.save(routeName.value, {
- name: finalName,
- displayName: form.value.displayName,
- description: form.value.description,
- graphData
- })
- message.success('工作流已保存')
- // 若 name 发生变化,跳转到新 URL
- if (updated?.name && updated.name !== routeName.value) {
- router.replace(`/orchestration/edit/${updated.name}`)
- }
- } else {
- // 新建:先 create 再 save 提交 graphData
- const created = await wfStore.create({
- name: finalName,
- displayName: form.value.displayName,
- description: form.value.description
- })
- await wfStore.save(created.name, {
- name: finalName,
- displayName: form.value.displayName,
- description: form.value.description,
- graphData
- })
- message.success('工作流已创建')
- router.replace(`/orchestration/edit/${created.name}`)
- }
- } catch (e) {
- message.error(e.response?.data?.message || '保存失败')
- } finally {
- saving.value = false
- }
- }
- function goBack() {
- router.push('/orchestration')
- }
- // ========== 文件选择(运行时上传) ==========
- function onFileSelect(e, isDir = false) {
- const files = Array.from(e.target.files || [])
- files.forEach(f => {
- const relativePath = isDir ? f.webkitRelativePath : f.name
- uploadFiles.value.push({ file: f, relativePath })
- })
- e.target.value = ''
- }
- // 文件上传列表(运行对话框使用)
- const uploadFiles = ref([])
- // 启动运行(包装 composable,处理上传文件 + 输入字段)
- async function handleStartRun() {
- const inputs = {}
- inputFields.value.forEach(f => {
- if (formData[f.name]) inputs[f.name] = formData[f.name]
- })
- await startRun(routeName.value, inputs, uploadFiles.value)
- uploadFiles.value = []
- }
- function handleOpenRunDialog() {
- openRunDialog(inputFields, formData)
- uploadFiles.value = []
- }
- // 回滚应用到画布(供 useVersionHistory 调用)
- async function applyVersionToCanvas(wf) {
- if (!wf) return
- // 回滚仅恢复 graphData + description;name/displayName 保持当前以避免与 URL 错位
- workflowDesc.value = wf.description || ''
- if (wf.graphData) {
- const data = JSON.parse(wf.graphData)
- setNodes((data.nodes || []).map(n => ({ ...n, data: { ...getDefaultData(n.type), ...n.data } })))
- setEdges((data.edges || []).map(e => ({
- ...e,
- type: 'bezier',
- animated: true,
- style: defaultEdgeStyle,
- data: e.data || {}
- })))
- await nextTick()
- for (const node of getNodes.value) {
- applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
- }
- fitView({ padding: 0.5 })
- }
- }
- // 包装版本历史的方法以适配本组件 API
- async function handleRollbackWrap(v) {
- await handleRollback(routeName.value, v, applyVersionToCanvas)
- }
- async function handleVersionSelectWrap(v) {
- await handleVersionSelect(routeName.value, v)
- }
- async function toggleVersionPanelWrap() {
- await toggleVersionPanel(routeName.value)
- }
- async function saveMessageWrap(v) {
- await saveMessage(routeName.value, v)
- }
- // ========== 初始化 ==========
- onMounted(async () => {
- skillStore.fetchSkills()
- fetchDbModels()
- // 预加载知识库检索节点所需的资源列表(失败不阻塞主流程)
- loadKrDocuments()
- loadKrDatasources()
- loadKrGraphSources()
- loadKrKnowledgeBases()
- if (routeName.value) {
- // 编辑已有工作流(routeName 为 kebab-case name 或旧数字 id 字符串)
- try {
- const wf = await wfStore.fetchOne(routeName.value)
- // 后端迁移后:wf.name 为 kebab-case 唯一标识,wf.displayName 为中文显示名
- form.value.name = wf.name || ''
- form.value.displayName = wf.displayName || wf.name || ''
- form.value.description = wf.description || ''
- if (wf.graphData) {
- applyGraphData(wf.graphData)
- }
- } catch {
- message.error('工作流不存在')
- goBack()
- }
- } else if (route.query.templateId) {
- // 从模板创建:加载模板 graphData 作为初始内容
- try {
- const tplId = Number(route.query.templateId)
- const { data: tpl } = await getAgentTemplate(tplId)
- if (tpl) {
- // 用模板名作为 displayName 初始值(name 留空,由用户填写并规范化)
- if (tpl.name) form.value.displayName = `${tpl.name}-工作流`
- if (tpl.description) form.value.description = tpl.description
- if (tpl.graphData) {
- applyGraphData(tpl.graphData)
- message.info(`已加载模板「${tpl.name}」`)
- }
- }
- } catch (e) {
- /* 全局拦截器已处理 */
- message.warning('模板加载失败,将创建空白工作流')
- }
- }
- await nextTick()
- fitView({ padding: 0.5 })
- })
- // 将 graphData(JSON 字符串)应用到画布
- function applyGraphData(graphData) {
- try {
- const parsed = typeof graphData === 'string' ? JSON.parse(graphData) : graphData
- addNodes((parsed.nodes || []).map(n => {
- const defaults = getDefaultData(n.type)
- return { ...n, data: { ...defaults, ...n.data, label: defaults.label } }
- }))
- const edgeData = (parsed.edges || []).map(e => ({
- ...e,
- type: 'bezier',
- animated: true,
- style: defaultEdgeStyle,
- data: e.data || {}
- }))
- if (edgeData.length) addEdges(edgeData)
- for (const node of getNodes.value) {
- applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
- }
- } catch { /* ignore */ }
- }
- </script>
- <template>
- <div class="workflow-editor" @keydown="onKeyDown" tabindex="0">
- <!-- 顶部工具栏 -->
- <div class="editor-toolbar">
- <div class="toolbar-left">
- <button class="icon-btn" title="返回列表" @click="goBack">
- <n-icon size="18">
- <ArrowBackOutline />
- </n-icon>
- </button>
- <div class="toolbar-form">
- <div class="wf-field wf-field-show">
- <label class="wf-label">
- 智能体展示名称
- <span class="label-hint">(可包含中文)</span>
- </label>
- <n-input v-model:value="form.displayName" placeholder="如:军事分析助手" style="width: 200px" size="small"
- :maxlength="100" />
- </div>
- <div class="wf-field wf-field-name">
- <label class="wf-label">
- 智能体名称
- <span class="label-hint">(kebab-case,唯一标识,禁用大写/空格/下划线/中文)</span>
- </label>
- <n-input v-model:value="form.name" placeholder="如:military-analyze-agent" size="small"
- :maxlength="64"
- :status="nameState.status === 'invalid' ? 'error' : nameState.status === 'will-rename' || nameState.status === 'will-normalize' ? 'warning' : 'success'" />
- </div>
- <div class="wf-field wf-field-grow">
- <label class="wf-label">智能体描述</label>
- <n-input v-model:value="form.description" placeholder="请输入智能体描述" size="small"
- :maxlength="500" />
- </div>
- <div class="wf-name-hint">
- <span v-if="nameState.status === 'will-rename'" class="hint-warn">
- ⚠ 保存时将重命名:<code>{{ routeName }}</code> → <code>{{ nameState.normalized }}</code>
- </span>
- <span v-else-if="nameState.status === 'will-normalize'" class="hint-warn">
- 将规范化为:<code>{{ nameState.normalized }}</code>
- </span>
- <span v-else-if="nameState.status === 'invalid'" class="hint-error">
- 规范化后仍非法(需含字母或数字)
- </span>
- <span v-else-if="nameState.status === 'valid'" class="hint-ok">
- ✓ 合法 kebab-case
- </span>
- </div>
- </div>
- </div>
- <div class="toolbar-right">
- <button :class="['version-btn', { active: showVersionPanel }]" @click="toggleVersionPanelWrap"
- :disabled="!routeName">
- <n-icon size="16">
- <TimeOutline />
- </n-icon>
- <span>版本历史</span>
- </button>
- <button class="associate-btn" :class="{ active: hasAssociations }" @click="handleAutoAssociate"
- :disabled="associating">
- <n-icon size="16">
- <LinkOutline />
- </n-icon>
- <span>{{ associating ? '分析中...' : hasAssociations ? '重新关联' : '自动关联' }}</span>
- </button>
- <button v-if="hasAssociations" class="clear-assoc-btn" @click="clearAssociations" title="清除关联">
- <n-icon size="14">
- <TrashOutline />
- </n-icon>
- </button>
- <button class="run-btn" :class="{ 'is-running': running }" @click="handleOpenRunDialog" :disabled="!canRun">
- <n-icon size="16">
- <PlayOutline />
- </n-icon>
- <span>{{ running ? '执行中...' : '运行' }}</span>
- </button>
- <button class="primary-btn" @click="handleSave" :disabled="saving">
- <n-icon size="16">
- <SaveOutline />
- </n-icon>
- <span>{{ saving ? '保存中...' : '完成' }}</span>
- </button>
- </div>
- </div>
- <div class="editor-body">
- <!-- 左侧节点工具栏 -->
- <div class="sidebar-nodes">
- <div class="sidebar-title">节点</div>
- <div v-for="nt in nodeTypes" :key="nt.type" class="node-type-item" draggable="true"
- @dragstart="onDragStart($event, nt.type)">
- <div class="nt-icon" :style="{ background: nt.color }">
- <n-icon size="14">
- <component :is="nt.icon" />
- </n-icon>
- </div>
- <span class="nt-label">{{ nt.label }}</span>
- </div>
- </div>
- <!-- 中间画布 -->
- <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
- <VueFlow :default-viewport="{ zoom: 1, x: 0, y: 0 }" :min-zoom="0.1" :max-zoom="2" :elements-selectable="true"
- :select-nodes-on-drag="false" @node-click="onNodeClick" @node-contextmenu="onNodeContextMenu"
- @pane-click="onPaneClick" @edge-click="onEdgeClick" :snap-to-grid="true" :snap-grid="[20, 20]">
- <template #node-userInput="nodeProps">
- <InputNode :data="nodeProps.data" />
- </template>
- <template #node-llm="nodeProps">
- <LLMNode :data="nodeProps.data" />
- </template>
- <template #node-agent="nodeProps">
- <AgentNode :data="nodeProps.data" />
- </template>
- <template #node-skill="nodeProps">
- <SkillNode :data="nodeProps.data" />
- </template>
- <template #node-knowledgeRetrieval="nodeProps">
- <KnowledgeRetrievalNode :data="nodeProps.data" />
- </template>
- <template #node-output="nodeProps">
- <OutputNode :data="nodeProps.data" />
- </template>
- <template #node-condition="nodeProps">
- <ConditionNode :data="nodeProps.data" />
- </template>
- <template #node-smartAction="nodeProps">
- <SmartActionNode :data="nodeProps.data" />
- </template>
- <template #node-virtualGroup="nodeProps">
- <VirtualGroupNode :data="nodeProps.data"
- :style="{ width: nodeProps.data.width + 'px', height: nodeProps.data.height + 'px' }" />
- </template>
- <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
- </VueFlow>
- </div>
- <!-- 右侧面板 - Tab 页 -->
- <div class="sidebar-right">
- <div class="sidebar-tabs">
- <button class="tab-btn" :class="{ active: activeTab === 'props' }" @click="activeTab = 'props'">节点属性</button>
- <button class="tab-btn" :class="{ active: activeTab === 'result' }" @click="activeTab = 'result'">
- 运行结果
- <span v-if="nodeResults.length" class="result-badge">{{ nodeResults.length }}</span>
- </button>
- </div>
- <div class="sidebar-tab-content">
- <!-- 节点属性 Tab -->
- <div v-show="activeTab === 'props'" class="tab-pane">
- <template v-if="selectedData">
- <!-- 节点名称(可选,画布内唯一) -->
- <div class="prop-section">
- <label class="prop-label">节点名称</label>
- <n-input :value="selectedData?.nodeName || ''" placeholder="可选,留空则仅显示节点类型" size="small"
- @update:value="v => updateNodeName(v)" />
- </div>
- <!-- 用户输入节点 -->
- <template v-if="selectedNode?.type === 'userInput'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输入变量</span>
- <button class="text-btn" @click="addVariable">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <div class="var-form-row">
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- <n-input :value="v.description" placeholder="描述" size="small"
- @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
- </div>
- <button class="text-btn danger" @click="removeVariable(i)">删除</button>
- </div>
- <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
- </div>
- </template>
- <!-- 大模型处理节点 -->
- <template v-if="selectedNode?.type === 'llm'">
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions" placeholder="选择模型(默认使用默认模型)" size="small" clearable
- @update:value="v => onModelChange(v, dbModels)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">系统提示词</label>
- <n-input :value="selectedData?.systemPrompt" type="textarea" :rows="3" placeholder="输入系统提示词"
- size="small" @update:value="v => onFieldChange('systemPrompt', v)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">用户提示词</label>
- <n-input :value="selectedData?.userPrompt" type="textarea" :rows="3"
- placeholder="输入用户提示词,可用 {{变量名}} 引用变量" size="small"
- @update:value="v => onFieldChange('userPrompt', v)" />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <div style="display:flex;gap:4px">
- <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
- <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
- </div>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
- </div>
- <div class="var-form-row mapping-row">
- <n-select :value="currentMappingValue(v)"
- :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
- clearable placeholder="关联方式:自动推断"
- @update:value="nv => updateInputMapping(v, nv)" />
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
- @update:value="v => onFieldChange('failStrategy', v)" />
- </div>
- </template>
- <!-- 智能体节点 -->
- <template v-if="selectedNode?.type === 'agent'">
- <div class="prop-section">
- <label class="prop-label">选择智能体</label>
- <n-select :value="selectedData?.agentId" :options="agentOptions" placeholder="选择技能" size="small"
- filterable @update:value="v => onFieldChange('agentId', v)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
- @update:value="v => onModelChange(v, dbModels)" />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- <div class="var-form-row mapping-row">
- <n-select :value="currentMappingValue(v)"
- :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
- clearable placeholder="关联方式:自动推断"
- @update:value="nv => updateInputMapping(v, nv)" />
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
- @update:value="v => onFieldChange('failStrategy', v)" />
- </div>
- </template>
- <!-- 技能节点 -->
- <template v-if="selectedNode?.type === 'skill'">
- <div class="prop-section">
- <label class="prop-label">选择技能</label>
- <n-select :value="selectedData?.skillId" :options="agentOptions" placeholder="选择技能" size="small"
- filterable @update:value="v => onFieldChange('skillId', v)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
- @update:value="v => onModelChange(v, dbModels)" />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- <div class="var-form-row mapping-row">
- <n-select :value="currentMappingValue(v)"
- :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
- clearable placeholder="关联方式:自动推断"
- @update:value="nv => updateInputMapping(v, nv)" />
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
- @update:value="v => onFieldChange('failStrategy', v)" />
- </div>
- </template>
- <!-- 智能操作节点 -->
- <template v-if="selectedNode?.type === 'smartAction'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">操作要求</span>
- <button class="text-btn" @click="openPromptEditor" title="在编辑器中打开">
- <n-icon size="12">
- <OpenOutline />
- </n-icon> 展开
- </button>
- </div>
- <n-input :value="selectedData?.actionPrompt" type="textarea" :rows="5" placeholder="描述你希望 AI 执行的操作..."
- size="small" @update:value="v => onFieldChange('actionPrompt', v)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">模型</label>
- <n-select :value="selectedData?.modelId == null ? null : Number(selectedData.modelId)"
- :options="modelOptions" placeholder="默认使用默认模型" size="small" clearable
- @update:value="v => onModelChange(v, dbModels)" />
- </div>
- <!-- 前置数据 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
- </div>
- <div class="var-form-row mapping-row">
- <n-select :value="currentMappingValue(v)"
- :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
- clearable placeholder="关联方式:自动推断"
- @update:value="nv => updateInputMapping(v, nv)" />
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
- </div>
- <!-- 输出变量 -->
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">输出变量</span>
- <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
- <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
- </div>
- <!-- 失败策略 -->
- <div class="prop-section">
- <label class="prop-label">失败策略</label>
- <n-select :value="selectedData?.failStrategy || 'abort'" :options="failStrategyOptions" size="small"
- @update:value="v => onFieldChange('failStrategy', v)" />
- </div>
- </template>
- <!-- 知识库检索节点 -->
- <template v-if="selectedNode?.type === 'knowledgeRetrieval'">
- <div class="prop-section">
- <label class="prop-label">检索来源</label>
- <n-select :value="selectedData?.source || 'document'" :options="krSourceOptions" size="small"
- @update:value="v => onFieldChange('source', v)" />
- </div>
- <div class="prop-section">
- <label class="prop-label">检索语句</label>
- <n-input :value="selectedData?.query" type="textarea" :rows="3" placeholder="输入检索语句,可用 {{变量名}} 引用上游变量"
- size="small" @update:value="v => onFieldChange('query', v)" />
- </div>
- <!-- 文档数据库 -->
- <template v-if="(selectedData?.source || 'document') === 'document'">
- <div class="prop-section">
- <label class="kr-checkbox-row">
- <n-checkbox :checked="!!selectedData?.allDocuments"
- @update:checked="v => onFieldChange('allDocuments', v)" />
- <span>在所有文档中检索</span>
- </label>
- <div v-if="!selectedData?.allDocuments" class="kr-list">
- <div v-if="krDocumentsLoading" class="empty-hint">加载中...</div>
- <div v-else-if="!krDocumentOptions.length" class="empty-hint">暂无文档</div>
- <label v-for="opt in krDocumentOptions" :key="opt.value" class="kr-checkbox-row">
- <n-checkbox :checked="(selectedData?.documentIds || []).includes(opt.value)"
- @update:checked="() => toggleKrListField('documentIds', opt.value)" />
- <span>{{ opt.label }}</span>
- </label>
- </div>
- </div>
- <div class="prop-section">
- <label class="prop-label">TopK(命中条数)</label>
- <n-input :value="String(selectedData?.topK ?? 5)" type="text" size="small"
- @update:value="v => onFieldChange('topK', Math.max(1, parseInt(v) || 5))" />
- </div>
- </template>
- <!-- 结构化数据库 -->
- <template v-if="selectedData?.source === 'structured'">
- <div class="prop-section">
- <label class="kr-checkbox-row">
- <n-checkbox :checked="!!selectedData?.allDatasources"
- @update:checked="v => onFieldChange('allDatasources', v)" />
- <span>在所有数据源中检索</span>
- </label>
- <div v-if="!selectedData?.allDatasources" class="kr-list">
- <div v-if="krDatasourcesLoading" class="empty-hint">加载中...</div>
- <div v-else-if="!krDatasourceOptions.length" class="empty-hint">暂无数据源</div>
- <label v-for="opt in krDatasourceOptions" :key="opt.value" class="kr-checkbox-row">
- <n-checkbox :checked="(selectedData?.datasourceIds || []).includes(opt.value)"
- @update:checked="() => toggleKrListField('datasourceIds', opt.value)" />
- <span>{{ opt.label }}</span>
- </label>
- </div>
- </div>
- </template>
- <!-- 知识图谱库 -->
- <template v-if="selectedData?.source === 'graph'">
- <div class="prop-section">
- <label class="kr-checkbox-row">
- <n-checkbox :checked="!!selectedData?.allGraphSources"
- @update:checked="v => onFieldChange('allGraphSources', v)" />
- <span>在所有数据源中检索</span>
- </label>
- <div v-if="!selectedData?.allGraphSources" class="kr-list">
- <div v-if="krGraphSourcesLoading" class="empty-hint">加载中...</div>
- <div v-else-if="!krGraphSourceOptions.length" class="empty-hint">暂无图谱数据源</div>
- <label v-for="opt in krGraphSourceOptions" :key="opt.value" class="kr-checkbox-row">
- <n-checkbox :checked="(selectedData?.graphSourceIds || []).includes(opt.value)"
- @update:checked="() => toggleKrListField('graphSourceIds', opt.value)" />
- <span>{{ opt.label }}</span>
- </label>
- </div>
- </div>
- </template>
- <!-- 混合检索 -->
- <template v-if="selectedData?.source === 'hybrid'">
- <div class="prop-section">
- <label class="prop-label">知识库</label>
- <div v-if="krKnowledgeBasesLoading" class="empty-hint">加载中...</div>
- <n-select :value="selectedData?.knowledgeBaseId" :options="krKnowledgeBaseOptions"
- placeholder="选择知识库" size="small" @update:value="v => onFieldChange('knowledgeBaseId', v)" />
- <div v-if="!krKnowledgeBaseOptions.length && !krKnowledgeBasesLoading" class="empty-hint">暂无知识库
- </div>
- </div>
- </template>
- </template>
- <!-- 条件分支节点 -->
- <template v-if="selectedNode?.type === 'condition'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">条件分支</span>
- <button class="text-btn" @click="addElif">+ ELIF</button>
- </div>
- <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
- <div class="cond-form-row">
- <span class="cond-tag">{{ c.type }}</span>
- <n-input :value="c.expression" placeholder="自然语言条件,可用 {{变量名}}" size="small"
- @update:value="v => onConditionChange(i, 'expression', v)" />
- <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
- </div>
- </div>
- </div>
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">前置数据</span>
- <button class="text-btn" @click="addConditionInput">+ 添加</button>
- </div>
- <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="v.name" placeholder="变量名" size="small"
- @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="v.label" placeholder="中文名" size="small"
- @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
- <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
- </div>
- <div class="var-form-row mapping-row">
- <n-select :value="currentMappingValue(v)"
- :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
- clearable placeholder="关联方式:自动推断"
- @update:value="nv => updateInputMapping(v, nv)" />
- </div>
- </div>
- <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
- </div>
- </template>
- <!-- 输出节点 -->
- <template v-if="selectedNode?.type === 'output'">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">接收字段</span>
- <button class="text-btn" @click="addOutputField">+ 添加</button>
- </div>
- <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
- <div class="var-form-row">
- <n-input :value="f.name" placeholder="字段名" size="small"
- @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px"
- @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- </div>
- <div class="var-form-row">
- <n-input :value="f.label" placeholder="中文名" size="small"
- @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
- <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
- </div>
- </div>
- <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
- </div>
- </template>
- </template>
- <!-- 边映射信息 -->
- <template v-else-if="selectedEdge?.data">
- <div class="prop-section">
- <div class="prop-header">
- <span class="prop-label">连接映射</span>
- <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
- {{ edgeStatusText(selectedEdge.data.status) }}
- </span>
- </div>
- <!-- 已映射字段 -->
- <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
- <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField"
- class="mapping-item mapped">
- <span class="mapping-field">{{ m.sourceField }}</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ m.targetField }}</span>
- </div>
- </div>
- <!-- 类型不匹配 -->
- <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
- <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name"
- class="mapping-item mismatch">
- <span class="mapping-field">{{ tm.source.name }}</span>
- <span class="mapping-type">({{ tm.source.type }})</span>
- <span class="mapping-arrow">→</span>
- <span class="mapping-field">{{ tm.target.name }}</span>
- <span class="mapping-type">({{ tm.target.type }})</span>
- </div>
- </div>
- <!-- 未匹配的目标前置数据 -->
- <div v-if="selectedEdge.data.unmatchedTarget?.length">
- <div class="prop-label" style="margin-top:8px">未匹配前置数据</div>
- <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <!-- 多余的源输出 -->
- <div v-if="selectedEdge.data.unmatchedSource?.length">
- <div class="prop-label" style="margin-top:8px">多余输出</div>
- <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
- <span class="mapping-field">{{ f.name }}</span>
- <span class="mapping-type">({{ f.type }})</span>
- </div>
- </div>
- <div
- v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)"
- class="empty-hint">
- 无需映射
- </div>
- </div>
- </template>
- <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
- </div>
- <!-- 运行结果 Tab -->
- <div v-show="activeTab === 'result'" class="tab-pane">
- <div v-if="nodeResults.length === 0" class="empty-hint">暂无运行结果</div>
- <div v-for="r in nodeResults" :key="r.nodeId" :id="'node-result-' + r.nodeId" class="node-result-card"
- :class="{ highlighted: highlightedNodeId === r.nodeId }" :style="{ borderLeftColor: r.color }">
- <div class="nrc-header" @click="toggleLogs(r.nodeId)" style="cursor: pointer">
- <div class="nrc-icon" :style="{ background: r.color }">
- <n-icon size="12">
- <component :is="nodeTypeInfo[r.type]?.icon" />
- </n-icon>
- </div>
- <div class="nrc-info">
- <span class="nrc-label">{{ r.label }}</span>
- <span class="nrc-type">{{ r.typeLabel }}</span>
- </div>
- <span v-if="r.logs && r.logs.length" class="nrc-log-count">{{ r.logs.length }} 条日志</span>
- <span class="nrc-status" :class="'status-' + r.status">{{ statusText(r.status) }}</span>
- <span v-if="r.logs && r.logs.length" class="nrc-expand"
- :class="{ expanded: expandedLogs[r.nodeId] }">▶</span>
- </div>
- <!-- 流式思考过程(实时累加,节点完成后保留为历史)
- 折叠时仅隐藏 body,streamingThinking 仍持续累加,展开后可继续输出 -->
- <div v-if="r.streamingThinking" class="nrc-thinking" :class="{ live: r.status === 'running' }">
- <div class="nrc-thinking-header" @click="toggleThinking(r.nodeId)" style="cursor: pointer">
- <span class="nrc-thinking-tag">💭 思考过程</span>
- <span v-if="r.status === 'running'" class="nrc-thinking-live">实时输出中…</span>
- <span v-else class="nrc-thinking-static">{{ r.streamingThinking.length }} 字</span>
- <span class="nrc-thinking-expand" :class="{ expanded: expandedThinking[r.nodeId] !== false }">▶</span>
- </div>
- <pre v-show="expandedThinking[r.nodeId] !== false"
- class="nrc-thinking-body">{{ r.streamingThinking }}</pre>
- </div>
- <div class="nrc-vars" v-if="r.output">
- <div class="nrc-var" v-for="(value, key) in r.output" :key="key">
- <span class="nrc-var-key" :style="{ color: r.color }">{{ key }}</span>
- <span class="nrc-var-value">{{ typeof value === 'object' ? JSON.stringify(value, null, 2) :
- String(value)
- }}</span>
- </div>
- </div>
- <!-- 完整上下文快照(节点执行后的全部 variables,debug 视图) -->
- <div v-if="r.contextSnapshot" class="nrc-ctx-toggle" @click="toggleContext(r.nodeId)">
- <span class="nrc-ctx-icon" :class="{ expanded: expandedContext[r.nodeId] }">▶</span>
- <span class="nrc-ctx-label">查看完整上下文({{ Object.keys(r.contextSnapshot).length }} 个变量)</span>
- </div>
- <div v-if="expandedContext[r.nodeId] && r.contextSnapshot" class="nrc-ctx-panel">
- <div class="nrc-ctx-var" v-for="(value, key) in r.contextSnapshot" :key="key">
- <span class="nrc-ctx-key">{{ key }}</span>
- <span class="nrc-ctx-value">{{ formatContextValue(value) }}</span>
- </div>
- </div>
- <!-- 执行日志 -->
- <div v-if="expandedLogs[r.nodeId] && r.logs && r.logs.length" class="nrc-logs">
- <div class="nrc-log" v-for="(log, idx) in r.logs" :key="idx"
- :class="'log-type-' + (log.type || 'info').toLowerCase()">
- <span class="nrc-log-icon">{{ logTypeIcon(log.type) }}</span>
- <span class="nrc-log-time">{{ formatTimestamp(log.timestamp) }}</span>
- <span class="nrc-log-tag">{{ logTypeLabel(log.type) }}</span>
- <span class="nrc-log-msg">{{ log.message }}</span>
- <div v-if="log.detail" class="nrc-log-detail">{{ log.detail }}</div>
- </div>
- </div>
- </div>
- <div v-if="showResult && workflowResult" class="nrc-clear-row">
- <a v-if="lastRunId && lastRunWorkflowId"
- :href="`/api/workflows/${lastRunWorkflowId}/runs/${lastRunId}/download`" class="text-btn"
- style="text-decoration:none; color: var(--primary)">下载工作目录</a>
- <button class="text-btn danger" @click="closeResult">清除结果</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 运行对话框 -->
- <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
- <div class="run-dialog">
- <div class="dialog-header">
- <span class="dialog-title">运行工作流</span>
- <button class="icon-btn" @click="cancelRun"><n-icon size="16">
- <CloseOutline />
- </n-icon></button>
- </div>
- <div class="dialog-body">
- <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
- <div v-for="f in inputFields" :key="f.name" class="run-field">
- <label class="run-field-label">{{ f.label || f.name }}</label>
- <n-input v-model:value="formData[f.name]" :placeholder="f.description || f.name"
- :type="f.type === 'number' ? 'text' : 'text'" size="small" />
- </div>
- <div class="run-upload">
- <label class="run-field-label">上传文件到工作目录</label>
- <div class="upload-btns">
- <div class="upload-area" @click="$refs.fileInput.click()">
- <input ref="fileInput" type="file" multiple style="display:none"
- @change="onFileSelect($event, false)" />
- <span class="upload-hint">选择文件</span>
- </div>
- <div class="upload-area" @click="$refs.dirInput.click()">
- <input ref="dirInput" type="file" webkitdirectory style="display:none"
- @change="onFileSelect($event, true)" />
- <span class="upload-hint">选择目录</span>
- </div>
- </div>
- <div v-if="uploadFiles.length" class="upload-list">
- <div v-for="(f, i) in uploadFiles" :key="i" class="upload-item">
- <span class="upload-name">{{ f.relativePath }}</span>
- <button class="upload-remove" @click="uploadFiles.splice(i, 1)">×</button>
- </div>
- </div>
- </div>
- </div>
- <div class="dialog-footer">
- <button class="cancel-btn" @click="cancelRun">取消</button>
- <button class="primary-btn" @click="handleStartRun">
- <n-icon size="14">
- <PlayOutline />
- </n-icon>
- <span>开始执行</span>
- </button>
- </div>
- </div>
- </div>
- <!-- 操作要求编辑器弹出框 -->
- <div class="dialog-overlay" v-if="showPromptEditor" @click.self="showPromptEditor = false">
- <div class="prompt-editor-dialog">
- <div class="dialog-header">
- <span class="dialog-title">编辑操作要求</span>
- <button class="icon-btn" @click="showPromptEditor = false"><n-icon size="16">
- <CloseOutline />
- </n-icon></button>
- </div>
- <div class="prompt-editor-body">
- <CodeEditor v-model="promptEditValue" language="markdown" height="100%" :minimap="false" />
- </div>
- <div class="dialog-footer">
- <button class="cancel-btn" @click="showPromptEditor = false">取消</button>
- <button class="primary-btn" @click="confirmPromptEdit">确认</button>
- </div>
- </div>
- </div>
- <!-- 版本历史面板 -->
- <transition name="slide-right">
- <div v-if="showVersionPanel" class="version-panel">
- <div class="vp-header">
- <h3 class="vp-title">版本历史</h3>
- <button class="icon-btn" @click="toggleVersionPanelWrap"><n-icon size="16">
- <CloseOutline />
- </n-icon></button>
- </div>
- <div class="vp-list">
- <n-spin v-if="versionsLoading" size="small" />
- <div v-else-if="!versions.length" class="vp-empty">暂无版本(保存后自动创建)</div>
- <div v-else class="version-items">
- <div v-for="v in versions" :key="v.number" class="version-item"
- :class="{ active: selectedVersion?.number === v.number }">
- <div class="vi-main" @click="handleVersionSelectWrap(v)">
- <div class="vi-row">
- <span class="vi-version">v{{ v.number }}<span v-if="v.current" class="vi-current">当前</span></span>
- <span class="vi-time">{{ formatTime(v.createdAt) }}</span>
- </div>
- <div v-if="editingMessage !== v.number" class="vi-message" @dblclick="startEditMessage(v)">
- {{ v.message || '双击编辑备注' }}
- </div>
- <div v-else class="vi-message-edit">
- <n-input v-model:value="editingMessageText" size="tiny" placeholder="版本备注"
- @keyup.enter="saveMessageWrap(v)" @keyup.escape="cancelEditMessage" />
- <n-button size="tiny" type="primary" @click="saveMessageWrap(v)">✓</n-button>
- <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
- </div>
- </div>
- <div class="vi-actions">
- <button v-if="!v.current" class="vi-action-btn rollback-btn" @click.stop="handleRollbackWrap(v)"
- title="回滚到此版本">↩</button>
- </div>
- </div>
- </div>
- </div>
- <div v-if="selectedVersion" class="vp-content">
- <div class="vp-content-header">
- <span>v{{ selectedVersion.number }} 的数据</span>
- </div>
- <pre class="version-json">{{ formatVersionJson(versionContent) }}</pre>
- </div>
- </div>
- </transition>
- <!-- 右键菜单 -->
- <Teleport to="body">
- <div v-if="contextMenu" class="context-menu-overlay" @click="closeContextMenu"
- @contextmenu.prevent="closeContextMenu">
- <div class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
- <button class="ctx-item ctx-danger" @click="deleteNodeById(contextMenu.nodeId)">
- 删除节点
- </button>
- </div>
- </div>
- </Teleport>
- </div>
- </template>
- <style scoped>
- .workflow-editor {
- height: calc(100vh - var(--header-height));
- display: flex;
- flex-direction: column;
- margin: -28px -32px;
- background: #13111a;
- }
- .editor-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- background: var(--bg-card, #1a1a2e);
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- flex-shrink: 0;
- }
- .toolbar-left,
- .toolbar-right {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- /* 让左侧工具栏占满 toolbar 中间空间,使描述输入框可扩展到版本历史按钮左侧 */
- .toolbar-left {
- flex: 1;
- min-width: 0;
- }
- /* 名称 5 态机提示 */
- .name-status-hint {
- font-size: 11px;
- white-space: nowrap;
- max-width: 220px;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .name-status-hint .hint-warn {
- color: #e0a526;
- }
- .name-status-hint .hint-error {
- color: #ef4444;
- }
- .name-status-hint .hint-ok {
- color: #22c55e;
- }
- /* 工具栏表单:grid 布局让名称提示跨名称、描述两列延伸到描述字段右边界 */
- .toolbar-form {
- display: grid;
- grid-template-columns: auto auto 1fr;
- column-gap: 14px;
- row-gap: 4px;
- align-items: start;
- flex: 1;
- min-width: 0;
- }
- .wf-field {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
- }
- /* 让 wf-field 内的 n-input 拉伸到 field 宽度(默认 inline-flex 不会自动占满) */
- .wf-field :deep(.n-input) {
- width: 100%;
- }
- /* 三列定位:展示名称 / 名称 / 描述(描述占满剩余空间) */
- .wf-field-show {
- grid-column: 1;
- grid-row: 1;
- }
- .wf-field-name {
- grid-column: 2;
- grid-row: 1;
- }
- .wf-field.wf-field-grow {
- grid-column: 3;
- grid-row: 1;
- min-width: 0;
- }
- .wf-label {
- font-size: 12px;
- font-weight: 500;
- color: var(--text-secondary, #aaa);
- line-height: 1.2;
- }
- .wf-label .label-hint {
- font-size: 11px;
- font-weight: 400;
- color: var(--text-tertiary, #888);
- }
- .wf-name-hint {
- grid-column: 2 / -1;
- grid-row: 2;
- font-size: 11px;
- min-height: 14px;
- line-height: 1.2;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .wf-name-hint code {
- background: rgba(255, 255, 255, 0.06);
- padding: 1px 5px;
- border-radius: 3px;
- font-family: 'Consolas', 'Monaco', monospace;
- font-size: 10px;
- }
- .wf-name-hint .hint-warn {
- color: #e0a526;
- }
- .wf-name-hint .hint-error {
- color: #ef4444;
- }
- .wf-name-hint .hint-ok {
- color: #22c55e;
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- /* 左侧节点栏 */
- .sidebar-nodes {
- width: 180px;
- background: var(--bg-card, #1a1a2e);
- border-right: 1px solid rgba(255, 255, 255, 0.08);
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex-shrink: 0;
- }
- .sidebar-title {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary, #999);
- text-transform: uppercase;
- letter-spacing: 1px;
- margin-bottom: 6px;
- }
- .node-type-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-radius: 8px;
- cursor: grab;
- transition: background 0.15s;
- border: 1px solid rgba(255, 255, 255, 0.06);
- }
- .node-type-item:hover {
- background: rgba(255, 255, 255, 0.06);
- }
- .node-type-item:active {
- cursor: grabbing;
- }
- .nt-icon {
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- color: #fff;
- flex-shrink: 0;
- }
- .nt-label {
- font-size: 13px;
- color: var(--text-primary, #e0e0e0);
- }
- /* 中间画布 */
- .canvas-area {
- flex: 1;
- background: #0f0d17;
- }
- /* 覆盖 VueFlow 默认主题的节点白色背景 */
- .canvas-area :deep(.vue-flow__node) {
- background: transparent !important;
- padding: 0 !important;
- border: none !important;
- box-shadow: none !important;
- }
- /* 选中连接线高亮 - stroke 必须作用于 path 子元素 */
- .canvas-area :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
- stroke: #facc15 !important;
- stroke-width: 3 !important;
- }
- .canvas-area :deep(.vue-flow__edge:hover .vue-flow__edge-path) {
- stroke: #facc15 !important;
- }
- .canvas-area :deep(.vue-flow__edge) {
- cursor: pointer;
- }
- /* 右侧面板 - Tab 页 */
- .sidebar-right {
- width: 300px;
- background: var(--bg-card, #1a1a2e);
- border-left: 1px solid rgba(255, 255, 255, 0.08);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- overflow: hidden;
- }
- .sidebar-tabs {
- display: flex;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- flex-shrink: 0;
- }
- .tab-btn {
- flex: 1;
- padding: 10px 0;
- border: none;
- background: transparent;
- color: var(--text-secondary, #999);
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 4px;
- }
- .tab-btn:hover {
- color: var(--text-primary, #e0e0e0);
- }
- .tab-btn.active {
- color: var(--text-primary, #e0e0e0);
- font-weight: 600;
- }
- .tab-btn.active::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 20%;
- right: 20%;
- height: 2px;
- background: var(--color-primary, #2563EB);
- border-radius: 1px;
- }
- .result-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 16px;
- height: 16px;
- padding: 0 4px;
- border-radius: 8px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- font-size: 10px;
- font-weight: 600;
- }
- .sidebar-tab-content {
- flex: 1;
- overflow-y: auto;
- }
- .tab-pane {
- padding: 12px;
- }
- .empty-props-hint {
- color: var(--text-tertiary, #555);
- font-size: 13px;
- text-align: center;
- padding: 40px 0;
- }
- /* 节点结果卡片 */
- .node-result-card {
- background: rgba(255, 255, 255, 0.03);
- border-radius: 8px;
- border-left: 3px solid #666;
- margin-bottom: 10px;
- overflow: hidden;
- transition: background 0.3s, box-shadow 0.3s;
- }
- .node-result-card.highlighted {
- animation: highlight-pulse 2s ease;
- }
- @keyframes highlight-pulse {
- 0% {
- background: rgba(129, 140, 248, 0.18);
- box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.45);
- }
- 70% {
- background: rgba(129, 140, 248, 0.08);
- box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.2);
- }
- 100% {
- background: rgba(255, 255, 255, 0.03);
- box-shadow: none;
- }
- }
- .nrc-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.04);
- }
- .nrc-icon {
- width: 22px;
- height: 22px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 5px;
- color: #fff;
- flex-shrink: 0;
- }
- .nrc-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 1px;
- }
- .nrc-label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .nrc-type {
- font-size: 10px;
- color: var(--text-tertiary, #666);
- }
- .nrc-status {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .nrc-status.status-running {
- background: rgba(99, 102, 241, 0.15);
- color: #818cf8;
- }
- .nrc-status.status-success {
- background: rgba(16, 185, 129, 0.15);
- color: #34d399;
- }
- .nrc-status.status-failed {
- background: rgba(239, 68, 68, 0.15);
- color: #f87171;
- }
- .nrc-status.status-skipped {
- background: rgba(107, 114, 128, 0.15);
- color: #9ca3af;
- }
- .nrc-status.status-no_run {
- background: rgba(107, 114, 128, 0.10);
- color: #6b7280;
- }
- .nrc-vars {
- padding: 6px 10px;
- }
- /* 流式思考过程区块 */
- .nrc-thinking {
- margin: 6px 10px;
- border-radius: 6px;
- background: rgba(168, 85, 247, 0.05);
- border: 1px solid rgba(168, 85, 247, 0.2);
- overflow: hidden;
- }
- .nrc-thinking.live {
- border-color: rgba(168, 85, 247, 0.5);
- box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15);
- }
- .nrc-thinking-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 5px 10px;
- font-size: 11px;
- background: rgba(168, 85, 247, 0.08);
- }
- .nrc-thinking-tag {
- color: #c084fc;
- font-weight: 600;
- }
- .nrc-thinking-live {
- color: #ec4899;
- font-size: 10px;
- animation: nrc-pulse 1.2s ease-in-out infinite;
- }
- .nrc-thinking-static {
- color: #888;
- font-size: 10px;
- }
- .nrc-thinking-expand {
- margin-left: auto;
- font-size: 10px;
- color: var(--text-tertiary, #666);
- transition: transform 0.2s;
- flex-shrink: 0;
- }
- .nrc-thinking-expand.expanded {
- transform: rotate(90deg);
- }
- .nrc-thinking-body {
- margin: 0;
- padding: 8px 12px;
- max-height: 320px;
- overflow: auto;
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', ui-monospace, monospace;
- font-size: 11px;
- line-height: 1.6;
- color: var(--text-secondary, #b0b0b0);
- white-space: pre-wrap;
- word-break: break-word;
- }
- @keyframes nrc-pulse {
- 0%,
- 100% {
- opacity: 0.55;
- }
- 50% {
- opacity: 1;
- }
- }
- .nrc-var {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 4px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.02);
- }
- .nrc-var:last-child {
- border-bottom: none;
- }
- .nrc-var-key {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- font-weight: 600;
- }
- .nrc-var-value {
- font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 120px;
- overflow-y: auto;
- }
- /* 完整上下文快照折叠区(debug 视图) */
- .nrc-ctx-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- cursor: pointer;
- border-top: 1px solid rgba(255, 255, 255, 0.04);
- font-size: 11px;
- color: var(--text-secondary, #aaa);
- user-select: none;
- }
- .nrc-ctx-toggle:hover {
- background: rgba(99, 102, 241, 0.05);
- }
- .nrc-ctx-icon {
- font-size: 9px;
- color: var(--text-tertiary, #888);
- transition: transform 0.2s;
- }
- .nrc-ctx-icon.expanded {
- transform: rotate(90deg);
- }
- .nrc-ctx-label {
- font-weight: 500;
- }
- .nrc-ctx-panel {
- padding: 6px 10px;
- border-top: 1px solid rgba(255, 255, 255, 0.04);
- max-height: 280px;
- overflow-y: auto;
- background: rgba(0, 0, 0, 0.15);
- }
- .nrc-ctx-var {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 4px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.02);
- }
- .nrc-ctx-var:last-child {
- border-bottom: none;
- }
- .nrc-ctx-key {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 11px;
- font-weight: 600;
- color: #818cf8;
- }
- .nrc-ctx-value {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 11px;
- color: var(--text-secondary, #ccc);
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 80px;
- overflow-y: auto;
- }
- .nrc-clear-row {
- text-align: center;
- padding: 10px 0 4px;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- margin-top: 8px;
- }
- .nrc-log-count {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- background: rgba(99, 102, 241, 0.1);
- color: #818cf8;
- flex-shrink: 0;
- }
- .nrc-expand {
- font-size: 10px;
- color: var(--text-tertiary, #666);
- transition: transform 0.2s;
- flex-shrink: 0;
- }
- .nrc-expand.expanded {
- transform: rotate(90deg);
- }
- .nrc-logs {
- border-top: 1px solid rgba(255, 255, 255, 0.04);
- padding: 6px 10px;
- max-height: 400px;
- overflow-y: auto;
- }
- .nrc-log {
- padding: 4px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.02);
- font-size: 11px;
- line-height: 1.5;
- }
- .nrc-log:last-child {
- border-bottom: none;
- }
- .nrc-log-icon {
- margin-right: 4px;
- }
- .nrc-log-time {
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 10px;
- color: var(--text-tertiary, #555);
- margin-right: 6px;
- }
- .nrc-log-tag {
- font-size: 10px;
- padding: 1px 4px;
- border-radius: 3px;
- margin-right: 6px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .log-type-tool_call .nrc-log-tag {
- background: rgba(59, 130, 246, 0.15);
- color: #60a5fa;
- }
- .log-type-tool_result .nrc-log-tag {
- background: rgba(16, 185, 129, 0.15);
- color: #34d399;
- }
- .log-type-thinking .nrc-log-tag {
- background: rgba(168, 85, 247, 0.15);
- color: #c084fc;
- }
- .log-type-error .nrc-log-tag {
- background: rgba(239, 68, 68, 0.15);
- color: #f87171;
- }
- .log-type-info .nrc-log-tag {
- background: rgba(107, 114, 128, 0.15);
- color: #9ca3af;
- }
- .nrc-log-msg {
- color: var(--text-secondary, #ccc);
- word-break: break-all;
- }
- .nrc-log-detail {
- margin-top: 3px;
- margin-left: 22px;
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- font-size: 10px;
- color: var(--text-tertiary, #888);
- background: rgba(0, 0, 0, 0.2);
- padding: 4px 6px;
- border-radius: 4px;
- white-space: pre-wrap;
- word-break: break-all;
- max-height: 100px;
- overflow-y: auto;
- }
- .prop-section {
- margin-bottom: 14px;
- }
- .prop-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 6px;
- }
- .prop-label {
- display: block;
- font-size: 12px;
- font-weight: 500;
- color: var(--text-secondary, #999);
- margin-bottom: 6px;
- }
- .var-form {
- background: rgba(255, 255, 255, 0.03);
- border-radius: 6px;
- padding: 8px;
- margin-bottom: 8px;
- }
- .var-form-row {
- display: flex;
- gap: 6px;
- margin-bottom: 6px;
- }
- /* 关联方式行:作为前置数据的一个属性,紧跟字段定义 */
- .var-form-row.mapping-row {
- margin-top: 2px;
- padding-top: 6px;
- border-top: 1px dashed rgba(255, 255, 255, 0.06);
- }
- .cond-form {
- margin-bottom: 8px;
- }
- .cond-form-row {
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .cond-tag {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- background: rgba(249, 115, 22, 0.15);
- color: #F97316;
- flex-shrink: 0;
- font-weight: 600;
- }
- .text-btn {
- background: none;
- border: none;
- color: var(--color-accent, #2563EB);
- font-size: 12px;
- cursor: pointer;
- padding: 2px 4px;
- }
- .text-btn:hover {
- text-decoration: underline;
- }
- .text-btn.danger {
- color: #EF4444;
- }
- .empty-hint {
- font-size: 12px;
- color: var(--text-tertiary, #555);
- font-style: italic;
- }
- .kr-checkbox-row {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- cursor: pointer;
- margin-bottom: 4px;
- }
- .kr-list {
- margin-top: 6px;
- max-height: 240px;
- overflow-y: auto;
- padding-right: 4px;
- }
- .passthrough-hint {
- font-size: 11px;
- color: var(--text-tertiary, #555);
- margin-top: 6px;
- padding: 4px 8px;
- background: rgba(249, 115, 22, 0.08);
- border-radius: 4px;
- color: #fb923c;
- }
- /* 通用按钮 */
- .icon-btn {
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- background: transparent;
- color: var(--text-secondary, #999);
- cursor: pointer;
- transition: all 0.15s;
- }
- .icon-btn:hover {
- background: rgba(255, 255, 255, 0.08);
- color: #fff;
- }
- .primary-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: var(--color-primary, #2563EB);
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .primary-btn:hover {
- filter: brightness(1.15);
- }
- .primary-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- /* 自动关联按钮 */
- .associate-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: rgba(129, 140, 248, 0.12);
- color: #818cf8;
- border: 1px solid rgba(129, 140, 248, 0.25);
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .associate-btn:hover {
- background: rgba(129, 140, 248, 0.2);
- border-color: rgba(129, 140, 248, 0.4);
- }
- .associate-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .associate-btn.active {
- background: rgba(129, 140, 248, 0.18);
- border-color: rgba(129, 140, 248, 0.35);
- }
- .clear-assoc-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: transparent;
- color: var(--text-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .clear-assoc-btn:hover {
- color: #f87171;
- border-color: rgba(239, 68, 68, 0.3);
- background: rgba(239, 68, 68, 0.08);
- }
- /* 虚拟节点(自动关联产生的技能节点) */
- .canvas-area :deep(.vue-flow__node.virtual-node) {
- opacity: 0.75;
- pointer-events: none;
- }
- .canvas-area :deep(.vue-flow__node.virtual-node .base-node) {
- /* 内容也穿透:鼠标按下技能节点时事件落到下层虚框,由父节点接管拖拽实现整体移动 */
- pointer-events: none;
- border-style: dashed !important;
- border-color: #818cf8 !important;
- background: rgba(129, 140, 248, 0.06) !important;
- }
- .canvas-area :deep(.vue-flow__node.virtual-node .base-node:hover) {
- border-color: #a5b4fc !important;
- }
- /* 虚线分组容器节点:必须保留 pointer-events 以接收拖动事件实现整体拖动 */
- .canvas-area :deep(.vue-flow__node.virtual-group-node) {
- pointer-events: all !important;
- }
- .canvas-area :deep(.vue-flow__node.virtual-group-node .vue-flow__handle) {
- pointer-events: none !important;
- opacity: 0 !important;
- width: 1px !important;
- height: 1px !important;
- min-width: 1px !important;
- min-height: 1px !important;
- background: transparent !important;
- border: none !important;
- }
- /* 虚线分组边 */
- .canvas-area :deep(.vue-flow__edge.virtual-group-edge .vue-flow__edge-path) {
- stroke: #818cf8;
- stroke-width: 1.5;
- stroke-dasharray: 5 4;
- opacity: 0.7;
- }
- /* 运行按钮 */
- .run-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 7px 16px;
- background: #10B981;
- color: #fff;
- border: none;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- }
- .run-btn:hover {
- filter: brightness(1.15);
- }
- .run-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .run-btn.is-running {
- background: #6366f1;
- animation: pulse-btn 1.5s infinite;
- }
- @keyframes pulse-btn {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.7;
- }
- }
- /* 节点执行状态 */
- .canvas-area :deep(.vue-flow__node.node-status-running) {
- animation: node-pulse 1.5s infinite;
- }
- .canvas-area :deep(.vue-flow__node.node-status-success) {
- filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.5));
- }
- .canvas-area :deep(.vue-flow__node.node-status-failed) {
- filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.6));
- }
- .canvas-area :deep(.vue-flow__node.node-status-skipped) {
- opacity: 0.4;
- }
- @keyframes node-pulse {
- 0%,
- 100% {
- filter: drop-shadow(0 0 6px rgba(37, 99, 235, 0.5));
- }
- 50% {
- filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.9));
- }
- }
- /* 运行对话框 */
- .dialog-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.75);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- }
- .run-dialog {
- background: var(--bg-card, #1a1a2e);
- border-radius: 12px;
- width: 400px;
- max-height: 80vh;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255, 255, 255, 0.1);
- }
- .prompt-editor-dialog {
- background: var(--bg-card, #1a1a2e);
- border-radius: 12px;
- width: 80vw;
- max-width: 960px;
- height: 75vh;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(255, 255, 255, 0.1);
- }
- .prompt-editor-body {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
- .dialog-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- }
- .dialog-title {
- font-size: 15px;
- font-weight: 600;
- color: var(--text-primary, #e0e0e0);
- }
- .dialog-body {
- padding: 16px 18px;
- overflow-y: auto;
- flex: 1;
- }
- .dialog-footer {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- padding: 12px 18px;
- border-top: 1px solid rgba(255, 255, 255, 0.08);
- }
- .cancel-btn {
- padding: 7px 16px;
- border: 1px solid rgba(255, 255, 255, 0.15);
- background: transparent;
- color: var(--text-secondary, #999);
- border-radius: 6px;
- cursor: pointer;
- font-size: 13px;
- }
- .cancel-btn:hover {
- background: rgba(255, 255, 255, 0.06);
- }
- .run-field {
- margin-bottom: 12px;
- }
- .run-field-label {
- display: block;
- font-size: 12px;
- color: var(--text-secondary, #999);
- margin-bottom: 4px;
- }
- /* 文件上传区域 */
- .run-upload {
- margin-top: 16px;
- }
- .upload-btns {
- display: flex;
- gap: 8px;
- }
- .upload-area {
- flex: 1;
- border: 1px dashed rgba(255, 255, 255, 0.2);
- border-radius: 6px;
- padding: 12px;
- text-align: center;
- cursor: pointer;
- transition: border-color 0.2s;
- }
- .upload-area:hover {
- border-color: var(--primary);
- }
- .upload-hint {
- font-size: 12px;
- color: var(--text-secondary, #999);
- }
- .upload-list {
- margin-top: 8px;
- }
- .upload-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 8px;
- font-size: 12px;
- color: var(--text-primary, #eee);
- background: rgba(255, 255, 255, 0.05);
- border-radius: 4px;
- margin-bottom: 4px;
- }
- .upload-name {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .upload-remove {
- background: none;
- border: none;
- color: var(--text-secondary, #999);
- cursor: pointer;
- font-size: 16px;
- padding: 0 4px;
- line-height: 1;
- }
- .upload-remove:hover {
- color: #f87171;
- }
- /* 边映射状态 */
- .edge-status-badge {
- font-size: 11px;
- padding: 2px 8px;
- border-radius: 4px;
- font-weight: 600;
- }
- .edge-status-badge.es-ok {
- background: rgba(34, 197, 94, 0.15);
- color: #4ade80;
- }
- .edge-status-badge.es-unmapped {
- background: rgba(107, 114, 128, 0.15);
- color: #9ca3af;
- }
- .edge-status-badge.es-partial {
- background: rgba(245, 158, 11, 0.15);
- color: #fbbf24;
- }
- .edge-status-badge.es-mismatch {
- background: rgba(239, 68, 68, 0.15);
- color: #f87171;
- }
- .mapping-list {
- margin-top: 6px;
- }
- .mapping-item {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- border-radius: 4px;
- margin-bottom: 3px;
- font-size: 12px;
- font-family: 'Cascadia Code', 'Fira Code', monospace;
- }
- .mapping-item.mapped {
- background: rgba(34, 197, 94, 0.08);
- }
- .mapping-item.mismatch {
- background: rgba(239, 68, 68, 0.08);
- }
- .mapping-item.unmapped {
- background: rgba(245, 158, 11, 0.08);
- }
- .mapping-item.extra {
- background: rgba(107, 114, 128, 0.06);
- }
- .mapping-field {
- color: var(--text-primary, #e0e0e0);
- font-weight: 500;
- }
- .mapping-arrow {
- color: var(--text-tertiary, #666);
- }
- .mapping-type {
- color: var(--text-tertiary, #666);
- font-size: 10px;
- }
- /* 右键菜单 */
- .context-menu-overlay {
- position: fixed;
- inset: 0;
- z-index: 200;
- }
- .context-menu {
- position: fixed;
- background: #1e1b2e;
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- padding: 4px;
- min-width: 140px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
- }
- .ctx-item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 8px 12px;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: var(--text-primary, #e0e0e0);
- font-size: 13px;
- cursor: pointer;
- transition: background 0.12s;
- text-align: left;
- }
- .ctx-item:hover {
- background: rgba(255, 255, 255, 0.08);
- }
- .ctx-item.ctx-danger {
- color: #f87171;
- }
- .ctx-item.ctx-danger:hover {
- background: rgba(239, 68, 68, 0.15);
- }
- /* 版本历史面板 */
- .version-panel {
- position: fixed;
- right: 0;
- top: 0;
- bottom: 0;
- width: 380px;
- background: var(--bg-secondary, #fff);
- border-left: 1px solid var(--border-color, #e5e7eb);
- display: flex;
- flex-direction: column;
- z-index: 100;
- box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
- }
- .version-panel .vp-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- border-bottom: 1px solid var(--border-color, #e5e7eb);
- }
- .version-panel .vp-title {
- font-size: 15px;
- font-weight: 600;
- margin: 0;
- }
- .version-panel .vp-list {
- max-height: 300px;
- overflow-y: auto;
- padding: 8px;
- }
- .version-panel .vp-empty {
- text-align: center;
- color: var(--text-tertiary, #999);
- padding: 24px 0;
- font-size: 13px;
- }
- .version-panel .version-items {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .version-panel .version-item {
- display: flex;
- align-items: stretch;
- border-radius: 6px;
- border: 1px solid transparent;
- cursor: pointer;
- transition: all 0.15s;
- }
- .version-panel .version-item:hover {
- background: rgba(37, 99, 235, 0.06);
- }
- .version-panel .version-item.active {
- background: rgba(37, 99, 235, 0.1);
- border-color: rgba(37, 99, 235, 0.3);
- }
- .version-panel .vi-main {
- flex: 1;
- padding: 8px 10px;
- min-width: 0;
- }
- .version-panel .vi-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- }
- .version-panel .vi-version {
- font-size: 13px;
- font-weight: 600;
- color: var(--color-accent, #2563eb);
- }
- .version-panel .vi-current {
- font-size: 10px;
- font-weight: 500;
- background: rgba(34, 197, 94, 0.15);
- color: #22c55e;
- padding: 0 4px;
- border-radius: 3px;
- margin-left: 4px;
- vertical-align: middle;
- }
- .version-panel .vi-time {
- font-size: 11px;
- color: var(--text-tertiary, #999);
- }
- .version-panel .vi-message {
- font-size: 12px;
- color: var(--text-secondary, #666);
- margin-top: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .version-panel .vi-message-edit {
- display: flex;
- gap: 4px;
- margin-top: 4px;
- align-items: center;
- }
- .version-panel .vi-actions {
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 2px;
- padding: 4px 6px;
- opacity: 0;
- transition: opacity 0.15s;
- }
- .version-panel .version-item:hover .vi-actions {
- opacity: 1;
- }
- .vi-action-btn {
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 13px;
- background: transparent;
- transition: background 0.15s;
- }
- .rollback-btn {
- color: var(--color-danger, #ef4444);
- }
- .rollback-btn:hover {
- background: rgba(239, 68, 68, 0.15);
- }
- .version-panel .vp-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- border-top: 1px solid var(--border-color, #e5e7eb);
- }
- .version-panel .vp-content-header {
- padding: 8px 16px;
- font-size: 13px;
- font-weight: 500;
- color: var(--text-secondary, #666);
- border-bottom: 1px solid var(--border-color, #e5e7eb);
- flex-shrink: 0;
- }
- .version-json {
- flex: 1;
- overflow: auto;
- padding: 12px 16px;
- margin: 0;
- font-size: 11px;
- line-height: 1.5;
- white-space: pre-wrap;
- word-break: break-all;
- color: var(--text-secondary, #666);
- font-family: 'Consolas', 'Monaco', monospace;
- background: transparent;
- }
- /* 版本按钮 */
- .version-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 5px 10px;
- border: 1px solid var(--border-color, #e5e7eb);
- border-radius: 6px;
- background: var(--bg-secondary, #fff);
- color: var(--text-secondary, #666);
- cursor: pointer;
- font-size: 13px;
- transition: all 0.2s;
- }
- .version-btn:hover:not(:disabled) {
- border-color: var(--color-accent, #2563eb);
- color: var(--color-accent, #2563eb);
- }
- .version-btn.active {
- background: var(--color-accent, #2563eb);
- color: #fff;
- border-color: var(--color-accent, #2563eb);
- }
- .version-btn:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
- /* 右侧滑入动画 */
- .slide-right-enter-active,
- .slide-right-leave-active {
- transition: transform 0.25s ease;
- }
- .slide-right-enter-from,
- .slide-right-leave-to {
- transform: translateX(100%);
- }
- </style>
|