RamEx-Flow / front /src /components /NodeEditor.vue
Yanmei-Zhang51
Localize node editor labels and use local Docker build
2351614
Raw
History Blame Contribute Delete
77.8 kB
<template>
<div class="node-editor-wrapper">
<div class="node-editor">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<h2 class="editor-title">{{ topSectionTitle }}</h2>
<div class="toolbar-actions">
<LanguageSwitcher />
<button
type="button"
class="toolbar-btn"
@click="goBack"
:title="t('editor.backTitle')"
>
{{ t('editor.back') }}
</button>
<button
v-if="showRunButton"
class="toolbar-btn primary"
@click="executeWorkflow"
:disabled="isExecuting"
:title="projectId ? '' : t('editor.missingProjectIdTitle')"
>
<svg v-if="!isExecuting" viewBox="0 0 24 24" fill="none">
<polygon points="5,3 19,12 5,21" fill="currentColor"/>
</svg>
<svg v-else class="spinning" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="31.416" stroke-dashoffset="31.416">
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416;0 31.416" repeatCount="indefinite"/>
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416;-31.416" repeatCount="indefinite"/>
</circle>
</svg>
{{ isExecuting ? t('editor.parsing') : t('editor.parse') }}
</button>
<button
v-if="showDownloadButton"
class="toolbar-btn"
@click="downloadAnalysisExport"
:disabled="isExecuting || isDownloading"
:title="t('editor.downloadTitle')"
>
<svg v-if="!isDownloading" viewBox="0 0 24 24" fill="none">
<path d="M12 3v10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M8 11l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 21h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg v-else class="spinning" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="31.416" stroke-dashoffset="31.416">
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416;0 31.416" repeatCount="indefinite"/>
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416;-31.416" repeatCount="indefinite"/>
</circle>
</svg>
{{ isDownloading ? t('editor.downloading') : t('editor.download') }}
</button>
</div>
</div>
<!-- 主内容区域 -->
<div class="editor-main">
<!-- 左侧参数树(点击条目直接在画布生成节点) -->
<aside class="node-palette">
<div class="palette-header">
<div class="palette-flow">
<div class="palette-flow-title">{{ t('editor.flowTitle') }}</div>
<div class="palette-flow-steps" role="tablist" :aria-label="t('editor.flowAria')">
<button
type="button"
class="flow-step"
role="tab"
:class="{ active: topSection === 'preprocess' }"
:aria-selected="topSection === 'preprocess'"
@click="topSection = 'preprocess'"
>
<span class="flow-step-indicator" aria-hidden="true"></span>
<span class="flow-step-num">1</span>
<span class="flow-step-label">{{ t('editor.sectionPreprocess') }}</span>
</button>
<button
type="button"
class="flow-step"
role="tab"
:class="{ active: topSection === 'metaFree' }"
:aria-selected="topSection === 'metaFree'"
@click="topSection = 'metaFree'"
>
<span class="flow-step-indicator" aria-hidden="true"></span>
<span class="flow-step-num">2</span>
<span class="flow-step-label">{{ t('editor.sectionMetaFree') }}</span>
</button>
<button
type="button"
class="flow-step"
role="tab"
:class="{ active: topSection === 'meta' }"
:aria-selected="topSection === 'meta'"
@click="topSection = 'meta'"
>
<span class="flow-step-indicator" aria-hidden="true"></span>
<span class="flow-step-num">3</span>
<span class="flow-step-label">{{ t('editor.sectionMetaBased') }}</span>
</button>
</div>
</div>
<div class="palette-modules-head">
<span class="palette-modules-title">{{ t('editor.modulesTitle') }}</span>
</div>
</div>
<div class="palette-content" role="tree" :aria-label="t('editor.treeAria')">
<!-- 2. 数据预处理 -->
<template v-if="topSection === 'preprocess'">
<button class="tree-sub-head" type="button" @click="toggleExpanded('qc')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('qc')"></span>
<span class="caret" :class="{ open: expanded.qc }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.qc') }}</span>
</button>
<div v-if="expanded.qc" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('qc-euclidean')">{{ t('editor.palette.euclidean') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('qc-snr')">{{ t('editor.palette.snr') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('qc-icod')">{{ t('editor.palette.icod') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('qc-hotelling')">Hotelling T2</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('qc-pcmcd')">{{ t('editor.palette.pcmcd') }}</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('peak')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('peak')"></span>
<span class="caret" :class="{ open: expanded.peak }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.peakRemoval') }}</span>
</button>
<div v-if="expanded.peak" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('peak-removal')">{{ t('editor.palette.usePeakRemoval') }}</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('smooth')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('smooth')"></span>
<span class="caret" :class="{ open: expanded.smooth }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.smoothSection') }}</span>
</button>
<div v-if="expanded.smooth" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('smooth-sg')">Savitzky-Golay</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('smooth-snv')">SNV</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('base')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('base')"></span>
<span class="caret" :class="{ open: expanded.base }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.baselineSection') }}</span>
</button>
<div v-if="expanded.base" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('base-polyfit')">{{ t('editor.palette.polyfit') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('base-bubble')">{{ t('editor.palette.bubble') }}</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('trunc')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('trunc')"></span>
<span class="caret" :class="{ open: expanded.trunc }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.trunc') }}</span>
</button>
<div v-if="expanded.trunc" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('trunc-range')">{{ t('editor.palette.truncEnable') }}</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('norm')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('norm')"></span>
<span class="caret" :class="{ open: expanded.norm }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.normSection') }}</span>
</button>
<div v-if="expanded.norm" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('norm-method')">{{ t('editor.palette.normMethod') }}</button>
</div>
</template>
<!-- 3. 无元数据分析 -->
<template v-else-if="topSection === 'metaFree'">
<button class="tree-sub-head" type="button" @click="toggleExpanded('dim')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('dim')"></span>
<span class="caret" :class="{ open: expanded.dim }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.dim') }}</span>
</button>
<div v-if="expanded.dim" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('dim-pca')">PCA</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('dim-tsne')">t-SNE</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('dim-umap')">UMAP</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('dim-pcoa')">PCoA</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('phen')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('phen')"></span>
<span class="caret" :class="{ open: expanded.phen }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.phenotype') }}</span>
</button>
<div v-if="expanded.phen" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('phen-louvain')">{{ t('editor.palette.louvain') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('phen-hca')">{{ t('editor.palette.hca') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('phen-kmeans')">K-means</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('decomp')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('decomp')"></span>
<span class="caret" :class="{ open: expanded.decomp }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.spectral') }}</span>
</button>
<div v-if="expanded.decomp" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('decomp-mcrals')">MCR-ALS</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('decomp-ica')">ICA</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('decomp-nmf')">NMF</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('intra')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('intra')"></span>
<span class="caret" :class="{ open: expanded.intra }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.intra') }}</span>
</button>
<div v-if="expanded.intra" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('intra-irca')">{{ t('editor.palette.ircaGlobal') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('intra-irca-local')">{{ t('editor.palette.ircaLocal') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('intra-2dcos')">{{ t('editor.palette.cos2d') }}</button>
</div>
</template>
<!-- 4. 基于元数据分析 -->
<template v-else>
<button class="tree-sub-head" type="button" @click="toggleExpanded('cls')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('cls')"></span>
<span class="caret" :class="{ open: expanded.cls }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.classification') }}</span>
</button>
<div v-if="expanded.cls" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('cls-lda')">LDA</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('cls-rf')">{{ t('editor.palette.rf') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('cls-svm')">SVM</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('quant')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('quant')"></span>
<span class="caret" :class="{ open: expanded.quant }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.regression') }}</span>
</button>
<div v-if="expanded.quant" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('quant-pls')">PLS</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('quant-mlr')">MLR</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('quant-glm')">GLM</button>
</div>
<button class="tree-sub-head" type="button" @click="toggleExpanded('markers')">
<span class="tree-cat-icon" aria-hidden="true" v-html="paletteIconHtml('markers')"></span>
<span class="caret" :class="{ open: expanded.markers }" aria-hidden="true"></span>
<span class="tree-sub-head-label">{{ t('editor.palette.markers') }}</span>
</button>
<div v-if="expanded.markers" class="tree-leaves">
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('marker-rbcs')">RBCS</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('marker-roc')">{{ t('editor.palette.roc') }}</button>
<button class="tree-leaf-btn" type="button" @click="addNodeFromLibrary('marker-correlations')">{{ t('editor.palette.correlations') }}</button>
</div>
</template>
</div>
</aside>
<!-- 节点编辑器画布 -->
<div class="editor-container">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:fit-view-on-init="true"
:node-types="nodeTypes"
:is-valid-connection="isValidConnection"
:connection-line-style="{ stroke: '#1a1a1a', strokeWidth: 2, strokeDasharray: '5,5' }"
:default-edge-options="{
animated: true,
style: { stroke: '#1a1a1a', strokeWidth: 2, strokeDasharray: '5,5' },
type: 'default'
}"
@connect="onConnect"
@node-click="onNodeClick"
@nodes-change="onNodesChange"
@pane-click="onPaneClick"
@ready="onVueFlowReady"
@init="onVueFlowInit"
>
<Background
v-if="backgroundType !== 'none'"
:pattern-color="backgroundType === 'dots' ? '#e2e8f0' : '#d1d5db'"
:gap="backgroundType === 'dots' ? 16 : 20"
:size="backgroundType === 'dots' ? 1 : 2"
/>
<Controls />
<MiniMap />
</VueFlow>
</div>
<!-- 右侧分析结果侧边栏 -->
<AnalysisResultSidebar
v-if="analysisResult"
:data="analysisResult"
:open="showResultSidebar"
@close="showResultSidebar = false"
@toggle="showResultSidebar = !showResultSidebar"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, markRaw, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import LanguageSwitcher from './LanguageSwitcher.vue'
import { i18n } from '../i18n'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import type { Connection } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css'
import WorkflowNodeComponent from './WorkflowNodeComponent.vue'
import AnalysisResultSidebar from './AnalysisResultSidebar.vue'
const { t } = useI18n()
const et = (key: string) => i18n.global.t(key) as string
// Vue 3 script setup 组件可以直接导入使用
import type { WorkflowConfig } from '../types/nodeTypes'
import { API_BASE_URL, ApiError, downloadProjectAnalysisExportFile, getProject, getProjectAnalysisResults, runProjectAnalysis } from '../api/http'
import { paletteIconSvg, getPaletteCategoryFromNodeType } from '../utils/paletteIcons'
const paletteIconHtml = (key: string) => paletteIconSvg(key)
const route = useRoute()
const router = useRouter()
const projectId = computed(() => String(route.query.projectId ?? ''))
const activeDataFileName = ref('')
const loadActiveDataFileName = async () => {
const pid = projectId.value
if (!pid) {
activeDataFileName.value = ''
return
}
try {
const project = await getProject(pid)
const active = project.active_data_file
const files = project.data_files ?? project.files ?? []
const file = active ?? files[0]
activeDataFileName.value = String(file?.filename ?? file?.name ?? '').trim()
} catch (e) {
console.warn('load project file name failed', e)
activeDataFileName.value = ''
}
}
const goBack = () => {
router.back()
}
const mediaBaseUrl = API_BASE_URL.replace(/\/api$/, '')
const leafPlotKeyMap: Record<string, string[]> = {
'dim-pca': ['pca'],
'dim-tsne': ['tsne'],
'dim-umap': ['umap'],
'dim-pcoa': ['pcoa'],
'phen-louvain': ['louvain'],
'phen-hca': ['hca'],
'phen-kmeans': ['kmeans'],
'decomp-mcrals': ['mcrals_spec', 'mcrals_violin'],
'decomp-ica': ['ica_spec', 'ica_violin'],
'decomp-nmf': ['nmf_spec', 'nmf_violin'],
'intra-irca': ['irca_global'],
'intra-irca-local': ['irca_local'],
'intra-2dcos': ['2dcos'],
'cls-lda': ['lda'],
'cls-rf': ['rf'],
'cls-svm': ['svm'],
'quant-pls': ['pls'],
'quant-mlr': ['mlr'],
'quant-glm': ['glm'],
'marker-rbcs': ['rbcs'],
'marker-roc': ['roc'],
'marker-correlations': ['correlations']
}
interface WorkflowNode {
id: string
type: string
position: { x: number; y: number }
data: {
label: string
nodeType?: 'local-csv' | 'missing-values' | 'distribution' | 'kmeans' | string
content?: string
properties?: Record<string, any>
previewUrl?: string
previewLoading?: boolean
previewFileName?: string
/** 与左侧树分类图标一致,用于节点 header 展示 */
paletteCategory?: string
schema?: Array<{
key: string
label: string
type: 'number' | 'select' | 'checkbox' | 'table'
hint?: string
min?: number
max?: number
step?: number
checkboxText?: string
options?: Array<{ label: string; value: any }>
columns?: Array<{ key: string; label: string; type: 'number' | 'text' }>
minRows?: number
}>
headerColor?: string
status?: 'ready' | 'pending' | 'error'
showSettings?: boolean
tabs?: Array<{ id: string; label: string }>
inputs?: Array<{ id: string; label: string }>
outputs?: Array<{ id: string; label?: string }>
}
}
interface WorkflowEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}
const nodes = ref<WorkflowNode[]>([])
const edges = ref<WorkflowEdge[]>([])
const vueFlowReady = ref(false)
const { screenToFlowCoordinate } = useVueFlow()
// 记录用户上一次在画布空白处点击的位置,新节点将添加到此处
const lastClickPosition = ref<{ x: number; y: number } | null>(null)
const onPaneClick = (event: MouseEvent) => {
lastClickPosition.value = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
}
// 监听nodes变化,用于调试
watch(nodes, (newNodes) => {
console.log('Nodes changed, count:', newNodes.length)
newNodes.forEach((node, index) => {
console.log(`Node ${index}:`, { id: node.id, type: node.type, position: node.position })
})
}, { deep: true, immediate: true })
// 监听edges变化,自动清理无效连接
watch(edges, (newEdges) => {
// 过滤掉无效的edge(缺少source或target,或自连接,或引用的节点不存在)
const validEdges = newEdges.filter((edge: WorkflowEdge) => {
// 检查edge是否有必需的字段
if (!edge || !edge.id) {
return false
}
if (!edge.source || !edge.target || edge.source === '' || edge.target === '') {
console.log('移除无效edge(缺少source/target):', edge)
return false
}
if (edge.source === edge.target) {
console.log('移除自连接:', edge)
return false
}
// 检查source和target对应的节点是否存在
const sourceNodeExists = nodes.value.some(node => node.id === edge.source)
const targetNodeExists = nodes.value.some(node => node.id === edge.target)
if (!sourceNodeExists || !targetNodeExists) {
console.log('移除无效edge(节点不存在):', edge, { sourceNodeExists, targetNodeExists })
return false
}
return true
})
// 如果有无效edge被移除,更新edges(使用nextTick避免循环更新)
if (validEdges.length !== newEdges.length) {
nextTick(() => {
edges.value = validEdges
})
}
}, { deep: true, immediate: false })
// 注册自定义节点类型(使用通用节点组件)
const nodeTypes: any = computed(() => {
const types = {
'default': markRaw(WorkflowNodeComponent)
}
console.log('Node types registered:', Object.keys(types), types)
return types
})
const isExecuting = ref(false)
const nodeIdCounter = ref(0)
const backgroundType = ref<'dots' | 'lines' | 'none'>('dots')
const expanded = ref({
qc: true,
peak: true,
smooth: true,
base: true,
trunc: true,
norm: true,
dim: true,
phen: true,
decomp: true,
intra: true,
cls: true,
quant: true,
markers: true
})
const topSection = ref<'preprocess' | 'metaFree' | 'meta'>('preprocess')
const topSectionTitle = computed(() => {
if (topSection.value === 'preprocess') return t('editor.sectionPreprocess')
if (topSection.value === 'metaFree') return t('editor.sectionMetaFree')
return t('editor.sectionMetaBased')
})
const showRunButton = computed(() => {
// 只要画布上存在任意节点就显示运行按钮(支持无元数据/基于元数据分析)
return nodes.value.length > 0
})
interface AnalysisResultData {
label?: string
plots?: Record<string, string | string[]>
analyses?: unknown
message?: string
exportId?: string
}
const analysisResult = ref<AnalysisResultData | null>(null)
const showResultSidebar = ref(false)
const buildPlotsFromResp = (resp: any): Record<string, string | string[]> => {
const plots: Record<string, string | string[]> = {}
const rawPlots = resp?.result?.plots ?? resp?.plots ?? {}
const toAbsUrl = (v: unknown) => {
const url = String(v ?? '')
return url.startsWith('http') ? url : `${mediaBaseUrl}${url}`
}
Object.entries(rawPlots).forEach(([k, v]) => {
plots[k] = Array.isArray(v) ? v.map(toAbsUrl) : toAbsUrl(v)
})
return plots
}
const pickLeafPreviewUrlFromPlots = (leafType: string, plots: Record<string, string | string[]>) => {
const keys = leafPlotKeyMap[leafType] ?? []
for (const k of keys) {
const v = plots?.[k]
const u = (Array.isArray(v) ? v[0] : v)
const trimmed = String(u ?? '').trim()
if (trimmed) return trimmed
}
return ''
}
// 把这次分析结果里的图,按节点类型分发回画布上对应的分析节点,作为节点内预览
// (节点在“运行/解析”之前只显示骨架占位,不再逐个自动请求后端)
const applyPreviewsToNodes = (plots: Record<string, string | string[]>) => {
nodes.value = nodes.value.map(n => {
const nodeType = String(n.data?.nodeType ?? '')
if (!leafPlotKeyMap[nodeType]) return n
const url = pickLeafPreviewUrlFromPlots(nodeType, plots)
return {
...n,
data: {
...n.data,
previewUrl: url || undefined,
previewFileName: url ? (activeDataFileName.value || undefined) : undefined
}
} as WorkflowNode
})
}
// 有预览能力的节点在运行期间显示加载态骨架,运行结束后统一回填/清空
const setLeafNodesLoading = (loading: boolean) => {
nodes.value = nodes.value.map(n => {
const nodeType = String(n.data?.nodeType ?? '')
if (!leafPlotKeyMap[nodeType]) return n
return { ...n, data: { ...n.data, previewLoading: loading } } as WorkflowNode
})
}
const updateResultNodeFromResp = (resp: any) => {
const plots = buildPlotsFromResp(resp)
analysisResult.value = {
label: t('common.analysisResult'),
plots,
analyses: resp?.result?.analyses ?? resp?.analyses ?? null,
message: resp?.message ?? resp?.result?.message ?? '',
exportId: String(resp?.result?.artifacts?.export_id ?? resp?.result?.export_id ?? resp?.export_id ?? '').trim() || undefined
}
showResultSidebar.value = true
applyPreviewsToNodes(plots)
}
const showDownloadButton = computed(() => {
if (!projectId.value) return false
const result = analysisResult.value
if (!result) return false
const plots = result.plots ?? {}
const hasPlots = typeof plots === 'object' && plots && Object.keys(plots).length > 0
const hasAnalyses = Boolean(result.analyses)
const hasMessage = Boolean(String(result.message ?? '').trim())
return hasPlots || hasAnalyses || hasMessage
})
const isDownloading = ref(false)
const downloadAnalysisExport = async () => {
const pid = projectId.value
if (!pid) {
alert(t('common.errors.missingProjectId'))
return
}
isDownloading.value = true
try {
const { blob, filename } = await downloadProjectAnalysisExportFile(pid)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = (filename && filename.trim()) ? filename.trim() : `analysis-export-${pid}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (e: any) {
alert(e instanceof ApiError ? e.message : (e?.message ?? t('common.errors.download')))
} finally {
isDownloading.value = false
}
}
const toggleExpanded = (key: keyof typeof expanded.value) => {
expanded.value[key] = !expanded.value[key]
}
const isNodeEnabled = (nodeType: string) =>
nodes.value.some(n => (n.data?.nodeType ?? '') === nodeType)
const getLibraryConfig = (nodeType: string): Partial<WorkflowNode['data']> | null => {
const configs: Record<string, Partial<WorkflowNode['data']>> = {
'qc-euclidean': {
label: et('editor.palette.euclidean'),
nodeType: 'qc-euclidean',
headerColor: '#0f172a',
properties: { maxDistance: 1 },
schema: [{ key: 'maxDistance', label: et('editor.fields.maxDistance'), type: 'number', min: 0, step: 0.1 }]
},
'qc-snr': {
label: et('editor.palette.snr'),
nodeType: 'qc-snr',
headerColor: '#0f172a',
properties: { strictness: '中' },
schema: [{
key: 'strictness',
label: et('editor.fields.strictness'),
type: 'select',
options: [
{ label: et('common.strictness.low'), value: '低' },
{ label: et('common.strictness.medium'), value: '中' },
{ label: et('common.strictness.high'), value: '高' }
]
}]
},
'qc-icod': {
label: et('editor.palette.icod'),
nodeType: 'qc-icod',
headerColor: '#0f172a',
properties: { peakTolerance: 0.5, maxIterations: 100 },
schema: [
{ key: 'peakTolerance', label: et('editor.fields.peakTolerance'), type: 'number', min: 0, step: 0.1 },
{ key: 'maxIterations', label: et('editor.fields.maxIterations'), type: 'number', min: 1, step: 1 }
]
},
'qc-hotelling': {
label: 'Hotelling T2',
nodeType: 'qc-hotelling',
headerColor: '#0f172a',
properties: { alpha: 0.05 },
schema: [{ key: 'alpha', label: et('editor.fields.alpha'), type: 'number', min: 0, max: 1, step: 0.01 }]
},
'qc-pcmcd': {
label: 'PC-MCD',
nodeType: 'qc-pcmcd',
headerColor: '#0f172a',
properties: { mcdRatio: 0.5, alpha: 0.01 },
schema: [
{ key: 'mcdRatio', label: et('editor.fields.mcdRatio'), type: 'number', min: 0, max: 1, step: 0.01 },
{ key: 'alpha', label: et('editor.fields.alpha'), type: 'number', min: 0, max: 1, step: 0.01 }
]
},
'peak-removal': {
label: et('editor.palette.peakRemoval'),
nodeType: 'peak-removal',
headerColor: '#0f172a',
properties: { device: 'CPU' },
schema: [{
key: 'device',
label: et('editor.fields.device'),
type: 'select',
options: [{ label: 'CPU', value: 'CPU' }, { label: 'GPU', value: 'GPU' }]
}]
},
'smooth-sg': {
label: et('editor.nodes.smoothSg'),
nodeType: 'smooth-sg',
headerColor: '#0f172a',
properties: { differentiation_order: 0, polynomial_order: 5, window_size: 11 },
schema: [
{ key: 'differentiation_order', label: et('editor.fields.differentiationOrder'), type: 'number', min: 0, max: 2, step: 1 },
{ key: 'polynomial_order', label: et('editor.fields.polynomialOrder'), type: 'number', min: 1, max: 9, step: 1 },
{ key: 'window_size', label: et('editor.fields.windowSize'), type: 'number', min: 3, step: 2 }
]
},
'smooth-snv': {
label: et('editor.nodes.smoothSnv'),
nodeType: 'smooth-snv',
headerColor: '#0f172a',
properties: {},
schema: []
},
'base-polyfit': {
label: et('editor.nodes.basePolyfit'),
nodeType: 'base-polyfit',
headerColor: '#0f172a',
properties: { poly_order_fingerprint: 1, poly_order_others: 6 },
schema: [
{ key: 'poly_order_fingerprint', label: et('editor.fields.polyOrderFingerprint'), type: 'number', min: 1, max: 10, step: 1 },
{ key: 'poly_order_others', label: et('editor.fields.polyOrderOthers'), type: 'number', min: 1, max: 10, step: 1 }
]
},
'base-bubble': {
label: et('editor.nodes.baseBubble'),
nodeType: 'base-bubble',
headerColor: '#0f172a',
properties: { min_bubble_width: 50 },
schema: [{ key: 'min_bubble_width', label: et('editor.fields.minBubbleWidth'), type: 'number', min: 1, step: 1 }]
},
'trunc-range': {
label: et('editor.nodes.truncRange'),
nodeType: 'trunc-range',
headerColor: '#0f172a',
properties: { wavenumber_min: 550, wavenumber_max: 1800 },
schema: [
{ key: 'wavenumber_min', label: et('editor.fields.wavenumberMin'), type: 'number', min: 0, step: 1 },
{ key: 'wavenumber_max', label: et('editor.fields.wavenumberMax'), type: 'number', min: 0, step: 1 }
]
},
'norm-method': {
label: et('editor.nodes.normMethod'),
nodeType: 'norm-method',
headerColor: '#0f172a',
properties: { method: 'max', peak_value: 1004 },
schema: [
{
key: 'method',
label: et('editor.fields.normMethod'),
type: 'select',
options: [
{ label: 'max', value: 'max' },
{ label: 'area', value: 'area' },
{ label: 'vector', value: 'vector' },
{ label: 'minmax', value: 'minmax' },
{ label: 'specific', value: 'specific' },
{ label: 'ch', value: 'ch' }
]
},
{ key: 'peak_value', label: et('editor.fields.peakValue'), type: 'number', min: 0, step: 1 }
]
},
// 无元数据分析 - 降维
'dim-pca': {
label: 'PCA',
nodeType: 'dim-pca',
headerColor: '#0f172a',
properties: { n_pc: 2 },
schema: [{ key: 'n_pc', label: et('editor.fields.nPc'), type: 'number', min: 1, step: 1 }]
},
'dim-tsne': {
label: 't-SNE',
nodeType: 'dim-tsne',
headerColor: '#0f172a',
properties: { pca: 20, n_pc: 2, perplexity: 5, theta: 0.5, max_iter: 1000 },
schema: [
{ key: 'pca', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 },
{ key: 'n_pc', label: et('editor.fields.tsnePc'), type: 'number', min: 1, step: 1 },
{ key: 'perplexity', label: et('editor.fields.perplexity'), type: 'number', min: 1, step: 1 },
{ key: 'theta', label: et('editor.fields.theta'), type: 'number', min: 0, max: 1, step: 0.01 },
{ key: 'max_iter', label: et('editor.fields.maxIterations'), type: 'number', min: 1, step: 1 }
]
},
'dim-umap': {
label: 'UMAP',
nodeType: 'dim-umap',
headerColor: '#0f172a',
properties: { pca: 20, n_pc: 2, n_neighbors: 30, min_dist: 0.01, spread: 1 },
schema: [
{ key: 'pca', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 },
{ key: 'n_pc', label: et('editor.fields.umapPc'), type: 'number', min: 1, step: 1 },
{ key: 'n_neighbors', label: et('editor.fields.nNeighbors'), type: 'number', min: 2, step: 1 },
{ key: 'min_dist', label: et('editor.fields.minDist'), type: 'number', min: 0, max: 1, step: 0.001 },
{ key: 'spread', label: et('editor.fields.spread'), type: 'number', min: 0.1, step: 0.1 }
]
},
'dim-pcoa': {
label: 'PCoA',
nodeType: 'dim-pcoa',
headerColor: '#0f172a',
properties: { pca: 20, n_pc: 2, distance: 'euclidean' },
schema: [
{ key: 'pca', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 },
{ key: 'n_pc', label: et('editor.fields.pcoaPc'), type: 'number', min: 1, step: 1 },
{
key: 'distance',
label: et('editor.fields.distanceMethod'),
type: 'select',
options: [
{ label: et('editor.distance.euclidean'), value: 'euclidean' },
{ label: et('editor.distance.maximum'), value: 'maximum' },
{ label: et('editor.distance.manhattan'), value: 'manhattan' },
{ label: et('editor.distance.canberra'), value: 'canberra' },
{ label: et('editor.distance.binary'), value: 'binary' },
{ label: et('editor.distance.minkowski'), value: 'minkowski' }
]
}
]
},
// 无元数据分析 - 表型
'phen-louvain': {
label: et('editor.nodes.phenLouvain'),
nodeType: 'phen-louvain',
headerColor: '#0f172a',
properties: { resolution: 0.4, n_pc: 10, threshold: 0.001, k: 30, n_tree: 50 },
schema: [
{ key: 'resolution', label: et('editor.fields.resolution'), type: 'number', min: 0.01, step: 0.01 },
{ key: 'n_pc', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 },
{ key: 'threshold', label: et('editor.fields.clusterThreshold'), type: 'number', min: 0, step: 0.0001 },
{ key: 'k', label: et('editor.fields.kNeighbors'), type: 'number', min: 1, step: 1 },
{ key: 'n_tree', label: et('editor.fields.nTree'), type: 'number', min: 1, step: 1 }
]
},
'phen-hca': {
label: et('editor.nodes.phenHca'),
nodeType: 'phen-hca',
headerColor: '#0f172a',
properties: {},
schema: []
},
'phen-kmeans': {
label: 'K-means',
nodeType: 'phen-kmeans',
headerColor: '#0f172a',
properties: { k: 4, n_pc: 10 },
schema: [
{ key: 'k', label: et('editor.fields.kClusters'), type: 'number', min: 2, step: 1 },
{ key: 'n_pc', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 }
]
},
// 无元数据分析 - 谱图分解
'decomp-mcrals': {
label: 'MCR-ALS',
nodeType: 'decomp-mcrals',
headerColor: '#0f172a',
properties: { n_comp: 3 },
schema: [{ key: 'n_comp', label: et('editor.fields.nComp'), type: 'number', min: 1, step: 1 }]
},
'decomp-ica': {
label: 'ICA',
nodeType: 'decomp-ica',
headerColor: '#0f172a',
properties: { n_comp: 3 },
schema: [{ key: 'n_comp', label: et('editor.fields.nComp'), type: 'number', min: 1, step: 1 }]
},
'decomp-nmf': {
label: 'NMF',
nodeType: 'decomp-nmf',
headerColor: '#0f172a',
properties: { n_comp: 3, max_iter: 100, tol: 0.0001 },
schema: [
{ key: 'n_comp', label: et('editor.fields.nComp'), type: 'number', min: 1, step: 1 },
{ key: 'max_iter', label: et('editor.fields.maxIterations'), type: 'number', min: 1, step: 10 },
{ key: 'tol', label: et('editor.fields.tol'), type: 'number', min: 0, step: 0.0001 }
]
},
// 无元数据分析 - 拉曼组内分析
'intra-irca': {
label: et('editor.nodes.intraIrca'),
nodeType: 'intra-irca',
headerColor: '#0f172a',
properties: { threshold: 0.6 },
schema: [{ key: 'threshold', label: et('editor.fields.correlationThreshold'), type: 'number', min: 0, max: 1, step: 0.01 }]
},
'intra-irca-local': {
label: et('editor.nodes.intraIrcaLocal'),
nodeType: 'intra-irca-local',
headerColor: '#0f172a',
properties: {
threshold: 0.6,
bands_ann: [
{ wave_num: 742, group: 'Nucleic acid' },
{ wave_num: 850, group: 'Nucleic acid' },
{ wave_num: 872, group: 'Nucleic acid' },
{ wave_num: 971, group: 'Nucleic acid' },
{ wave_num: 997, group: 'Nucleic acid' },
{ wave_num: 1098, group: 'Nucleic acid' },
{ wave_num: 1293, group: 'Nucleic acid' },
{ wave_num: 1328, group: 'Nucleic acid' },
{ wave_num: 1426, group: 'Nucleic acid' },
{ wave_num: 1576, group: 'Nucleic acid' },
{ wave_num: 824, group: 'Protein' },
{ wave_num: 883, group: 'Protein' },
{ wave_num: 1005, group: 'Protein' },
{ wave_num: 1033, group: 'Protein' },
{ wave_num: 1051, group: 'Protein' },
{ wave_num: 1237, group: 'Protein' },
{ wave_num: 1559, group: 'Protein' },
{ wave_num: 1651, group: 'Protein' },
{ wave_num: 1076, group: 'Lipids' },
{ wave_num: 1119, group: 'Lipids' },
{ wave_num: 1370, group: 'Lipids' },
{ wave_num: 2834, group: 'Lipids' },
{ wave_num: 2866, group: 'Lipids' },
{ wave_num: 2912, group: 'Lipids' }
]
},
schema: [
{ key: 'threshold', label: et('editor.fields.correlationThreshold'), type: 'number', min: 0, max: 1, step: 0.01 },
{
key: 'bands_ann',
label: et('editor.fields.bandsAnn'),
type: 'table',
hint: et('editor.fields.bandsAnnHint'),
minRows: 2,
columns: [
{ key: 'wave_num', label: et('editor.fields.bandWaveNum'), type: 'number' },
{ key: 'group', label: et('editor.fields.bandAnnotation'), type: 'text' }
]
}
]
},
'intra-2dcos': {
label: et('editor.nodes.intra2dcos'),
nodeType: 'intra-2dcos',
headerColor: '#0f172a',
properties: {},
schema: []
},
// 基于元数据分析 - 分类
'cls-lda': {
label: 'LDA',
nodeType: 'cls-lda',
headerColor: '#0f172a',
properties: { n_pc: 20 },
schema: [{ key: 'n_pc', label: '主成分数量', type: 'number', min: 1, step: 1 }]
},
'cls-rf': {
label: et('editor.nodes.clsRf'),
nodeType: 'cls-rf',
headerColor: '#0f172a',
properties: { n_estimators: 500, mtry: 2 },
schema: [
{ key: 'n_estimators', label: et('editor.fields.nEstimators'), type: 'number', min: 10, step: 50 },
{ key: 'mtry', label: et('editor.fields.mtry'), type: 'number', min: 1, step: 1 }
]
},
'cls-svm': {
label: 'SVM',
nodeType: 'cls-svm',
headerColor: '#0f172a',
properties: {},
schema: []
},
// 基于元数据分析 - 量化/回归
'quant-pls': {
label: 'PLS',
nodeType: 'quant-pls',
headerColor: '#0f172a',
properties: { n_comp: 10 },
schema: [{ key: 'n_comp', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 }]
},
'quant-mlr': {
label: 'MLR',
nodeType: 'quant-mlr',
headerColor: '#0f172a',
properties: { n_pc: 10 },
schema: [{ key: 'n_pc', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 }]
},
'quant-glm': {
label: 'GLM',
nodeType: 'quant-glm',
headerColor: '#0f172a',
properties: { n_pc: 10 },
schema: [{ key: 'n_pc', label: et('editor.fields.pcaPre'), type: 'number', min: 1, step: 1 }]
},
// 基于元数据分析 - 拉曼标记
'marker-rbcs': {
label: 'RBCS',
nodeType: 'marker-rbcs',
headerColor: '#0f172a',
properties: { threshold: 0.002, n_estimators: 1000, nfolds: 3 },
schema: [
{ key: 'threshold', label: et('editor.fields.importanceThreshold'), type: 'number', min: 0, step: 0.0001 },
{ key: 'n_estimators', label: et('editor.fields.nTree'), type: 'number', min: 10, step: 50 },
{ key: 'nfolds', label: et('editor.fields.nFolds'), type: 'number', min: 2, step: 1 }
]
},
'marker-roc': {
label: et('editor.nodes.markerRoc'),
nodeType: 'marker-roc',
headerColor: '#0f172a',
properties: { threshold: 0.75, batch_size: 1000 },
schema: [
{ key: 'threshold', label: et('editor.fields.aucThreshold'), type: 'number', min: 0.5, max: 1, step: 0.01 },
{ key: 'batch_size', label: et('editor.fields.batchSize'), type: 'number', min: 1, step: 100 }
]
},
'marker-correlations': {
label: et('editor.nodes.markerCorrelations'),
nodeType: 'marker-correlations',
headerColor: '#0f172a',
properties: { min_cor: 0.8, min_range: 30, by_average: false },
schema: [
{ key: 'min_cor', label: et('editor.fields.minCor'), type: 'number', min: 0, max: 1, step: 0.01 },
{ key: 'min_range', label: et('editor.fields.minRange'), type: 'number', min: 1, step: 1 },
{ key: 'by_average', label: et('editor.fields.byAverage'), type: 'checkbox', checkboxText: et('editor.checkbox.enabled') }
]
}
}
return configs[nodeType] ?? null
}
const addNodeFromLibrary = (nodeType: string, position?: { x: number; y: number }) => {
const config = getLibraryConfig(nodeType)
if (!config) return ''
const newNode: WorkflowNode = {
id: `node-${++nodeIdCounter.value}`,
type: 'default',
position: position ?? lastClickPosition.value ?? { x: 420 + (nodes.value.length % 3) * 40, y: 220 + (nodes.value.length % 5) * 40 },
data: {
label: config.label || t('editor.defaultNodeName', { index: nodeIdCounter.value }),
nodeType: config.nodeType || nodeType,
content: '',
properties: config.properties || {},
schema: config.schema,
headerColor: config.headerColor || '#0f172a',
previewUrl: undefined,
previewLoading: false,
paletteCategory: getPaletteCategoryFromNodeType(String(config.nodeType || nodeType))
}
}
nodes.value.push(newNode)
return newNode.id
}
// 将当前画布序列化为可持久化的工作流配置,随分析请求一起交给后端保存
const serializeWorkflow = (): WorkflowConfig => ({
nodes: nodes.value.map(n => ({
id: n.id,
type: String(n.data?.nodeType ?? ''),
position: n.position,
properties: n.data?.properties ?? {},
previewUrl: n.data?.previewUrl,
previewFileName: n.data?.previewFileName
})),
edges: edges.value.map(e => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle
}))
})
// 依据保存的工作流配置重建画布节点/连线(节点样式、schema 等元数据从节点库配置中恢复)
const restoreWorkflow = (config: WorkflowConfig) => {
if (!config || !Array.isArray(config.nodes) || config.nodes.length === 0) return
let maxCounter = nodeIdCounter.value
const restoredNodes: WorkflowNode[] = []
for (const n of config.nodes) {
const libConfig = getLibraryConfig(n.type)
if (!libConfig) continue
restoredNodes.push({
id: n.id,
type: 'default',
position: n.position,
data: {
label: libConfig.label || n.type,
nodeType: libConfig.nodeType || n.type,
content: '',
properties: { ...(libConfig.properties || {}), ...(n.properties || {}) },
schema: libConfig.schema,
headerColor: libConfig.headerColor || '#0f172a',
previewUrl: n.previewUrl || undefined,
previewLoading: false,
previewFileName: n.previewFileName || undefined,
paletteCategory: getPaletteCategoryFromNodeType(String(libConfig.nodeType || n.type))
}
})
const match = /^node-(\d+)$/.exec(n.id)
if (match) maxCounter = Math.max(maxCounter, Number(match[1]))
}
if (restoredNodes.length === 0) return
nodes.value = restoredNodes
edges.value = Array.isArray(config.edges) ? config.edges.map(e => ({ ...e })) : []
nodeIdCounter.value = maxCounter
}
const buildAnalysisPayload = () => {
// 组装:quality_control / spike_removal / smoothing / baseline / truncation / normalization / dim_reduction / phenotype
// spectral_decomposition / intra_ramanome / classification / quantification / raman_markers
const qcMethods: string[] = []
const qcParams: Record<string, any> = {}
const spikeRemoval = { enabled: false, parameters: {} as Record<string, any> }
const smoothingMethods: string[] = []
const smoothingParams: Record<string, any> = {}
const baselineMethods: string[] = []
const baselineParams: Record<string, any> = {}
const truncation = { enabled: false, parameters: {} as Record<string, any> }
const normalization = { method: '', parameters: {} as Record<string, any> }
const dimMethods: string[] = []
const dimParams: Record<string, any> = {}
const phenMethods: string[] = []
const phenParams: Record<string, any> = {}
const decompMethods: string[] = []
const decompParams: Record<string, any> = {}
const intraMethods: string[] = []
const intraParams: Record<string, any> = {}
const clsMethods: string[] = []
const clsParams: Record<string, any> = {}
const quantMethods: string[] = []
const quantParams: Record<string, any> = {}
const markerMethods: string[] = []
const markerParams: Record<string, any> = {}
const byType = (t: string) => nodes.value.find(n => n.data?.nodeType === t)?.data?.properties ?? {}
if (isNodeEnabled('qc-euclidean')) {
qcMethods.push('euclidean')
qcParams.max_distance = byType('qc-euclidean').maxDistance
}
if (isNodeEnabled('qc-snr')) {
qcMethods.push('snr')
const map: Record<string, string> = { '低': 'low', '中': 'medium', '高': 'high' }
qcParams.snr_level = map[String(byType('qc-snr').strictness ?? '中')] ?? 'medium'
}
if (isNodeEnabled('qc-icod')) {
qcMethods.push('icod')
qcParams.var_tol = byType('qc-icod').peakTolerance
qcParams.max_iterations = byType('qc-icod').maxIterations
}
if (isNodeEnabled('qc-hotelling')) {
qcMethods.push('hotelling')
qcParams.signif = byType('qc-hotelling').alpha
}
if (isNodeEnabled('qc-pcmcd')) {
qcMethods.push('pcmcd')
qcParams.fraction_mcd = byType('qc-pcmcd').mcdRatio
qcParams.alpha_mcd = byType('qc-pcmcd').alpha
}
if (isNodeEnabled('peak-removal')) {
spikeRemoval.enabled = true
spikeRemoval.parameters.device = byType('peak-removal').device ?? 'CPU'
}
if (isNodeEnabled('smooth-sg')) {
smoothingMethods.push('savitzky-golay')
smoothingParams['savitzky-golay'] = { ...byType('smooth-sg') }
}
if (isNodeEnabled('smooth-snv')) {
smoothingMethods.push('snv')
smoothingParams.snv = {}
}
if (isNodeEnabled('base-polyfit')) {
baselineMethods.push('polyfit')
baselineParams.polyfit = { ...byType('base-polyfit') }
}
if (isNodeEnabled('base-bubble')) {
baselineMethods.push('bubble')
baselineParams.bubble = { ...byType('base-bubble') }
}
if (isNodeEnabled('trunc-range')) {
truncation.enabled = true
truncation.parameters = { ...byType('trunc-range') }
}
if (isNodeEnabled('norm-method')) {
const p = byType('norm-method')
normalization.method = String(p.method ?? '')
if (normalization.method === 'specific') {
normalization.parameters.peak_value = p.peak_value
}
}
if (isNodeEnabled('dim-pca')) {
dimMethods.push('pca')
dimParams.pca = { n_pc: byType('dim-pca').n_pc }
}
if (isNodeEnabled('dim-tsne')) {
dimMethods.push('tsne')
dimParams.tsne = { ...byType('dim-tsne') }
}
if (isNodeEnabled('dim-umap')) {
dimMethods.push('umap')
dimParams.umap = { ...byType('dim-umap') }
}
if (isNodeEnabled('dim-pcoa')) {
dimMethods.push('pcoa')
dimParams.pcoa = { ...byType('dim-pcoa') }
}
if (isNodeEnabled('phen-louvain')) {
phenMethods.push('louvain')
phenParams.louvain = { ...byType('phen-louvain') }
}
if (isNodeEnabled('phen-hca')) {
phenMethods.push('hca')
phenParams.hca = {}
}
if (isNodeEnabled('phen-kmeans')) {
phenMethods.push('kmeans')
phenParams.kmeans = { ...byType('phen-kmeans') }
}
// 谱图分解
if (isNodeEnabled('decomp-mcrals')) {
decompMethods.push('mcrals')
decompParams.mcrals = { ...byType('decomp-mcrals') }
}
if (isNodeEnabled('decomp-ica')) {
decompMethods.push('ica')
decompParams.ica = { ...byType('decomp-ica') }
}
if (isNodeEnabled('decomp-nmf')) {
decompMethods.push('nmf')
decompParams.nmf = { ...byType('decomp-nmf') }
}
// 拉曼组内分析
if (isNodeEnabled('intra-irca')) {
intraMethods.push('irca')
intraParams.irca = { ...byType('intra-irca') }
}
if (isNodeEnabled('intra-irca-local')) {
intraMethods.push('irca_local')
intraParams.irca_local = { ...byType('intra-irca-local') }
}
if (isNodeEnabled('intra-2dcos')) {
intraMethods.push('2dcos')
intraParams['2dcos'] = {}
}
// 分类分析
if (isNodeEnabled('cls-lda')) {
clsMethods.push('lda')
clsParams.lda = { ...byType('cls-lda') }
}
if (isNodeEnabled('cls-rf')) {
clsMethods.push('rf')
clsParams.rf = { ...byType('cls-rf') }
}
if (isNodeEnabled('cls-svm')) {
clsMethods.push('svm')
clsParams.svm = {}
}
// 回归与量化分析
if (isNodeEnabled('quant-pls')) {
quantMethods.push('pls')
quantParams.pls = { ...byType('quant-pls') }
}
if (isNodeEnabled('quant-mlr')) {
quantMethods.push('mlr')
quantParams.mlr = { ...byType('quant-mlr') }
}
if (isNodeEnabled('quant-glm')) {
quantMethods.push('glm')
quantParams.glm = { ...byType('quant-glm') }
}
// 拉曼标记分析
if (isNodeEnabled('marker-rbcs')) {
markerMethods.push('rbcs')
markerParams.rbcs = { ...byType('marker-rbcs') }
}
if (isNodeEnabled('marker-roc')) {
markerMethods.push('roc')
markerParams.roc = { ...byType('marker-roc') }
}
if (isNodeEnabled('marker-correlations')) {
markerMethods.push('correlations')
markerParams.correlations = { ...byType('marker-correlations') }
}
return {
quality_control: { methods: qcMethods, parameters: qcParams },
spike_removal: spikeRemoval,
smoothing: { methods: smoothingMethods, parameters: smoothingParams },
baseline: { methods: baselineMethods, parameters: baselineParams },
truncation,
normalization,
dim_reduction: { methods: dimMethods, parameters: dimParams },
phenotype: { methods: phenMethods, parameters: phenParams },
spectral_decomposition: { methods: decompMethods, parameters: decompParams },
intra_ramanome: { methods: intraMethods, parameters: intraParams },
classification: { methods: clsMethods, parameters: clsParams },
quantification: { methods: quantMethods, parameters: quantParams },
raman_markers: { methods: markerMethods, parameters: markerParams }
}
}
// 验证连接是否有效(禁止自连接)
const isValidConnection = (connection: any) => {
console.log('isValidConnection 被调用:', connection)
// VueFlow 在 edge 已经创建后也可能用同一校验函数回放校验;
// 这时 connection 会带 id(edge 对象),应直接放行,避免把“自身”误判为重复连接
if (connection && typeof connection === 'object' && 'id' in connection && connection.id) {
return true
}
// 检查源节点和目标节点ID
const source = connection.source || connection.sourceNode?.id
const target = connection.target || connection.targetNode?.id
const sourceHandle = connection.sourceHandle
const targetHandle = connection.targetHandle
console.log('连接参数:', { source, target, sourceHandle, targetHandle })
// 拖拽连线过程中,target 可能还未命中某个 handle;此时不应直接判无效,否则会导致“怎么都连不上”
if (!source || source === '') {
console.log('❌ 连接验证失败: 缺少source', { connection, source })
return false
}
if (!target || target === '') {
return true
}
// 如果源和目标相同,禁止连接
if (source === target) {
console.log('❌ 连接验证失败: 自连接', { source, target })
return false
}
// 检查节点是否存在
const sourceNodeExists = nodes.value.some(node => node.id === source)
const targetNodeExists = nodes.value.some(node => node.id === target)
if (!sourceNodeExists || !targetNodeExists) {
console.log('❌ 连接验证失败: 节点不存在', {
source,
target,
sourceNodeExists,
targetNodeExists,
availableNodes: nodes.value.map(n => n.id)
})
return false
}
// 检查是否已经存在相同的连接(包括相同的handle)
const existingEdge = edges.value.find(
(edge: WorkflowEdge) =>
edge.source === source &&
edge.target === target &&
edge.sourceHandle === sourceHandle &&
edge.targetHandle === targetHandle
)
if (existingEdge) {
console.log('❌ 连接验证失败: 连接已存在', { source, target, sourceHandle, targetHandle, existingEdge })
return false
}
console.log('✅ 连接验证通过:', { source, target, sourceHandle, targetHandle })
return true
}
const onConnect = (params: Connection) => {
// 使用 v-model:edges 时,通常需要在 connect 里手动写入 edge
const source = params?.source
const target = params?.target
if (!source || !target || source === '' || target === '') {
console.warn('❌ 连接失败: 缺少source或target', params)
return
}
console.log('✅ 连接事件触发:', { source, target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle })
const sourceHandle = params.sourceHandle || 'output'
const targetHandle = params.targetHandle || 'input'
// 去重:同 source/target/handles 只保留一条
const exists = edges.value.some(
(e: WorkflowEdge) =>
e.source === source &&
e.target === target &&
(e.sourceHandle || 'output') === sourceHandle &&
(e.targetHandle || 'input') === targetHandle
)
if (exists) return
const id = `e-${source}-${sourceHandle}-${target}-${targetHandle}-${Date.now()}`
edges.value.push({
id,
source,
target,
sourceHandle,
targetHandle
})
}
const onNodeClick = (event: any) => {
// 检查是否点击了删除按钮
const target = event.event?.target as HTMLElement
const deleteBtn = target?.closest('button[title="删除"]') || target?.closest('.header-delete-btn')
if (deleteBtn) {
handleDeleteNode(event.node.id)
return
}
}
const onNodesChange = (changes: any[]) => {
// 处理节点变化(包括删除)
console.log('onNodesChange called, changes:', changes)
console.log('Current nodes:', nodes.value)
changes.forEach(change => {
if (change.type === 'remove') {
// 节点被删除,清除相关边
edges.value = edges.value.filter(
edge => edge.source !== change.id && edge.target !== change.id
)
}
})
}
const onVueFlowInit = () => {
console.log('Vue Flow init event fired')
}
const onVueFlowReady = (flowInstance: any) => {
// Vue Flow 已经准备好
vueFlowReady.value = true
console.log('Vue Flow ready, nodes:', nodes.value)
console.log('Vue Flow instance:', flowInstance)
console.log('Node types:', nodeTypes.value)
console.log('节点数量:', nodes.value.length)
// 尝试自动适应视图
if (flowInstance && flowInstance.fitView) {
nextTick(() => {
flowInstance.fitView({ padding: 0.2 })
})
}
}
const handleDeleteNode = (nodeId: string) => {
if (confirm(t('editor.deleteConfirm'))) {
// 删除相关的边
edges.value = edges.value.filter(
edge => edge.source !== nodeId && edge.target !== nodeId
)
// 删除节点
nodes.value = nodes.value.filter(node => node.id !== nodeId)
}
}
// 旧工作流配置导出逻辑已不使用(当前以分析参数为主)
// 执行工作流(传给后端)
const executeWorkflow = async () => {
if (!projectId.value) {
alert(t('common.errors.missingProjectIdEditor'))
return
}
isExecuting.value = true
setLeafNodesLoading(true)
try {
const payload = buildAnalysisPayload()
// 先拉取一次已有分析结果(用于复用旧图/旧状态),不阻塞后续触发分析
try {
const resultsResp: any = await getProjectAnalysisResults(projectId.value)
updateResultNodeFromResp(resultsResp)
} catch (e) {
// results 接口可能在“尚无结果”时返回错误;此处忽略,继续触发解析
console.warn('analysis/results 拉取失败,继续触发解析', e)
}
// 再触发解析接口,同时把当前画布的工作流结构一并保存,便于日后重新打开还原
const resp: any = await runProjectAnalysis(projectId.value, payload, serializeWorkflow())
updateResultNodeFromResp(resp)
} catch (e) {
alert(e instanceof ApiError ? e.message : t('common.errors.runFailed'))
} finally {
setLeafNodesLoading(false)
isExecuting.value = false
}
}
// 打开编辑器时尝试恢复该项目最近一次的分析结果与画布工作流,
// 避免退出面板后就再也看不到已完成的结果和当时搭建的流程
const loadExistingAnalysisState = async () => {
const pid = projectId.value
if (!pid) return
try {
const resp: any = await getProjectAnalysisResults(pid)
const result = resp?.result ?? {}
if (nodes.value.length === 0 && result?.workflow) {
restoreWorkflow(result.workflow as WorkflowConfig)
}
const hasPlots = result?.plots && Object.keys(result.plots).length > 0
const hasAnalyses = Boolean(result?.analyses && Object.keys(result.analyses).length > 0)
if (hasPlots || hasAnalyses) {
updateResultNodeFromResp(resp)
}
} catch (e) {
console.warn('恢复历史分析结果失败', e)
}
}
onMounted(() => {
void loadActiveDataFileName()
void loadExistingAnalysisState()
console.log('onMounted - 节点数量:', nodes.value.length)
console.log('onMounted - 节点列表:', nodes.value)
console.log('onMounted - nodeTypes:', nodeTypes.value)
})
watch(projectId, () => {
void loadActiveDataFileName()
})
</script>
<style scoped>
.node-editor-wrapper {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
flex: 1;
overflow: hidden;
}
.node-editor {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: #f8fafc;
flex: 1;
overflow: hidden;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.editor-title {
font-size: 1.25rem;
font-weight: 700;
color: #2d3748;
margin: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 2px;
color: #4a5568;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
}
.toolbar-btn:hover {
background: #f7fafc;
border-color: #cbd5e0;
}
.toolbar-btn.primary {
background: #1a1a1a;
color: white;
border: none;
}
.toolbar-btn.primary:hover:not(:disabled) {
transform: translateY(-1px);
background: #1d4ed8;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-btn svg {
width: 16px;
height: 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.editor-main {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
}
/* 左侧节点面板 */
.node-palette {
width: 280px;
background: #ffffff;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.palette-header {
padding: 0.85rem 1rem 0.65rem;
border-bottom: 1px solid #e5e7eb;
background: #f8fafc;
}
.palette-flow-title {
font-size: 0.7rem;
font-weight: 700;
color: #64748b;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 0.45rem;
}
.palette-flow-steps {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.flow-step {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.flow-step:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.flow-step.active {
background: #1a1a1a;
border-color: #1d4ed8;
color: #fff;
}
.flow-step-indicator {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #cbd5e1;
flex-shrink: 0;
position: relative;
}
.flow-step.active .flow-step-indicator {
border-color: rgba(255, 255, 255, 0.95);
}
.flow-step.active .flow-step-indicator::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: #fff;
}
.flow-step-num {
flex: 0 0 auto;
min-width: 1rem;
font-weight: 700;
font-size: 0.72rem;
opacity: 0.85;
}
.flow-step.active .flow-step-num {
opacity: 1;
}
.flow-step-label {
flex: 1;
min-width: 0;
line-height: 1.25;
}
.palette-modules-head {
padding-top: 0.35rem;
border-top: 1px solid #e2e8f0;
margin-top: 0.15rem;
}
.palette-modules-title {
font-size: 0.7rem;
font-weight: 700;
color: #64748b;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.palette-header-top {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 0.6rem;
}
.palette-back-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid rgba(226, 232, 240, 1);
background: #ffffff;
color: #0f172a;
padding: 0.35rem 0.5rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.75rem;
line-height: 1;
font-weight: 600;
}
.palette-back-btn:hover {
background: #f8fafc;
}
.tree-empty {
padding: 0.75rem 0.65rem;
color: rgba(226, 232, 240, 0.75);
font-size: 0.9rem;
line-height: 1.5;
}
.palette-header h3 {
font-size: 1rem;
font-weight: 700;
color: #111827;
margin: 0 0 0.75rem 0;
}
.palette-search {
width: 100%;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 2px;
font-size: 0.875rem;
color: #374151;
background: #ffffff;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: #1a1a1a;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.tree-sub-head {
width: 100%;
background: transparent;
border: none;
color: inherit;
padding: 0.5rem 0.45rem;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 650;
color: #111827;
}
.tree-cat-icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-cat-icon :deep(svg) {
display: block;
}
.tree-sub-head-label {
flex: 1;
min-width: 0;
text-align: left;
}
.tree-sub-head:hover {
background: rgba(15, 23, 42, 0.04);
}
.caret {
display: inline-block;
transform: rotate(0deg);
transition: transform 0.12s ease;
color: rgba(15, 23, 42, 0.55);
}
.caret.open {
transform: rotate(90deg);
}
.tree-leaves {
padding: 0.15rem 0.2rem 0.65rem 1.65rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.tree-leaf-btn {
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #334155;
padding: 0.28rem 0.2rem 0.28rem 0.15rem;
border-radius: 2px;
cursor: pointer;
font-size: 0.72rem;
line-height: 1.35;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.35rem;
}
.tree-leaf-btn:hover {
background: rgba(15, 23, 42, 0.05);
color: #0f172a;
}
.palette-content {
flex: 1;
/* 强制常驻滚动条(避免 macOS 叠加滚动条看起来“消失”) */
overflow-y: scroll;
scrollbar-gutter: stable;
padding: 0.75rem;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.9) rgba(241, 245, 249, 1);
}
/* WebKit 滚动条(Chrome/Safari) */
.palette-content::-webkit-scrollbar {
width: 10px;
}
.palette-content::-webkit-scrollbar-track {
background: rgba(241, 245, 249, 1);
}
.palette-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.9);
border-radius: 2px;
border: 2px solid rgba(241, 245, 249, 1);
}
.palette-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.95);
}
.palette-category {
margin-bottom: 1.5rem;
}
.palette-category:last-child {
margin-bottom: 0;
}
.category-title {
font-size: 0.75rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.75rem;
padding: 0 0.5rem;
}
.category-nodes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.palette-node-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 2px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.palette-node-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--node-color, #1a1a1a);
transform: scaleY(0);
transition: transform 0.2s ease;
}
.palette-node-item:hover {
background: #f9fafb;
border-color: var(--node-color, #1a1a1a);
transform: translateX(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.palette-node-item:hover::before {
transform: scaleY(1);
}
.node-item-icon {
width: 40px;
height: 40px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.node-item-info {
flex: 1;
min-width: 0;
}
.node-item-label {
font-size: 0.9rem;
font-weight: 600;
color: #111827;
margin-bottom: 0.25rem;
}
.node-item-desc {
font-size: 0.75rem;
color: #6b7280;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.editor-container {
flex: 1;
min-height: 400px; /* 确保有最小高度 */
position: relative;
background: #f9fafb;
width: 100%;
height: 100%;
overflow: hidden;
min-width: 0;
}
/* 确保 VueFlow 根元素有高度 */
.editor-container > :deep(.vue-flow) {
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
}
.floating-toolbar {
position: absolute;
top: 20px;
left: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #374151;
font-size: 0.875rem;
font-weight: 500;
min-width: 140px;
}
.toolbar-item:hover {
background: #f9fafb;
border-color: #1a1a1a;
color: #1a1a1a;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.2), 0 4px 8px rgba(0, 0, 0, 0.08);
}
.toolbar-item svg {
width: 20px;
height: 20px;
color: #1a1a1a;
flex-shrink: 0;
}
.toolbar-item:hover svg {
color: #1a1a1a;
}
.context-menu {
position: absolute;
background: #ffffff;
border-radius: 4px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 0.5rem;
z-index: 1000;
min-width: 260px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid #e5e7eb;
backdrop-filter: blur(10px);
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e0 #f1f5f9;
}
/* Webkit浏览器滚动条样式 */
.context-menu::-webkit-scrollbar {
width: 8px;
}
.context-menu::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.context-menu::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
transition: background 0.2s ease;
}
.context-menu::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.menu-category {
margin-bottom: 0.5rem;
}
.menu-category:last-child {
margin-bottom: 0;
}
.category-header {
padding: 0.625rem 0.875rem;
font-size: 0.7rem;
font-weight: 700;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.8px;
background: #f9fafb;
margin: 0.25rem 0;
border-radius: 2px;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #374151;
margin: 0.125rem 0;
}
.menu-item:hover {
background: #f3f4f6;
color: #1a1a1a;
transform: translateX(2px);
}
.menu-item-icon {
width: 36px;
height: 36px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 0.95rem;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.menu-item-info {
flex: 1;
min-width: 0;
}
.menu-item-label {
font-size: 0.9rem;
font-weight: 500;
color: #2d3748;
}
.menu-item-desc {
font-size: 0.75rem;
color: #718096;
margin-top: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<style>
/* 确保 VueFlow 容器有正确的高度 - 使用全局选择器 */
.editor-container .vue-flow,
.editor-container > div[class*="vue-flow"] {
width: 100% !important;
height: 100% !important;
min-height: 0 !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
}
.editor-container .vue-flow__viewport,
.editor-container [class*="viewport"] {
width: 100% !important;
height: 100% !important;
}
.editor-container .vue-flow__container,
.editor-container [class*="container"] {
width: 100% !important;
height: 100% !important;
}
/* 连接线 - 实线 */
.vue-flow__edge-path {
stroke: #1a1a1a;
stroke-width: 2;
stroke-dasharray: none;
transition: stroke-width 0.2s ease, stroke 0.2s ease;
}
.vue-flow__edge:hover .vue-flow__edge-path {
stroke-width: 3;
stroke: #5568d3;
}
.vue-flow__edge.selected .vue-flow__edge-path {
stroke: #5568d3;
stroke-width: 3;
}
/* 全局样式:确保节点按钮可见 */
.vue-flow__node .node-footer,
.vue-flow__node .footer-btn,
.vue-flow__node .node-actions,
.vue-flow__node .action-btn {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
.vue-flow__node .node-footer {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
.vue-flow__node:hover .node-footer {
opacity: 1 !important;
display: flex !important;
}
.vue-flow__node.selected .node-footer {
opacity: 1 !important;
display: flex !important;
}
/* 确保按钮不被隐藏 */
.vue-flow__node button.footer-btn,
.vue-flow__node button.action-btn {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
/* 确保节点内容不被裁剪 */
.vue-flow__node {
overflow: visible !important;
outline: none !important;
/* 去掉 VueFlow 默认节点外框(连接/选中时会出现黑框) */
border: none !important;
background: transparent !important;
}
/* 确保连接点可见且可交互 */
.vue-flow__handle {
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
z-index: 1000 !important;
width: 12px !important;
height: 12px !important;
border: 2px solid white !important;
border-radius: 50% !important;
cursor: pointer !important;
position: absolute !important;
}
.vue-flow__pane {
cursor: default !important;
}
.vue-flow__node {
cursor: grab !important;
}
/* 输入连接点(绿色) */
.vue-flow__handle[data-handlepos="left"],
.vue-flow__handle.node-handle-input {
background: #10b981 !important;
}
/* 输出连接点(橙色) */
.vue-flow__handle[data-handlepos="right"],
.vue-flow__handle.node-handle-output {
background: #3b82f6 !important;
}
.vue-flow__handle:hover {
transform: scale(1.3) !important;
box-shadow: 0 0 0 6px rgba(102, 126, 234, 0.15) !important;
z-index: 1001 !important;
}
.vue-flow__node:hover .vue-flow__handle {
opacity: 1 !important;
visibility: visible !important;
}
/* 确保节点容器不裁剪连接点 */
.vue-flow__node .workflow-node-component,
.vue-flow__node .result-node-component {
overflow: visible !important;
}
/* 确保连接线可见 - 实线 */
.vue-flow__edge {
stroke: #1a1a1a !important;
stroke-width: 2 !important;
stroke-dasharray: none !important;
z-index: 100 !important;
}
.vue-flow__edge-path {
stroke: #1a1a1a !important;
stroke-width: 2 !important;
stroke-dasharray: none !important;
}
/* 移除Vue Flow默认的选中边框 */
.vue-flow__node.selected {
outline: none !important;
box-shadow: none !important;
}
/* 某些主题会给 default 节点包一层边框 */
.vue-flow__node-default,
.vue-flow__node-input,
.vue-flow__node-output {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.vue-flow__node.selected .workflow-node-component {
outline: none !important;
}
/* 强制显示操作按钮 */
.vue-flow__node .workflow-node-component .node-footer {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
.vue-flow__node .workflow-node-component .node-actions {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
/* 确保按钮可见 */
.vue-flow__node button.footer-btn,
.vue-flow__node button.action-btn {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
}
</style>