Spaces:
Sleeping
Sleeping
| <template> | |
| <div | |
| class="workflow-node-component" | |
| :class="{ | |
| selected: isSelected, | |
| 'workflow-node--preview-right': hasPreview && previewRight && !useModernHeader, | |
| 'workflow-node--split': useSplitLayout, | |
| 'workflow-node--ramex': useModernHeader | |
| }" | |
| > | |
| <!-- 输入连接点 --> | |
| <Handle | |
| v-if="nodeCategory !== 'input'" | |
| id="input" | |
| type="target" | |
| :position="Position.Left" | |
| class="node-handle node-handle-input" | |
| /> | |
| <!-- RamEx 参数节点:参考 Chart 编辑器 — 左侧黑底标题条 + 右侧预览文件名 --> | |
| <div v-if="useModernHeader" class="node-chrome-header"> | |
| <div class="node-chrome-header__left"> | |
| <div class="node-type-pill"> | |
| <span class="node-type-pill__icon" aria-hidden="true" v-html="paletteIconSvg(ramexCategory)"></span> | |
| <span class="node-type-pill__label">{{ data.label || t('common.node') }}</span> | |
| </div> | |
| </div> | |
| <div class="node-chrome-header__right"> | |
| <!-- <span v-if="hasPreview && previewFileLabel" class="node-preview-filename" :title="previewFileLabel"> | |
| {{ previewFileLabel }} | |
| </span> --> | |
| <button class="delete-btn header-delete-btn delete-btn--chrome" :title="t('common.delete')" @click="handleDelete"> | |
| <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 其他演示节点:保留原彩色 header --> | |
| <div v-else class="node-header" :style="{ background: getHeaderColor() }"> | |
| <div class="header-left"> | |
| <div class="header-icon" v-html="getNodeIcon()"></div> | |
| <span class="header-title">{{ data.label || t('common.node') }}</span> | |
| </div> | |
| <div class="header-right"> | |
| <span v-if="data.status === 'ready'" class="ready-badge">Ready</span> | |
| <button class="delete-btn header-delete-btn" :title="t('common.delete')" @click="handleDelete"> | |
| <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| <button v-if="data.showSettings" class="settings-btn" @click.stop="handleSettings"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/> | |
| <path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 节点内容 --> | |
| <div | |
| class="node-body" | |
| :class="{ | |
| 'node-body--split': useSplitLayout, | |
| 'node-body--preview-right': hasPreview && previewRight && !useModernHeader, | |
| 'node-body--ramex': useModernHeader | |
| }" | |
| > | |
| <!-- 演示节点保留标签页;RamEx 节点不显示 Type/Data/Options --> | |
| <div v-if="!useModernHeader && data.tabs && data.tabs.length > 0" class="node-tabs"> | |
| <button | |
| v-for="tab in data.tabs" | |
| :key="tab.id" | |
| class="tab-btn" | |
| :class="{ active: activeTab === tab.id }" | |
| @click.stop="activeTab = tab.id" | |
| > | |
| {{ tab.label }} | |
| </button> | |
| </div> | |
| <!-- 配置区(左侧参数) --> | |
| <div class="node-config" :class="{ 'node-config--modern': useModernHeader }"> | |
| <!-- 输入数据类节点 --> | |
| <template v-if="nodeCategory === 'input'"> | |
| <div class="field-group"> | |
| <label class="field-label">FILE NAME:</label> | |
| <div class="file-input-wrapper"> | |
| <svg class="file-icon" viewBox="0 0 24 24" fill="none"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2"/> | |
| <polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| <span class="file-name">{{ data.properties?.fileName || 'sales_data_2023.csv' }}</span> | |
| </div> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">DELIMITER:</label> | |
| <select v-model="data.properties.delimiter" class="select-input" @change="updateProperty('delimiter', data.properties.delimiter)"> | |
| <option value="comma">Comma (,)</option> | |
| <option value="semicolon">Semicolon (;)</option> | |
| <option value="tab">Tab</option> | |
| </select> | |
| </div> | |
| </template> | |
| <!-- 算法类节点 - Missing Values --> | |
| <template v-else-if="data.nodeType === 'missing-values'"> | |
| <div v-if="activeTab === 'settings'" class="tab-content"> | |
| <div class="field-group"> | |
| <label class="field-label">STRATEGY:</label> | |
| <div class="strategy-buttons"> | |
| <button | |
| v-for="strategy in strategies" | |
| :key="strategy.value" | |
| class="strategy-btn" | |
| :class="{ active: data.properties?.strategy === strategy.value }" | |
| @click.stop="updateProperty('strategy', strategy.value)" | |
| > | |
| <span v-html="strategy.icon" class="strategy-icon"></span> | |
| <span>{{ strategy.label }}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">TARGET COLUMNS:</label> | |
| <select v-model="data.properties.targetColumns" class="select-input" @change="updateProperty('targetColumns', data.properties.targetColumns)"> | |
| <option value="all-numeric">All Numeric</option> | |
| <option value="all">All</option> | |
| <option value="selected">Selected</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div v-else class="tab-content"> | |
| <div class="field-group"> | |
| <label class="field-label">Advanced Settings:</label> | |
| <p class="field-hint">{{ t('editor.advancedHint') }}</p> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- 算法类节点 - K-Means --> | |
| <template v-else-if="data.nodeType === 'kmeans'"> | |
| <div class="field-group"> | |
| <label class="field-label">No. of Clusters (K):</label> | |
| <input | |
| type="number" | |
| v-model.number="data.properties.k" | |
| class="number-input" | |
| min="1" | |
| @input="updateProperty('k', data.properties.k)" | |
| /> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Max Iterations:</label> | |
| <div class="slider-wrapper"> | |
| <input | |
| type="range" | |
| v-model.number="data.properties.maxIterations" | |
| class="slider-input" | |
| min="1" | |
| max="1000" | |
| step="1" | |
| @input="updateProperty('maxIterations', data.properties.maxIterations)" | |
| /> | |
| <span class="slider-value">{{ data.properties.maxIterations || 300 }}</span> | |
| </div> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">FEATURES:</label> | |
| <div class="features-list"> | |
| <div | |
| v-for="(feature, index) in data.properties.features" | |
| :key="index" | |
| class="feature-item" | |
| > | |
| <span>{{ feature }}</span> | |
| <button class="remove-feature-btn" @click.stop="removeFeature(index)"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <button class="add-feature-btn" @click.stop="addFeature"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| + Add | |
| </button> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- 显示类节点 - Distribution --> | |
| <template v-else-if="nodeCategory === 'display'"> | |
| <div class="field-group"> | |
| <label class="field-label">TARGET:</label> | |
| <select v-model="data.properties.target" class="select-input" @change="updateProperty('target', data.properties.target)"> | |
| <option value="# Age_Distribution"># Age_Distribution</option> | |
| </select> | |
| </div> | |
| <div class="field-group"> | |
| <label class="checkbox-label"> | |
| <input | |
| type="checkbox" | |
| v-model="data.properties.normalizeHistogram" | |
| class="checkbox-input" | |
| @change="updateProperty('normalizeHistogram', data.properties.normalizeHistogram)" | |
| /> | |
| <span>Normalize Histogram</span> | |
| </label> | |
| </div> | |
| </template> | |
| <!-- 通用表单渲染(用于预处理/分析参数节点) --> | |
| <template v-else-if="data.schema && data.schema.length"> | |
| <div class="schema-form" :class="{ 'schema-form--modern': useModernHeader }"> | |
| <section v-if="useModernHeader" class="config-section"> | |
| <h4 class="config-section-title">{{ t('editor.params') }}</h4> | |
| <div | |
| v-for="field in data.schema" | |
| :key="field.key" | |
| class="config-row" | |
| > | |
| <label class="config-row-label">{{ field.label }}</label> | |
| <div class="config-row-control"> | |
| <input | |
| v-if="field.type === 'number'" | |
| type="number" | |
| class="config-input config-input--compact" | |
| :min="field.min" | |
| :max="field.max" | |
| :step="field.step ?? 1" | |
| v-model.number="data.properties[field.key]" | |
| @input="updateProperty(field.key, data.properties[field.key])" | |
| /> | |
| <select | |
| v-else-if="field.type === 'select'" | |
| class="config-input" | |
| v-model="data.properties[field.key]" | |
| @change="updateProperty(field.key, data.properties[field.key])" | |
| > | |
| <option | |
| v-for="opt in field.options ?? []" | |
| :key="String(opt.value)" | |
| :value="opt.value" | |
| > | |
| {{ opt.label }} | |
| </option> | |
| </select> | |
| <label v-else-if="field.type === 'checkbox'" class="config-checkbox"> | |
| <input | |
| type="checkbox" | |
| class="checkbox-input" | |
| v-model="data.properties[field.key]" | |
| @change="updateProperty(field.key, data.properties[field.key])" | |
| /> | |
| <span>{{ field.checkboxText ?? t('common.enabled') }}</span> | |
| </label> | |
| <div v-else-if="field.type === 'table'" class="config-table nodrag"> | |
| <table class="table-input"> | |
| <thead> | |
| <tr> | |
| <th v-for="col in field.columns ?? []" :key="col.key">{{ col.label }}</th> | |
| <th class="table-input-action-col"></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr v-for="(row, rowIndex) in (data.properties[field.key] ?? [])" :key="rowIndex"> | |
| <td v-for="col in field.columns ?? []" :key="col.key"> | |
| <input | |
| :type="col.type === 'number' ? 'number' : 'text'" | |
| class="config-input" | |
| :value="row[col.key]" | |
| @input="updateTableCell(field, rowIndex, col, ($event.target as HTMLInputElement).value)" | |
| /> | |
| </td> | |
| <td class="table-input-action-col"> | |
| <button type="button" class="table-row-remove" :disabled="typeof field.minRows === 'number' && (data.properties[field.key] ?? []).length <= field.minRows" @click="removeTableRow(field, rowIndex)">×</button> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <button type="button" class="table-row-add" @click="addTableRow(field)">+ {{ t('editor.addRow') }}</button> | |
| </div> | |
| <p v-if="field.hint" class="field-hint">{{ field.hint }}</p> | |
| </div> | |
| </div> | |
| </section> | |
| <template v-else> | |
| <div | |
| v-for="field in data.schema" | |
| :key="field.key" | |
| class="field-group" | |
| > | |
| <label class="field-label">{{ field.label }}</label> | |
| <input | |
| v-if="field.type === 'number'" | |
| type="number" | |
| class="number-input" | |
| :min="field.min" | |
| :max="field.max" | |
| :step="field.step ?? 1" | |
| v-model.number="data.properties[field.key]" | |
| @input="updateProperty(field.key, data.properties[field.key])" | |
| /> | |
| <select | |
| v-else-if="field.type === 'select'" | |
| class="select-input" | |
| v-model="data.properties[field.key]" | |
| @change="updateProperty(field.key, data.properties[field.key])" | |
| > | |
| <option | |
| v-for="opt in field.options ?? []" | |
| :key="String(opt.value)" | |
| :value="opt.value" | |
| > | |
| {{ opt.label }} | |
| </option> | |
| </select> | |
| <label v-else-if="field.type === 'checkbox'" class="checkbox-label"> | |
| <input | |
| type="checkbox" | |
| class="checkbox-input" | |
| v-model="data.properties[field.key]" | |
| @change="updateProperty(field.key, data.properties[field.key])" | |
| /> | |
| <span>{{ field.checkboxText ?? '启用' }}</span> | |
| </label> | |
| <div v-else-if="field.type === 'table'" class="config-table nodrag"> | |
| <table class="table-input"> | |
| <thead> | |
| <tr> | |
| <th v-for="col in field.columns ?? []" :key="col.key">{{ col.label }}</th> | |
| <th class="table-input-action-col"></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr v-for="(row, rowIndex) in (data.properties[field.key] ?? [])" :key="rowIndex"> | |
| <td v-for="col in field.columns ?? []" :key="col.key"> | |
| <input | |
| :type="col.type === 'number' ? 'number' : 'text'" | |
| class="config-input" | |
| :value="row[col.key]" | |
| @input="updateTableCell(field, rowIndex, col, ($event.target as HTMLInputElement).value)" | |
| /> | |
| </td> | |
| <td class="table-input-action-col"> | |
| <button type="button" class="table-row-remove" :disabled="typeof field.minRows === 'number' && (data.properties[field.key] ?? []).length <= field.minRows" @click="removeTableRow(field, rowIndex)">×</button> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <button type="button" class="table-row-add" @click="addTableRow(field)">+ {{ t('editor.addRow') }}</button> | |
| </div> | |
| <p v-if="field.hint" class="field-hint">{{ field.hint }}</p> | |
| </div> | |
| </template> | |
| </div> | |
| </template> | |
| </div> | |
| <!-- 节点内结果预览(右侧图表区) --> | |
| <div v-if="hasPreview" class="node-preview" :class="{ 'node-preview--panel': useSplitLayout }"> | |
| <div v-if="data.previewLoading" class="node-preview-loading">{{ t('editor.previewLoading') }}</div> | |
| <img | |
| v-else-if="data.previewUrl" | |
| class="node-preview-img" | |
| :src="data.previewUrl" | |
| :alt="t('editor.previewAlt', { label: data.label || t('common.node') })" | |
| loading="lazy" | |
| /> | |
| <div v-else class="node-preview-skeleton" aria-hidden="true"> | |
| <div class="node-preview-skeleton-shimmer"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 输出连接点 --> | |
| <template v-if="data.outputs && data.outputs.length > 1"> | |
| <Handle | |
| v-for="(output, index) in data.outputs" | |
| :key="output.id || index" | |
| :id="output.id || `output-${index}`" | |
| type="source" | |
| :position="Position.Right" | |
| :style="{ top: `${36 + index * 30}px` }" | |
| class="node-handle node-handle-output" | |
| /> | |
| </template> | |
| <Handle | |
| v-else-if="nodeCategory !== 'display'" | |
| id="output" | |
| type="source" | |
| :position="Position.Right" | |
| class="node-handle node-handle-output" | |
| /> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { Handle, Position } from '@vue-flow/core' | |
| const { t } = useI18n() | |
| import type { NodeProps } from '@vue-flow/core' | |
| import { paletteIconSvg, getPaletteCategoryFromNodeType, hasInlinePreviewCategory } from '../utils/paletteIcons' | |
| interface NodeData { | |
| label: string | |
| nodeType?: 'local-csv' | 'missing-values' | 'distribution' | 'kmeans' | string | |
| properties: Record<string, any> | |
| 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 }> | |
| outputs?: Array<{ id: string; label?: string }> | |
| previewUrl?: string | |
| previewLoading?: boolean | |
| previewFileName?: string | |
| paletteCategory?: string | |
| } | |
| interface Props extends NodeProps { | |
| data: NodeData | |
| } | |
| const props = defineProps<Props>() | |
| const emit = defineEmits<{ | |
| delete: [id: string] | |
| settings: [id: string] | |
| update: [data: Partial<NodeData>] | |
| }>() | |
| const isSelected = computed(() => props.selected || false) | |
| const activeTab = ref(props.data.tabs?.[0]?.id || '') | |
| const ramexCategory = computed(() => { | |
| const fromData = String(props.data.paletteCategory ?? '').trim() | |
| if (fromData) return fromData | |
| return getPaletteCategoryFromNodeType(String(props.data.nodeType || '')) | |
| }) | |
| // 分析类节点(PCA / LDA 等)始终预留预览区:未运行前显示骨架占位,运行完成后回填实际图片 | |
| const hasPreview = computed(() => | |
| hasInlinePreviewCategory(ramexCategory.value) || | |
| Boolean(props.data.previewLoading) || | |
| Boolean(String(props.data.previewUrl ?? '').trim()) | |
| ) | |
| const useModernHeader = computed(() => Boolean(ramexCategory.value)) | |
| const useSplitLayout = computed(() => useModernHeader.value && hasPreview.value) | |
| const previewRight = computed(() => { | |
| if (useModernHeader.value) return hasPreview.value | |
| const schemaLen = props.data.schema?.length ?? 0 | |
| return schemaLen >= 4 | |
| }) | |
| // 判断节点类别 | |
| const nodeCategory = computed(() => { | |
| if (props.data.nodeType === 'local-csv') return 'input' | |
| if (props.data.nodeType === 'distribution') return 'display' | |
| return 'algorithm' | |
| }) | |
| // 节点类型对应的头部颜色 | |
| const getHeaderColor = (): string => { | |
| const colorMap: Record<string, string> = { | |
| 'local-csv': '#3B82F6', // 蓝色 | |
| 'missing-values': '#F97316', // 橙色 | |
| 'distribution': '#10B981', // 绿色 | |
| 'kmeans': '#8B5CF6', // 紫色 | |
| } | |
| return props.data.headerColor || colorMap[props.data.nodeType || ''] || '#6B7280' | |
| } | |
| // 获取节点图标 | |
| const getNodeIcon = (): string => { | |
| const iconMap: Record<string, string> = { | |
| 'local-csv': DatabaseIconSvg, | |
| 'missing-values': TriangleIconSvg, | |
| 'distribution': BarChartIconSvg, | |
| 'kmeans': TargetIconSvg, | |
| } | |
| return iconMap[props.data.nodeType || ''] || '' | |
| } | |
| // 初始化属性 | |
| const initializeNodeProperties = () => { | |
| // properties 在类型上为必填;这里做运行时兜底 | |
| if (!props.data.properties) props.data.properties = {} | |
| // 如果带 schema 且 properties 缺省,则填充默认值(按当前值或合理默认) | |
| if (props.data.schema && props.data.schema.length > 0) { | |
| props.data.schema.forEach((f) => { | |
| if (props.data.properties && props.data.properties[f.key] !== undefined) return | |
| if (f.type === 'checkbox') props.data.properties![f.key] = false | |
| else if (f.type === 'number') props.data.properties![f.key] = typeof f.min === 'number' ? f.min : 0 | |
| else if (f.type === 'select') props.data.properties![f.key] = f.options?.[0]?.value ?? '' | |
| else if (f.type === 'table') props.data.properties![f.key] = [] | |
| }) | |
| } | |
| if (props.data.nodeType === 'missing-values') { | |
| if (!props.data.properties.strategy) { | |
| props.data.properties.strategy = 'drop' | |
| } | |
| if (!props.data.properties.targetColumns) { | |
| props.data.properties.targetColumns = 'all-numeric' | |
| } | |
| if (!props.data.outputs) { | |
| props.data.outputs = [ | |
| { id: 'output-1', label: 'Output 1' }, | |
| { id: 'output-2', label: 'Output 2' } | |
| ] | |
| } | |
| if (!props.data.status) { | |
| props.data.status = 'ready' | |
| } | |
| if (!props.data.tabs) { | |
| props.data.tabs = [ | |
| { id: 'settings', label: 'Settings' }, | |
| { id: 'advanced', label: 'Advanced' } | |
| ] | |
| activeTab.value = 'settings' | |
| } | |
| } | |
| if (props.data.nodeType === 'distribution') { | |
| if (!props.data.properties.target) { | |
| props.data.properties.target = '# Age_Distribution' | |
| } | |
| if (props.data.properties.normalizeHistogram === undefined) { | |
| props.data.properties.normalizeHistogram = true | |
| } | |
| } | |
| if (props.data.nodeType === 'kmeans') { | |
| if (!props.data.properties.k) { | |
| props.data.properties.k = 3 | |
| } | |
| if (!props.data.properties.maxIterations) { | |
| props.data.properties.maxIterations = 300 | |
| } | |
| if (!props.data.properties.features) { | |
| props.data.properties.features = ['Age', 'Income', 'Score'] | |
| } | |
| if (props.data.showSettings === undefined) { | |
| props.data.showSettings = true | |
| } | |
| } | |
| if (props.data.nodeType === 'local-csv') { | |
| if (!props.data.properties.fileName) { | |
| props.data.properties.fileName = 'sales_data_2023.csv' | |
| } | |
| if (!props.data.properties.delimiter) { | |
| props.data.properties.delimiter = 'comma' | |
| } | |
| } | |
| } | |
| // 初始化 | |
| initializeNodeProperties() | |
| const updateProperty = (key: string, value: any) => { | |
| if (!props.data.properties) { | |
| props.data.properties = {} | |
| } | |
| props.data.properties[key] = value | |
| emit('update', { | |
| properties: { ...props.data.properties }, | |
| [key]: value | |
| }) | |
| } | |
| const handleDelete = () => { | |
| emit('delete', props.id) | |
| } | |
| const handleSettings = () => { | |
| emit('settings', props.id) | |
| } | |
| const removeFeature = (index: number | string) => { | |
| const i = typeof index === 'number' ? index : Number(index) | |
| if (Number.isNaN(i)) return | |
| if (props.data.properties?.features) { | |
| props.data.properties.features.splice(i, 1) | |
| emit('update', { properties: { ...props.data.properties } }) | |
| } | |
| } | |
| const addFeature = () => { | |
| if (!props.data.properties) { | |
| props.data.properties = {} | |
| } | |
| if (!props.data.properties.features) { | |
| props.data.properties.features = [] | |
| } | |
| props.data.properties.features.push('New Feature') | |
| emit('update', { properties: { ...props.data.properties } }) | |
| } | |
| type SchemaField = NonNullable<NodeData['schema']>[number] | |
| const addTableRow = (field: SchemaField) => { | |
| if (!props.data.properties) props.data.properties = {} | |
| if (!Array.isArray(props.data.properties[field.key])) props.data.properties[field.key] = [] | |
| const row: Record<string, any> = {} | |
| ;(field.columns ?? []).forEach((col) => { | |
| row[col.key] = col.type === 'number' ? 0 : '' | |
| }) | |
| props.data.properties[field.key].push(row) | |
| updateProperty(field.key, props.data.properties[field.key]) | |
| } | |
| const removeTableRow = (field: SchemaField, index: number) => { | |
| const rows = props.data.properties?.[field.key] | |
| if (!Array.isArray(rows)) return | |
| if (typeof field.minRows === 'number' && rows.length <= field.minRows) return | |
| rows.splice(index, 1) | |
| updateProperty(field.key, rows) | |
| } | |
| const updateTableCell = (field: SchemaField, index: number, column: { key: string; type: 'number' | 'text' }, value: string) => { | |
| const rows = props.data.properties?.[field.key] | |
| if (!Array.isArray(rows) || !rows[index]) return | |
| rows[index][column.key] = column.type === 'number' ? Number(value) : value | |
| updateProperty(field.key, rows) | |
| } | |
| // 图标SVG字符串 | |
| const DatabaseIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 16px; height: 16px;"> | |
| <ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" stroke-width="2"/> | |
| <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" stroke="currentColor" stroke-width="2"/> | |
| <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| ` | |
| const TriangleIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 16px; height: 16px;"> | |
| <path d="M12 5l-7 14h14L12 5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| ` | |
| const BarChartIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 16px; height: 16px;"> | |
| <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/> | |
| <path d="M7 12h10M7 8h6M7 16h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| ` | |
| const TargetIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 16px; height: 16px;"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/> | |
| <circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="2"/> | |
| <circle cx="12" cy="12" r="2" fill="currentColor"/> | |
| </svg> | |
| ` | |
| const TrashIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 14px; height: 14px;"> | |
| <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| ` | |
| const CalculatorIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 14px; height: 14px;"> | |
| <rect x="4" y="2" width="16" height="20" rx="2" stroke="currentColor" stroke-width="2"/> | |
| <path d="M8 6h8M8 10h8M8 14h4M12 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| ` | |
| const ZeroIconSvg = ` | |
| <svg viewBox="0 0 24 24" fill="none" style="width: 14px; height: 14px;"> | |
| <circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| ` | |
| // Missing Values 策略选项(放在 SVG 常量之后,避免“先用后声明”告警) | |
| const strategies = [ | |
| { value: 'drop', label: 'Drop', icon: TrashIconSvg }, | |
| { value: 'mean', label: 'Mean', icon: CalculatorIconSvg }, | |
| { value: 'zero', label: 'Zero', icon: ZeroIconSvg } | |
| ] | |
| </script> | |
| <style scoped> | |
| .workflow-node-component { | |
| --ramex-node-width: 340px; | |
| --ramex-split-width: 800px; | |
| --ramex-config-width: 300px; | |
| --ramex-header-height: 40px; | |
| --ramex-body-min-height: 168px; | |
| --ramex-split-body-height: 360px; | |
| background: #ffffff ; | |
| border: none ; | |
| border-radius: 0 ; | |
| width: var(--ramex-node-width) ; | |
| min-height: var(--ramex-body-min-height) ; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) ; | |
| transition: all 0.2s ease ; | |
| overflow: visible ; | |
| position: relative; | |
| z-index: 1; | |
| cursor: default; | |
| display: flex; | |
| flex-direction: column; | |
| outline: none ; | |
| } | |
| .workflow-node--preview-right, | |
| .workflow-node--split { | |
| width: var(--ramex-split-width) ; | |
| min-height: calc(var(--ramex-header-height) + var(--ramex-split-body-height)) ; | |
| } | |
| .workflow-node--ramex:not(.workflow-node--split) { | |
| width: var(--ramex-node-width) ; | |
| min-height: calc(var(--ramex-header-height) + var(--ramex-body-min-height)) ; | |
| } | |
| .workflow-node-component:hover { | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) ; | |
| } | |
| .workflow-node-component.selected { | |
| box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18), 0 3px 6px rgba(0, 0, 0, 0.12) ; | |
| } | |
| /* 头部样式 */ | |
| .node-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 12px; | |
| color: white; | |
| font-size: 13px; | |
| font-weight: 500; | |
| border-radius: 0; | |
| min-height: 36px; | |
| } | |
| /* RamEx:Chart 风格顶栏 */ | |
| .node-chrome-header { | |
| display: flex; | |
| align-items: stretch; | |
| min-height: var(--ramex-header-height); | |
| flex-shrink: 0; | |
| border-bottom: 1px solid #e5e7eb; | |
| background: #ffffff; | |
| } | |
| .node-chrome-header__left { | |
| display: flex; | |
| align-items: stretch; | |
| flex-shrink: 0; | |
| } | |
| .node-chrome-header__right { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| padding: 0 10px 0 12px; | |
| min-width: 0; | |
| background: #ffffff; | |
| } | |
| .node-type-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 0 16px; | |
| background: #0f172a; | |
| color: #ffffff; | |
| font-size: 13px; | |
| font-weight: 600; | |
| min-height: var(--ramex-header-height); | |
| } | |
| .node-type-pill__icon { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .node-type-pill__icon :deep(svg) { | |
| width: 16px; | |
| height: 16px; | |
| stroke: #ffffff ; | |
| } | |
| .node-type-pill__icon :deep(svg [stroke]) { | |
| stroke: #ffffff ; | |
| } | |
| .node-type-pill__icon :deep(svg [fill]:not([fill='none'])) { | |
| fill: #ffffff ; | |
| } | |
| .node-type-pill__label { | |
| max-width: 200px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .node-preview-filename { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: #0f172a; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| min-width: 0; | |
| flex: 1; | |
| text-align: right; | |
| } | |
| .delete-btn--chrome { | |
| color: #64748b ; | |
| opacity: 1 ; | |
| flex-shrink: 0; | |
| } | |
| .delete-btn--chrome:hover { | |
| background: #f1f5f9 ; | |
| color: #0f172a ; | |
| } | |
| .header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .header-icon { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| color: white; | |
| } | |
| .header-title { | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| font-weight: 500; | |
| } | |
| .header-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| .ready-badge { | |
| background: #10b981; | |
| color: white; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| margin-right: 4px; | |
| } | |
| .delete-btn { | |
| width: 20px; | |
| height: 20px; | |
| padding: 0; | |
| background: transparent; | |
| border: none; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| color: white; | |
| opacity: 0.8; | |
| flex-shrink: 0; | |
| } | |
| .delete-btn:hover { | |
| opacity: 1; | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |
| .delete-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| .settings-btn { | |
| width: 20px; | |
| height: 20px; | |
| padding: 0; | |
| background: transparent; | |
| border: none; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| color: white; | |
| opacity: 0.8; | |
| flex-shrink: 0; | |
| } | |
| .settings-btn:hover { | |
| opacity: 1; | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |
| .settings-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| /* 节点内容 */ | |
| .node-body { | |
| background: #ffffff; | |
| border-radius: 0; | |
| padding: 12px; | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| } | |
| .node-body--ramex { | |
| background: #ffffff ; | |
| padding: 0 ; | |
| } | |
| .node-body--ramex:not(.node-body--split) .node-config--modern { | |
| padding: 14px 16px; | |
| } | |
| .workflow-node--ramex { | |
| border: 1px solid #cbd5e1 ; | |
| border-radius: 2px ; | |
| } | |
| .node-body--split, | |
| .node-body--preview-right { | |
| flex-direction: row; | |
| align-items: stretch; | |
| gap: 0; | |
| min-height: var(--ramex-split-body-height); | |
| flex: 1; | |
| } | |
| .node-body--split .node-config, | |
| .node-body--preview-right .node-config { | |
| flex: 0 0 var(--ramex-config-width); | |
| min-width: var(--ramex-config-width); | |
| max-width: var(--ramex-config-width); | |
| min-height: var(--ramex-split-body-height); | |
| padding: 14px 16px; | |
| border-right: 1px solid #e5e7eb; | |
| overflow-y: auto; | |
| align-self: stretch; | |
| } | |
| .node-config--modern { | |
| background: #ffffff; | |
| } | |
| /* 标签页 */ | |
| .node-tabs { | |
| display: flex; | |
| gap: 0; | |
| border-bottom: 1px solid #e5e7eb; | |
| margin-bottom: 12px; | |
| margin: -12px -12px 12px -12px; | |
| padding: 0 12px; | |
| } | |
| .tab-btn { | |
| padding: 8px 12px; | |
| background: transparent; | |
| border: none; | |
| border-bottom: 2px solid transparent; | |
| color: #6b7280; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| margin-bottom: -1px; | |
| } | |
| .tab-btn:hover { | |
| color: #374151; | |
| } | |
| .tab-btn.active { | |
| color: #3B82F6; | |
| border-bottom-color: #3B82F6; | |
| } | |
| .node-config { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| padding: 12px; | |
| } | |
| .node-preview { | |
| margin-top: 10px; | |
| padding-top: 10px; | |
| border-top: 1px solid #e5e7eb; | |
| } | |
| .node-preview--panel, | |
| .node-body--preview-right .node-preview { | |
| margin-top: 0; | |
| padding: 16px; | |
| border-top: none; | |
| border-left: none; | |
| flex: 1 1 auto; | |
| min-width: 0; | |
| min-height: var(--ramex-split-body-height); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: #fafafa; | |
| } | |
| .node-body--split .node-preview--panel { | |
| align-self: stretch; | |
| } | |
| .node-preview-loading { | |
| font-size: 12px; | |
| color: #64748b; | |
| } | |
| .node-preview-skeleton { | |
| width: 100%; | |
| height: 100%; | |
| min-height: 120px; | |
| border-radius: 4px; | |
| background: #eef1f5; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .node-preview--panel .node-preview-skeleton, | |
| .node-body--preview-right .node-preview .node-preview-skeleton { | |
| min-height: calc(var(--ramex-split-body-height) - 32px); | |
| } | |
| .node-preview-skeleton-shimmer { | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.7), transparent); | |
| transform: translateX(-100%); | |
| animation: node-preview-shimmer 1.4s ease-in-out infinite; | |
| } | |
| @keyframes node-preview-shimmer { | |
| 100% { | |
| transform: translateX(100%); | |
| } | |
| } | |
| .node-preview-img { | |
| width: 100%; | |
| max-height: 160px; | |
| object-fit: cover; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| background: #fff; | |
| } | |
| .node-body--preview-right .node-preview .node-preview-img, | |
| .node-preview--panel .node-preview-img { | |
| width: auto; | |
| max-width: 100%; | |
| height: auto; | |
| max-height: calc(var(--ramex-split-body-height) - 32px); | |
| object-fit: contain; | |
| border: none; | |
| border-radius: 0; | |
| background: transparent; | |
| } | |
| .tab-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .field-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .field-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: #374151; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .field-hint { | |
| font-size: 12px; | |
| color: #9ca3af; | |
| font-style: italic; | |
| } | |
| /* 文件输入 */ | |
| .file-input-wrapper { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 12px; | |
| background: #f9fafb; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 13px; | |
| color: #374151; | |
| } | |
| .file-icon { | |
| width: 16px; | |
| height: 16px; | |
| color: #6b7280; | |
| flex-shrink: 0; | |
| } | |
| .file-name { | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| /* 下拉选择 */ | |
| .select-input { | |
| width: 100%; | |
| padding: 8px 12px; | |
| background: #ffffff; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 13px; | |
| color: #374151; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .select-input:focus { | |
| outline: none; | |
| border-color: #3B82F6; | |
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | |
| } | |
| /* 策略按钮组 */ | |
| .strategy-buttons { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .strategy-btn { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 10px 8px; | |
| background: #ffffff; | |
| border: 2px solid #e5e7eb; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #6b7280; | |
| } | |
| .strategy-btn:hover { | |
| border-color: #d1d5db; | |
| background: #f9fafb; | |
| } | |
| .strategy-btn.active { | |
| border-color: #3B82F6; | |
| background: var(--ramex-bg-subtle); | |
| color: #3B82F6; | |
| } | |
| .strategy-icon { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .strategy-icon svg { | |
| width: 18px; | |
| height: 18px; | |
| color: currentColor; | |
| } | |
| /* 复选框 */ | |
| .checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| color: #374151; | |
| } | |
| .checkbox-input { | |
| width: 16px; | |
| height: 16px; | |
| cursor: pointer; | |
| } | |
| /* 通用 schema 表单 */ | |
| .schema-form { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| } | |
| .schema-form--modern { | |
| gap: 0; | |
| } | |
| .config-section { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0; | |
| } | |
| .config-section-title { | |
| margin: 0 0 10px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: #64748b; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .config-row { | |
| display: grid; | |
| grid-template-columns: 112px 1fr; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 8px 0; | |
| border-bottom: 1px solid #f1f5f9; | |
| } | |
| .config-row:last-child { | |
| border-bottom: none; | |
| } | |
| .config-row-label { | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #475569; | |
| line-height: 1.3; | |
| text-align: left; | |
| } | |
| .config-row-control { | |
| min-width: 0; | |
| } | |
| .config-input { | |
| width: 100%; | |
| padding: 5px 8px; | |
| font-size: 12px; | |
| color: #0f172a; | |
| background: #ffffff; | |
| border: 1px solid #d1d5db; | |
| border-radius: 2px; | |
| box-sizing: border-box; | |
| } | |
| .config-input--compact { | |
| max-width: 96px; | |
| } | |
| .config-input:focus { | |
| outline: none; | |
| border-color: #64748b; | |
| } | |
| .config-input[type='number']::-webkit-outer-spin-button, | |
| .config-input[type='number']::-webkit-inner-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| .config-input[type='number'] { | |
| -moz-appearance: textfield; | |
| } | |
| .config-checkbox { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| color: #334155; | |
| cursor: pointer; | |
| } | |
| /* 表格型属性(如波段注释表) */ | |
| .config-table { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| width: 100%; | |
| } | |
| .table-input { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| .table-input th { | |
| font-size: 10px; | |
| font-weight: 600; | |
| color: #64748b; | |
| text-align: left; | |
| padding: 2px 4px; | |
| } | |
| .table-input td { | |
| padding: 2px 4px; | |
| } | |
| .table-input td .config-input { | |
| padding: 3px 6px; | |
| font-size: 11px; | |
| } | |
| .table-input-action-col { | |
| width: 20px; | |
| } | |
| .table-row-remove { | |
| border: none; | |
| background: transparent; | |
| color: #94a3b8; | |
| cursor: pointer; | |
| font-size: 13px; | |
| line-height: 1; | |
| padding: 2px 4px; | |
| } | |
| .table-row-remove:hover { | |
| color: #ef4444; | |
| } | |
| .table-row-add { | |
| align-self: flex-start; | |
| border: 1px dashed #cbd5e1; | |
| background: transparent; | |
| color: #64748b; | |
| font-size: 11px; | |
| border-radius: 3px; | |
| padding: 3px 8px; | |
| cursor: pointer; | |
| } | |
| .table-row-add:hover { | |
| border-color: #64748b; | |
| color: #334155; | |
| } | |
| /* 数字输入 */ | |
| .number-input { | |
| width: 100%; | |
| padding: 8px 12px; | |
| background: #ffffff; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 13px; | |
| color: #374151; | |
| transition: all 0.2s ease; | |
| } | |
| .number-input:focus { | |
| outline: none; | |
| border-color: #3B82F6; | |
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | |
| } | |
| .number-input::-webkit-outer-spin-button, | |
| .number-input::-webkit-inner-spin-button { | |
| -webkit-appearance: none; | |
| margin: 0; | |
| } | |
| .number-input { | |
| -moz-appearance: textfield; | |
| } | |
| /* 滑块 */ | |
| .slider-wrapper { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .slider-input { | |
| flex: 1; | |
| height: 6px; | |
| background: #e5e7eb; | |
| border-radius: 3px; | |
| outline: none; | |
| cursor: pointer; | |
| -webkit-appearance: none; | |
| } | |
| .slider-input::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: #3B82F6; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| .slider-input::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| background: #3B82F6; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| border: none; | |
| } | |
| .slider-value { | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: #374151; | |
| min-width: 40px; | |
| text-align: right; | |
| } | |
| /* 特征列表 */ | |
| .features-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .feature-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 6px 10px; | |
| background: #f9fafb; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 13px; | |
| color: #374151; | |
| } | |
| .feature-item span { | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .remove-feature-btn { | |
| width: 18px; | |
| height: 18px; | |
| padding: 0; | |
| background: transparent; | |
| border: none; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| color: #ef4444; | |
| opacity: 0.6; | |
| flex-shrink: 0; | |
| } | |
| .remove-feature-btn:hover { | |
| opacity: 1; | |
| background: #fee2e2; | |
| } | |
| .remove-feature-btn svg { | |
| width: 12px; | |
| height: 12px; | |
| } | |
| .add-feature-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| padding: 8px 12px; | |
| background: #ffffff; | |
| border: 1px solid #e5e7eb; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #6b7280; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| width: 100%; | |
| } | |
| .add-feature-btn:hover { | |
| border-color: #3B82F6; | |
| color: #3B82F6; | |
| background: var(--ramex-bg-subtle); | |
| } | |
| .add-feature-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| flex-shrink: 0; | |
| } | |
| /* 连接点样式 */ | |
| .node-handle { | |
| width: 12px ; | |
| height: 12px ; | |
| border: 2px solid white ; | |
| border-radius: 50% ; | |
| background: #6b7280 ; | |
| cursor: crosshair ; | |
| transition: all 0.2s ease ; | |
| } | |
| .node-handle-input { | |
| left: -6px ; | |
| background: #10b981 ; | |
| } | |
| .node-handle-output { | |
| right: -6px ; | |
| background: #3b82f6 ; | |
| } | |
| .node-handle:hover { | |
| transform: scale(1.3) ; | |
| box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15) ; | |
| } | |
| </style> | |