Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion ui/src/workflow-canvas/WORKFLOW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ src/workflow-canvas/
├── config/ # 节点数据、映射、常量及预留的本地化配置
├── core/ # 稳定的画布内核与所有节点共用的基础能力
│ ├── edge/ # 普通边、循环边及边删除按钮
│ └── node-container/ # 节点容器及其私有的条件、操作下拉组件
│ └── node-container/ # 节点容器、锚点按钮及私有的条件、操作下拉组件
├── icons/ # 节点图标及图标解析工具
├── node-menu/ # 基础组件、工具和智能体节点菜单及其菜单配置
├── nodes/ # 已迁入节点的注册文件和 Vue 实现
Expand All @@ -49,6 +49,11 @@ src/workflow-canvas/
`visible-change` 状态,并在节点组件卸载时调用 `reset()`;不要通过 Teleport 到 `body` 或增加
`z-index` 绕过画布的缩放和 SVG 绘制顺序。

锚点按钮与 `el-tooltip` 集中在 `core/node-container/NodeAnchor.vue`。`workflow-node.ts` 负责
锚点坐标、连接状态,并将 LogicFlow 组件的挂载、Props 更新和卸载同步到现有 Vue Teleport
容器;`teleport.connect()` 的可选第五个参数用于传入组件 Props。节点容器
保留菜单开关与外部点击关闭逻辑,不维护锚点 tooltip 的状态或虚拟触发器。

### `config/`

`config` 是随业务持续维护的配置层,增加、删除或调整节点时通常会更新:
Expand Down
2 changes: 1 addition & 1 deletion ui/src/workflow-canvas/core/edge/DeleteEdgeButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const deleteEdge = () => {
</script>
<template>
<div class="workflow-node-delete-icon" @mouseup.stop @click.stop @click="deleteEdge">
<MkIcon name="icon_close_bold_outlined" class="text-white!" />
<MkIcon name="icon_close_bold_outlined" class="text-white!" :size="12"/>
</div>
</template>
<style lang="scss">
Expand Down
41 changes: 41 additions & 0 deletions ui/src/workflow-canvas/core/node-container/NodeAnchor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Model } from '@logicflow/core'
import type { WorkflowNodeModel } from '../workflow-node'

defineOptions({ name: 'WorkflowNodeAnchor' })

const props = defineProps<{
anchorData: Model.AnchorConfig
canOpenNodeMenu: boolean
connected: boolean
nodeModel: WorkflowNodeModel
}>()
const tooltipSuppressed = ref(false)

function openNodeMenu() {
tooltipSuppressed.value = true
if (props.canOpenNodeMenu) props.nodeModel.openNodeMenu?.(props.anchorData)
}
</script>

<template>
<el-tooltip :disabled="!canOpenNodeMenu || tooltipSuppressed" :enterable="false" placement="top">
<template #content>点击添加节点<br />拖拽连接节点</template>
<div
class="workflow-node-anchor"
:class="{
'is-right': anchorData.type === 'right',
'is-connected': connected,
'is-abnormal': anchorData.id?.endsWith('_exception_right'),
}"
:data-node-menu-node-id="nodeModel.id"
:data-node-menu-anchor-id="anchorData.id"
@click="openNodeMenu"
@pointerdown="tooltipSuppressed = true"
@mouseleave="tooltipSuppressed = false"
>
<MkIcon v-if="!connected || anchorData.type === 'right'" name="icon_add_bold_outlined" class="text-white!" :size="12" />
</div>
</el-tooltip>
</template>
21 changes: 0 additions & 21 deletions ui/src/workflow-canvas/core/node-container/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,6 @@ const showAnchor = ref(false)
const nodeMenuDragClosing = ref(false)
const nodeMenuRef = ref<InstanceType<typeof NodeMenu>>()
const anchorData = ref<Model.AnchorConfig>()
const anchorTooltipRef = ref<HTMLElement>()
const anchorTooltipVisible = ref(false)
// 通过虚拟触发器将 LogicFlow 锚点交给 Element Plus 定位提示。
const setAnchorTooltip = (anchorElement?: HTMLElement) => {
if (anchorElement) anchorTooltipRef.value = anchorElement
anchorTooltipVisible.value = Boolean(anchorElement)
}
const dropdownMenuStyle = computed(() => {
return { top: anchorData.value ? anchorData.value.y - model.y + model.height / 2 + 'px' : '0px' }
})
Expand Down Expand Up @@ -247,7 +240,6 @@ const highlightedStepName = (contentText: string) => {
}
}
onMounted(() => {
model.setAnchorTooltip = setAnchorTooltip
model.openNodeMenu = (anchor: Model.AnchorConfig) => {
showAnchor.value && anchorData.value?.id === anchor.id ? closeNodeMenu() : openNodeMenu(anchor)
}
Expand All @@ -273,7 +265,6 @@ const stepContainerRef = ref<HTMLElement>()
onBeforeUnmount(() => {
closeNodeMenu()
model.openNodeMenu = undefined
model.setAnchorTooltip = undefined
disposeSelectionReaction()
resizeObserver?.disconnect()
resizeObserver = null
Expand Down Expand Up @@ -358,18 +349,6 @@ onBeforeUnmount(() => {
</div>
</div>

<el-tooltip
virtual-triggering
:virtual-ref="anchorTooltipRef"
:visible="anchorTooltipVisible && !showAnchor"
:teleported="false"
:enterable="false"
effect="dark"
placement="top"
>
<template #content>点击添加节点<br />拖拽连接节点</template>
</el-tooltip>

<el-collapse-transition>
<NodeMenu
v-if="showAnchor"
Expand Down
12 changes: 10 additions & 2 deletions ui/src/workflow-canvas/core/teleport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ type ProvideFactory<NodeModel extends TeleportNodeModel> = () => TeleportProvide
let active = false
const teleportItems = reactive<Record<string, Component>>({})

export function connect<NodeModel extends TeleportNodeModel>(id: string, component: Component, container: HTMLDivElement, get_provide: ProvideFactory<NodeModel>) {
export function connect<NodeModel extends TeleportNodeModel>(
id: string,
component: Component,
container: HTMLDivElement,
get_provide: ProvideFactory<NodeModel>,
props: object = {},
) {
if (active) {
teleportItems[id] = markRaw(defineComponent({ render: () => h(Teleport, { to: container }, [h(component)]), provide: () => get_provide() }))
teleportItems[id] = markRaw(
defineComponent({ render: () => h(Teleport, { to: container }, [h(component, props)]), provide: () => get_provide() }),
)
}
}

Expand Down
73 changes: 38 additions & 35 deletions ui/src/workflow-canvas/core/workflow-node.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { createApp, reactive, type Component } from 'vue'
import { createApp, reactive, shallowReactive, type Component } from 'vue'
import { cloneDeep } from 'lodash'
import { h as createLogicFlowElement, HtmlNode, HtmlNodeModel, type GraphModel, type IHtmlNodeProperties, type Model } from '@logicflow/core'
import {
Component as LogicFlowComponent,
createRef,
h as createLogicFlowElement,
HtmlNode,
HtmlNodeModel,
type GraphModel,
type IHtmlNodeProperties,
type Model,
} from '@logicflow/core'
import { nodeDict } from '@/workflow-canvas/config/node-mapping'
import { WorkflowKind, WorkflowNodeType, type WorkflowNodeField } from '@/workflow-canvas/types'
import { connect, disconnect } from './teleport'
import NodeAnchor from './node-container/NodeAnchor.vue'

type NodeViewProps = ConstructorParameters<typeof HtmlNode>[0]
type NodeFieldGroup = Record<string, WorkflowNodeField[]>
Expand All @@ -24,6 +34,31 @@ interface WorkflowNodeProperties extends IHtmlNodeProperties {
user_input_field_list?: unknown[]
}

/** 将 LogicFlow 锚点的挂载、更新和卸载同步到 Vue 组件。 */
class WorkflowNodeAnchor extends LogicFlowComponent<InstanceType<typeof NodeAnchor>['$props']> {
private readonly containerRef = createRef<HTMLDivElement>()
private readonly anchorProps = shallowReactive({ ...this.props })
private readonly teleportId = `${this.props.nodeModel.graphModel.flowId}:${this.props.nodeModel.id}:anchor:${this.props.anchorData.id}`

componentDidMount() {
if (this.containerRef.current) {
connect(this.teleportId, NodeAnchor, this.containerRef.current, () => ({}), this.anchorProps)
}
}

componentDidUpdate() {
Object.assign(this.anchorProps, this.props)
}

componentWillUnmount() {
disconnect(this.teleportId)
}

render() {
return createLogicFlowElement('div', { ref: this.containerRef })
}
}

export class WorkflowNodeView extends HtmlNode {
private nodeApp?: ReturnType<typeof createApp>
private readonly vueComponent: Component
Expand Down Expand Up @@ -69,38 +104,7 @@ export class WorkflowNodeView extends HtmlNode {
return createLogicFlowElement(
'foreignObject',
{ ...anchorData, className: 'workflow-node-anchor-wrapper', x: x - 12, y: y - 12, width: 24, height: 24 },
[
createLogicFlowElement(
'div',
{
'data-node-menu-node-id': nodeModel.id,
'data-node-menu-anchor-id': anchorData.id,
className: `workflow-node-anchor${type === 'right' ? ' is-right' : ''}${connected ? ' is-connected' : ''}${anchorData.id?.endsWith('_exception_right') ? ' is-abnormal' : ''}`,
onClick: () => {
if (canOpenNodeMenu) nodeModel.openNodeMenu?.(anchorData)
},
onMouseEnter: (event: MouseEvent) => {
if (canOpenNodeMenu && event.currentTarget instanceof HTMLElement) nodeModel.setAnchorTooltip?.(event.currentTarget)
},
onMouseLeave: () => nodeModel.setAnchorTooltip?.(),
onPointerDown: () => nodeModel.setAnchorTooltip?.(),
},
connected && type !== 'right'
? []
: [
createLogicFlowElement(
'svg',
{
'aria-hidden': 'true',
className: 'workflow-node-add-icon',
fill: 'currentColor',
focusable: 'false',
},
[createLogicFlowElement('use', { href: '#icon_add_bold_outlined' })],
),
],
),
],
[createLogicFlowElement(WorkflowNodeAnchor, { key: anchorData.id, anchorData, canOpenNodeMenu, connected, nodeModel })],
)
}

Expand Down Expand Up @@ -134,7 +138,6 @@ export class WorkflowNodeModel extends HtmlNodeModel<WorkflowNodeProperties> {
private upNodeFieldDict?: NodeFieldGroup

openNodeMenu?: (anchorData: Model.AnchorConfig) => void
setAnchorTooltip?: (anchorElement?: HTMLElement) => void
validate?: () => Promise<unknown>

setAttributes() {
Expand Down
9 changes: 0 additions & 9 deletions ui/src/workflow-canvas/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
}
}


/* anchor */
// 连接节点按钮
.workflow-node-anchor-wrapper {
Expand Down Expand Up @@ -73,12 +72,4 @@
border: 2px solid var(--el-color-warning);
}

.workflow-node-add-icon {
height: calc(var(--spacing) * 6);
transform-origin: center;
width: calc(var(--spacing) * 6);
color: white;
padding: 4px;
transition: opacity 0.2s ease;
}
}
Loading