Spaces:
Sleeping
Sleeping
| <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% ; | |
| height: 100% ; | |
| min-height: 0 ; | |
| position: absolute ; | |
| top: 0 ; | |
| left: 0 ; | |
| right: 0 ; | |
| bottom: 0 ; | |
| } | |
| .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% ; | |
| height: 100% ; | |
| min-height: 0 ; | |
| position: absolute ; | |
| top: 0 ; | |
| left: 0 ; | |
| right: 0 ; | |
| bottom: 0 ; | |
| } | |
| .editor-container .vue-flow__viewport, | |
| .editor-container [class*="viewport"] { | |
| width: 100% ; | |
| height: 100% ; | |
| } | |
| .editor-container .vue-flow__container, | |
| .editor-container [class*="container"] { | |
| width: 100% ; | |
| height: 100% ; | |
| } | |
| /* 连接线 - 实线 */ | |
| .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 ; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| pointer-events: auto ; | |
| } | |
| .vue-flow__node .node-footer { | |
| display: flex ; | |
| opacity: 1 ; | |
| visibility: visible ; | |
| } | |
| .vue-flow__node:hover .node-footer { | |
| opacity: 1 ; | |
| display: flex ; | |
| } | |
| .vue-flow__node.selected .node-footer { | |
| opacity: 1 ; | |
| display: flex ; | |
| } | |
| /* 确保按钮不被隐藏 */ | |
| .vue-flow__node button.footer-btn, | |
| .vue-flow__node button.action-btn { | |
| display: flex ; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| pointer-events: auto ; | |
| } | |
| /* 确保节点内容不被裁剪 */ | |
| .vue-flow__node { | |
| overflow: visible ; | |
| outline: none ; | |
| /* 去掉 VueFlow 默认节点外框(连接/选中时会出现黑框) */ | |
| border: none ; | |
| background: transparent ; | |
| } | |
| /* 确保连接点可见且可交互 */ | |
| .vue-flow__handle { | |
| visibility: visible ; | |
| opacity: 1 ; | |
| pointer-events: auto ; | |
| z-index: 1000 ; | |
| width: 12px ; | |
| height: 12px ; | |
| border: 2px solid white ; | |
| border-radius: 50% ; | |
| cursor: pointer ; | |
| position: absolute ; | |
| } | |
| .vue-flow__pane { | |
| cursor: default ; | |
| } | |
| .vue-flow__node { | |
| cursor: grab ; | |
| } | |
| /* 输入连接点(绿色) */ | |
| .vue-flow__handle[data-handlepos="left"], | |
| .vue-flow__handle.node-handle-input { | |
| background: #10b981 ; | |
| } | |
| /* 输出连接点(橙色) */ | |
| .vue-flow__handle[data-handlepos="right"], | |
| .vue-flow__handle.node-handle-output { | |
| background: #3b82f6 ; | |
| } | |
| .vue-flow__handle:hover { | |
| transform: scale(1.3) ; | |
| box-shadow: 0 0 0 6px rgba(102, 126, 234, 0.15) ; | |
| z-index: 1001 ; | |
| } | |
| .vue-flow__node:hover .vue-flow__handle { | |
| opacity: 1 ; | |
| visibility: visible ; | |
| } | |
| /* 确保节点容器不裁剪连接点 */ | |
| .vue-flow__node .workflow-node-component, | |
| .vue-flow__node .result-node-component { | |
| overflow: visible ; | |
| } | |
| /* 确保连接线可见 - 实线 */ | |
| .vue-flow__edge { | |
| stroke: #1a1a1a ; | |
| stroke-width: 2 ; | |
| stroke-dasharray: none ; | |
| z-index: 100 ; | |
| } | |
| .vue-flow__edge-path { | |
| stroke: #1a1a1a ; | |
| stroke-width: 2 ; | |
| stroke-dasharray: none ; | |
| } | |
| /* 移除Vue Flow默认的选中边框 */ | |
| .vue-flow__node.selected { | |
| outline: none ; | |
| box-shadow: none ; | |
| } | |
| /* 某些主题会给 default 节点包一层边框 */ | |
| .vue-flow__node-default, | |
| .vue-flow__node-input, | |
| .vue-flow__node-output { | |
| border: none ; | |
| background: transparent ; | |
| box-shadow: none ; | |
| } | |
| .vue-flow__node.selected .workflow-node-component { | |
| outline: none ; | |
| } | |
| /* 强制显示操作按钮 */ | |
| .vue-flow__node .workflow-node-component .node-footer { | |
| display: flex ; | |
| opacity: 1 ; | |
| visibility: visible ; | |
| } | |
| .vue-flow__node .workflow-node-component .node-actions { | |
| display: flex ; | |
| opacity: 1 ; | |
| visibility: visible ; | |
| } | |
| /* 确保按钮可见 */ | |
| .vue-flow__node button.footer-btn, | |
| .vue-flow__node button.action-btn { | |
| display: inline-flex ; | |
| visibility: visible ; | |
| opacity: 1 ; | |
| pointer-events: auto ; | |
| } | |
| </style> | |