4 Commits

Author SHA1 Message Date
v-zhangjc9
566dfef208 feat(web): 增加流程图连线限制 2025-06-24 14:07:07 +08:00
1cba0f4422 feat(web): 完成流程图外部加载 2025-06-24 10:44:18 +08:00
ab56385c8a feat(web): 增加节点输出编辑 2025-06-24 10:15:08 +08:00
b58c34443f feat(web): 增加跨站导航列表 2025-06-24 09:29:48 +08:00
8 changed files with 261 additions and 90 deletions

View File

@@ -1,8 +1,17 @@
import {ProLayout} from '@ant-design/pro-components'
import {ConfigProvider} from 'antd'
import React from 'react'
import {Outlet, useLocation, useNavigate} from 'react-router'
import {menus} from '../route.tsx'
import {ConfigProvider} from 'antd'
const apps: { title: string, desc: string, url: string, icon?: string }[] = [
{
icon: 'http://132.126.207.124:8686/udal-manager/static/favicon.ico',
title: 'CSV-HUDI处理平台',
desc: 'Hudi 批量割接、稽核任务管理平台',
url: 'http://132.126.207.124:8686/udal-manager/',
},
]
const App: React.FC = () => {
const navigate = useNavigate()
@@ -10,6 +19,8 @@ const App: React.FC = () => {
return (
<ProLayout
token={{
colorTextAppListIcon: '#dfdfdf',
colorTextAppListIconHover: '#ffffff',
header: {
colorBgHeader: '#292f33',
colorHeaderTitle: '#ffffff',
@@ -21,6 +32,8 @@ const App: React.FC = () => {
colorTextRightActionsItem: '#dfdfdf',
},
}}
appList={apps}
disableMobile={true}
logo={<img src="icon.png" alt="logo"/>}
title="Hudi 服务总台"
route={menus}
@@ -36,14 +49,14 @@ const App: React.FC = () => {
contentStyle={{backgroundColor: 'white', padding: '10px 10px 10px 20px'}}
>
<ConfigProvider
theme={{
components: {
Card: {
bodyPadding: 0,
bodyPaddingSM: 0,
}
}
}}
theme={{
components: {
Card: {
bodyPadding: 0,
bodyPaddingSM: 0,
},
},
}}
>
<Outlet/>
</ConfigProvider>

View File

@@ -2,8 +2,10 @@ import {PlusCircleFilled, SaveFilled} from '@ant-design/icons'
import {
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
getIncomers,
getOutgoers,
MiniMap,
type Node,
type NodeProps,
@@ -17,18 +19,16 @@ import {type JSX, useState} from 'react'
import styled from 'styled-components'
import '@xyflow/react/dist/style.css'
import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx'
import StartNode from './node/StartNode.tsx'
import EndNode from './node/EndNode.tsx'
import LlmNode from './node/LlmNode.tsx'
import StartNode from './node/StartNode.tsx'
import {useDataStore} from './store/DataStore.ts'
import {useFlowStore} from './store/FlowStore.ts'
const FlowableDiv = styled.div`
height: 93vh;
height: 92vh;
.toolbar {
z-index: 999;
position: absolute;
}
.node-card {
@@ -40,22 +40,6 @@ const FlowableDiv = styled.div`
}
`
const initialNodes: Node[] = [
{
id: 'BMFP3Eov94',
type: 'start-amis-node',
position: {x: 10, y: 100},
data: {},
},
{
id: 'PYK8LjduQ1',
type: 'end-amis-node',
position: {x: 500, y: 100},
data: {},
},
]
const initialEdges: Edge[] = []
function FlowEditor() {
const [messageApi, contextHolder] = message.useMessage()
const [nodeDef] = useState<{
@@ -81,9 +65,10 @@ function FlowEditor() {
])
const [open, setOpen] = useState(false)
const {getData, getDataById, setDataById} = useDataStore()
const {data, setData, getDataById, setDataById} = useDataStore()
const {
nodes,
getNodeById,
addNode,
removeNode,
setNodes,
@@ -155,7 +140,7 @@ function FlowEditor() {
},
],
},
]
],
},
getDataById(id),
),
@@ -164,7 +149,141 @@ function FlowEditor() {
}
}
const checkNode = (type: string) => {
if (isEqual(type, 'start-amis-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) {
throw new Error('只能存在1个开始节点')
}
if (isEqual(type, 'end-amis-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) {
throw new Error('只能存在1个结束节点')
}
}
const checkConnection = (connection: Connection) => {
let sourceNode = getNodeById(connection.source)
if (!sourceNode) {
throw new Error('连线起始节点未找到')
}
let targetNode = getNodeById(connection.target)
if (!targetNode) {
throw new Error('连线目标节点未找到')
}
console.log(sourceNode, targetNode, connection)
// 禁止短路整个流程
if (isEqual('start-amis-node', sourceNode.type) && isEqual('end-amis-node', targetNode.type)) {
throw new Error('开始节点不能直连结束节点')
}
// 禁止流程出现环,必须是有向无环图
const hasCycle = (node: Node, visited = new Set()) => {
if (visited.has(node.id)) return false
visited.add(node.id)
for (const outgoer of getOutgoers(node, nodes, edges)) {
if (isEqual(outgoer.id, sourceNode?.id)) return true
if (hasCycle(outgoer, visited)) return true
}
}
if (isEqual(sourceNode.id, targetNode.id)) {
throw new Error('节点不能直连自身')
} else if (hasCycle(targetNode)) {
throw new Error('禁止流程循环')
}
const hasShortcut = (node: Node, visited = new Set()) => {
if (visited.has(node.id)) return false
visited.add(node.id)
for (const incomer of getIncomers(node, nodes, edges)) {
if (isEqual(incomer.id, sourceNode?.id)) return true
if (hasShortcut(incomer, visited)) return true
}
}
console.log(getIncomers(targetNode, nodes, edges))
}
useMount(() => {
// language=JSON
let initialData = JSON.parse(`{
"nodes": [
{
"id": "BMFP3Eov94",
"type": "start-amis-node",
"position": {
"x": 10,
"y": 100
},
"data": {},
"measured": {
"width": 256,
"height": 83
},
"selected": false
},
{
"id": "PYK8LjduQ1",
"type": "end-amis-node",
"position": {
"x": 654,
"y": 332
},
"data": {},
"measured": {
"width": 256,
"height": 83
},
"selected": false,
"dragging": false
},
{
"id": "nCm-ij5I6o",
"type": "llm-amis-node",
"position": {
"x": 318,
"y": 208
},
"data": {},
"measured": {
"width": 256,
"height": 83
},
"selected": true,
"dragging": false
}
],
"edges": [
{
"source": "BMFP3Eov94",
"target": "nCm-ij5I6o",
"id": "xy-edge__BMFP3Eov94-nCm-ij5I6o"
}
],
"data": {
"BMFP3Eov94": {
"inputs": {
"文件名": {
"type": "text"
},
"文件描述": {
"type": "text",
"description": "文件描述"
}
}
},
"nCm-ij5I6o": {
"model": "qwen3",
"outputs": {
"text": {
"type": "string"
}
},
"systemPrompt": "你是个沙雕"
}
}
}`)
let initialNodes = initialData['nodes'] ?? []
let initialEdges = initialData['edges'] ?? []
let initialNodeData = initialData['data'] ?? {}
setData(initialNodeData)
for (let node of initialNodes) {
node.data = {
getDataById,
@@ -181,7 +300,10 @@ function FlowEditor() {
<FlowableDiv>
{contextHolder}
<Space className="toolbar">
<Button type="primary" onClick={() => console.log(JSON.stringify(getData()))}>
<Button type="primary" onClick={() => {
let saveData = {nodes, edges, data}
console.log(JSON.stringify(saveData, null, 2))
}}>
<SaveFilled/>
</Button>
@@ -189,26 +311,23 @@ function FlowEditor() {
menu={{
items: nodeDef.map(def => ({key: def.key, label: def.name})),
onClick: ({key}) => {
if (isEqual(key, 'start-amis-node') && findIdx(nodes, (node: Node) => isEqual(key, node.type)) > -1) {
messageApi.error('只能存在1个开始节点')
return
try {
checkNode(key)
addNode({
id: randomId(10),
type: key,
position: {x: 100, y: 100},
data: {
getDataById,
setDataById,
removeNode,
editNode,
},
})
} catch (e) {
// @ts-ignore
messageApi.error(e.message)
}
if (isEqual(key, 'end-amis-node') && findIdx(nodes, (node: Node) => isEqual(key, node.type)) > -1) {
messageApi.error('只能存在1个结束节点')
return
}
addNode({
id: randomId(10),
type: key,
position: {x: 100, y: 100},
data: {
getDataById,
setDataById,
removeNode,
editNode,
},
})
},
}}
>
@@ -233,7 +352,15 @@ function FlowEditor() {
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnect={(connection) => {
try {
checkConnection(connection)
onConnect(connection)
} catch (e) {
// @ts-ignore
messageApi.error(e.message)
}
}}
// @ts-ignore
nodeTypes={arrToMap(
nodeDef.map(def => def.key),

View File

@@ -1,12 +1,59 @@
import {DeleteFilled, EditFilled} from '@ant-design/icons'
import {Handle, type NodeProps, Position} from '@xyflow/react'
import type {Schema} from 'amis'
import {Card, Dropdown} from 'antd'
import {DeleteFilled, EditFilled} from '@ant-design/icons'
import {isEmpty, isEqual} from 'licia'
import type {JSX} from 'react'
import {horizontalFormOptions} from '../../../../util/amis.tsx'
export type AmisNodeType = 'normal' | 'start' | 'end'
export function outputsFormColumns(editable: boolean = false, preload?: any): Schema[] {
return [
{
disabled: !editable,
type: 'input-kvs',
name: 'outputs',
label: '输出变量',
value: preload,
addButtonText: '新增输出',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数',
required: true,
selectFirst: true,
options: [
{
label: '文本',
value: 'string',
},
{
label: '数字',
value: 'number',
},
{
label: '文本数组',
value: 'array-string',
},
{
label: '对象数组',
value: 'array-object',
},
],
},
],
},
]
}
const AmisNode = (
props: NodeProps,
type: AmisNodeType,
@@ -67,10 +114,10 @@ const AmisNode = (
placeholder: nodeDescription,
},
{
type: 'divider'
type: 'divider',
},
...(columnSchema ?? [])
]
...(columnSchema ?? []),
],
)
break
case 'remove':

View File

@@ -1,6 +1,5 @@
import type {NodeProps} from '@xyflow/react'
import AmisNode from './AmisNode.tsx'
import {horizontalFormOptions} from '../../../../util/amis.tsx'
import AmisNode, {outputsFormColumns} from './AmisNode.tsx'
const EndNode = (props: NodeProps) => AmisNode(
props,
@@ -8,30 +7,7 @@ const EndNode = (props: NodeProps) => AmisNode(
'结束节点',
'定义输出变量',
undefined,
[
{
type: 'input-kvs',
name: 'outputVariables',
label: '输出变量',
addButtonText: '新增输出',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数',
required: true,
selectFirst: true,
options: [],
},
],
},
],
outputsFormColumns(true),
)
export default EndNode

View File

@@ -1,5 +1,5 @@
import type {NodeProps} from '@xyflow/react'
import AmisNode from './AmisNode.tsx'
import AmisNode, {outputsFormColumns} from './AmisNode.tsx'
const LlmNode = (props: NodeProps) => AmisNode(
props,
@@ -31,6 +31,10 @@ const LlmNode = (props: NodeProps) => AmisNode(
label: '系统提示词',
required: true,
},
{
type: 'divider',
},
...outputsFormColumns(false, {text: {type: 'string'}}),
],
)

View File

@@ -1,6 +1,6 @@
import type {NodeProps} from '@xyflow/react'
import AmisNode from './AmisNode.tsx'
import {horizontalFormOptions} from '../../../../util/amis.tsx'
import AmisNode from './AmisNode.tsx'
const StartNode = (props: NodeProps) => AmisNode(
props,
@@ -11,7 +11,7 @@ const StartNode = (props: NodeProps) => AmisNode(
[
{
type: 'input-kvs',
name: 'inputVariables',
name: 'inputs',
label: '输入变量',
addButtonText: '新增入参',
draggable: false,

View File

@@ -9,7 +9,9 @@ export const useDataStore = create<{
}>((set, get) => ({
data: {},
getData: () => get().data,
setData: (data) => set(data),
setData: (data) => set({
data: data
}),
getDataById: id => get().data[id],
setDataById: (id, data) => {
let updateData = get().data

View File

@@ -1,4 +1,3 @@
import {create} from 'zustand/react'
import {
addEdge,
applyEdgeChanges,
@@ -7,13 +6,15 @@ import {
type Node,
type OnConnect,
type OnEdgesChange,
type OnNodesChange
type OnNodesChange,
} from '@xyflow/react'
import {filter, isEqual} from 'licia'
import {filter, find, isEqual} from 'licia'
import {create} from 'zustand/react'
export const useFlowStore = create<{
nodes: Node[],
onNodesChange: OnNodesChange,
getNodeById: (id: string) => Node | undefined,
addNode: (node: Node) => void,
removeNode: (id: string) => void,
setNodes: (nodes: Node[]) => void,
@@ -30,6 +31,7 @@ export const useFlowStore = create<{
nodes: applyNodeChanges(changes, get().nodes),
})
},
getNodeById: (id: string) => find(get().nodes, node => isEqual(node.id, id)),
addNode: node => set({nodes: get().nodes.concat(node)}),
removeNode: id => {
set({