RamEx-Flow / front /src /components /WorkflowNodeComponent.vue
Aye10032
fix: Hide number input spin buttons
c88d153
Raw
History Blame Contribute Delete
43.5 kB
<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 !important;
border: none !important;
border-radius: 0 !important;
width: var(--ramex-node-width) !important;
min-height: var(--ramex-body-min-height) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
transition: all 0.2s ease !important;
overflow: visible !important;
position: relative;
z-index: 1;
cursor: default;
display: flex;
flex-direction: column;
outline: none !important;
}
.workflow-node--preview-right,
.workflow-node--split {
width: var(--ramex-split-width) !important;
min-height: calc(var(--ramex-header-height) + var(--ramex-split-body-height)) !important;
}
.workflow-node--ramex:not(.workflow-node--split) {
width: var(--ramex-node-width) !important;
min-height: calc(var(--ramex-header-height) + var(--ramex-body-min-height)) !important;
}
.workflow-node-component:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.workflow-node-component.selected {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18), 0 3px 6px rgba(0, 0, 0, 0.12) !important;
}
/* 头部样式 */
.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 !important;
}
.node-type-pill__icon :deep(svg [stroke]) {
stroke: #ffffff !important;
}
.node-type-pill__icon :deep(svg [fill]:not([fill='none'])) {
fill: #ffffff !important;
}
.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 !important;
opacity: 1 !important;
flex-shrink: 0;
}
.delete-btn--chrome:hover {
background: #f1f5f9 !important;
color: #0f172a !important;
}
.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 !important;
padding: 0 !important;
}
.node-body--ramex:not(.node-body--split) .node-config--modern {
padding: 14px 16px;
}
.workflow-node--ramex {
border: 1px solid #cbd5e1 !important;
border-radius: 2px !important;
}
.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 !important;
height: 12px !important;
border: 2px solid white !important;
border-radius: 50% !important;
background: #6b7280 !important;
cursor: crosshair !important;
transition: all 0.2s ease !important;
}
.node-handle-input {
left: -6px !important;
background: #10b981 !important;
}
.node-handle-output {
right: -6px !important;
background: #3b82f6 !important;
}
.node-handle:hover {
transform: scale(1.3) !important;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15) !important;
}
</style>