init
This commit is contained in:
62
frontend/src/components/ContextMenu.vue
Normal file
62
frontend/src/components/ContextMenu.vue
Normal 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>
|
||||
187
frontend/src/components/ContextMenuList.vue
Normal file
187
frontend/src/components/ContextMenuList.vue
Normal 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>
|
||||
70
frontend/src/components/PropertyPanel.vue
Normal file
70
frontend/src/components/PropertyPanel.vue
Normal 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>
|
||||
3
frontend/src/components/nodes/AggregationNode/index.scss
Normal file
3
frontend/src/components/nodes/AggregationNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-aggregationNode) {
|
||||
min-width: 230px;
|
||||
}
|
||||
1
frontend/src/components/nodes/AggregationNode/index.ts
Normal file
1
frontend/src/components/nodes/AggregationNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/AggregationNode/index.vue
Normal file
46
frontend/src/components/nodes/AggregationNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/AggregationNode/types.ts
Normal file
10
frontend/src/components/nodes/AggregationNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/ConditionalNode/index.scss
Normal file
3
frontend/src/components/nodes/ConditionalNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-conditionalNode) {
|
||||
min-width: 240px;
|
||||
}
|
||||
1
frontend/src/components/nodes/ConditionalNode/index.ts
Normal file
1
frontend/src/components/nodes/ConditionalNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/ConditionalNode/index.vue
Normal file
46
frontend/src/components/nodes/ConditionalNode/index.vue
Normal 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>
|
||||
11
frontend/src/components/nodes/ConditionalNode/types.ts
Normal file
11
frontend/src/components/nodes/ConditionalNode/types.ts
Normal 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>
|
||||
91
frontend/src/components/nodes/ContainerNode/index.scss
Normal file
91
frontend/src/components/nodes/ContainerNode/index.scss
Normal 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);
|
||||
}
|
||||
1
frontend/src/components/nodes/ContainerNode/index.ts
Normal file
1
frontend/src/components/nodes/ContainerNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
123
frontend/src/components/nodes/ContainerNode/index.vue
Normal file
123
frontend/src/components/nodes/ContainerNode/index.vue
Normal 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>
|
||||
16
frontend/src/components/nodes/ContainerNode/types.ts
Normal file
16
frontend/src/components/nodes/ContainerNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/DataInputNode/index.scss
Normal file
3
frontend/src/components/nodes/DataInputNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-dataInputNode) {
|
||||
min-width: 200px;
|
||||
}
|
||||
1
frontend/src/components/nodes/DataInputNode/index.ts
Normal file
1
frontend/src/components/nodes/DataInputNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
45
frontend/src/components/nodes/DataInputNode/index.vue
Normal file
45
frontend/src/components/nodes/DataInputNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/DataInputNode/types.ts
Normal file
10
frontend/src/components/nodes/DataInputNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/DataOutputNode/index.scss
Normal file
3
frontend/src/components/nodes/DataOutputNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-dataOutputNode) {
|
||||
min-width: 220px;
|
||||
}
|
||||
1
frontend/src/components/nodes/DataOutputNode/index.ts
Normal file
1
frontend/src/components/nodes/DataOutputNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
45
frontend/src/components/nodes/DataOutputNode/index.vue
Normal file
45
frontend/src/components/nodes/DataOutputNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/DataOutputNode/types.ts
Normal file
10
frontend/src/components/nodes/DataOutputNode/types.ts
Normal 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>
|
||||
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-dataTransferNode) {
|
||||
min-width: 230px;
|
||||
}
|
||||
1
frontend/src/components/nodes/DataTransferNode/index.ts
Normal file
1
frontend/src/components/nodes/DataTransferNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/DataTransferNode/index.vue
Normal file
46
frontend/src/components/nodes/DataTransferNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/DataTransferNode/types.ts
Normal file
10
frontend/src/components/nodes/DataTransferNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/DelayNode/index.scss
Normal file
3
frontend/src/components/nodes/DelayNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-delayNode) {
|
||||
min-width: 190px;
|
||||
}
|
||||
1
frontend/src/components/nodes/DelayNode/index.ts
Normal file
1
frontend/src/components/nodes/DelayNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/DelayNode/index.vue
Normal file
46
frontend/src/components/nodes/DelayNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/DelayNode/types.ts
Normal file
9
frontend/src/components/nodes/DelayNode/types.ts
Normal 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>
|
||||
11
frontend/src/components/nodes/HttpNode/index.scss
Normal file
11
frontend/src/components/nodes/HttpNode/index.scss
Normal 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;
|
||||
}
|
||||
1
frontend/src/components/nodes/HttpNode/index.ts
Normal file
1
frontend/src/components/nodes/HttpNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
53
frontend/src/components/nodes/HttpNode/index.vue
Normal file
53
frontend/src/components/nodes/HttpNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/HttpNode/types.ts
Normal file
9
frontend/src/components/nodes/HttpNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/ImageNode/index.scss
Normal file
3
frontend/src/components/nodes/ImageNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-imageNode) {
|
||||
min-width: 220px;
|
||||
}
|
||||
1
frontend/src/components/nodes/ImageNode/index.ts
Normal file
1
frontend/src/components/nodes/ImageNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/ImageNode/index.vue
Normal file
46
frontend/src/components/nodes/ImageNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/ImageNode/types.ts
Normal file
9
frontend/src/components/nodes/ImageNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/IntentNode/index.scss
Normal file
3
frontend/src/components/nodes/IntentNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-intentNode) {
|
||||
min-width: 210px;
|
||||
}
|
||||
1
frontend/src/components/nodes/IntentNode/index.ts
Normal file
1
frontend/src/components/nodes/IntentNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/IntentNode/index.vue
Normal file
46
frontend/src/components/nodes/IntentNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/IntentNode/types.ts
Normal file
10
frontend/src/components/nodes/IntentNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/LLMNode/index.scss
Normal file
3
frontend/src/components/nodes/LLMNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-llmNode) {
|
||||
min-width: 220px;
|
||||
}
|
||||
1
frontend/src/components/nodes/LLMNode/index.ts
Normal file
1
frontend/src/components/nodes/LLMNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/LLMNode/index.vue
Normal file
46
frontend/src/components/nodes/LLMNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/LLMNode/types.ts
Normal file
9
frontend/src/components/nodes/LLMNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/LoopNode/index.scss
Normal file
3
frontend/src/components/nodes/LoopNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-loopNode) {
|
||||
min-width: 210px;
|
||||
}
|
||||
1
frontend/src/components/nodes/LoopNode/index.ts
Normal file
1
frontend/src/components/nodes/LoopNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/LoopNode/index.vue
Normal file
46
frontend/src/components/nodes/LoopNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/LoopNode/types.ts
Normal file
10
frontend/src/components/nodes/LoopNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/MCPNode/index.scss
Normal file
3
frontend/src/components/nodes/MCPNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-mcpNode) {
|
||||
min-width: 210px;
|
||||
}
|
||||
1
frontend/src/components/nodes/MCPNode/index.ts
Normal file
1
frontend/src/components/nodes/MCPNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/MCPNode/index.vue
Normal file
46
frontend/src/components/nodes/MCPNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/MCPNode/types.ts
Normal file
9
frontend/src/components/nodes/MCPNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/ScriptNode/index.scss
Normal file
3
frontend/src/components/nodes/ScriptNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-scriptNode) {
|
||||
min-width: 220px;
|
||||
}
|
||||
1
frontend/src/components/nodes/ScriptNode/index.ts
Normal file
1
frontend/src/components/nodes/ScriptNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/ScriptNode/index.vue
Normal file
46
frontend/src/components/nodes/ScriptNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/ScriptNode/types.ts
Normal file
10
frontend/src/components/nodes/ScriptNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/UdpNode/index.scss
Normal file
3
frontend/src/components/nodes/UdpNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-udpNode) {
|
||||
min-width: 210px;
|
||||
}
|
||||
1
frontend/src/components/nodes/UdpNode/index.ts
Normal file
1
frontend/src/components/nodes/UdpNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/UdpNode/index.vue
Normal file
46
frontend/src/components/nodes/UdpNode/index.vue
Normal 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>
|
||||
9
frontend/src/components/nodes/UdpNode/types.ts
Normal file
9
frontend/src/components/nodes/UdpNode/types.ts
Normal 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>
|
||||
3
frontend/src/components/nodes/WebhookNode/index.scss
Normal file
3
frontend/src/components/nodes/WebhookNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:global(.vue-flow__node-webhookNode) {
|
||||
min-width: 240px;
|
||||
}
|
||||
1
frontend/src/components/nodes/WebhookNode/index.ts
Normal file
1
frontend/src/components/nodes/WebhookNode/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {}
|
||||
46
frontend/src/components/nodes/WebhookNode/index.vue
Normal file
46
frontend/src/components/nodes/WebhookNode/index.vue
Normal 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>
|
||||
10
frontend/src/components/nodes/WebhookNode/types.ts
Normal file
10
frontend/src/components/nodes/WebhookNode/types.ts
Normal 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>
|
||||
36
frontend/src/components/nodes/index.ts
Normal file
36
frontend/src/components/nodes/index.ts
Normal 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)
|
||||
}
|
||||
95
frontend/src/components/nodes/shared/StandardNode.vue
Normal file
95
frontend/src/components/nodes/shared/StandardNode.vue
Normal 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>
|
||||
114
frontend/src/components/nodes/shared/standard-node.scss
Normal file
114
frontend/src/components/nodes/shared/standard-node.scss
Normal 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;
|
||||
}
|
||||
42
frontend/src/components/nodes/shared/types.ts
Normal file
42
frontend/src/components/nodes/shared/types.ts
Normal 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[]
|
||||
}
|
||||
28
frontend/src/components/nodes/shared/useStandardNode.ts
Normal file
28
frontend/src/components/nodes/shared/useStandardNode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user