Procházet zdrojové kódy

refactor: 重构节点

jiaxing.liao před 12 hodinami
rodič
revize
f7f4d6e4d0

+ 81 - 0
apps/web/components.d.ts

@@ -5,6 +5,7 @@
 // ------
 // Generated by unplugin-vue-components
 // Read more: https://github.com/vuejs/core/pull/3399
+import { GlobalComponents } from 'vue'
 
 export {}
 
@@ -92,3 +93,83 @@ declare module 'vue' {
     vLoading: typeof import('element-plus/es')['ElLoadingDirective']
   }
 }
+
+// For TSX support
+declare global {
+  const BranchCard: typeof import('./src/components/SetterCommon/condition/BranchCard.vue')['default']
+  const CodeEditor: typeof import('./src/components/SetterCommon/Code/CodeEditor.vue')['default']
+  const CodeSetter: typeof import('./src/components/setter/CodeSetter.vue')['default']
+  const ConditionBuilder: typeof import('./src/components/SetterCommon/condition/ConditionBuilder.vue')['default']
+  const ConditionSetter: typeof import('./src/components/setter/ConditionSetter.vue')['default']
+  const CustomDropdown: typeof import('./src/components/CustomDropdown/index.vue')['default']
+  const DatabaseSetter: typeof import('./src/components/setter/DatabaseSetter.vue')['default']
+  const ElAside: typeof import('element-plus/es')['ElAside']
+  const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+  const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
+  const ElButton: typeof import('element-plus/es')['ElButton']
+  const ElCard: typeof import('element-plus/es')['ElCard']
+  const ElCol: typeof import('element-plus/es')['ElCol']
+  const ElCollapse: typeof import('element-plus/es')['ElCollapse']
+  const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+  const ElContainer: typeof import('element-plus/es')['ElContainer']
+  const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+  const ElDialog: typeof import('element-plus/es')['ElDialog']
+  const ElDivider: typeof import('element-plus/es')['ElDivider']
+  const ElDorpdown: typeof import('element-plus/es')['ElDorpdown']
+  const ElDrawer: typeof import('element-plus/es')['ElDrawer']
+  const ElDropdown: typeof import('element-plus/es')['ElDropdown']
+  const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+  const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+  const ElEmpty: typeof import('element-plus/es')['ElEmpty']
+  const ElFooter: typeof import('element-plus/es')['ElFooter']
+  const ElForm: typeof import('element-plus/es')['ElForm']
+  const ElFormItem: typeof import('element-plus/es')['ElFormItem']
+  const ElHeader: typeof import('element-plus/es')['ElHeader']
+  const ElIcon: typeof import('element-plus/es')['ElIcon']
+  const ElInput: typeof import('element-plus/es')['ElInput']
+  const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+  const ElInputTag: typeof import('element-plus/es')['ElInputTag']
+  const ElMain: typeof import('element-plus/es')['ElMain']
+  const ElMenu: typeof import('element-plus/es')['ElMenu']
+  const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+  const ElOption: typeof import('element-plus/es')['ElOption']
+  const ElPagination: typeof import('element-plus/es')['ElPagination']
+  const ElPopover: typeof import('element-plus/es')['ElPopover']
+  const ElRadio: typeof import('element-plus/es')['ElRadio']
+  const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+  const ElRow: typeof import('element-plus/es')['ElRow']
+  const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
+  const ElSelect: typeof import('element-plus/es')['ElSelect']
+  const ElSlider: typeof import('element-plus/es')['ElSlider']
+  const ElSplitter: typeof import('element-plus/es')['ElSplitter']
+  const ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
+  const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
+  const ElSwitch: typeof import('element-plus/es')['ElSwitch']
+  const ElTab: typeof import('element-plus/es')['ElTab']
+  const ElTable: typeof import('element-plus/es')['ElTable']
+  const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+  const ElTabPane: typeof import('element-plus/es')['ElTabPane']
+  const ElTabs: typeof import('element-plus/es')['ElTabs']
+  const ElTag: typeof import('element-plus/es')['ElTag']
+  const ElTooltip: typeof import('element-plus/es')['ElTooltip']
+  const ElTooltop: typeof import('element-plus/es')['ElTooltop']
+  const ErrorHandling: typeof import('./src/components/SetterCommon/Code/ErrorHandling.vue')['default']
+  const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
+  const HttpSetter: typeof import('./src/components/setter/HttpSetter.vue')['default']
+  const InputVariables: typeof import('./src/components/SetterCommon/Code/InputVariables.vue')['default']
+  const NodeToolBar: typeof import('../../packages/workflow/src/components/elements/node-tool-bar/index.vue')['default']
+  const NodeTools: typeof import('./src/components/NodeTools/index.vue')['default']
+  const OutputVariables: typeof import('./src/components/SetterCommon/Code/OutputVariables.vue')['default']
+  const RouterLink: typeof import('vue-router')['RouterLink']
+  const RouterView: typeof import('vue-router')['RouterView']
+  const RunWork: typeof import('./src/components/RunWorkflow/RunWork.vue')['default']
+  const RunWorkflow: typeof import('./src/components/RunWorkflow/index.vue')['default']
+  const SearchDialog: typeof import('./src/components/SearchDialog/index.vue')['default']
+  const Setter: typeof import('./src/components/setter/index.vue')['default']
+  const Sidebar: typeof import('./src/components/Sidebar/index.vue')['default']
+  const SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
+  const TabGroup: typeof import('./src/components/SetterCommon/Code/TabGroup.vue')['default']
+  const TemplateModal: typeof import('./src/components/TemplateModal/index.vue')['default']
+  const TestConfig: typeof import('./src/components/SetterCommon/Code/TestConfig.vue')['default']
+  const VariablePicker: typeof import('./src/components/SetterCommon/condition/VariablePicker.vue')['default']
+}

+ 6 - 5
apps/web/src/components/setter/index.vue

@@ -17,10 +17,10 @@ import ConditionSetter from './ConditionSetter.vue'
 import DatabaseSetter from './DatabaseSetter.vue'
 
 const setterMap = {
-	'http-node': HttpSetter,
-	'code-node': CodeSetter,
-	'condition-node': ConditionSetter,
-	'database-node': DatabaseSetter
+	'http-request': HttpSetter,
+	code: CodeSetter,
+	condition: ConditionSetter,
+	database: DatabaseSetter
 }
 
 // 异步加载映射
@@ -47,7 +47,8 @@ const node = computed(() => {
 })
 
 const setter = computed(() => {
-	return node.value?.type && setterMap[node.value.type as keyof typeof setterMap]
+	console.log(node.value)
+	return node.value?.data?.nodeType && setterMap[node.value.data.nodeType as keyof typeof setterMap]
 })
 
 const closeDrawer = () => {

+ 4 - 0
packages/nodes/Interface.ts

@@ -148,6 +148,10 @@ export interface INodeType {
 	 * 节点自定义渲染
 	 */
 	render?: (renderCallbackParams: RenderCallbackParams) => Element | VNode
+	/**
+	 * 配置校验
+	 */
+	validate?: (data: any) => boolean | string | Promise<boolean | string>
 	// 其他配置
 	[key: string]: any
 }

+ 3 - 3
packages/nodes/materials/code.tsx

@@ -17,9 +17,9 @@ export const codeNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'code',
 		zIndex: 1,
 		data: {

+ 7 - 5
packages/nodes/materials/condition.ts

@@ -7,8 +7,10 @@ export const conditionNode: INodeType = {
 	description: '根据条件判断',
 	icon: 'lucide:trending-up-down',
 	iconColor: '#b33be6',
-	inputs: [],
-	outputs: [NodeConnectionTypes.main],
+	inputs: [NodeConnectionTypes.main],
+	outputs: (data: any) => {
+		return [NodeConnectionTypes.main]
+	},
 	// 业务数据
 	schema: {
 		appAgentId: '',
@@ -17,9 +19,9 @@ export const conditionNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'condition',
 		zIndex: 1,
 		data: {}

+ 6 - 3
packages/nodes/materials/database.ts

@@ -9,6 +9,9 @@ export const databaseNode: INodeType = {
 	iconColor: '#64dc34',
 	inputs: [NodeConnectionTypes.main],
 	outputs: [NodeConnectionTypes.main],
+	validate: (data: any) => {
+		return !data?.table && '请选择数据表!'
+	},
 	// 业务数据
 	schema: {
 		appAgentId: '',
@@ -17,9 +20,9 @@ export const databaseNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'database',
 		zIndex: 1,
 		data: {}

+ 3 - 3
packages/nodes/materials/end.ts

@@ -17,9 +17,9 @@ export const endNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'end',
 		zIndex: 1,
 		data: {}

+ 9 - 4
packages/nodes/materials/http.ts

@@ -78,7 +78,12 @@ export const httpNode: INodeType = {
 	inputs: [NodeConnectionTypes.main],
 	outputs: (data: HttpData) => {
 		// todo: 判断异常处理,如果是分支,添加异常出口
-		return [NodeConnectionTypes.main]
+		return data?.error_strategy === 'exception'
+			? [NodeConnectionTypes.main, NodeConnectionTypes.main]
+			: [NodeConnectionTypes.main]
+	},
+	validate: (data: HttpData) => {
+		return !!data?.url.trim() ? false : '请填写URL'
 	},
 	// 业务数据
 	schema: {
@@ -88,9 +93,9 @@ export const httpNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'http-request',
 		zIndex: 1,
 		data: {

+ 3 - 3
packages/nodes/materials/start.ts

@@ -17,9 +17,9 @@ export const startNode: INodeType = {
 			x: 20,
 			y: 30
 		},
-		width: 280,
-		height: 60,
-		selected: true,
+		width: 96,
+		height: 96,
+		selected: false,
 		nodeType: 'start',
 		zIndex: 1,
 		data: {}

+ 2 - 1
packages/workflow/package.json

@@ -19,6 +19,7 @@
     "@vue-flow/minimap": "^1.5.4",
     "@vue-flow/node-resizer": "^1.5.0",
     "@vue-flow/node-toolbar": "^1.1.1",
+    "@vueuse/core": "^14.2.0",
     "less": "^4.5.1",
     "less-loader": "^12.3.0",
     "normalize.css": "^8.0.1",
@@ -26,9 +27,9 @@
     "vue": "^3.5.24"
   },
   "devDependencies": {
+    "@repo/nodes": "workspace:*",
     "@repo/typescript-config": "workspace:*",
     "@repo/ui": "workspace:*",
-    "@repo/nodes": "workspace:*",
     "@types/node": "^24.10.1",
     "@vitejs/plugin-vue": "^6.0.1",
     "@vue/tsconfig": "^0.8.1",

+ 93 - 19
packages/workflow/src/components/Canvas.vue

@@ -12,18 +12,13 @@ import type { NodeMouseEvent, Connection, NodeDragEvent } from '@vue-flow/core'
 import { ref, onMounted, computed, provide } from 'vue'
 import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
 import { MiniMap } from '@vue-flow/minimap'
+import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core'
 
 import CanvasNode from './elements/nodes/CanvasNode.vue'
 import CanvasEdge from './elements/edges/CanvasEdge.vue'
 import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'
 import CanvasBackground from './elements/background/CanvasBackground.vue'
 import CanvasControlBar from './elements/control-bar/CanvasControlBar.vue'
-import ConditionNode from './elements/node-temp/ConditionNode.vue'
-import StartNode from './elements/node-temp/StartNode.vue'
-import HttpNode from './elements/node-temp/HttpNode1.vue'
-import EndNode from './elements/node-temp/EndNode.vue'
-import CodeNode from './elements/node-temp/CodeNode.vue'
-import DataBaseNode from './elements/node-temp/DataBaseNode.vue'
 
 defineOptions({
 	name: 'workflow-canvas'
@@ -47,6 +42,7 @@ const emit = defineEmits<{
 	'update:logs:output-open': [open?: boolean]
 	'update:has-range-selection': [isActive: boolean]
 	'click:node': [id: string, position: XYPosition]
+	'dblclick:node': [id: string, position: XYPosition]
 	'click:node:add': [id: string, handle: string]
 	'initialized:nodes': []
 	'run:node': [id: string]
@@ -93,7 +89,20 @@ const props = withDefaults(
 const showMinimap = ref(false)
 const vueFlow = useVueFlow(props.id)
 
-const { viewport, viewportRef, project, zoomIn, zoomOut, fitView, zoomTo } = vueFlow
+const {
+	viewport,
+	viewportRef,
+	project,
+	zoomIn,
+	zoomOut,
+	fitView,
+	zoomTo,
+	onNodeMouseEnter,
+	onNodeMouseLeave,
+	onEdgeMouseEnter,
+	onEdgeMouseLeave,
+	onEdgeMouseMove
+} = vueFlow
 
 const nodeDataById = computed((): Record<string, IWorkflowNode['data']> => {
 	return props.nodes.reduce<Record<string, IWorkflowNode['data']>>((acc, node) => {
@@ -102,6 +111,57 @@ const nodeDataById = computed((): Record<string, IWorkflowNode['data']> => {
 	}, {})
 })
 
+/**
+ * Edge and Nodes Hovering
+ */
+
+const edgesHoveredById = ref<Record<string, boolean>>({})
+const edgesBringToFrontById = ref<Record<string, boolean>>({})
+
+onEdgeMouseEnter(({ edge }) => {
+	edgesBringToFrontById.value = { [edge.id]: true }
+	edgesHoveredById.value = { [edge.id]: true }
+})
+
+onEdgeMouseMove(
+	useThrottleFn(({ edge, event }) => {
+		const type = edge.data.source.type
+		if (type !== 'ai_tool') {
+			return
+		}
+
+		if (!edge.data.maxConnections || edge.data.maxConnections > 1) {
+			const projectedPosition = getProjectedPosition(event)
+			const yDiff = projectedPosition.y - edge.targetY
+			if (yDiff < 4 * 16) {
+				edgesBringToFrontById.value = { [edge.id]: false }
+			} else {
+				edgesBringToFrontById.value = { [edge.id]: true }
+			}
+		}
+	}, 100)
+)
+
+onEdgeMouseLeave(({ edge }) => {
+	edgesBringToFrontById.value = { [edge.id]: false }
+	edgesHoveredById.value = { [edge.id]: false }
+})
+
+function onUpdateEdgeLabelHovered(id: string, hovered: boolean) {
+	edgesBringToFrontById.value = { [id]: true }
+	edgesHoveredById.value[id] = hovered
+}
+
+const nodesHoveredById = ref<Record<string, boolean>>({})
+
+onNodeMouseEnter(({ node }) => {
+	nodesHoveredById.value = { [node.id]: true }
+})
+
+onNodeMouseLeave(({ node }) => {
+	nodesHoveredById.value = { [node.id]: false }
+})
+
 /**
  * Returns the position of a mouse or touch event
  */
@@ -130,6 +190,10 @@ const onNodeClick = ({ node, event }: NodeMouseEvent) => {
 	emit('click:node', node.id, getProjectedPosition(event))
 }
 
+const onNodeDoubleClick = ({ node, event }: NodeMouseEvent) => {
+	emit('dblclick:node', node.id, getProjectedPosition(event))
+}
+
 function onDrop(event: DragEvent) {
 	const position = getProjectedPosition(event)
 
@@ -258,6 +322,7 @@ defineExpose({
 		snap-to-grid
 		:snap-grid="[16, 16]"
 		@node-click="onNodeClick"
+		@node-double-click="onNodeDoubleClick"
 		@node-drag-stop="onNodeDragStop"
 		@drop="onDrop"
 		@connect="onConnect"
@@ -267,21 +332,30 @@ defineExpose({
 		v-bind="$attrs"
 	>
 		<template #node-canvas-node="nodeProps">
-			<CanvasNode
-				v-bind="nodeProps"
-				:data="nodeDataById[nodeProps.id]!"
-				@move="onUpdateNodePosition"
-				@update="onUpdateNodeAttrs"
-			/>
+			<slot name="node" v-bind="{ nodeProps }">
+				<CanvasNode
+					v-bind="nodeProps"
+					:data="nodeDataById[nodeProps.id]!"
+					:read-only="readOnly"
+					:hovered="nodesHoveredById[nodeProps.id]"
+					@move="onUpdateNodePosition"
+					@update="onUpdateNodeAttrs"
+				/>
+			</slot>
 		</template>
 
 		<template #edge-canvas-edge="edgeProps">
-			<CanvasEdge
-				v-bind="edgeProps"
-				marker-end="url(#custom-arrow-head-marker)"
-				@add="onClickConnectionAdd"
-				@delete="onDeleteConnection"
-			/>
+			<slot name="edge" v-bind="{ edgeProps }">
+				<CanvasEdge
+					v-bind="edgeProps"
+					:read-only="readOnly"
+					:hovered="edgesHoveredById[edgeProps.id]"
+					marker-end="url(#custom-arrow-head-marker)"
+					@add="onClickConnectionAdd"
+					@delete="onDeleteConnection"
+					@update:label:hovered="onUpdateEdgeLabelHovered(edgeProps.id, $event)"
+				/>
+			</slot>
 		</template>
 
 		<template #background>

+ 4 - 4
packages/workflow/src/components/elements/handles/HandlePort.vue

@@ -31,22 +31,22 @@ defineProps<{
 
 .renderType {
 	&.top {
-		margin-bottom: -12px;
+		margin-bottom: -16px;
 		transform: translate(0%, -50%);
 	}
 
 	&.right {
-		margin-left: -12px;
+		margin-left: -16px;
 		transform: translate(50%, 0%);
 	}
 
 	&.left {
-		margin-right: -12px;
+		margin-right: -16px;
 		transform: translate(-50%, 0%);
 	}
 
 	&.bottom {
-		margin-top: -12px;
+		margin-top: -16px;
 		transform: translate(0%, 50%);
 	}
 }

+ 30 - 16
packages/workflow/src/components/elements/node-tool-bar/index.vue

@@ -1,26 +1,40 @@
 <script setup lang="ts">
 import { Icon } from '@repo/ui'
-import {ref} from 'vue'
+import { ref } from 'vue'
 const barState = ref(false)
-const more = ()=>{
+const more = () => {
 	barState.value = !barState.value
 }
-const BarHandleClick = (state:string)=>{
+const BarHandleClick = (state: string) => {
 	console.log(state)
 	barState.value = false
-	if(state==='node-edit'){
-
+	if (state === 'node-edit') {
 	}
 }
 </script>
 
 <template>
-	<div class="node-tools relative" >
-		<div class="bar flex items-center bg-white shadow-lg shadow-gray-200 rounded-xl">
-			<Icon icon="lucide:play" width="20" height="20" class="text-gray-400 p-2 hover:cursor-pointer hover:bg-gray-200" @click="BarHandleClick('node-run')" />
-			<Icon icon="lucide:ellipsis" width="20" height="20" class="text-gray-400 p-2 hover:cursor-pointer hover:bg-gray-200" @click="more"  />
+	<div class="node-tools relative">
+		<div class="bar flex items-center">
+			<Icon
+				icon="lucide:play"
+				width="12"
+				height="12"
+				class="text-gray-400 p-2 hover:cursor-pointer hover:bg-gray-200"
+				@click="BarHandleClick('node-run')"
+			/>
+			<Icon
+				icon="lucide:ellipsis"
+				width="12"
+				height="12"
+				class="text-gray-400 p-2 hover:cursor-pointer hover:bg-gray-200"
+				@click.stop="more"
+			/>
 		</div>
-		<div class="modal absolute -right-26 z-100  bg-white rounded-lg  shadow-xl shadow-gray-200" v-show="barState">
+		<div
+			class="modal absolute -right-26 z-100 bg-white rounded-lg shadow-xl shadow-gray-200"
+			v-show="barState"
+		>
 			<ul class="text-sm">
 				<li @click="BarHandleClick('node-run')">
 					<p>运行此步骤</p>
@@ -43,23 +57,23 @@ const BarHandleClick = (state:string)=>{
 </template>
 
 <style scoped lang="less">
-.node-tools{
-	.modal{
+.node-tools {
+	.modal {
 		width: 240px;
-		ul{
+		ul {
 			margin: 0;
 			padding: 0;
 		}
-		li{
+		li {
 			padding: 6px;
 			text-align: left;
 			list-style: none;
 			border-bottom: 1px solid #eee;
 			p {
 				margin: 0;
-				padding:10px 20px;
+				padding: 10px 20px;
 				border-radius: 12px;
-				&:hover{
+				&:hover {
 					cursor: pointer;
 					background: #e5eff6;
 				}

+ 2 - 3
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -54,7 +54,7 @@ const createEndpoint = (data: {
  */
 const inputs = computed(() => {
 	const getInputs = nodeMap[props.data.nodeType]?.inputs
-	const inputs = typeof getInputs === 'function' ? getInputs(props.data) : getInputs || []
+	const inputs = typeof getInputs === 'function' ? getInputs(props.data?.data) : getInputs || []
 
 	return (inputs as CanvasConnectionPort[]).map((target, index) =>
 		createEndpoint({
@@ -72,8 +72,7 @@ const inputs = computed(() => {
  */
 const outputs = computed(() => {
 	const getOutputs = nodeMap[props.data.nodeType]?.outputs
-	const outputs = typeof getOutputs === 'function' ? getOutputs(props.data) : getOutputs || []
-
+	const outputs = typeof getOutputs === 'function' ? getOutputs(props.data?.data) : getOutputs || []
 	return (outputs as CanvasConnectionPort[]).map((target, index) =>
 		createEndpoint({
 			port: target,

+ 0 - 45
packages/workflow/src/components/elements/nodes/render-types/NodeDefault copy.vue

@@ -1,45 +0,0 @@
-<template>
-	<div
-		:class="nodeClass"
-		class="default-node w-96px h-96px bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-8px relative"
-	>
-		<div className="w-full h-full relative flex items-center justify-center">
-			<Icon :icon="data?.icon" height="40" width="40" :color="data?.iconColor" />
-		</div>
-
-		<div className="absolute w-full bottom--24px text-12px text-center text-#333">
-			<div>{{ data?.displayName }}</div>
-			<div className="absolute w-full bottom--40px text-12px text-center text-#999 truncate">
-				{{ data?.subtitle }}
-			</div>
-		</div>
-	</div>
-</template>
-
-<script setup lang="ts">
-import { inject, computed, type Ref } from 'vue'
-import { Icon } from '@repo/ui'
-
-import type { NodeProps } from '@vue-flow/core'
-import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
-
-const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']>
-	inputs?: Ref<CanvasConnectionPort[]>
-	outputs?: Ref<CanvasConnectionPort[]>
-}>('canvas-node-data')
-
-const nodeClass = computed(() => {
-	let classes: string[] = []
-	if (node?.props?.selected) {
-		classes.push('ring-6px', 'ring-#e0e2e7')
-	}
-	if (node?.inputs?.value?.length === 0) {
-		classes.push('rounded-l-36px')
-	}
-
-	return classes
-})
-
-const data = computed<IWorkflowNode['data'] | undefined>(() => node?.props?.data)
-</script>

+ 85 - 38
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -1,61 +1,108 @@
 <template>
-	<div class="relative min-w-[240px] transition-all duration-300 ease-out">
-		<!-- 节点主体 -->
-		<div
-			class="bg-gradient-to-br from-white to-blue-50 border-2 rounded-xl shadow-md transition-all duration-300 relative overflow-hidden"
-			:class="
-				node?.props?.selected
-					? 'border-blue-500 shadow-blue-200 shadow-lg'
-					: 'border-blue-300 hover:shadow-lg hover:shadow-blue-100'
-			"
-		>
-			<!-- 头部 -->
-			<div class="flex items-center gap-3 px-4 py-3 border-b border-blue-100">
-				<!-- 图标 -->
+	<div
+		:class="nodeClass"
+		class="default-node w-96px h-96px bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-8px relative"
+	>
+		<div className="w-full h-full relative flex items-center justify-center">
+			<div
+				class="relative flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg"
+				:style="{ background: nodeType?.iconColor }"
+			>
 				<div
-					class="relative flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg"
-					:style="{ background: nodeType?.iconColor }"
-				>
-					<div
-						class="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent rounded-lg"
-					></div>
-					<Icon
-						:icon="nodeType?.icon ?? 'lucide:cloud'"
-						color="#ffffff"
-						class="relative z-10"
-						:size="20"
-					/>
-				</div>
-
-				<!-- 标题 -->
-				<div class="flex-1 min-w-0">
-					<div class="text-sm font-semibold text-gray-800 truncate">
-						{{ data?.title || nodeType?.displayName || '节点标题' }}
-					</div>
-					<div v-if="node?.props?.data?.subtitle" class="text-xs text-gray-500 mt-0.5 truncate">
-						{{ data?.subtitle }}
-					</div>
-				</div>
+					class="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent rounded-lg"
+				></div>
+				<Icon
+					:icon="nodeType?.icon ?? 'lucide:cloud'"
+					color="#ffffff"
+					class="relative z-10"
+					:size="20"
+				/>
+			</div>
+		</div>
+
+		<!-- toolbar -->
+		<div class="tool-bar absolute -top-9 right-0 h-7 pb-1 transition-all duration-300 ease-out">
+			<NodeToolbar v-if="delayedHovered" />
+		</div>
+
+		<!-- warning -->
+		<el-tooltip>
+			<template #content>{{ warningInfo || '请检查配置' }}</template>
+			<div v-if="warningInfo" class="absolute right-10px bottom-0">
+				<Icon icon="clarity:warning-solid" color="#ff4d4f" size="16" />
+			</div>
+		</el-tooltip>
+
+		<div className="absolute w-full bottom--24px text-12px text-center text-#333">
+			<div>{{ data?.title || nodeType?.displayName || '节点标题' }}</div>
+			<div className="text-12px text-center text-#999 truncate">
+				{{ data?.subtitle }}
 			</div>
 		</div>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { inject, computed, type Ref } from 'vue'
+import { ref, inject, computed, watch, type Ref } from 'vue'
 import { Icon } from '@repo/ui'
 import { nodeMap } from '@repo/nodes'
+import NodeToolbar from '../../node-tool-bar/index.vue'
 
 import type { NodeProps } from '@vue-flow/core'
 import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
 
 const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']>
+	props?: NodeProps<IWorkflowNode['data']> & {
+		readOnly?: boolean
+		hovered?: boolean
+	}
 	inputs?: Ref<CanvasConnectionPort[]>
 	outputs?: Ref<CanvasConnectionPort[]>
 }>('canvas-node-data')
 
+const nodeClass = computed(() => {
+	let classes: string[] = []
+	if (node?.props?.selected) {
+		classes.push('ring-6px', 'ring-#e0e2e7')
+	}
+	if (node?.inputs?.value?.length === 0) {
+		classes.push('rounded-l-36px')
+	}
+	if (node?.outputs?.value?.length === 0) {
+		classes.push('rounded-r-36px')
+	}
+
+	return classes
+})
+
 const data = computed<IWorkflowNode['data'] | undefined>(() => node?.props?.data)
 
 const nodeType = computed(() => nodeMap[data.value?.nodeType!])
+
+const warningInfo = computed(() => {
+	const validate = nodeType.value?.validate
+	return validate && validate(data.value?.data)
+})
+
+const delayedHovered = ref()
+const delayedHoveredSetTimeoutRef = ref<ReturnType<typeof setTimeout> | null>(null)
+const delayedHoveredTimeout = 600
+
+watch(
+	() => node?.props?.hovered,
+	(isHovered) => {
+		console.log('isHovered', isHovered)
+		if (isHovered) {
+			if (delayedHoveredSetTimeoutRef.value) clearTimeout(delayedHoveredSetTimeoutRef.value)
+			delayedHovered.value = true
+		} else {
+			delayedHoveredSetTimeoutRef.value = setTimeout(() => {
+				delayedHovered.value = false
+			}, delayedHoveredTimeout)
+		}
+	},
+	{ immediate: true }
+)
+
+const renderToolbar = computed(() => delayedHovered.value && !node?.props?.readOnly)
 </script>

+ 61 - 0
packages/workflow/src/components/elements/nodes/render-types/NodeDefault_backup.vue

@@ -0,0 +1,61 @@
+<template>
+	<div class="relative min-w-[240px] transition-all duration-300 ease-out">
+		<!-- 节点主体 -->
+		<div
+			class="bg-gradient-to-br from-white to-blue-50 border-2 rounded-xl shadow-md transition-all duration-300 relative overflow-hidden"
+			:class="
+				node?.props?.selected
+					? 'border-blue-500 shadow-blue-200 shadow-lg'
+					: 'border-blue-300 hover:shadow-lg hover:shadow-blue-100'
+			"
+		>
+			<!-- 头部 -->
+			<div class="flex items-center gap-3 px-4 py-3 border-b border-blue-100">
+				<!-- 图标 -->
+				<div
+					class="relative flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg"
+					:style="{ background: nodeType?.iconColor }"
+				>
+					<div
+						class="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent rounded-lg"
+					></div>
+					<Icon
+						:icon="nodeType?.icon ?? 'lucide:cloud'"
+						color="#ffffff"
+						class="relative z-10"
+						:size="20"
+					/>
+				</div>
+
+				<!-- 标题 -->
+				<div class="flex-1 min-w-0">
+					<div class="text-sm font-semibold text-gray-800 truncate">
+						{{ data?.title || nodeType?.displayName || '节点标题' }}
+					</div>
+					<div v-if="node?.props?.data?.subtitle" class="text-xs text-gray-500 mt-0.5 truncate">
+						{{ data?.subtitle }}
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { inject, computed, type Ref } from 'vue'
+import { Icon } from '@repo/ui'
+import { nodeMap } from '@repo/nodes'
+
+import type { NodeProps } from '@vue-flow/core'
+import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+
+const node = inject<{
+	props?: NodeProps<IWorkflowNode['data']>
+	inputs?: Ref<CanvasConnectionPort[]>
+	outputs?: Ref<CanvasConnectionPort[]>
+}>('canvas-node-data')
+
+const data = computed<IWorkflowNode['data'] | undefined>(() => node?.props?.data)
+
+const nodeType = computed(() => nodeMap[data.value?.nodeType!])
+</script>

+ 29 - 0
pnpm-lock.yaml

@@ -372,6 +372,9 @@ importers:
       '@vue-flow/node-toolbar':
         specifier: ^1.1.1
         version: 1.1.1(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
+      '@vueuse/core':
+        specifier: ^14.2.0
+        version: 14.2.0(vue@3.5.27(typescript@5.9.3))
       less:
         specifier: ^4.5.1
         version: 4.5.1
@@ -3083,12 +3086,20 @@ packages:
     peerDependencies:
       vue: ^3.5.0
 
+  '@vueuse/core@14.2.0':
+    resolution: {integrity: sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==}
+    peerDependencies:
+      vue: ^3.5.0
+
   '@vueuse/metadata@10.11.1':
     resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
 
   '@vueuse/metadata@13.9.0':
     resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
 
+  '@vueuse/metadata@14.2.0':
+    resolution: {integrity: sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==}
+
   '@vueuse/shared@10.11.1':
     resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
 
@@ -3097,6 +3108,11 @@ packages:
     peerDependencies:
       vue: ^3.5.0
 
+  '@vueuse/shared@14.2.0':
+    resolution: {integrity: sha512-Z0bmluZTlAXgUcJ4uAFaML16JcD8V0QG00Db3quR642I99JXIDRa2MI2LGxiLVhcBjVnL1jOzIvT5TT2lqJlkA==}
+    peerDependencies:
+      vue: ^3.5.0
+
   a-sync-waterfall@1.0.1:
     resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==}
 
@@ -10928,10 +10944,19 @@ snapshots:
       '@vueuse/shared': 13.9.0(vue@3.5.27(typescript@5.9.3))
       vue: 3.5.27(typescript@5.9.3)
 
+  '@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.21
+      '@vueuse/metadata': 14.2.0
+      '@vueuse/shared': 14.2.0(vue@3.5.27(typescript@5.9.3))
+      vue: 3.5.27(typescript@5.9.3)
+
   '@vueuse/metadata@10.11.1': {}
 
   '@vueuse/metadata@13.9.0': {}
 
+  '@vueuse/metadata@14.2.0': {}
+
   '@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.2))':
     dependencies:
       vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.2))
@@ -10950,6 +10975,10 @@ snapshots:
     dependencies:
       vue: 3.5.27(typescript@5.9.3)
 
+  '@vueuse/shared@14.2.0(vue@3.5.27(typescript@5.9.3))':
+    dependencies:
+      vue: 3.5.27(typescript@5.9.3)
+
   a-sync-waterfall@1.0.1: {}
 
   acorn-jsx@5.3.2(acorn@8.15.0):