feat: add UI-only group node types and enhance workflow graph processing

This commit is contained in:
zhsama
2025-12-22 17:35:33 +08:00
parent fc9d5b2a62
commit 93b516a4ec
11 changed files with 483 additions and 4 deletions

View File

@ -0,0 +1,11 @@
export const CUSTOM_GROUP_NODE = 'custom-group'
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
export const GROUP_CHILDREN_Z_INDEX = 1002
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
CUSTOM_GROUP_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_EXIT_PORT_NODE,
])

View File

@ -0,0 +1,54 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import type { CustomGroupExitPortNodeData } from './types'
import cn from '@/utils/classnames'
type CustomGroupExitPortNodeProps = {
id: string
data: CustomGroupExitPortNodeData
}
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-green-green-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives internal connections from leaf nodes */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to external nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
)
}
export default memo(CustomGroupExitPortNode)

View File

@ -0,0 +1,55 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import type { CustomGroupInputNodeData } from './types'
import cn from '@/utils/classnames'
type CustomGroupInputNodeProps = {
id: string
data: CustomGroupInputNodeData
}
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
return (
<div
className={cn(
'flex items-center justify-center',
'h-8 w-8 rounded-full',
'bg-util-colors-blue-blue-500 shadow-md',
data.selected && 'ring-2 ring-primary-400',
)}
>
{/* Target handle - receives external connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Source handle - connects to entry nodes */}
<Handle
id="source"
type="source"
position={Position.Right}
className="!h-2 !w-2 !border-0 !bg-white"
/>
{/* Icon */}
<svg
className="h-4 w-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
</div>
)
}
export default memo(CustomGroupInputNode)

View File

@ -0,0 +1,49 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
import type { CustomGroupNodeData } from './types'
import cn from '@/utils/classnames'
type CustomGroupNodeProps = {
id: string
data: CustomGroupNodeData
}
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ id: _id, data }) => {
const { group } = data
return (
<div
className={cn(
'bg-workflow-block-parma-bg/50 relative rounded-2xl border-2 border-dashed border-components-panel-border',
data.selected && 'border-primary-400',
)}
style={{
width: data.width || 280,
height: data.height || 200,
}}
>
{/* Group Header */}
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
<span className="text-xs font-medium text-text-tertiary">
{group.title}
</span>
</div>
{/* Target handle for incoming connections */}
<Handle
id="target"
type="target"
position={Position.Left}
className="!h-4 !w-4 !border-2 !border-white !bg-primary-500"
style={{ top: '50%' }}
/>
{/* Source handles will be rendered by exit port nodes */}
</div>
)
}
export default memo(CustomGroupNode)

View File

@ -0,0 +1,19 @@
export {
CUSTOM_GROUP_NODE,
CUSTOM_GROUP_INPUT_NODE,
CUSTOM_GROUP_EXIT_PORT_NODE,
GROUP_CHILDREN_Z_INDEX,
UI_ONLY_GROUP_NODE_TYPES,
} from './constants'
export type {
CustomGroupNodeData,
CustomGroupInputNodeData,
CustomGroupExitPortNodeData,
ExitPortInfo,
GroupMember,
} from './types'
export { default as CustomGroupNode } from './custom-group-node'
export { default as CustomGroupInputNode } from './custom-group-input-node'
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'

View File

@ -0,0 +1,80 @@
import type { BlockEnum } from '../types'
/**
* Exit port info stored in Group node
*/
export type ExitPortInfo = {
portNodeId: string
leafNodeId: string
sourceHandle: string
name: string
}
/**
* Group node data structure
* node.type = 'custom-group'
* node.data.type = '' (empty string to bypass backend NodeType validation)
*/
export type CustomGroupNodeData = {
type: '' // Empty string bypasses backend NodeType validation
title: string
desc?: string
group: {
groupId: string
title: string
memberNodeIds: string[]
entryNodeIds: string[]
inputNodeId: string
exitPorts: ExitPortInfo[]
collapsed: boolean
}
width?: number
height?: number
selected?: boolean
_isTempNode?: boolean
}
/**
* Group Input node data structure
* node.type = 'custom-group-input'
* node.data.type = ''
*/
export type CustomGroupInputNodeData = {
type: ''
title: string
desc?: string
groupInput: {
groupId: string
title: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Exit Port node data structure
* node.type = 'custom-group-exit-port'
* node.data.type = ''
*/
export type CustomGroupExitPortNodeData = {
type: ''
title: string
desc?: string
exitPort: {
groupId: string
leafNodeId: string
sourceHandle: string
name: string
}
selected?: boolean
_isTempNode?: boolean
}
/**
* Member node info for display
*/
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}