侧边增加栏折叠和滚动条
This commit is contained in:
@ -17,14 +17,30 @@
|
|||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<aside class="node-sidebar">
|
<aside class="node-sidebar">
|
||||||
<div v-for="category in nodeCategories" :key="category.key" class="node-section">
|
<div class="node-section-list">
|
||||||
<div class="section-title">{{ category.label }}</div>
|
<div
|
||||||
<div class="node-list">
|
v-for="category in nodeCategories"
|
||||||
<div v-for="n in category.nodes" :key="n.type" class="node-item" draggable="true" @dragstart="onDragStart($event, n.type)">
|
:key="category.key"
|
||||||
<span class="node-icon">
|
class="node-section"
|
||||||
<Icon :icon="n.icon" width="18" />
|
:class="{ 'is-collapsed': isSectionCollapsed(category.key) }"
|
||||||
</span>
|
>
|
||||||
<span class="node-label">{{ n.label }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -173,6 +189,18 @@ const contextMenu = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const paneClickPosition = ref({ x: 0, y: 0 })
|
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 spawnNodeAt = (type: string, screenPoint: { x: number; y: number }) => {
|
||||||
const position = screenToFlowCoordinate({ x: screenPoint.x, y: screenPoint.y })
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.node-section-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.node-section { padding: 16px; border-bottom: 1px solid #f0f0f0; }
|
.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-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.node-item {
|
.node-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
:global(.vue-flow__node-dataInputNode) {
|
:global(.vue-flow__node-dataInputNode) {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-input-node__file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="data-input-node">
|
<div class="data-input-node">
|
||||||
<StandardNode
|
<StandardNode
|
||||||
:icon="definition.icon"
|
:icon="definition.icon"
|
||||||
@ -10,18 +10,94 @@
|
|||||||
:handles="definition.handles"
|
:handles="definition.handles"
|
||||||
:body-items="bodyItems"
|
:body-items="bodyItems"
|
||||||
:selected="props.selected"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { Position } from '@vue-flow/core'
|
import { Position } from '@vue-flow/core'
|
||||||
|
import { useFlowStore } from '@/stores/flow'
|
||||||
import StandardNode from '../shared/StandardNode.vue'
|
import StandardNode from '../shared/StandardNode.vue'
|
||||||
import { useStandardNode } from '../shared/useStandardNode'
|
import { useStandardNode } from '../shared/useStandardNode'
|
||||||
import type { BaseNodeProps } from '../shared/types'
|
import type { BaseNodeProps } from '../shared/types'
|
||||||
import type { DataInputNodeData } from './types'
|
import type { DataInputNodeData } from './types'
|
||||||
|
|
||||||
const props = defineProps<BaseNodeProps<DataInputNodeData>>()
|
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, {
|
const { definition, bodyItems, badge, status } = useStandardNode(props, {
|
||||||
icon: 'mdi:database-import',
|
icon: 'mdi:database-import',
|
||||||
@ -31,11 +107,12 @@ const { definition, bodyItems, badge, status } = useStandardNode(props, {
|
|||||||
handles: {
|
handles: {
|
||||||
outputs: [Position.Right]
|
outputs: [Position.Right]
|
||||||
},
|
},
|
||||||
status: ({ data }) => data.status ?? '待运行',
|
status: ({ data }) => data.status ?? 'Idle',
|
||||||
badge: ({ data }) => data.format ?? '自动检测',
|
badge: ({ data }) => data.format ?? '自动检测',
|
||||||
body: ({ data }) => [
|
body: ({ data }) => [
|
||||||
{ label: '来源', value: data.sourceType ?? '未配置' },
|
{ label: '来源', value: data.sourceType ?? '未配置' },
|
||||||
{ label: '路径', value: data.filePath ?? '待绑定' }
|
{ label: '路径', value: data.filePath ?? '待绑定' },
|
||||||
|
{ label: 'Last Sync', value: data.lastSync ?? 'Never' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface DataInputNodeData {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
format?: string
|
format?: string
|
||||||
status?: string
|
status?: string
|
||||||
|
lastSync?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataInputNodeProps = BaseNodeProps<DataInputNodeData>
|
export type DataInputNodeProps = BaseNodeProps<DataInputNodeData>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<span v-if="badge" class="standard-node__badge">{{ badge }}</span>
|
<span v-if="badge" class="standard-node__badge">{{ badge }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="standard-node__body">
|
<div class="standard-node__body">
|
||||||
<slot name="body">
|
<slot name="body" :items="bodyItems">
|
||||||
<div
|
<div
|
||||||
v-for="item in bodyItems"
|
v-for="item in bodyItems"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
@ -20,6 +20,9 @@
|
|||||||
<span class="row-label">{{ item.label }}</span>
|
<span class="row-label">{{ item.label }}</span>
|
||||||
<span class="row-value">{{ item.value }}</span>
|
<span class="row-value">{{ item.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="$slots['body-append']" class="standard-node__controls">
|
||||||
|
<slot name="body-append" />
|
||||||
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="standard-node__footer">
|
<div class="standard-node__footer">
|
||||||
|
|||||||
@ -68,6 +68,44 @@
|
|||||||
color: #4a4a4a;
|
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 {
|
.standard-node__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user