This commit is contained in:
yinsx
2025-12-13 15:40:01 +08:00
commit 39c0f7e708
104 changed files with 6460 additions and 0 deletions

View File

@ -0,0 +1,62 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="context-menu-overlay"
@click="emit('close')"
@contextmenu.prevent="emit('close')"
>
<div
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
@click.stop
>
<ContextMenuList :items="items" @select="handleSelect" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import ContextMenuList from './ContextMenuList.vue'
import type { ContextMenuItem } from '../types/context-menu'
defineProps<{
visible: boolean
x: number
y: number
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: ContextMenuItem): void
(e: 'close'): void
}>()
const handleSelect = (item: ContextMenuItem) => {
emit('select', item)
emit('close')
}
</script>
<style scoped>
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
padding: 4px 0;
z-index: 10000;
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div class="context-menu-list">
<div
v-for="item in items"
:key="item.action ? `${item.action}-${item.label}` : item.label"
class="context-menu-item"
:class="{ disabled: item.disabled, 'has-children': hasChildren(item) }"
@mouseenter="handleItemEnter(item)"
@mouseleave="handleItemLeave(item)"
@click.stop="handleClick(item)"
>
<div class="context-menu-item-content">
<Icon :icon="item.icon" width="16" />
<span>{{ item.label }}</span>
</div>
<Icon v-if="hasChildren(item)" icon="mdi:chevron-right" width="14" class="submenu-arrow" />
<div
v-if="hasChildren(item)"
class="context-submenu"
:class="{ open: isOpen(item) }"
@mouseenter="handleSubmenuEnter"
@mouseleave="handleSubmenuLeave(getKey(item))"
>
<ContextMenuList :items="item.children!" @select="emit('select', $event)" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onBeforeUnmount, ref } from 'vue'
import type { ContextMenuItem } from '../types/context-menu'
defineOptions({ name: 'ContextMenuList' })
const props = defineProps<{
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: ContextMenuItem): void
}>()
const hasChildren = (item: ContextMenuItem) => Array.isArray(item.children) && item.children.length > 0
const openItemKey = ref<string | null>(null)
let openTimer: number | null = null
let closeTimer: number | null = null
const clearTimers = () => {
if (openTimer) {
clearTimeout(openTimer)
openTimer = null
}
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
onBeforeUnmount(() => {
clearTimers()
})
const getKey = (item: ContextMenuItem) => {
if (item.action) return `${item.action}-${item.label}`
return item.label
}
const openSubmenu = (key: string) => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (openItemKey.value === key) return
if (openTimer) clearTimeout(openTimer)
openTimer = window.setTimeout(() => {
openItemKey.value = key
}, 120)
}
const closeSubmenu = (key: string) => {
if (openTimer) {
clearTimeout(openTimer)
openTimer = null
}
closeTimer = window.setTimeout(() => {
if (openItemKey.value === key) {
openItemKey.value = null
}
}, 200)
}
const handleItemEnter = (item: ContextMenuItem) => {
if (!hasChildren(item)) {
openItemKey.value = null
return
}
openSubmenu(getKey(item))
}
const handleItemLeave = (item: ContextMenuItem) => {
if (!hasChildren(item)) return
closeSubmenu(getKey(item))
}
const handleSubmenuEnter = () => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
const handleSubmenuLeave = (key: string) => {
closeSubmenu(key)
}
const isOpen = (item: ContextMenuItem) => openItemKey.value === getKey(item)
const handleClick = (item: ContextMenuItem) => {
if (item.disabled || hasChildren(item)) return
emit('select', item)
}
</script>
<style scoped>
.context-menu-list {
display: flex;
flex-direction: column;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: background 0.2s;
position: relative;
}
.context-menu-item-content {
display: flex;
align-items: center;
gap: 8px;
}
.context-menu-item:hover {
background: #f0f7ff;
}
.context-menu-item.disabled {
color: #999;
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background: transparent;
}
.context-submenu {
position: absolute;
top: 0;
left: 100%;
margin-left: 0;
transform: translateX(1px);
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
padding: 4px 0;
z-index: 1000;
display: none;
}
.submenu-arrow {
color: #999;
}
.context-submenu.open {
display: block;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="property-panel">
<div class="panel-header">
<span class="panel-title">{{ title || '属性配置' }}</span>
<button class="close-btn" @click="emit('close')">
<Icon icon="mdi:close" width="18" />
</button>
</div>
<div class="panel-body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineProps<{ title?: string }>()
const emit = defineEmits<{ close: [] }>()
</script>
<style scoped>
.property-panel {
position: absolute;
top: 16px;
right: 16px;
width: 320px;
max-height: calc(100% - 32px);
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: #666;
transition: all 0.2s;
}
.close-btn:hover {
background: #f5f5f5;
color: #333;
}
.panel-body {
padding: 16px;
overflow-y: auto;
flex: 1;
}
</style>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-aggregationNode) {
min-width: 230px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="aggregation-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { AggregationNodeData } from './types'
const props = defineProps<BaseNodeProps<AggregationNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:sigma-lower',
title: '聚合节点',
subtitle: 'Metrics',
accent: '#08979c',
handles: {
inputs: [Position.Left, Position.Top],
outputs: [Position.Right]
},
badge: ({ data }) => data.metric ?? '计数',
status: ({ data }) => data.status ?? '聚合中',
body: ({ data }) => [
{ label: '维度', value: data.dimension ?? 'global' },
{ label: '窗口', value: data.window ?? '1m' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface AggregationNodeData {
metric?: string
dimension?: string
window?: string
status?: string
}
export type AggregationNodeProps = BaseNodeProps<AggregationNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-conditionalNode) {
min-width: 240px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="conditional-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ConditionalNodeData } from './types'
const props = defineProps<BaseNodeProps<ConditionalNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:help-rhombus',
title: '条件判断',
subtitle: 'Flow Control',
accent: '#faad14',
handles: {
inputs: [Position.Left],
outputs: [Position.Right, Position.Bottom]
},
badge: ({ data }) => data.mode ?? '分支',
status: ({ data }) => data.status ?? '待判定',
body: ({ data }) => [
{ label: '表达式', value: data.expression ?? 'true' },
{ label: '真/假', value: `${data.trueLabel ?? '通过'} / ${data.falseLabel ?? '驳回'}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,11 @@
import type { BaseNodeProps } from '../shared/types'
export interface ConditionalNodeData {
expression?: string
trueLabel?: string
falseLabel?: string
mode?: string
status?: string
}
export type ConditionalNodeProps = BaseNodeProps<ConditionalNodeData>

View File

@ -0,0 +1,91 @@
.container-node {
position: relative;
border: 2px dashed rgba(91, 143, 249, 0.6);
border-radius: 16px;
background: rgba(91, 143, 249, 0.04);
padding: 16px;
min-width: 320px;
min-height: 200px;
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.2s;
}
.container-node.is-selected {
border-style: solid;
}
.container-node__header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #1f2329;
}
.container-node__header .title {
display: inline-flex;
align-items: center;
gap: 6px;
}
.container-node__header .actions {
display: flex;
gap: 6px;
}
.ghost-btn {
border: none;
padding: 4px;
background: rgba(0, 0, 0, 0.04);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.ghost-btn:hover {
background: rgba(0, 0, 0, 0.08);
}
.container-node__body {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: 12px;
padding: 12px;
font-size: 12px;
color: #4a4a4a;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.container-node__stats {
display: flex;
justify-content: space-between;
color: #8c8c8c;
font-size: 11px;
}
.container-node__resize {
position: absolute;
right: 8px;
bottom: 8px;
width: 20px;
height: 20px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: se-resize;
}
.container-node.is-resizing .container-node__resize {
background: rgba(91, 143, 249, 0.2);
}
:global(.vue-flow__node-containerNode .vue-flow__handle) {
background: #fff;
border-color: rgba(91, 143, 249, 0.8);
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,123 @@
<template>
<div
class="container-node"
:class="{ 'is-selected': props.selected, 'is-resizing': resizing }"
:style="containerStyle"
ref="containerRef"
>
<div class="container-node__header">
<div class="title">
<Icon icon="mdi:shape-outline" width="16" />
<span>{{ props.data.title ?? '逻辑容器' }}</span>
</div>
<div class="actions">
<el-tooltip content="自适应大小">
<button class="ghost-btn" @click.stop="handleAutoSize">
<Icon icon="mdi:aspect-ratio" width="14" />
</button>
</el-tooltip>
<el-tooltip content="自由缩放">
<button class="ghost-btn" @click.stop="toggleAutoSize">
<Icon :icon="props.data.autoSize ? 'mdi:lock' : 'mdi:lock-open-variant'" width="14" />
</button>
</el-tooltip>
</div>
</div>
<div class="container-node__body">
<p class="description">
{{ props.data.description ?? '将流程节点放入同一容器,统一拖拽与管理。' }}
</p>
<div class="container-node__stats">
<span>{{ childCount }} 个节点</span>
<span>{{ props.data.autoSize ? '自动调整' : '自由缩放' }}</span>
</div>
</div>
<div class="container-node__resize" @mousedown="onResizeStart">
<Icon icon="mdi:cursor-move" width="14" />
</div>
<Handle type="target" :position="Position.Left" />
<Handle type="source" :position="Position.Right" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { Icon } from '@iconify/vue'
import { useFlowStore } from '@/stores/flow'
import type { ContainerNodeProps } from './types'
const props = defineProps<ContainerNodeProps>()
const store = useFlowStore()
const containerRef = ref<HTMLElement>()
const resizing = ref(false)
const startPoint = ref({ x: 0, y: 0 })
const startSize = ref({ width: 0, height: 0 })
const size = computed(() => ({
width: props.data.size?.width ?? 360,
height: props.data.size?.height ?? 240
}))
const containerStyle = computed(() => ({
width: `${size.value.width}px`,
height: `${size.value.height}px`,
borderColor: props.data.color ?? '#5b8ff9'
}))
const childCount = computed(() => store.countNodesInContainer(props.id))
const stopResize = () => {
resizing.value = false
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = (event: MouseEvent) => {
if (!resizing.value) return
const deltaX = event.clientX - startPoint.value.x
const deltaY = event.clientY - startPoint.value.y
const width = Math.max(240, startSize.value.width + deltaX)
const height = Math.max(160, startSize.value.height + deltaY)
store.updateNodeData(props.id, {
size: { width, height },
autoSize: false
})
}
const handleMouseUp = () => {
stopResize()
}
const onResizeStart = (event: MouseEvent) => {
if (props.data.autoSize) return
event.preventDefault()
event.stopPropagation()
resizing.value = true
startPoint.value = { x: event.clientX, y: event.clientY }
startSize.value = { ...size.value }
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
const handleAutoSize = () => {
store.autoSizeContainer(props.id)
}
const toggleAutoSize = () => {
const nextState = !props.data.autoSize
store.updateNodeData(props.id, { autoSize: nextState })
if (nextState) {
store.autoSizeContainer(props.id)
}
}
onBeforeUnmount(() => {
stopResize()
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,16 @@
import type { BaseNodeProps } from '../shared/types'
export interface ContainerNodeSize {
width: number
height: number
}
export interface ContainerNodeData {
title?: string
description?: string
color?: string
autoSize?: boolean
size?: ContainerNodeSize
}
export type ContainerNodeProps = BaseNodeProps<ContainerNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataInputNode) {
min-width: 200px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,45 @@
<template>
<div class="data-input-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
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 { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:database-import',
title: '数据输入',
subtitle: 'Input Stream',
accent: '#52c41a',
handles: {
outputs: [Position.Right]
},
status: ({ data }) => data.status ?? '待运行',
badge: ({ data }) => data.format ?? '自动检测',
body: ({ data }) => [
{ label: '来源', value: data.sourceType ?? '未配置' },
{ label: '路径', value: data.filePath ?? '待绑定' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataInputNodeData {
sourceType?: string
filePath?: string
format?: string
status?: string
}
export type DataInputNodeProps = BaseNodeProps<DataInputNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataOutputNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,45 @@
<template>
<div class="data-output-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DataOutputNodeData } from './types'
const props = defineProps<BaseNodeProps<DataOutputNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:database-export',
title: '数据输出',
subtitle: 'Output Sink',
accent: '#f5222d',
handles: {
inputs: [Position.Left]
},
status: ({ data }) => data.status ?? '待推送',
badge: ({ data }) => data.strategy ?? '即时',
body: ({ data }) => [
{ label: '目标', value: data.destination ?? '未配置' },
{ label: '格式', value: data.format ?? '原样输出' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataOutputNodeData {
destination?: string
format?: string
strategy?: string
status?: string
}
export type DataOutputNodeProps = BaseNodeProps<DataOutputNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataTransferNode) {
min-width: 230px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="data-transfer-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DataTransferNodeData } from './types'
const props = defineProps<BaseNodeProps<DataTransferNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:swap-horizontal',
title: '数据转接',
subtitle: 'Transfer Hub',
accent: '#1890ff',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.protocol ?? '自动',
status: ({ data }) => data.status ?? '就绪',
body: ({ data }) => [
{ label: '源头', value: data.source ?? '未配置' },
{ label: '目标', value: data.target ?? '未配置' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataTransferNodeData {
source?: string
target?: string
protocol?: string
status?: string
}
export type DataTransferNodeProps = BaseNodeProps<DataTransferNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-delayNode) {
min-width: 190px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="delay-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DelayNodeData } from './types'
const props = defineProps<BaseNodeProps<DelayNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:timer-sand',
title: '延迟节点',
subtitle: 'Wait / Backoff',
accent: '#722ed1',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.mode ?? '固定',
status: ({ data }) => data.status ?? '排队',
body: ({ data }) => [
{ label: '时长', value: `${data.duration ?? 0}s` },
{ label: '策略', value: data.mode ?? '固定' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface DelayNodeData {
duration?: number
mode?: string
status?: string
}
export type DelayNodeProps = BaseNodeProps<DelayNodeData>

View File

@ -0,0 +1,11 @@
:global(.vue-flow__node-httpNode) {
min-width: 260px;
}
.url-row .row-value {
max-width: 140px;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,53 @@
<template>
<div class="http-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
>
<template #body>
<div class="standard-node__row">
<span class="row-label">方法</span>
<span class="row-value">{{ props.data.method ?? 'GET' }}</span>
</div>
<div class="standard-node__row url-row">
<span class="row-label">URL</span>
<span class="row-value">{{ props.data.url ?? '未配置' }}</span>
</div>
</template>
</StandardNode>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { HttpNodeData } from './types'
const props = defineProps<BaseNodeProps<HttpNodeData>>()
const { definition,bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:web',
title: 'HTTP 请求',
subtitle: 'REST Hook',
accent: '#fa8c16',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.method ?? 'GET',
status: ({ data }) => data.status ?? '待触发'
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface HttpNodeData {
method?: string
url?: string
status?: string
}
export type HttpNodeProps = BaseNodeProps<HttpNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-imageNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="image-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ImageNodeData } from './types'
const props = defineProps<BaseNodeProps<ImageNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:image-filter-center-focus',
title: '图像处理',
subtitle: 'Vision Ops',
accent: '#d4380d',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.operation ?? '增强',
status: ({ data }) => data.status ?? '等待资源',
body: ({ data }) => [
{ label: '操作', value: data.operation ?? '未配置' },
{ label: '分辨率', value: data.resolution ?? 'Auto' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface ImageNodeData {
operation?: string
resolution?: string
status?: string
}
export type ImageNodeProps = BaseNodeProps<ImageNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-intentNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="intent-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { IntentNodeData } from './types'
const props = defineProps<BaseNodeProps<IntentNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:target',
title: '意图识别',
subtitle: 'Intent Parser',
accent: '#13c2c2',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => `${data.intents?.length ?? 0} 意图`,
status: ({ data }) => data.status ?? '待识别',
body: ({ data }) => [
{ label: '领域', value: data.domain ?? '通用' },
{ label: '置信度', value: `${data.confidence ?? 0}%` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface IntentNodeData {
intents?: string[]
confidence?: number
domain?: string
status?: string
}
export type IntentNodeProps = BaseNodeProps<IntentNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-llmNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="llm-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { LLMNodeData } from './types'
const props = defineProps<BaseNodeProps<LLMNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:robot',
title: '大模型',
subtitle: 'LLM Inference',
accent: '#722ed1',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.model ?? '未选择',
status: ({ data }) => data.status ?? '空闲',
body: ({ data }) => [
{ label: '模型', value: data.model ?? '未配置' },
{ label: '温度', value: `${data.temperature ?? 0.7}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface LLMNodeData {
model?: string
temperature?: number
status?: string
}
export type LLMNodeProps = BaseNodeProps<LLMNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-loopNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="loop-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { LoopNodeData } from './types'
const props = defineProps<BaseNodeProps<LoopNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:repeat-variant',
title: '循环节点',
subtitle: 'Iteration',
accent: '#fa541c',
handles: {
inputs: [Position.Left],
outputs: [Position.Right, Position.Bottom]
},
badge: ({ data }) => `${data.mode ?? '次数'}`,
status: ({ data }) => data.status ?? '迭代中',
body: ({ data }) => [
{ label: '次数', value: `${data.iterations ?? 1}` },
{ label: '变量', value: data.iterator ?? 'item' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface LoopNodeData {
iterations?: number
iterator?: string
mode?: string
status?: string
}
export type LoopNodeProps = BaseNodeProps<LoopNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-mcpNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="mcp-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { MCPNodeData } from './types'
const props = defineProps<BaseNodeProps<MCPNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:power-plug',
title: 'MCP 调用',
subtitle: 'Service Bridge',
accent: '#eb2f96',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.service ?? '未绑定',
status: ({ data }) => data.status ?? '空闲',
body: ({ data }) => [
{ label: '服务', value: data.service ?? '未知' },
{ label: '动作', value: data.action ?? '未配置' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface MCPNodeData {
service?: string
action?: string
status?: string
}
export type MCPNodeProps = BaseNodeProps<MCPNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-scriptNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="script-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ScriptNodeData } from './types'
const props = defineProps<BaseNodeProps<ScriptNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:code-tags',
title: '脚本节点',
subtitle: 'Custom Logic',
accent: '#177ddc',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.language ?? 'TypeScript',
status: ({ data }) => data.status ?? '待执行',
body: ({ data }) => [
{ label: '入口', value: data.entry ?? 'main' },
{ label: '依赖', value: `${data.dependencies ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface ScriptNodeData {
language?: string
entry?: string
dependencies?: number
status?: string
}
export type ScriptNodeProps = BaseNodeProps<ScriptNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-udpNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="udp-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { UdpNodeData } from './types'
const props = defineProps<BaseNodeProps<UdpNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:broadcast',
title: 'UDP 发送',
subtitle: 'Realtime Push',
accent: '#2f54eb',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => `${data.port ?? 0}`,
status: ({ data }) => data.status ?? '监听中',
body: ({ data }) => [
{ label: '主机', value: data.host ?? '0.0.0.0' },
{ label: '端口', value: `${data.port ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface UdpNodeData {
host?: string
port?: number
status?: string
}
export type UdpNodeProps = BaseNodeProps<UdpNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-webhookNode) {
min-width: 240px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="webhook-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { WebhookNodeData } from './types'
const props = defineProps<BaseNodeProps<WebhookNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:webhook',
title: 'Webhook',
subtitle: 'Outbound Hook',
accent: '#52c41a',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.method ?? 'POST',
status: ({ data }) => data.status ?? '监听中',
body: ({ data }) => [
{ label: 'Endpoint', value: data.endpoint ?? '未配置' },
{ label: '重试', value: `${data.retries ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface WebhookNodeData {
endpoint?: string
method?: string
retries?: number
status?: string
}
export type WebhookNodeProps = BaseNodeProps<WebhookNodeData>

View File

@ -0,0 +1,36 @@
import { markRaw } from 'vue'
import AggregationNode from './AggregationNode/index.vue'
import ConditionalNode from './ConditionalNode/index.vue'
import ContainerNode from './ContainerNode/index.vue'
import DataInputNode from './DataInputNode/index.vue'
import DataOutputNode from './DataOutputNode/index.vue'
import DataTransferNode from './DataTransferNode/index.vue'
import DelayNode from './DelayNode/index.vue'
import HttpNode from './HttpNode/index.vue'
import ImageNode from './ImageNode/index.vue'
import IntentNode from './IntentNode/index.vue'
import LLMNode from './LLMNode/index.vue'
import LoopNode from './LoopNode/index.vue'
import MCPNode from './MCPNode/index.vue'
import ScriptNode from './ScriptNode/index.vue'
import UdpNode from './UdpNode/index.vue'
import WebhookNode from './WebhookNode/index.vue'
export const nodeTypes = {
dataInputNode: markRaw(DataInputNode),
dataTransferNode: markRaw(DataTransferNode),
dataOutputNode: markRaw(DataOutputNode),
llmNode: markRaw(LLMNode),
intentNode: markRaw(IntentNode),
mcpNode: markRaw(MCPNode),
httpNode: markRaw(HttpNode),
udpNode: markRaw(UdpNode),
conditionalNode: markRaw(ConditionalNode),
delayNode: markRaw(DelayNode),
loopNode: markRaw(LoopNode),
scriptNode: markRaw(ScriptNode),
imageNode: markRaw(ImageNode),
aggregationNode: markRaw(AggregationNode),
webhookNode: markRaw(WebhookNode),
containerNode: markRaw(ContainerNode)
}

View File

@ -0,0 +1,95 @@
<template>
<div class="standard-node" :class="nodeClasses" :style="nodeStyle">
<div class="standard-node__header">
<div class="standard-node__icon">
<Icon :icon="props.icon" width="18" />
</div>
<div class="standard-node__meta">
<div class="standard-node__title">{{ props.title }}</div>
<div v-if="props.subtitle" class="standard-node__subtitle">{{ props.subtitle }}</div>
</div>
<span v-if="badge" class="standard-node__badge">{{ badge }}</span>
</div>
<div class="standard-node__body">
<slot name="body">
<div
v-for="item in bodyItems"
:key="item.label"
class="standard-node__row"
>
<span class="row-label">{{ item.label }}</span>
<span class="row-value">{{ item.value }}</span>
</div>
</slot>
</div>
<div class="standard-node__footer">
<slot name="footer">
<span v-if="status" class="node-status">
<span class="dot" />
{{ status }}
</span>
</slot>
</div>
<div class="standard-node__handles standard-node__handles--input">
<Handle
v-for="handle in inputHandles"
:key="`input-${handle}`"
type="target"
:position="handle"
/>
</div>
<div class="standard-node__handles standard-node__handles--output">
<Handle
v-for="handle in outputHandles"
:key="`output-${handle}`"
type="source"
:position="handle"
/>
</div>
<div class="standard-node__handles standard-node__handles--bidirectional">
<Handle
v-for="handle in bidirectionalHandles"
:key="`both-${handle}`"
type="source"
:position="handle"
/>
<Handle
v-for="handle in bidirectionalHandles"
:key="`both-target-${handle}`"
type="target"
:position="handle"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { Icon } from '@iconify/vue'
import type { StandardNodeProps } from './types'
const props = defineProps<StandardNodeProps>()
const nodeClasses = computed(() => ({
'is-selected': props.selected
}))
const nodeStyle = computed(() => ({
'--node-accent': props.accent
}))
const mapHandles = (handles?: Position[]) => handles ?? []
const inputHandles = computed(() => mapHandles(props.handles?.inputs))
const outputHandles = computed(() => mapHandles(props.handles?.outputs))
const bidirectionalHandles = computed(() => mapHandles(props.handles?.both))
const bodyItems = computed(() => props.bodyItems ?? [])
const badge = computed(() => props.badge)
const status = computed(() => props.status)
</script>
<style scoped lang="scss">
@use './standard-node.scss';
</style>

View File

@ -0,0 +1,114 @@
.standard-node {
position: relative;
min-width: 160px;
min-height: 96px;
padding: 12px 14px;
background: #fff;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(24, 39, 75, 0.08);
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.standard-node.is-selected {
border-color: var(--node-accent, #409eff);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.25);
}
.standard-node__header {
display: flex;
align-items: center;
gap: 10px;
}
.standard-node__icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(64, 158, 255, 0.08);
color: var(--node-accent, #409eff);
display: flex;
align-items: center;
justify-content: center;
}
.standard-node__meta {
display: flex;
flex-direction: column;
flex: 1;
}
.standard-node__title {
font-size: 14px;
font-weight: 600;
color: #1f2329;
}
.standard-node__subtitle {
font-size: 11px;
color: #8c8c8c;
}
.standard-node__badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(64, 158, 255, 0.12);
color: var(--node-accent, #409eff);
}
.standard-node__body {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #4a4a4a;
}
.standard-node__row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.row-label {
color: #8c8c8c;
}
.row-value {
color: #1f2329;
font-weight: 500;
}
.standard-node__footer {
font-size: 11px;
color: #8c8c8c;
}
.node-status {
display: inline-flex;
align-items: center;
gap: 4px;
}
.node-status .dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--node-accent, #409eff);
display: inline-flex;
}
.standard-node__handles {
pointer-events: none;
}
:deep(.vue-flow__handle) {
background: #fff;
border: 2px solid var(--node-accent, #409eff);
width: 10px;
height: 10px;
}

View File

@ -0,0 +1,42 @@
import type { Position } from '@vue-flow/core'
export interface NodeBodyItem {
label: string
value: string
}
export interface NodeHandleConfig {
inputs?: Position[]
outputs?: Position[]
both?: Position[]
}
export interface StandardNodeProps {
icon: string
title: string
subtitle?: string
badge?: string
accent?: string
status?: string
bodyItems?: NodeBodyItem[]
handles?: NodeHandleConfig
selected?: boolean
}
export interface BaseNodeProps<T = Record<string, any>> {
id: string
label?: string
selected?: boolean
data: T
}
export interface StandardNodeOptions<TData> {
icon: string
title: string
subtitle?: string
accent?: string
handles?: NodeHandleConfig
badge?: string | ((props: BaseNodeProps<TData>) => string | undefined)
status?: (props: BaseNodeProps<TData>) => string | undefined
body?: (props: BaseNodeProps<TData>) => NodeBodyItem[]
}

View File

@ -0,0 +1,28 @@
import { computed } from 'vue'
import type { BaseNodeProps, StandardNodeOptions } from './types'
export function useStandardNode<TData>(
props: BaseNodeProps<TData>,
options: StandardNodeOptions<TData>
) {
const bodyItems = computed(() => {
return options.body ? options.body(props) : []
})
const badge = computed(() => {
if (typeof options.badge === 'function') {
return options.badge(props)
}
return options.badge
})
const status = computed(() => options.status?.(props))
return {
props,
definition: options,
bodyItems,
badge,
status
}
}