侧边增加栏折叠和滚动条
This commit is contained in:
@ -17,14 +17,30 @@
|
||||
|
||||
<div class="main-content">
|
||||
<aside class="node-sidebar">
|
||||
<div v-for="category in nodeCategories" :key="category.key" class="node-section">
|
||||
<div class="section-title">{{ category.label }}</div>
|
||||
<div class="node-list">
|
||||
<div v-for="n in category.nodes" :key="n.type" class="node-item" draggable="true" @dragstart="onDragStart($event, n.type)">
|
||||
<span class="node-icon">
|
||||
<Icon :icon="n.icon" width="18" />
|
||||
</span>
|
||||
<span class="node-label">{{ n.label }}</span>
|
||||
<div class="node-section-list">
|
||||
<div
|
||||
v-for="category in nodeCategories"
|
||||
:key="category.key"
|
||||
class="node-section"
|
||||
:class="{ 'is-collapsed': isSectionCollapsed(category.key) }"
|
||||
>
|
||||
<button class="section-header" type="button" @click="toggleSection(category.key)">
|
||||
<span class="section-title">{{ category.label }}</span>
|
||||
<Icon :icon="isSectionCollapsed(category.key) ? 'mdi:chevron-down' : 'mdi:chevron-up'" width="16" />
|
||||
</button>
|
||||
<div class="node-list" v-show="!isSectionCollapsed(category.key)">
|
||||
<div
|
||||
v-for="n in category.nodes"
|
||||
:key="n.type"
|
||||
class="node-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, n.type)"
|
||||
>
|
||||
<span class="node-icon">
|
||||
<Icon :icon="n.icon" width="18" />
|
||||
</span>
|
||||
<span class="node-label">{{ n.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -173,6 +189,18 @@ const contextMenu = ref({
|
||||
})
|
||||
|
||||
const paneClickPosition = ref({ x: 0, y: 0 })
|
||||
const collapsedSections = ref<Record<string, boolean>>(
|
||||
nodeCategories.reduce((acc, category) => {
|
||||
acc[category.key] = false
|
||||
return acc
|
||||
}, {} as Record<string, boolean>)
|
||||
)
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
collapsedSections.value[key] = !isSectionCollapsed(key)
|
||||
}
|
||||
|
||||
const isSectionCollapsed = (key: string) => collapsedSections.value[key] ?? false
|
||||
|
||||
const spawnNodeAt = (type: string, screenPoint: { x: number; y: number }) => {
|
||||
const position = screenToFlowCoordinate({ x: screenPoint.x, y: screenPoint.y })
|
||||
@ -442,9 +470,27 @@ html, body, #app { height: 100%; font-family: -apple-system, BlinkMacSystemFont,
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.node-section-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.node-section { padding: 16px; border-bottom: 1px solid #f0f0f0; }
|
||||
.section-title { font-size: 12px; color: #999; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
.section-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.node-section.is-collapsed .section-title { color: #bbb; }
|
||||
.node-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.node-item {
|
||||
display: flex;
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
:global(.vue-flow__node-dataInputNode) {
|
||||
:global(.vue-flow__node-dataInputNode) {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.data-input-node__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="data-input-node">
|
||||
<StandardNode
|
||||
:icon="definition.icon"
|
||||
@ -10,18 +10,94 @@
|
||||
:handles="definition.handles"
|
||||
:body-items="bodyItems"
|
||||
:selected="props.selected"
|
||||
/>
|
||||
>
|
||||
<template #body-append>
|
||||
<div class="node-control">
|
||||
<span class="node-control__label">Source</span>
|
||||
<el-select
|
||||
size="small"
|
||||
:model-value="props.data.sourceType"
|
||||
placeholder="Choose source"
|
||||
@change="handleSourceTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in sourceOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="node-control">
|
||||
<span class="node-control__label">File</span>
|
||||
<div class="node-control__field">
|
||||
<el-input
|
||||
size="small"
|
||||
:model-value="props.data.filePath"
|
||||
placeholder="No file selected"
|
||||
readonly
|
||||
/>
|
||||
<el-button size="small" type="primary" link @click="triggerFileDialog">Browse</el-button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="data-input-node__file-input"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<div class="node-control__actions">
|
||||
<el-button size="small" type="success" plain @click="handleSync">
|
||||
Load Data
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</StandardNode>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Position } from '@vue-flow/core'
|
||||
import { useFlowStore } from '@/stores/flow'
|
||||
import StandardNode from '../shared/StandardNode.vue'
|
||||
import { useStandardNode } from '../shared/useStandardNode'
|
||||
import type { BaseNodeProps } from '../shared/types'
|
||||
import type { DataInputNodeData } from './types'
|
||||
|
||||
const props = defineProps<BaseNodeProps<DataInputNodeData>>()
|
||||
const store = useFlowStore()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const sourceOptions = [
|
||||
{ label: 'File Upload', value: 'file' },
|
||||
{ label: 'API Endpoint', value: 'api' },
|
||||
{ label: 'Database', value: 'database' }
|
||||
]
|
||||
|
||||
const handleSourceTypeChange = (value: string) => {
|
||||
store.updateNodeData(props.id, { sourceType: value })
|
||||
}
|
||||
|
||||
const triggerFileDialog = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
store.updateNodeData(props.id, { filePath: file.name })
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
store.updateNodeData(props.id, {
|
||||
status: 'Preparing',
|
||||
lastSync: new Date().toLocaleTimeString()
|
||||
})
|
||||
}
|
||||
|
||||
const { definition, bodyItems, badge, status } = useStandardNode(props, {
|
||||
icon: 'mdi:database-import',
|
||||
@ -31,11 +107,12 @@ const { definition, bodyItems, badge, status } = useStandardNode(props, {
|
||||
handles: {
|
||||
outputs: [Position.Right]
|
||||
},
|
||||
status: ({ data }) => data.status ?? '待运行',
|
||||
status: ({ data }) => data.status ?? 'Idle',
|
||||
badge: ({ data }) => data.format ?? '自动检测',
|
||||
body: ({ data }) => [
|
||||
{ label: '来源', value: data.sourceType ?? '未配置' },
|
||||
{ label: '路径', value: data.filePath ?? '待绑定' }
|
||||
{ label: '路径', value: data.filePath ?? '待绑定' },
|
||||
{ label: 'Last Sync', value: data.lastSync ?? 'Never' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -5,6 +5,7 @@ export interface DataInputNodeData {
|
||||
filePath?: string
|
||||
format?: string
|
||||
status?: string
|
||||
lastSync?: string
|
||||
}
|
||||
|
||||
export type DataInputNodeProps = BaseNodeProps<DataInputNodeData>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<span v-if="badge" class="standard-node__badge">{{ badge }}</span>
|
||||
</div>
|
||||
<div class="standard-node__body">
|
||||
<slot name="body">
|
||||
<slot name="body" :items="bodyItems">
|
||||
<div
|
||||
v-for="item in bodyItems"
|
||||
:key="item.label"
|
||||
@ -20,6 +20,9 @@
|
||||
<span class="row-label">{{ item.label }}</span>
|
||||
<span class="row-value">{{ item.value }}</span>
|
||||
</div>
|
||||
<div v-if="$slots['body-append']" class="standard-node__controls">
|
||||
<slot name="body-append" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="standard-node__footer">
|
||||
|
||||
@ -68,6 +68,44 @@
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.standard-node__controls {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.standard-node__controls .node-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.standard-node__controls .node-control__label {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.standard-node__controls .node-control__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.standard-node__controls .node-control__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.standard-node__controls :deep(.el-select),
|
||||
.standard-node__controls :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.standard-node__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user