<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, NModal, useMessage
} from 'naive-ui'
import {
  ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
  SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
  PlayOutline, CloseOutline, ColorWandOutline, LinkOutline, TrashOutline, OpenOutline,
  TimeOutline, LibraryOutline, DownloadOutline, CloudUploadOutline
} 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, getNodeInputs, getReachablePredecessors, getReachableSuccessors, IO_TYPES } from '../../utils/ioInference'
import { useVersionHistory } from '../../composables/useVersionHistory'
import { useWorkflowRunner, unwrapNodeOutput, nodeOutputStatus, nodeOutputMessage } 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, nodeName: node.data?.nodeName || '输入节点' })
      }
    }
  }
  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,
      inputs: [],
      outputs: [
        { name: 'evidences', label: '检索证据列表', type: 'array', description: '命中的知识片段集合', mapping: null },
        { name: 'evidenceCount', label: '证据数量', type: 'number', description: '命中证据条数', mapping: null },
        { name: 'sourceType', label: '来源类型', type: 'string', description: 'DOCUMENT / STRUCTURED_DATA / GRAPH / HYBRID', mapping: null },
        { name: 'diagnostics', label: '诊断信息', type: 'object', description: '检索过程的诊断信息', mapping: null }
      ]
    }
    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)
  // 节点删除后，其后继节点中指向该节点的显式关联回退为自动推断
  revertSuccessorMappings(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])
}

// ========== 字段引入对话框：从前驱/后继节点引入字段 ==========
const importDialog = reactive({
  open: false,
  mode: null,    // 'predecessor' | 'successor'
  candidates: []
})

// 前驱输出候选项：用于"前置数据"面板的引入按钮
const predecessorOutputCandidates = computed(() => {
  if (!selectedNode.value) return []
  const predecessors = getReachablePredecessors(selectedNode.value.id, getNodes.value, getEdges.value)
  const result = []
  for (const src of predecessors) {
    for (const out of getNodeOutputs(src)) {
      result.push({
        nodeId: src.id,
        nodeName: src.data?.nodeName || src.data?.label || src.id,
        fieldName: out.name,
        fieldLabel: out.label || out.name,
        fieldType: out.type || 'string',
        fieldDescription: out.description || ''
      })
    }
  }
  return result
})

// 后继输入候选项：用于"输出变量"面板的引入按钮
const successorInputCandidates = computed(() => {
  if (!selectedNode.value) return []
  const successors = getReachableSuccessors(selectedNode.value.id, getNodes.value, getEdges.value)
  const result = []
  for (const dst of successors) {
    for (const inp of getNodeInputs(dst)) {
      result.push({
        nodeId: dst.id,
        nodeName: dst.data?.nodeName || dst.data?.label || dst.id,
        fieldName: inp.name,
        fieldLabel: inp.label || inp.name,
        fieldType: inp.type || 'string',
        fieldDescription: inp.description || ''
      })
    }
  }
  return result
})

function openImportDialog(mode) {
  importDialog.mode = mode
  importDialog.candidates = mode === 'predecessor' ? predecessorOutputCandidates.value : successorInputCandidates.value
  importDialog.open = true
}

// 确认引入：从前驱输出导入到 inputs（普通节点）或 fields（输出节点），
//          或从后继输入导入到 outputs（普通节点）或 variables（输入节点）
function confirmImportField(candidate) {
  if (!selectedNode.value) return
  const nodeType = selectedNode.value.type
  if (importDialog.mode === 'predecessor') {
    // 输出节点：引入到 fields（接收字段仍作为业务输出）；其他节点：引入到 inputs（含 mapping）
    const targetField = nodeType === 'output' ? 'fields' : 'inputs'
    const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
    if (exists) {
      message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
      return
    }
    const newField = {
      name: candidate.fieldName,
      label: candidate.fieldLabel || candidate.fieldName,
      type: candidate.fieldType || 'string',
      description: candidate.fieldDescription || '',
      mapping: {
        sourceNodeId: candidate.nodeId,
        sourceField: candidate.fieldName,
        sourcePath: null,
        autoMapped: false
      }
    }
    const updated = [...(selectedData.value?.[targetField] || []), newField]
    vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
  } else if (importDialog.mode === 'successor') {
    // 输入节点：引入到 variables（输入变量仍对业务暴露）；其他节点：引入到 outputs
    const targetField = nodeType === 'userInput' ? 'variables' : 'outputs'
    const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
    if (exists) {
      message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
      return
    }
    const newField = {
      name: candidate.fieldName,
      label: candidate.fieldLabel || candidate.fieldName,
      type: candidate.fieldType || 'string',
      description: candidate.fieldDescription || ''
    }
    const updated = [...(selectedData.value?.[targetField] || []), newField]
    vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
    // 同步设置后继节点对应 input 的 mapping，指向本节点该输出字段
    // （userInput 的 variables 即为其对后继的输出，见 ioInference.getNodeOutputs）
    const successorNode = getNodes.value.find(n => n.id === candidate.nodeId)
    if (successorNode) {
      const succInputs = (successorNode.data?.inputs || []).map(inp => {
        if (inp.name === candidate.fieldName) {
          return {
            ...inp,
            mapping: {
              sourceNodeId: selectedNode.value.id,
              sourceField: candidate.fieldName,
              sourcePath: null,
              autoMapped: false
            }
          }
        }
        return inp
      })
      vfUpdateNode(candidate.nodeId, { inputs: succInputs })
    }
  }
  // 刷新当前节点关联的边映射，并同步 selectedNode 引用
  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
  importDialog.open = false
}

// ========== 节点名称编辑（含重名校验） ==========
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 FIELD_LIST_LABELS = {
  variables: '输入变量',
  inputs: '前置数据',
  outputs: '输出变量',
  fields: '接收字段'
}

function updateFieldName(item, index, listKey, newName) {
  const list = selectedData.value?.[listKey] || []
  const trimmed = (newName || '').trim()
  if (trimmed && list.some((f, i) => i !== index && f.name === trimmed)) {
    message.warning(`变量名「${trimmed}」在${FIELD_LIST_LABELS[listKey] || listKey}中已存在`)
  }
  item.name = newName
  onFieldChange(listKey, [...list])
}

// ========== 智能体选项 ==========
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,
  revertSuccessorMappings
} = 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 }
}

// 校验各节点内部变量/字段名是否重复
function validateDuplicateFieldNames() {
  const listConfigs = [
    { key: 'variables', label: '输入变量' },
    { key: 'inputs', label: '前置数据' },
    { key: 'outputs', label: '输出变量' },
    { key: 'fields', label: '接收字段' }
  ]
  const duplicates = []
  for (const node of getNodes.value.filter(n => !n.data?.virtual)) {
    const d = node.data || {}
    const nodeLabel = d.nodeName || d.label || node.id
    for (const { key, label } of listConfigs) {
      const items = d[key] || []
      const seen = new Map()
      const dupNames = new Set()
      for (const item of items) {
        const name = (item.name || '').trim()
        if (!name) continue
        if (seen.has(name)) {
          dupNames.add(name)
        } else {
          seen.set(name, true)
        }
      }
      if (dupNames.size) {
        duplicates.push({ nodeLabel, listLabel: label, names: [...dupNames] })
      }
    }
  }
  return duplicates
}

// ========== 保存 ==========
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
  }
  const duplicates = validateDuplicateFieldNames()
  if (duplicates.length) {
    const lines = duplicates.map(d => `${d.nodeLabel}（${d.listLabel}）：${d.names.join('、')}`)
    message.warning('以下节点存在重名变量，请修改后再保存：\n' + lines.join('\n'))
    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')
}

// ========== 运行对话框变量导出/导入 ==========
async function exportRunVariables() {
  const data = {}
  for (const f of inputFields.value) {
    const value = formData[f.name]
    if (value !== undefined && value !== null && String(value).trim() !== '') {
      data[f.name] = value
    }
  }
  if (!Object.keys(data).length) {
    message.warning('当前没有已输入的变量可导出')
    return
  }
  const workflowName = form.value.displayName || form.value.name || 'workflow'
  const nodeName = inputFields.value[0]?.nodeName || 'input'
  const fileName = `${workflowName}-${nodeName}-输入变量.json`
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })

  // 优先使用 File System Access API，强制弹出保存位置选择框
  if (typeof window !== 'undefined' && window.showSaveFilePicker) {
    try {
      const handle = await window.showSaveFilePicker({
        suggestedName: fileName,
        types: [{
          description: 'JSON 文件',
          accept: { 'application/json': ['.json'] }
        }]
      })
      const writable = await handle.createWritable()
      await writable.write(blob)
      await writable.close()
      message.success('变量已导出')
    } catch (error) {
      if (error.name === 'AbortError') {
        // 用户取消选择，静默处理
        return
      }
      message.error(`导出失败：${error.message}`)
    }
    return
  }

  // 不支持 File System Access API 的浏览器回退到默认下载
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = fileName
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  URL.revokeObjectURL(url)
  message.success('变量已导出（当前浏览器不支持选择保存位置）')
}

function onImportVariablesClick() {
  const input = document.createElement('input')
  input.type = 'file'
  input.accept = 'application/json,.json'
  input.onchange = e => importRunVariables(e.target.files?.[0])
  input.click()
}

async function importRunVariables(file) {
  if (!file) return
  try {
    const text = await file.text()
    const data = JSON.parse(text)
    if (!data || typeof data !== 'object') {
      message.error('导入文件格式错误，应为 JSON 对象')
      return
    }
    let count = 0
    for (const f of inputFields.value) {
      if (Object.prototype.hasOwnProperty.call(data, f.name)) {
        formData[f.name] = data[f.name]
        count++
      }
    }
    message.success(`已导入 ${count} 个变量`)
  } catch (error) {
    message.error(`导入失败：${error.message}`)
  }
}

// ========== 文件选择（运行时上传） ==========
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([])

function handleOpenRunDialog() {
  openRunDialog(inputFields, formData)
  uploadFiles.value = []
}

// 启动运行（包装 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 = []
}

// 回滚应用到画布（供 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>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addVariable">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'variables', nv) }" />
                      <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="openImportDialog('predecessor')">引入</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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用，或手动添加</div>
                </div>
                <!-- 输出变量 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">输出变量</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'outputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </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('outputs', [...selectedData.outputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </div>
                    <div class="var-form-row" style="justify-content:flex-end">
                      <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
                </div>
                <!-- 重试配置（仅声明 outputs 时生效；未配置时使用全局默认） -->
                <details class="retry-config">
                  <summary class="prop-label">重试配置</summary>
                  <div class="retry-config-body">
                    <div class="var-form-row">
                      <span class="retry-field-label">最大重试次数</span>
                      <n-input-number :value="selectedData?.maxRetries" :min="1" :max="10" size="small"
                        placeholder="使用全局默认" clearable
                        @update:value="v => onFieldChange('maxRetries', v == null ? undefined : Number(v))" />
                    </div>
                    <div class="retry-checkbox-group">
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnParseError ?? false"
                          @update:checked="v => onFieldChange('retryOnParseError', v)" />
                        <span>JSON 解析失败时重试</span>
                      </label>
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnMissingRequired ?? false"
                          @update:checked="v => onFieldChange('retryOnMissingRequired', v)" />
                        <span>缺少必填字段时重试</span>
                      </label>
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnTypeMismatch ?? false"
                          @update:checked="v => onFieldChange('retryOnTypeMismatch', v)" />
                        <span>类型不匹配时重试</span>
                      </label>
                    </div>
                    <div class="retry-hint">未勾选时使用全局默认；声明 outputs 后此项才生效</div>
                  </div>
                </details>
                <!-- 失败策略 -->
                <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>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addAgentField('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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
                </div>
                <!-- 输出变量 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">输出变量</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'outputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </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('outputs', [...selectedData.outputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </div>
                    <div class="var-form-row" style="justify-content:flex-end">
                      <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>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addAgentField('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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
                </div>
                <!-- 输出变量 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">输出变量</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'outputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </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('outputs', [...selectedData.outputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </div>
                    <div class="var-form-row" style="justify-content:flex-end">
                      <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>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addAgentField('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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用，或手动添加</div>
                </div>
                <!-- 输出变量 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">输出变量</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'outputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </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('outputs', [...selectedData.outputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </div>
                    <div class="var-form-row" style="justify-content:flex-end">
                      <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
                </div>
                <!-- 重试配置（仅声明 outputs 时生效；未配置时使用全局默认） -->
                <details class="retry-config">
                  <summary class="prop-label">重试配置</summary>
                  <div class="retry-config-body">
                    <div class="var-form-row">
                      <span class="retry-field-label">最大重试次数</span>
                      <n-input-number :value="selectedData?.maxRetries" :min="1" :max="10" size="small"
                        placeholder="使用全局默认" clearable
                        @update:value="v => onFieldChange('maxRetries', v == null ? undefined : Number(v))" />
                    </div>
                    <div class="retry-checkbox-group">
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnParseError ?? false"
                          @update:checked="v => onFieldChange('retryOnParseError', v)" />
                        <span>JSON 解析失败时重试</span>
                      </label>
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnMissingRequired ?? false"
                          @update:checked="v => onFieldChange('retryOnMissingRequired', v)" />
                        <span>缺少必填字段时重试</span>
                      </label>
                      <label class="retry-checkbox-row">
                        <n-checkbox :checked="selectedData?.retryOnTypeMismatch ?? false"
                          @update:checked="v => onFieldChange('retryOnTypeMismatch', v)" />
                        <span>类型不匹配时重试</span>
                      </label>
                    </div>
                    <div class="retry-hint">未勾选时使用全局默认；声明 outputs 后此项才生效</div>
                  </div>
                </details>
                <!-- 失败策略 -->
                <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>
                <!-- 前置数据 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">前置数据</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addListField('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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeListField('inputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用，或手动添加</div>
                </div>
                <!-- 输出变量 -->
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">输出变量</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('successor')">引入</button>
                      <button class="text-btn" @click="addListField('outputs')">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'outputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </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('outputs', [...selectedData.outputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                    </div>
                    <div class="var-form-row" style="justify-content:flex-end">
                      <button class="text-btn danger" @click="removeListField('outputs', i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 evidences / evidenceCount / sourceType / diagnostics</div>
                </div>
              </template>

              <!-- 条件分支节点 -->
              <template v-if="selectedNode?.type === 'condition'">
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">条件分支</span>
                    <button class="text-btn" @click="addElif">+ ELIF</button>
                  </div>
                  <div v-for="(c, i) in selectedData?.conditions || []" :key="i" class="cond-form">
                    <div class="cond-form-row">
                      <span class="cond-tag">{{ c.type }}</span>
                      <n-input :value="c.expression" placeholder="自然语言条件，可用 {{变量名}}" size="small"
                        @update:value="v => onConditionChange(i, 'expression', v)" />
                      <button v-if="c.type === 'ELIF'" class="text-btn danger" @click="removeElif(i)">✕</button>
                    </div>
                  </div>
                </div>
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">前置数据</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addConditionInput">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(v, i, 'inputs', nv) }" />
                      <n-input :value="v.label" placeholder="中文名" size="small"
                        @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </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('inputs', [...selectedData.inputs]) }" />
                      <n-input :value="v.description" placeholder="描述" size="small"
                        @update:value="nv => { v.description = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(v)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(v, nv)" />
                      <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
                    </div>
                  </div>
                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断，或手动添加</div>
                  <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
                </div>
              </template>

              <!-- 输出节点 -->
              <template v-if="selectedNode?.type === 'output'">
                <div class="prop-section">
                  <div class="prop-header">
                    <span class="prop-label">接收字段</span>
                    <div style="display:flex;gap:4px">
                      <button class="text-btn" @click="openImportDialog('predecessor')">引入</button>
                      <button class="text-btn" @click="addOutputField">+ 添加</button>
                    </div>
                  </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 => { updateFieldName(f, i, 'fields', nv) }" />
                      <n-input :value="f.label" placeholder="中文名" size="small"
                        @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
                    </div>
                    <div class="var-form-row">
                      <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px"
                        @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
                      <n-input :value="f.description" placeholder="描述" size="small"
                        @update:value="nv => { f.description = nv; onFieldChange('fields', [...selectedData.fields]) }" />
                    </div>
                    <div class="var-form-row mapping-row">
                      <n-select :value="currentMappingValue(f)"
                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                        clearable placeholder="关联方式：自动推断" style="flex:1"
                        @update:value="nv => updateInputMapping(f, nv)" />
                      <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="unwrapNodeOutput(r.output) && Object.keys(unwrapNodeOutput(r.output)).length">
                <!-- envelope 失败（status=400）时显示红色徽章 + message -->
                <div v-if="nodeOutputStatus(r.output) === 400" class="nrc-env-fail">
                  <span class="nrc-env-badge failed">失败</span>
                  <span class="nrc-env-msg">{{ nodeOutputMessage(r.output) }}</span>
                </div>
                <div class="nrc-var" v-for="(value, key) in unwrapNodeOutput(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">
      <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 class="run-actions" v-if="inputFields.length">
            <button class="text-btn" @click="exportRunVariables">
              <n-icon size="12"><DownloadOutline /></n-icon> 变量导出
            </button>
            <button class="text-btn" @click="onImportVariablesClick">
              <n-icon size="12"><CloudUploadOutline /></n-icon> 变量导入
            </button>
          </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>

    <!-- 字段引入对话框：从前驱输出或后继输入中引入字段 -->
    <n-modal v-model:show="importDialog.open" preset="card" :mask-closable="false" style="max-width:560px"
      :title="importDialog.mode === 'predecessor' ? '从前驱节点引入字段' : '向后继节点引入字段'">
      <div class="import-candidate-list">
        <div v-if="!importDialog.candidates.length" class="empty-hint">暂无可引入字段</div>
        <div v-for="(c, i) in importDialog.candidates" :key="i" class="import-candidate"
          @click="confirmImportField(c)">
          <div class="candidate-main">
            <span class="candidate-node">{{ c.nodeName }}</span>
            <span class="candidate-sep">.</span>
            <span class="candidate-field">{{ c.fieldName }}</span>
          </div>
          <div class="candidate-meta">
            <span class="candidate-type">{{ c.fieldType }}</span>
            <span v-if="c.fieldLabel && c.fieldLabel !== c.fieldName" class="candidate-label">{{ c.fieldLabel }}</span>
          </div>
          <div v-if="c.fieldDescription" class="candidate-desc">{{ c.fieldDescription }}</div>
        </div>
      </div>
    </n-modal>

  </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;
}

/* envelope 失败提示条：status=400 时显示在变量列表顶部 */
.nrc-env-fail {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  margin-bottom: 6px;
  background: rgba(239, 68, 68, 0.08);
  border-left: 2px solid #ef4444;
  border-radius: 3px;
  font-size: 11px;
}

.nrc-env-badge {
  font-weight: 600;
  padding: 1px 6px;
  border-radius: 3px;
  flex-shrink: 0;
}

.nrc-env-badge.failed {
  background: rgba(239, 68, 68, 0.15);
  color: #f87171;
}

.nrc-env-msg {
  color: #f87171;
  word-break: break-all;
}

/* 流式思考过程区块 */
.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;
}

/* 引入对话框候选列表 */
.import-candidate-list {
  max-height: 360px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.import-candidate {
  padding: 8px 10px;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.06);
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.import-candidate:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(255, 255, 255, 0.14);
}

.import-candidate .candidate-main {
  font-size: 13px;
  color: var(--text-primary, #eee);
  margin-bottom: 2px;
}

.import-candidate .candidate-node {
  color: var(--text-secondary, #aaa);
}

.import-candidate .candidate-sep {
  color: var(--text-tertiary, #666);
  margin: 0 2px;
}

.import-candidate .candidate-field {
  font-family: 'Consolas', 'Monaco', monospace;
  font-weight: 500;
}

.import-candidate .candidate-meta {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 11px;
  margin-bottom: 2px;
}

.import-candidate .candidate-type {
  padding: 1px 6px;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-secondary, #aaa);
}

.import-candidate .candidate-label {
  color: var(--text-tertiary, #888);
}

.import-candidate .candidate-desc {
  font-size: 11px;
  color: var(--text-tertiary, #777);
  line-height: 1.4;
}

.kr-checkbox-row {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  cursor: pointer;
  margin-bottom: 4px;
}

/* 重试配置折叠面板 */
.retry-config {
  margin: 8px 0 12px;
  padding: 6px 10px;
  background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
  border-radius: 4px;
  border-left: 2px solid #8b5cf6;
}

.retry-config > summary {
  cursor: pointer;
  list-style: none;
  user-select: none;
  font-size: 12px;
  font-weight: 500;
}

.retry-config > summary::-webkit-details-marker {
  display: none;
}

.retry-config > summary::before {
  content: "▶";
  display: inline-block;
  margin-right: 6px;
  font-size: 10px;
  transition: transform 0.15s;
}

.retry-config[open] > summary::before {
  transform: rotate(90deg);
}

.retry-config-body {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.retry-field-label {
  font-size: 11px;
  color: var(--text-secondary, #666);
  flex-shrink: 0;
  min-width: 80px;
}

.retry-checkbox-group {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.retry-checkbox-row {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  cursor: pointer;
}

.retry-hint {
  font-size: 10px;
  color: var(--text-tertiary, #888);
  margin-top: 2px;
}

.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-actions {
  display: flex;
  gap: 10px;
  margin-bottom: 12px;
}

.run-actions .text-btn {
  display: inline-flex;
  align-items: center;
  gap: 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>
