diff --git a/.dumi/theme/slots/Header/Navigation.tsx b/.dumi/theme/slots/Header/Navigation.tsx index 853da4a90..a401265f2 100644 --- a/.dumi/theme/slots/Header/Navigation.tsx +++ b/.dumi/theme/slots/Header/Navigation.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import { CodeOutlined, DownOutlined } from '@ant-design/icons'; import { Menu } from 'antd'; import { Link } from 'dumi'; -import { CodeOutlined, DownOutlined, MobileOutlined, SwapOutlined, ToolOutlined } from '@ant-design/icons'; +import React from 'react'; const Navigation: React.FC = () => { const items: any = [ @@ -13,9 +13,13 @@ const Navigation: React.FC = () => { label: TableRender, key: 'table-render', }, + // { + // label: XFlow, + // key: 'xflow', + // }, { label: FRMobile, - key: 'form-render-mobile' + key: 'form-render-mobile', }, // { // label: DataView, @@ -23,7 +27,7 @@ const Navigation: React.FC = () => { // }, { label: Playground, - key: 'playground' + key: 'playground', }, { label: SchemaBuilder, @@ -33,17 +37,23 @@ const Navigation: React.FC = () => { label: (
更多 - +
), children: [ { - label: ChartRender, + label: ( + + ChartRender + + ), key: 'chart-render', icon: , - } - ] - } + }, + ], + }, ]; return ; diff --git a/.dumirc.ts b/.dumirc.ts index b89b92635..84bdc3999 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -76,6 +76,7 @@ export default defineConfig({ 'form-render-mobile': path.resolve(__dirname, 'packages/form-render-mobile/src'), '@xrenders/schema-builder': path.resolve(__dirname, 'tools/schema-builder/src'), '@xrenders/data-render': path.resolve(__dirname, 'packages/data-render/src'), + '@xrenders/xflow': path.resolve(__dirname, 'packages/x-flow/src'), }, codeSplitting: { jsStrategy: 'granularChunks' }, //...(process.env.NODE_ENV === 'development' ? {} : { ssr: {} }), diff --git a/.npmrc b/.npmrc index 214c29d13..5e4086a7f 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -registry=https://registry.npmjs.org/ +registry=https://registry.npmmirror.com/ diff --git a/.turbo/daemon/58af6c43ce0c4d29-turbo.log.2024-12-04 b/.turbo/daemon/58af6c43ce0c4d29-turbo.log.2024-12-04 new file mode 100644 index 000000000..437d22305 --- /dev/null +++ b/.turbo/daemon/58af6c43ce0c4d29-turbo.log.2024-12-04 @@ -0,0 +1,8 @@ +2024-12-04T00:52:39.144082Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/package.json")} +2024-12-04T00:52:39.144107Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) +2024-12-04T00:54:43.043002Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("docs/data-render/index.md")} +2024-12-04T00:54:43.043046Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2024-12-04T00:55:35.341519Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("docs/xflow/index.md")} +2024-12-04T00:55:35.341535Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2024-12-04T00:56:31.240344Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".dumi/theme/slots/Header/Navigation.tsx")} +2024-12-04T00:56:31.240359Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) diff --git a/.turbo/daemon/9af73f40ad414c73-turbo.log.2024-12-03 b/.turbo/daemon/9af73f40ad414c73-turbo.log.2024-12-03 new file mode 100644 index 000000000..a2ece7970 --- /dev/null +++ b/.turbo/daemon/9af73f40ad414c73-turbo.log.2024-12-03 @@ -0,0 +1,10 @@ +2024-12-03T17:26:27.588772Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/src/types.ts")} +2024-12-03T17:26:27.588794Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) +2024-12-03T17:27:03.185223Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/src/types.ts")} +2024-12-03T17:27:03.185237Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) +2024-12-03T17:27:46.488909Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/src/withProvider.tsx")} +2024-12-03T17:27:46.488931Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) +2024-12-03T17:27:51.584147Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/src/types.ts")} +2024-12-03T17:27:51.584179Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) +2024-12-03T17:29:05.583324Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf("packages/x-flow/src/withProvider.tsx")} +2024-12-03T17:29:05.583342Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Other("@xrenders/xflow"), path: AnchoredSystemPathBuf("packages/x-flow") }})) diff --git a/docs/form-render/advanced-widget.md b/docs/form-render/advanced-widget.md index 8d9e2fa89..e78bce8c3 100644 --- a/docs/form-render/advanced-widget.md +++ b/docs/form-render/advanced-widget.md @@ -1,5 +1,5 @@ --- -order: 4 +order: 2 toc: content mobile: false group: diff --git a/docs/xflow/FlowProvider.md b/docs/xflow/FlowProvider.md new file mode 100644 index 000000000..086b200ce --- /dev/null +++ b/docs/xflow/FlowProvider.md @@ -0,0 +1,12 @@ +--- +order: 3 +title: '多实例画布' +mobile: false +group: + title: 最佳展示 + order: 4 +--- + +# 基础交互 + + diff --git a/docs/xflow/api.md b/docs/xflow/api.md new file mode 100644 index 000000000..d13cbdc56 --- /dev/null +++ b/docs/xflow/api.md @@ -0,0 +1,49 @@ +--- +order: 1 +toc: content +title: API +--- +# API + +## XFlow + +| 属性 | 描述 | 类型 | 默认值 | +| ------------- | ------------------------------------ | ----------------------------------------------------------- | ------ | +| initialValues | 初始的节点和边数据 | `{nodes:any[],edges:any[]}` | - | - | +| layout | 节点布局的方向 | `LR \| TB` | LR | - | +| widgets | 自定义组件 | `Record` | - | - | +| settings | 节点配置,定义页面中可拖动的节点配置 | ( [TNodeGroup](#tnodegroup) \| [TNodeItem](#tnodeitem) )[ ] | | +| nodeSelector | 节点选择器配置,可控制节点的可搜索性 | `TNodeSelector` | | + +### TNodeGroup + +节点分组配置 + +| 属性 | 描述 | 类型 | 默认值 | +| ----- | ------------ | ------------- | ------ | +| title | 分组名称 | `string` | | +| type | 分组类型 | `_group` | _group | +| items | 节点配置信息 | `TNodeItem[]` | | + +## TNodeItem + +单个节点配置 + +| 属性 | 描述 | 类型 | 默认值 | +| ------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| title | 节点名称 | `string` | | +| type | 节点类型 | `string` | | +| hidden | 是否在配置面板中显示节点 | `boolean` | false | +| targetHandleHidden | 是否隐藏左侧输入连接头 | `boolean` | false | +| sourceHandleHidden | 是否隐藏右侧输出连接头 | `boolean` | false | +| icon | 节点的图标配置 | `{type:string;bgColor:string}` | | +| settingSchema | 节点的业务配置信息,详见[form-render 文档](/form-render/api-schema)。同时设置`settingSchema`和`settingWidget`只生效`settingWidget` | SchemaBase | | +| settingWidget | 自定义节点的业务配置组件,在弹窗中展示。同时设置`settingSchema`和`settingWidget`只生效`settingWidget`。定义之后需要在`widgets`中引入自定义组件。 | `string` | | +| nodeWidget | 自定义节点的业务配置信息展示组件,在节点内部展示业务配置信息。定义之后需要在`widgets`中引入自定义组件。 | `string` | | + +## TNodeSelector + +| 属性 | 描述 | 类型 | 默认值 | +| ---------- | ---------------- | ----------------------------------------------------------- | ------ | +| showSearch | 节点是否可被搜索 | `boolean` | false | +| items | 节点配置 | ( [TNodeGroup](#tnodegroup) \| [TNodeItem](#tnodeitem) )[ ] | | diff --git a/docs/xflow/custom-node-setting.md b/docs/xflow/custom-node-setting.md new file mode 100644 index 000000000..0bb976f60 --- /dev/null +++ b/docs/xflow/custom-node-setting.md @@ -0,0 +1,75 @@ +--- +order: 2 +toc: content +mobile: false +group: + title: 最佳展示 + order: 1 +--- + +# 节点配置面板 +节点配置面板支持以下两种渲染方式自定义: + +- **Schema 方式**: 适用于节点配置较为简单的场景。通过 FormRender 配置 schema 来实现快速渲染。 +- **Widget 方式**: 针对复杂的配置需求,schema 无法满足时,可以通过自定义组件进行灵活渲染。 + + +## Schema +通过配置节点的 settingSchema 属性,实现节点数据配置项的自定义渲染。 + +```jsx +import { Input } from 'antd'; +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './schema/custom-settings.ts'; + + +const customWidget=({value,onChange})=>{ + return +} +export default () => { + const nodes = [ + { + id: '1', + type: 'Start', + data: { + inputVal:'我是自定义组件' + }, + position: { + x: 40, + y: 240, + } + }, + { + id: '2', + type: 'End', + data: {}, + position: { + x: 500, + y: 240, + } + } + ]; + + const edges = [ + { source: '1', target: '2', id: '234123' } + ] + + return ( +
+ +
+ ); +} + + +``` + +## Widget +通过配置节点的 `settingWidget` 属性,实现节点数据配置项的自定义渲染。 + + diff --git a/docs/xflow/custom-node-view.md b/docs/xflow/custom-node-view.md new file mode 100644 index 000000000..787884b37 --- /dev/null +++ b/docs/xflow/custom-node-view.md @@ -0,0 +1,15 @@ +--- +order: 2 +toc: content +mobile: false +group: + title: 最佳展示 + order: 1 +--- + +# 节点内容展示 +当需要额外展示节点内容时,可以通过 `nodeWidget` 进行灵活渲染。 + +使用`nodeWidget`自定义节点的业务配置信息展示组件,在节点内部展示业务配置信息 + + diff --git a/docs/xflow/data/basic.ts b/docs/xflow/data/basic.ts new file mode 100644 index 000000000..e4e869ffc --- /dev/null +++ b/docs/xflow/data/basic.ts @@ -0,0 +1,51 @@ +export default { + "creator": "清风徐来", + "relevanceCode": "421421", + "desc": "浙江省杭州市工专路", + "create-time": "2019-10-10", + "effective-date": "2019-10-10 ~ 2020-10-31", + "safety": { + "name": "Test demo 001", + "app": "中后台详情页面", + "mode": "代码包", + "yum": "592342323904823489", + "fore": "23" + }, + "operLog": [{ + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }, { + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }, { + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }, { + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }, { + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }, { + "type": "创建测试", + "creator": "清风徐来", + "time": "2019-10-30 12:23:45", + "result": "1", + "desc": "这是备注" + }] +} \ No newline at end of file diff --git a/docs/xflow/demo/basic/index.tsx b/docs/xflow/demo/basic/index.tsx new file mode 100644 index 000000000..6ae0ae689 --- /dev/null +++ b/docs/xflow/demo/basic/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './setting'; + +export default () => { + const nodes = [ + { + id: '1', + type: 'Start', + data: {}, + position: { + x: 40, + y: 240, + } + }, + { + id: '2', + type: 'End', + data: {}, + position: { + x: 500, + y: 240, + } + } + ]; + + const edges = [ + { source: '1', target: '2', id: '234123' } + ] + + return ( +
+ +
+ ); +} diff --git a/docs/xflow/demo/basic/setting.tsx b/docs/xflow/demo/basic/setting.tsx new file mode 100644 index 000000000..937413778 --- /dev/null +++ b/docs/xflow/demo/basic/setting.tsx @@ -0,0 +1,299 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + } + } + }, + ], + }, +]; diff --git a/docs/xflow/demo/custom-flow/customWidget.tsx b/docs/xflow/demo/custom-flow/customWidget.tsx new file mode 100644 index 000000000..a5fe73558 --- /dev/null +++ b/docs/xflow/demo/custom-flow/customWidget.tsx @@ -0,0 +1,13 @@ +import { Input } from 'antd'; + +const customWidget = ({ value, onChange }) => { + + return ( + onChange({ inputVal: e.target.value })} + /> + ); +}; + +export default customWidget; diff --git a/docs/xflow/demo/custom-flow/index.tsx b/docs/xflow/demo/custom-flow/index.tsx new file mode 100644 index 000000000..e3c50cf59 --- /dev/null +++ b/docs/xflow/demo/custom-flow/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './setting'; +import customWidget from './customWidget'; + +export default () => { + const nodes = [ + { + id: '1', + type: 'Start', + data: { + inputVal:'我是自定义组件' + }, + position: { + x: 40, + y: 240, + } + }, + { + id: '2', + type: 'End', + data: {}, + position: { + x: 500, + y: 240, + } + } + ]; + + const edges = [ + { source: '1', target: '2', id: '234123' } + ] + + return ( +
+ +
+ ); +} diff --git a/docs/xflow/demo/custom-flow/setting.tsx b/docs/xflow/demo/custom-flow/setting.tsx new file mode 100644 index 000000000..282e3dbc7 --- /dev/null +++ b/docs/xflow/demo/custom-flow/setting.tsx @@ -0,0 +1,141 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingWidget:"customWidget" + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/demo/flow-provider/edges.ts b/docs/xflow/demo/flow-provider/edges.ts new file mode 100644 index 000000000..d3f67bd08 --- /dev/null +++ b/docs/xflow/demo/flow-provider/edges.ts @@ -0,0 +1 @@ +export const edges = [{ source: '1', target: '2', id: '234123' }]; diff --git a/docs/xflow/demo/flow-provider/index.tsx b/docs/xflow/demo/flow-provider/index.tsx new file mode 100644 index 000000000..39287d968 --- /dev/null +++ b/docs/xflow/demo/flow-provider/index.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import XFlow, { FlowProvider, useNodes } from '@xrenders/xflow'; +import { edges as initialEdges } from './edges'; +import { nodes as initialNodes } from './nodes'; +import settings from './setting'; + +const App = () => { + return ( + +
+ +
+ +
+ ); +}; + +function Sidebar() { + // This hook will only work if the component it's used in is a child of a + // . + const nodes = useNodes(); + + return ( + + ); +} + +export default () => { + return ( + + + + ); +}; diff --git a/docs/xflow/demo/flow-provider/nodes.ts b/docs/xflow/demo/flow-provider/nodes.ts new file mode 100644 index 000000000..79e981129 --- /dev/null +++ b/docs/xflow/demo/flow-provider/nodes.ts @@ -0,0 +1,20 @@ +export const nodes = [ + { + id: '1', + type: 'Start', + data: {}, + position: { + x: 40, + y: 240, + }, + }, + { + id: '2', + type: 'End', + data: {}, + position: { + x: 500, + y: 240, + }, + }, +]; diff --git a/docs/xflow/demo/flow-provider/setting.tsx b/docs/xflow/demo/flow-provider/setting.tsx new file mode 100644 index 000000000..72f080f69 --- /dev/null +++ b/docs/xflow/demo/flow-provider/setting.tsx @@ -0,0 +1,199 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/demo/layout/LR/index.tsx b/docs/xflow/demo/layout/LR/index.tsx new file mode 100644 index 000000000..c95655c45 --- /dev/null +++ b/docs/xflow/demo/layout/LR/index.tsx @@ -0,0 +1,65 @@ +import XFlow from '@xrenders/xflow'; +import settings from './setting'; +import React from 'react'; + +export default () => { + const nodes = [ + { + type: 'Start', + id: '1', + position: { x: -35, y: 268 }, + }, + { + type: 'Switch', + id: '2', + position: { x: 277.5, y: 268 }, + }, + { + type: 'Code', + id: '3', + position: { x: 675, y: 123.75 }, + }, + { + type: 'tool', + id: '4', + position: { x: 686.25, y: 495 }, + }, + { + type: 'End', + id: '5', + position: { x: 1176.2499999999998, y: 281.25 }, + }, + ]; + const edges = [ + { source: '1', target: '2', id: 'e1-2' }, + { + source: '2', + target: '3', + id: 'e2-3', + }, + { source: '2', target: '4', id: 'e2-4' }, + { + source: '3', + target: '5', + id: 'e3-5', + }, + { + source: '4', + target: '5', + id: 'e4-5', + }, + ]; + + return ( +
+ +
+ ); +}; diff --git a/docs/xflow/demo/layout/LR/setting.tsx b/docs/xflow/demo/layout/LR/setting.tsx new file mode 100644 index 000000000..72f080f69 --- /dev/null +++ b/docs/xflow/demo/layout/LR/setting.tsx @@ -0,0 +1,199 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/demo/layout/TB/index.tsx b/docs/xflow/demo/layout/TB/index.tsx new file mode 100644 index 000000000..9b717eb3b --- /dev/null +++ b/docs/xflow/demo/layout/TB/index.tsx @@ -0,0 +1,73 @@ +import XFlow from '@xrenders/xflow'; +import settings from './setting'; +import React from 'react'; + +export default () => { + const nodes = [ + { + type: 'Start', + id: '1', + position: { x: 327.5, y: -14.5 }, + }, + { + type: 'Switch', + id: '2', + position: { x: 328.75, y: 108 }, + }, + { + type: 'Code', + id: '3', + position: { x: 638.75, y: 247.5 }, + }, + { + type: 'tool', + id: '4', + position: { x: 75.00000000000003, y: 261.25 }, + }, + { + type: 'End', + id: '5', + position: { x: 360, y: 501.25 }, + }, + ]; + const edges = [ + { + source: '1', + target: '2', + id: 'e1-2', + }, + { + source: '2', + target: '3', + id: 'e2-3', + }, + { + source: '2', + target: '4', + id: 'e2-4', + }, + { + source: '3', + target: '5', + id: 'e3-5', + }, + { + source: '4', + target: '5', + id: 'e4-5', + }, + ]; + + return ( +
+ +
+ ); +}; diff --git a/docs/xflow/demo/layout/TB/setting.tsx b/docs/xflow/demo/layout/TB/setting.tsx new file mode 100644 index 000000000..72f080f69 --- /dev/null +++ b/docs/xflow/demo/layout/TB/setting.tsx @@ -0,0 +1,199 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/demo/nodeSetting/index.tsx b/docs/xflow/demo/nodeSetting/index.tsx new file mode 100644 index 000000000..5deb83592 --- /dev/null +++ b/docs/xflow/demo/nodeSetting/index.tsx @@ -0,0 +1,219 @@ +import XFlow from '@xrenders/xflow'; +// import settings from './setting'; +import React from 'react'; + +const settings = [ + { + title: '开始', // 节点名称 + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { // 图标描述 + type: 'icon-start', // icon-font + bgColor: '#17B26A', // 图标背景颜色 + }, + settingSchema: { // 自定义节点配置 + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', // 节点分组 + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +] +export default () => { + const nodes = [ + { + type: 'Start', + id: '1', + position: { x: -35, y: 268 }, + }, + { + type: 'Switch', + id: '2', + position: { x: 277.5, y: 268 }, + }, + { + type: 'Code', + id: '3', + position: { x: 675, y: 123.75 }, + }, + { + type: 'tool', + id: '4', + position: { x: 686.25, y: 495 }, + }, + { + type: 'End', + id: '5', + position: { x: 1176.2499999999998, y: 281.25 }, + }, + ]; + const edges = [ + { source: '1', target: '2', id: 'e1-2' }, + { + source: '2', + target: '3', + id: 'e2-3', + }, + { source: '2', target: '4', id: 'e2-4' }, + { + source: '3', + target: '5', + id: 'e3-5', + }, + { + source: '4', + target: '5', + id: 'e4-5', + }, + ]; + + return ( +
+ +
+ ); +}; diff --git a/docs/xflow/demo/nodeSetting/setting.tsx b/docs/xflow/demo/nodeSetting/setting.tsx new file mode 100644 index 000000000..72f080f69 --- /dev/null +++ b/docs/xflow/demo/nodeSetting/setting.tsx @@ -0,0 +1,199 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/demo/nodeWidget/index.tsx b/docs/xflow/demo/nodeWidget/index.tsx new file mode 100644 index 000000000..5acd4e865 --- /dev/null +++ b/docs/xflow/demo/nodeWidget/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './setting'; +import { Space, Tag, Typography } from 'antd'; + + +const customNodeWidget = ({ data }) => { + return
+

变量一:{data?.input}

+

变量二:{data?.select}

+
+} + + +const endNodeWidget = ({ data }) => { + return
+ {data?.input} +
+} + + +const LLMNodeWidget = ({ data }) => { + const labels = Object.keys(data) || []; + return + {labels?.map(item => {data[item]})} + +} + +export default () => { + const nodes = [ + { id: '1', type: 'Start', data: { input: '开始节点', select: "b" }, position: { x: 40, y: 240 } }, + { id: '2', type: 'LLM', data: { input1: 'input1' }, position: { x: 400, y: 240 } }, + { id: '3', type: 'End', data: { input: '通过nodeWidget自定义配置展示' }, position: { x: 800, y: 240 } } + ]; + + const edges = [ + { source: '1', target: '2', id: '234123' }, + { source: '2', target: '3', id: '56891' } + ] + + return ( +
+ +
+ ); +} diff --git a/docs/xflow/demo/nodeWidget/setting.tsx b/docs/xflow/demo/nodeWidget/setting.tsx new file mode 100644 index 000000000..26d8c6aca --- /dev/null +++ b/docs/xflow/demo/nodeWidget/setting.tsx @@ -0,0 +1,90 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + }, + nodeWidget:'customNodeWidget' + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '结束原因', + type: 'string', + widget: 'textArea', + }, + } + }, + nodeWidget: 'endNodeWidget', + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + settingSchema: { + type: 'object', + displayType: 'row', + labelCol: 6, + fieldCol:18, + properties: { + input1: { + title: 'Field A', + type: 'string', + }, + input2: { + title: 'Field B', + type: 'string' + }, + input3: { + title: 'Field C', + type: 'string' + }, + input4: { + title: 'Field D', + type: 'string' + } + } + }, + nodeWidget: 'LLMNodeWidget', + }, +]; diff --git a/docs/xflow/demo/switchNode/index.tsx b/docs/xflow/demo/switchNode/index.tsx new file mode 100644 index 000000000..5a9c47fdd --- /dev/null +++ b/docs/xflow/demo/switchNode/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './setting'; + +export default () => { + const nodes = [ + { id: '1', type: 'Switch', data: { input: '开始节点', select: "b" }, position: { x: 40, y: 240 } }, + ]; + + const edges = [] + + return ( +
+ +
+ ); +} diff --git a/docs/xflow/demo/switchNode/setting.tsx b/docs/xflow/demo/switchNode/setting.tsx new file mode 100644 index 000000000..1c790f4ef --- /dev/null +++ b/docs/xflow/demo/switchNode/setting.tsx @@ -0,0 +1,29 @@ +export default [ + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, +]; diff --git a/docs/xflow/index.md b/docs/xflow/index.md new file mode 100644 index 000000000..b11d9abd8 --- /dev/null +++ b/docs/xflow/index.md @@ -0,0 +1,81 @@ +--- +order: 0 +title: 开始使用 +mobile: false +--- + +
+ logo + XFlow +
+

+ + npm + + + NPM downloads + + + NPM all downloads + + + PRs Welcome + +

+ +画布流程编排解决方案 + +## 安装 +```shell +npm i @xrenders/xflow --save +``` + +## 使用方式 + +**函数组件** + +```jsx +/** + * transform: true + * defaultShowCode: true + */ +import React from 'react'; +import XFlow from '@xrenders/xflow'; +import settings from './schema/settings'; + +export default () => { + const nodes = [ + { + id: '1', // 节点 ID + type: 'Start', // 节点类型 + data: {}, // 节点配置数据 + position: { // 节点画布坐标位置 + x: 40, + y: 240, + } + }, + { + id: '2', + type: 'End', // 节点类型 + data: {}, // 节点配置数据 + position: { // 节点画布坐标位置 + x: 500, + y: 240, + } + } + ]; + + const edges = [ + { source: '1', target: '2', id: '234123' } + ] + + return ( +
+ +
+ ); +} +``` diff --git a/docs/xflow/layout.md b/docs/xflow/layout.md new file mode 100644 index 000000000..937ef17e7 --- /dev/null +++ b/docs/xflow/layout.md @@ -0,0 +1,19 @@ +--- +order: 5 +title: '画布布局' +mobile: false +group: + title: 最佳展示 + order: 1 +--- +# 布局方向 + +## LR布局 +从左到右布局 + + + +## TB布局 +从上到下布局 + + diff --git a/docs/xflow/nodeBuildIn.md b/docs/xflow/nodeBuildIn.md new file mode 100644 index 000000000..0df7c0a19 --- /dev/null +++ b/docs/xflow/nodeBuildIn.md @@ -0,0 +1,13 @@ +--- +order: 4 +title: '内置节点' +mobile: false +group: + title: 最佳展示 + order: 2 +--- +# 内置节点 + +## 条件内置节点 +SwitchNode + diff --git a/docs/xflow/nodeSetting.md b/docs/xflow/nodeSetting.md new file mode 100644 index 000000000..ac9e72810 --- /dev/null +++ b/docs/xflow/nodeSetting.md @@ -0,0 +1,13 @@ +--- +order: 1 +title: '节点菜单配置' +mobile: false +group: + title: 最佳展示 + order: 1 +--- +# 节点配置 + +在`settings`属性中你可以自定义每个节点的名称、图标、描述以及配置方案。如果是分组节点,type只能为`_group`。 + + diff --git a/docs/xflow/schema/basic.ts b/docs/xflow/schema/basic.ts new file mode 100644 index 000000000..fd9be2c96 --- /dev/null +++ b/docs/xflow/schema/basic.ts @@ -0,0 +1,165 @@ +export default [ + { + "widget": "FPanel", + "style": { + "paddingTop": "20px", + "paddingLeft": "20px", + "paddingBottom": "20px", + "paddingRight": "20px", + "backgroundColor": "#ffffff", + "marginBottom": "12px" + }, + "children": [ + { + "widget": "FTitle", + "data": "基础信息" + }, + { + "widget": "FDescriptions", + "column": 3, + "items": [ + { + "label": "创建人", + "dataKey": "creator" + }, + { + "label": "关联单据", + "dataKey": "relevanceCode" + }, + { + "label": "单据备注", + "dataKey": "desc" + }, + { + "label": "创建时间", + "dataKey": "create-time" + }, + { + "label": "生效日期", + "dataKey": "effective-date" + }, + { + "label": "描述项", + "showLevel": 1 + } + ], + "style": { + "backgroundColor": "#ffffff", + "paddingTop": "0px", + "paddingLeft": "0px", + "paddingRight": "0px", + "paddingBottom": "0px" + }, + "itemShowLevel": 1, + "getCompProps": "xxxx" + } + ] + }, + { + "widget": "FTabs", + "items": [ + { + "label": "负载均衡(SLB)", + "children": [ + { + "widget": "FPanel", + "style": { + "paddingTop": "20px", + "paddingLeft": "20px", + "paddingBottom": "20px", + "paddingRight": "20px", + "backgroundColor": "#ffffff", + "marginBottom": "12px" + }, + "children": [ + { + "widget": "FTitle", + "data": "安全信息" + }, + { + "widget": "FDescriptions", + "column": 2, + "items": [ + { + "label": "安全构建名称", + "dataKey": "name" + }, + { + "label": "所属应用", + "dataKey": "app" + }, + { + "label": "构建模式", + "dataKey": "mode" + }, + { + "label": "公网域名", + "dataKey": "yum" + }, + { + "label": "保留计算实例", + "dataKey": "fore" + } + ], + "dataKey": "safety" + } + ] + }, + { + "widget": "FPanel", + "style": { + "paddingTop": "20px", + "paddingLeft": "20px", + "paddingBottom": "20px", + "paddingRight": "20px", + "backgroundColor": "#ffffff", + "marginBottom": "12px" + }, + "children": [ + { + "widget": "FTitle", + "data": "操作日志" + }, + { + "widget": "FTable", + "pagination": { + "pageSize": "3" + }, + "style": { + "backgroundColor": "#ffffff" + }, + "dataKey": "operLog", + "column": { + "type": { + "title": "操作类型", + "dataKey": "type" + }, + "creator": { + "title": "操作人", + "dataKey": "creator" + }, + "time": { + "title": "操作时间", + "dataKey": "time" + }, + "result": { + "title": "执行结果", + "dataKey": "result" + }, + "desc": { + "title": "备注", + "dataKey": "desc" + } + } + } + ] + } + ] + }, + { + "label": "云服务器(ECS)", + "children": [] + } + ] + } +] \ No newline at end of file diff --git a/docs/xflow/schema/custom-settings.ts b/docs/xflow/schema/custom-settings.ts new file mode 100644 index 000000000..58e00b49c --- /dev/null +++ b/docs/xflow/schema/custom-settings.ts @@ -0,0 +1,150 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'customWidget', + }, + } + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/docs/xflow/schema/settings.ts b/docs/xflow/schema/settings.ts new file mode 100644 index 000000000..72f080f69 --- /dev/null +++ b/docs/xflow/schema/settings.ts @@ -0,0 +1,199 @@ +export default [ + { + title: '开始', + type: 'Start', + hidden: true, + targetHandleHidden: true, + icon: { + type: 'icon-start', + bgColor: '#17B26A', + }, + settingSchema: { + type: 'object', + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + radio1: { + title: '点击单选', + type: 'string', + widget: 'radio', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + textarea1: { + title: '长文本', + type: 'string', + widget: 'textArea' + }, + date1: { + title: '日期选择', + type: 'string', + widget: 'datePicker' + }, + dateRange1: { + title: '日期范围', + type: 'range', + widget: 'dateRange' + }, + time1: { + title: '时间选择', + type: 'string', + widget: 'timePicker' + }, + timeRange1: { + title: '时间范围', + type: 'range', + widget: 'timeRange' + }, + }, + }, + }, + { + title: '结束', + type: 'End', + hidden: true, + sourceHandleHidden: true, + icon: { + type: 'icon-end', + bgColor: '#F79009', + }, + settingSchema: { + type: "object", + properties: { + input: { + title: '变量一', + type: 'string', + widget: 'input', + }, + select: { + title: '变量二', + type: 'string', + widget: 'select', + props: { + options: [ + { label: 'a', value: 'a' }, + { label: 'b', value: 'b' }, + { label: 'c', value: 'c' }, + ], + }, + }, + } + } + }, + { + title: 'LLM', + type: 'LLM', + description: '调用大语言模型回答问题或者对自然语言进行处理', + icon: { + type: 'icon-model', + bgColor: '#6172F3', + }, + }, + { + title: 'Prompt', + type: 'Prompt', + description: '通过精心设计提示词,提升大语言模型回答效果', + icon: { + type: 'icon-prompt', + bgColor: '#17B26A', + }, + }, + { + title: '知识库', + type: 'knowledge', + description: '允许你从知识库中查询与用户问题相关的文本内容', + icon: { + type: 'icon-knowledge', + bgColor: '#6172F3', + }, + }, + { + title: 'Switch', + type: 'Switch', + description: '允许你根据 if/else 条件将 workflow 拆分成两个分支', + icon: { + type: 'icon-switch', + bgColor: '#06AED4', + }, + }, + { + title: 'HSF', + type: 'hsf', + description: '允许通过 HSF 协议发送服务器请求', + icon: { + type: 'icon-hsf', + bgColor: '#875BF7', + }, + }, + { + title: 'Http', + type: 'http', + description: '允许通过 HTTP 协议发送服务器请求', + icon: { + type: 'icon-http', + bgColor: '#875BF7', + }, + }, + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: '_group', + items: [ + { + title: '代码执行', + type: 'Code', + description: '执行一段 Groovy 或 Python 或 NodeJS 代码实现自定义逻辑', + icon: { + type: 'icon-code', + bgColor: '#2E90FA', + }, + }, + { + title: '工具', + type: 'tool', + description: '允许使用工具能力', + icon: { + type: 'icon-gongju', + bgColor: '#2E90FA', + }, + }, + ], + }, +]; diff --git a/packages/x-flow/.fatherrc.js b/packages/x-flow/.fatherrc.js new file mode 100644 index 000000000..4d442af1f --- /dev/null +++ b/packages/x-flow/.fatherrc.js @@ -0,0 +1,35 @@ +import copy from 'rollup-plugin-copy'; + +export default { + cjs: 'babel', + esm: { + type: 'babel', + importLibToEs: true, + }, + lessInBabelMode: true, + extraRollupPlugins: [ + copy({ + targets: [{ src: 'src/index.d.ts', dest: 'dist/' }], + }), + ], + extraBabelPlugins: [ + [ + 'import', + { + libraryName: 'antd', + libraryDirectory: 'es', + style: true, + }, + 'antd', + ], + [ + 'import', + { + libraryName: '@ant-design/icons', + libraryDirectory: 'lib/icons', + camel2DashComponentName: false, + }, + '@ant-design/icons', + ], + ], +}; diff --git a/packages/x-flow/CHANGELOG.md b/packages/x-flow/CHANGELOG.md new file mode 100644 index 000000000..a8635cefa --- /dev/null +++ b/packages/x-flow/CHANGELOG.md @@ -0,0 +1,2 @@ +# 更新日志 + diff --git a/packages/x-flow/CONTRIBUTING.md b/packages/x-flow/CONTRIBUTING.md new file mode 100644 index 000000000..b44c74a66 --- /dev/null +++ b/packages/x-flow/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# 如何贡献代码 + +欢迎给 FormRender 提优化建议,或者修复已有 Bug,共促其发展 + +## Branch 管理 + +``` +master + ↑ +dev <--- Develop/PR +``` + +- `dev` 分支 + - 所有的开发均在 dev 分支进行 + - 提 PR 时候请提交到 dev 分支 +- `master` 分支 + - `master` 是稳定不改的分支,不会在上面进行代码开发 + - 在 dev 分支 publish 后会 merge 到 master,同时打对应 tag + +## Commit 格式 + +``` +[{action}] {description} +``` + +- `{action}` + - `+` 新增功能 + - `!` 更新或者修复 bug + - `-` 移除功能 +- `{description}` + - 尽可能详细的描述就好 + +for example: + +- [+] 列表选项新增拖拽功能 +- [!] 修复输入框长按闪烁的问题 + +## 更多 + +- 很推荐在提交 PR 前,先在钉钉群里进行讨论,已防止此功能已经有同学在开发了 +- 但是如果是想修复文档和明显代码错误,直接提交 PR 就好 + + diff --git a/packages/x-flow/LICENSE b/packages/x-flow/LICENSE new file mode 100644 index 000000000..a68fd737b --- /dev/null +++ b/packages/x-flow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present XRender Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/x-flow/README.md b/packages/x-flow/README.md new file mode 100644 index 000000000..c8988944a --- /dev/null +++ b/packages/x-flow/README.md @@ -0,0 +1,126 @@ +
+ logo +

FormRender

+
+

+ + npm + + + NPM downloads + + + NPM all downloads + + + PRs Welcome + +

+ +> 一站式中后台**表单解决方案** + +## 官网 + + + +FormRender 是中后台开箱即用的表单解决方案,通过 JsonSchema 协议动态渲染表单。为了能切实承接日益复杂的表单场景需求,2.0 我们进行了底层重构。我们的目标是以强大的扩展能力对表单场景 100% 的覆盖支持,同时保持开发者能快速上手,并以表单编辑器、插件、自定义组件等一系列周边产品带来极致的开发体验。在开发 1.0 的道路上,我们做了一系列的取舍,详见[v2 升级方案](https://xrender.fun/form-render/migrate) + +## 安装 + +FormRender 依赖 ant design,单独使用不要忘记同时安装 `antd` + +```shell +npm i form-render --save +``` + +## 使用 + +**最简使用 demo:** + +```jsx +import React from 'react'; +import { Button } from 'antd'; +import FormRender, { connectForm } from 'form-render'; + +const schema = { + type: 'object', + properties: { + input1: { + title: '简单输入框', + type: 'string', + required: true, + }, + select1: { + title: '单选', + type: 'string', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + }, + }, +}; + +class Demo extends React.Component { + render() { + const { form } = this.props; + return ( +
+ + +
+ ); + } +} + +export default connectForm(Demo); +``` + +**对于函数组件,FormRender 提供了 `useForm` hooks, 书写更为灵活** + +```jsx +import React from 'react'; +import { Button } from 'antd'; +import FormRender, { useForm } from 'form-render'; + +const schema = { + type: 'object', + properties: { + input1: { + title: '简单输入框', + type: 'string', + required: true, + }, + select1: { + title: '单选', + type: 'string', + props: { + options: [ + { label: '早', value: 'a' }, + { label: '中', value: 'b' }, + { label: '晚', value: 'c' } + ] + } + } + } +}; + +const Demo = () => { + const form = useForm(); + return ( +
+ + +
+ ); +}; + +export default Demo; +``` \ No newline at end of file diff --git a/packages/x-flow/package.json b/packages/x-flow/package.json new file mode 100644 index 000000000..77aaf6a0d --- /dev/null +++ b/packages/x-flow/package.json @@ -0,0 +1,78 @@ +{ + "name": "@xrenders/xflow", + "version": "1.0.0-beta.2", + "description": "一款功能强大、易用灵活的流程编辑器框架,帮助你轻松构建复杂的工作流和流程产品", + "keywords": [ + "xflow" + ], + "homepage": "https://xrender.fun/xflow", + "bugs": { + "url": "https://github.com/alibaba/x-render/issues" + }, + "repository": { + "type": "git", + "url": "git@github.com:alibaba/form-render.git" + }, + "license": "MIT", + "contributors": [ + { + "name": "lhbxs", + "email": "596850703@qq.com" + } + ], + "main": "lib/index.js", + "module": "es/index.js", + "files": [ + "es", + "lib", + "package.json" + ], + "scripts": { + "beta": "npm publish --tag beta", + "build": "father-build", + "next": "npm publish --tag next", + "prepare": "npm run build", + "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", + "postpublish": "git push --tags", + "test:ui": "vitest --ui" + }, + "lint-staged": { + "*.{js,jsx,less,md,json}": [ + "prettier --write" + ], + "*.ts?(x)": [ + "prettier --parser=typescript --write" + ] + }, + "dependencies": { + "@ant-design/icons": "^4.0.2", + "classnames": "^2.3.1", + "lodash-es": "^4.17.21", + "dayjs": "^1.11.7", + "ahooks": "^3.7.5", + "zustand": "^4.5.4", + "@xyflow/react": "^12.3.2", + "@remixicon/react": "^4.2.0", + "@dagrejs/dagre": "^1.1.3", + "zundo": "^2.1.0", + "use-context-selector": "^1.4.1", + "form-render": "^2.3.4" + }, + "devDependencies": { + "deep-equal": "^2.0.3", + "rollup-plugin-copy": "^3.4.0" + }, + "peerDependencies": { + "antd": "4.x || 5.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "gitHooks": { + "pre-commit": "lint-staged" + }, + "sideEffect": false, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + } +} diff --git a/packages/x-flow/src/XFlow.tsx b/packages/x-flow/src/XFlow.tsx new file mode 100644 index 000000000..9f8e5e590 --- /dev/null +++ b/packages/x-flow/src/XFlow.tsx @@ -0,0 +1,290 @@ +import { + Background, + BackgroundVariant, + MarkerType, + ReactFlow, + useReactFlow, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { useEventListener, useMemoizedFn } from 'ahooks'; +import produce, { setAutoFreeze } from 'immer'; +import { debounce } from 'lodash'; +import type { FC } from 'react'; +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import CandidateNode from './components/CandidateNode'; +import CustomEdge from './components/CustomEdge'; +import PanelContainer from './components/PanelContainer'; +import { useEventEmitterContextContext } from './models/event-emitter'; + +import CustomNodeComponent from './components/CustomNode'; +import { useStore, useStoreApi } from './hooks/useStore'; +import { useTemporalStore } from './hooks/useTemporalStore'; + +import Operator from './operator'; +import FlowProps from './types'; +import { transformNodes, uuid } from './utils'; +import autoLayoutNodes from './utils/autoLayoutNodes'; + +import { shallow } from 'zustand/shallow'; +import NodeEditor from './components/NodeEditor'; +import { useFlow } from './hooks/useFlow'; +import './index.less'; + +const CustomNode = memo(CustomNodeComponent); +const edgeTypes = { buttonedge: memo(CustomEdge) }; + +/*** + * + * XFlow 入口 + * + */ +const XFlow: FC = memo(props => { + const { initialValues, settings } = props; + const workflowContainerRef = useRef(null); + const store = useStoreApi(); + const { zoomTo } = useReactFlow(); + const { + layout, + nodes, + edges, + panOnDrag, + onNodesChange, + onEdgesChange, + onConnect, + setLayout, + setCandidateNode, + setMousePosition, + } = useStore( + state => ({ + nodes: state.nodes, + edges: state.edges, + layout: state.layout, + panOnDrag: state.panOnDrag, + setLayout: state.setLayout, + setMousePosition: state.setMousePosition, + setCandidateNode: state.setCandidateNode, + onNodesChange: state.onNodesChange, + onEdgesChange: state.onEdgesChange, + onConnect: state.onConnect, + }), + shallow + ); + const { setNodes, setEdges } = useFlow(); + const [activeNode, setActiveNode] = useState(null); + const temporalStore = useTemporalStore(); + useEffect(() => { + zoomTo(0.8); + setAutoFreeze(false); + return () => { + setAutoFreeze(true); + }; + }, []); + + useEffect(() => { + setLayout(props.layout); + // TODO: 默认关闭时间机器,可以向 zundo 贡献一个配置 + temporalStore.pause(); + setNodes(transformNodes(initialValues?.nodes)); + setEdges(initialValues?.edges); + }, []); + + useEventListener('keydown', e => { + if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) + e.preventDefault(); + if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) + e.preventDefault(); + if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) + e.preventDefault(); + if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) + e.preventDefault(); + }); + + useEventListener('mousemove', e => { + const containerClientRect = + workflowContainerRef.current?.getBoundingClientRect(); + if (containerClientRect) { + setMousePosition({ + pageX: e.clientX, + pageY: e.clientY, + elementX: e.clientX - containerClientRect.left, + elementY: e.clientY - containerClientRect.top, + }); + } + }); + + const { eventEmitter } = useEventEmitterContextContext(); + eventEmitter?.useSubscription((v: any) => { + // 整理节点 + if (v.type === 'auto-layout-nodes') { + const newNodes: any = autoLayoutNodes(store.getState().nodes, edges); + setNodes(newNodes); + } + + if (v.type === 'deleteNode') { + setActiveNode(null); + } + }); + + // 新增节点 + const handleAddNode = (data: any) => { + const newNode = { + id: uuid(), + type: 'custom', + data, + position: { + x: 0, + y: 0, + }, + }; + setCandidateNode(newNode); + }; + + // 插入节点 + // const handleInsertNode = () => { + // const newNode = { + // id: uuid(), + // data: { label: 'new node' }, + // position: { + // x: 0, + // y: 0, + // }, + // }; + // // record(() => { + // addNodes(newNode); + // addEdges({ + // id: uuid(), + // source: '2', + // target: newNode.id, + // }); + // const targetEdge = edges.find(edge => edge.source === '2'); + // updateEdge(targetEdge?.id as string, { + // source: newNode.id, + // }); + // // }); + // }; + + // edge 移入/移出效果 + const getUpdateEdgeConfig = useMemoizedFn((edge: any, color: string) => { + const newEdges = produce(edges, draft => { + const currEdge: any = draft.find(e => e.id === edge.id); + currEdge.style = { + ...edge.style, + stroke: color, + }; + currEdge.markerEnd = { + ...edge?.markerEnd, + color, + }; + }); + setEdges(newEdges); + }); + + const handleNodeValueChange = debounce((data: any) => { + for (let node of nodes) { + if (node.id === data.id) { + node.data = { + ...node?.data, + ...data?.values, + }; + break; + } + } + setNodes([...nodes]); + }, 200); + + const nodeTypes = useMemo(() => { + return { + custom: (props: any) => { + const { data, id, ...rest } = props; + const { _nodeType, ...restData } = data || {}; + + return ( + { + setActiveNode({ id, _nodeType, values: { ...restData } }); + }} + /> + ); + }, + }; + }, [layout]); + + // const edgeTypes = { buttonedge: (edgeProps: any) => }; + // const { icon, description } = + // settings.find( + // item => item.type?.toLowerCase() === activeNode?.node?.toLowerCase() + // ) || {}; + + const NodeEditorWrap = useMemo(() => { + return ( + + ); + }, [activeNode?.id]); + + return ( +
+ { + onNodesChange(changes); + }} + onEdgesChange={changes => { + onEdgesChange(changes); + }} + onEdgeMouseEnter={(_, edge: any) => { + getUpdateEdgeConfig(edge, '#2970ff'); + }} + onEdgeMouseLeave={(_, edge) => { + getUpdateEdgeConfig(edge, '#c9c9c9'); + }} + > + + + + {activeNode && ( + setActiveNode(null)} + node={activeNode} + data={activeNode?.values} + // disabled + > + {NodeEditorWrap} + + )} + +
+ ); +}); + +export default XFlow; diff --git a/packages/x-flow/src/components/CandidateNode/index.tsx b/packages/x-flow/src/components/CandidateNode/index.tsx new file mode 100644 index 000000000..4e6bd6269 --- /dev/null +++ b/packages/x-flow/src/components/CandidateNode/index.tsx @@ -0,0 +1,72 @@ +import { useReactFlow, useViewport } from '@xyflow/react'; +import { useEventListener } from 'ahooks'; +import React, { memo } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useStore } from '../../hooks/useStore'; +import CustomNode from '../CustomNode'; + +const CandidateNode = () => { + const { zoom } = useViewport(); + const reactflow = useReactFlow(); + const { candidateNode, mousePosition, setCandidateNode, addNodes } = useStore( + (state: any) => ({ + nodes: state.nodes, + edges: state.edges, + addNodes: state.addNodes, + candidateNode: state.candidateNode, + mousePosition: state.mousePosition, + setCandidateNode: state.setCandidateNode, + onNodesChange: state.onNodesChange, + onEdgesChange: state.onEdgesChange, + }), + shallow + ); + + useEventListener('click', ev => { + if (!candidateNode) { + return; + } + ev.preventDefault(); + const { screenToFlowPosition } = reactflow; + const { x, y } = screenToFlowPosition({ + x: mousePosition.pageX, + y: mousePosition.pageY, + }); + + const newNodes = { + ...candidateNode, + data: { + ...candidateNode.data, + _isCandidate: false, + }, + position: { x, y }, + }; + // @ts-ignore + addNodes(newNodes); + setCandidateNode(null); + }); + + if (!candidateNode) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default memo(CandidateNode); diff --git a/packages/x-flow/src/components/CustomEdge/index.less b/packages/x-flow/src/components/CustomEdge/index.less new file mode 100644 index 000000000..9b41ecff7 --- /dev/null +++ b/packages/x-flow/src/components/CustomEdge/index.less @@ -0,0 +1,24 @@ + +.custom-edge-line { + position: absolute; + z-index: 1000; + pointer-events: all; + + .line-content { + width: 60px; + display: flex; + justify-content: space-around; + align-items: center; + } + + .line-icon-box { + width: 16px; + height: 16px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background: #296dff; + visibility: visible; + } +} \ No newline at end of file diff --git a/packages/x-flow/src/components/CustomEdge/index.tsx b/packages/x-flow/src/components/CustomEdge/index.tsx new file mode 100644 index 000000000..975595e52 --- /dev/null +++ b/packages/x-flow/src/components/CustomEdge/index.tsx @@ -0,0 +1,138 @@ +import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; +import { + BezierEdge, + EdgeLabelRenderer, + getBezierPath, + useReactFlow, +} from '@xyflow/react'; +import produce from 'immer'; +import React, { memo, useState } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useStore } from '../../hooks/useStore'; +import { uuid } from '../../utils'; +import NodeSelectPopover from '../NodesPopover'; +import './index.less'; + +export default memo((edge: any) => { + const { id, selected, sourceX, sourceY, targetX, targetY, source, target } = + edge; + + const reactflow = useReactFlow(); + const [isHovered, setIsHovered] = useState(false); + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + }); + + const { + nodes, + edges, + setNodes, + setEdges, + mousePosition, + onEdgesChange, + layout, + } = useStore( + (state: any) => ({ + layout: state.layout, + nodes: state.nodes, + edges: state.edges, + mousePosition: state.mousePosition, + setNodes: state.setNodes, + setEdges: state.setEdges, + onEdgesChange: state.onEdgesChange, + }), + shallow + ); + + const handleAddNode = (data: any) => { + const { screenToFlowPosition } = reactflow; + const { x, y } = screenToFlowPosition({ + x: mousePosition.pageX, + y: mousePosition.pageY, + }); + + const targetId = uuid(); + const newNodes = produce(nodes, (draft: any) => { + draft.push({ + id: targetId, + type: 'custom', + data, + position: { x, y }, + }); + }); + + const newEdges = produce(edges, (draft: any) => { + draft.push( + ...[ + { + id: uuid(), + source, + target: targetId, + }, + { + id: uuid(), + source: targetId, + target, + }, + ] + ); + }); + + setNodes(newNodes); + setEdges(newEdges); + onEdgesChange([{ id, type: 'remove' }]); + }; + + let edgeExtra: any = { + sourceX: edge.sourceX - 15, + targetX: edge.targetX + 15, + }; + if (layout === 'TB') { + edgeExtra = { + sourceY: edge.sourceY - 15, + targetY: edge.targetY + 13, + }; + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + +
+
+
onEdgesChange([{ id, type: 'remove' }])} + > + +
+ +
+ +
+
+
+
+ + ) + } + /> +
+ ); +}); diff --git a/packages/x-flow/src/components/CustomHtml/index.tsx b/packages/x-flow/src/components/CustomHtml/index.tsx new file mode 100644 index 000000000..64eda1582 --- /dev/null +++ b/packages/x-flow/src/components/CustomHtml/index.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Tooltip, Typography } from 'antd'; +import _ from 'lodash'; +import IconView from '../IconView'; + +const { Text } = Typography; + +export default (props: any) => { + const { value, config, showEllipsis, width } = props; + const item = config?.[value]; + + if (showEllipsis && width) { + return ( + + {value} + + ); + } + + if (config?.requiredByValue && props.addons.schemaPath.includes('[]')) { + // 根据当前行的数据判断是否必填 + const values = props.addons.getValues(); + const dataPath = props.addons.dataPath; + const record = _.get(values, dataPath.slice(0, dataPath.lastIndexOf('.'))); + const pathName = dataPath.slice(dataPath.lastIndexOf('.') + 1); + // console.log('🚀 ~ record:', record, pathName); + // 获取当前行的数据 + + return ( + + {record?.required && config?.[pathName] === 'required' ? ( + * + ) : ( + '' + )} + {value} + + ); + } + + if (!item) { + return value; + } + const { required, tooltip } = item; + + return ( + + {required && ( + * + )} + {item?.label || value} + {!!tooltip && ( + + + + )} + + ); +}; diff --git a/packages/x-flow/src/components/CustomNode/index.less b/packages/x-flow/src/components/CustomNode/index.less new file mode 100644 index 000000000..5c060dc6b --- /dev/null +++ b/packages/x-flow/src/components/CustomNode/index.less @@ -0,0 +1,78 @@ +.xflow-node-container { + border: 2px solid #fff; + border-radius: 14px; + + .react-flow__edge-path, + .react-flow__connection-path { + stroke: #d0d5dc; + stroke-width: 2px; + } + + .react-flow__handle { + width: 30px; + height: 30px; + background: transparent; + border-radius: 0; + border: none; + } + + .react-flow__handle::after { + content: ''; + --tw-bg-opacity: 1; + background-color: #2970ff; + width: 2px; + height: 10px; + display: block; + margin: 11px 0 8px 15px; + } + + .react-flow__handle:hover { + .xflow-node-add-box { + scale: 130%; + } + } + + .xflow-node-add-box { + position: absolute; + top: 8px; + right: 9px; + width: 16px; + height: 16px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background-color: #2970ff; + pointer-events: none; + transition: all 0.3s; + } + + .xflow-node-switch-title{ + position: absolute; + left: -45px; + top: 6px; + font-weight: 600; + text-align: right; + width: 35px; + } +} + +.xflow-node-container-tb { + .react-flow__handle::after { + content: ''; + --tw-bg-opacity: 1; + background-color: #2970ff; + width: 8px; + height: 2px; + display: block; + margin: 15px 0 0 12px; + } +} + +.xflow-node-container-selected { + border: 2px solid #296dff; + + .react-flow__handle::after { + display: none; + } +} diff --git a/packages/x-flow/src/components/CustomNode/index.tsx b/packages/x-flow/src/components/CustomNode/index.tsx new file mode 100644 index 000000000..bbff9b7fb --- /dev/null +++ b/packages/x-flow/src/components/CustomNode/index.tsx @@ -0,0 +1,143 @@ +import { Handle, Position, useReactFlow } from '@xyflow/react'; +import classNames from 'classnames'; +import produce from 'immer'; +import React, { memo, useContext, useState } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useStore } from '../../hooks/useStore'; +import { ConfigContext } from '../../models/context'; +import { capitalize, uuid } from '../../utils'; +import './index.less'; +import SourceHandle from './sourceHandle'; + +const generateRandomArray = (x: number, type?: string) => { + const randomArray = []; + for (let i = 0; i < x; i++) { + const id = `id_${i}`; + if (type === 'Switch') { + if (i === 0) { + randomArray.push({ id, switchTitle: 'IF' }); + } else if (i === x - 1) { + randomArray.push({ id, switchTitle: 'ELSE' }); + } else { + randomArray.push({ id, switchTitle: 'ELIF' }); + } + } else { + randomArray.push({ id }); + } + } + return randomArray; +}; + +export default memo((props: any) => { + const { id, type, data, layout, isConnectable, selected, onClick } = props; + const { widgets, settingMap } = useContext(ConfigContext); + const NodeWidget = + widgets[`${capitalize(type)}Node`] || widgets['CommonNode']; + const [isHovered, setIsHovered] = useState(false); + const reactflow = useReactFlow(); + const { edges, nodes, setNodes, setEdges, mousePosition } = useStore( + (state: any) => ({ + nodes: state.nodes, + edges: state.edges, + mousePosition: state.mousePosition, + setNodes: state.setNodes, + setEdges: state.setEdges, + onEdgesChange: state.onEdgesChange, + }), + shallow + ); + // data中的switchData的长度,即:if和if else 的数量,条件数量 + const switchDataLength = + data?.switchData?.length >= 1 ? Number(data?.switchData?.length + 1) : 2; + const nodeHeight = 45; // 每次增长的节点高度 + // 1.switch节点 if,else数量 + const sourceHandleNum = + type === 'Switch' + ? generateRandomArray(switchDataLength, 'Switch') + : generateRandomArray(1); + const nodeMinHeight = + type === 'Switch' ? Number(switchDataLength * nodeHeight) : undefined; + + // 增加节点并进行联系 + const handleAddNode = (data: any) => { + const { screenToFlowPosition } = reactflow; + const { x, y } = screenToFlowPosition({ + x: mousePosition.pageX + 100, + y: mousePosition.pageY + 100, + }); + const targetId = uuid(); + + const newNodes = produce(nodes, (draft: any) => { + draft.push({ + id: targetId, + type: 'custom', + data, + position: { x, y }, + }); + }); + const newEdges = produce(edges, (draft: any) => { + draft.push({ + id: uuid(), + source: id, + target: targetId, + }); + }); + setNodes(newNodes); + setEdges(newEdges); + }; + + let targetPosition = Position.Left; + let sourcePosition = Position.Right; + if (layout === 'TB') { + targetPosition = Position.Top; + sourcePosition = Position.Bottom; + } + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {!settingMap?.[type]?.targetHandleHidden && ( + + )} + onClick(data)} + nodeMinHeight={nodeMinHeight} + /> + {!settingMap?.[type]?.sourceHandleHidden && ( + <> + {(sourceHandleNum || [])?.map((item, key) => ( + + ))} + + )} +
+ ); +}); diff --git a/packages/x-flow/src/components/CustomNode/sourceHandle.tsx b/packages/x-flow/src/components/CustomNode/sourceHandle.tsx new file mode 100644 index 000000000..9202b000b --- /dev/null +++ b/packages/x-flow/src/components/CustomNode/sourceHandle.tsx @@ -0,0 +1,66 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Handle } from '@xyflow/react'; +import { Tooltip } from 'antd'; +import React, { memo, useRef, useState } from 'react'; +import NodeSelectPopover from '../NodesPopover'; + +export default memo((props: any) => { + const { + position, + isConnectable, + selected, + isHovered, + handleAddNode, + id, + switchTitle, + ...rest + } = props; + const [isShowTooltip, setIsShowTooltip] = useState(false); + const [openNodeSelectPopover, setOpenNodeSelectPopover] = useState(false); + const popoverRef = useRef(null); + + return ( + setIsShowTooltip(true)} + onMouseLeave={() => setIsShowTooltip(false)} + onClick={e => { + e.stopPropagation(); + popoverRef?.current?.changeOpen(true); + setIsShowTooltip(false); + setOpenNodeSelectPopover(true); + }} + id={id} + {...rest} + > + {(selected || isHovered || openNodeSelectPopover) && ( + <> + {switchTitle &&
{switchTitle}
} +
+ setOpenNodeSelectPopover(val)} + > + + + + +
+ + )} +
+ ); +}); diff --git a/packages/x-flow/src/components/FAutoComplete/index.tsx b/packages/x-flow/src/components/FAutoComplete/index.tsx new file mode 100644 index 000000000..5f1e86a0b --- /dev/null +++ b/packages/x-flow/src/components/FAutoComplete/index.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react'; +import { AutoComplete, InputNumber } from 'antd'; +import _ from 'lodash'; + +const FAutoComplete: React.FC = (props) => { + const { + value, + onChange, + placeholder, + optionList, + width = '100%', + disabled, + } = props; + const [options, setOptions] = useState<{ value: string }[]>(optionList); + + useEffect(() => { + setOptions(optionList); + }, [optionList]); + const handleSearch = async (value: string) => { + if (!props.request) { + return; + } + const res = await props.request(value); + setOptions(res); + }; + + let customDisabled = false; + const dependValues = props.addons.dependValues; + if (dependValues) { + // 知识库组件的特殊处理 + if (dependValues.length > 1) { + customDisabled = dependValues[1] === 'vector_weight' && dependValues[0]; + } + if (dependValues[1] === 'vector_weight') { + const onNumberChange = (val: any) => { + let newValue: string | number = val; + if (val === null || val === undefined) { + newValue = ''; + } + onChange(newValue); + }; + return ( + + ); + } + // if ( + // ['search_attachment', 'with_associated_documents'].includes( + // dependValues[1], + // ) + // ) { + // return ( + // onChange(e.target.checked)} + // style={{ width }} + // disabled={disabled} + // /> + // ); + // } + } + const values = props.addons.getValues(); + const dataPath = props.addons.dataPath; + const record = _.get(values, dataPath.slice(0, dataPath.lastIndexOf('.'))); + const pathName = dataPath.slice(dataPath.lastIndexOf('.') + 1); + + return ( + { + onChange(val); + }} + disabled={disabled} + placeholder={placeholder} + /> + ); +}; +export default FAutoComplete; diff --git a/packages/x-flow/src/components/FlowProvider/index.tsx b/packages/x-flow/src/components/FlowProvider/index.tsx new file mode 100644 index 000000000..ed34fcf5c --- /dev/null +++ b/packages/x-flow/src/components/FlowProvider/index.tsx @@ -0,0 +1,44 @@ +import StoreContext, { Provider } from '../../models/context'; +import { createStore } from '../../models/store'; + +import type { ReactNode } from 'react'; +import React, { memo, useContext, useState } from 'react'; + +export const FlowProvider = memo<{ + initialNodes: any[]; + initialEdges: any[]; + children: ReactNode; +}>(({ initialNodes: nodes = [], initialEdges: edges = [], children }) => { + const [store] = useState(() => + createStore({ + nodes, + edges, + }) + ); + + return {children}; +}); + +export const FlowProviderWrapper = ({ + children, + nodes, + edges, +}: { + children: React.ReactNode; + nodes: any[]; + edges: any[]; +}) => { + const isWrapped = useContext(StoreContext); + + if (isWrapped) { + // we need to wrap it with a fragment because it's not allowed for children to be a ReactNode + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051 + return <>{children}; + } + + return ( + + {children} + + ); +}; diff --git a/packages/x-flow/src/components/IconView/index.tsx b/packages/x-flow/src/components/IconView/index.tsx new file mode 100644 index 000000000..eb681076f --- /dev/null +++ b/packages/x-flow/src/components/IconView/index.tsx @@ -0,0 +1,13 @@ +import { createFromIconfontCN } from '@ant-design/icons'; + +/** + * icon 图标库 + * 图标库资源变动需要更新 scriptUrl 资源路径 + * https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=4201076 + */ + +const Icon = createFromIconfontCN({ + scriptUrl: '//at.alicdn.com/t/a/font_4201076_frx3c9x07if.js', +}); + +export default Icon; diff --git a/packages/x-flow/src/components/NodeContainer/index.less b/packages/x-flow/src/components/NodeContainer/index.less new file mode 100644 index 000000000..c268b45a4 --- /dev/null +++ b/packages/x-flow/src/components/NodeContainer/index.less @@ -0,0 +1,38 @@ +.custom-node-container { + width: 240px; + padding: 13px 12px 5px 12px; + background: #fff; + border-radius: 12px; + + .node-title { + display: flex; + height: 24px; + margin-bottom: 8px; + align-items: center; + font-size: 13px; + color: #1D2939; + span { + font-weight: bold; + } + } + + .node-desc { + color: #676f83; + font-size: 12px; + padding-top: 4px; + margin-top: 10px; + } + + .icon-box { + width: 24px; + height: 24px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + } + + .node-widget{ + width: 100%; + } +} diff --git a/packages/x-flow/src/components/NodeContainer/index.tsx b/packages/x-flow/src/components/NodeContainer/index.tsx new file mode 100644 index 000000000..5340bc2c1 --- /dev/null +++ b/packages/x-flow/src/components/NodeContainer/index.tsx @@ -0,0 +1,52 @@ +import { Typography } from 'antd'; +import classNames from 'classnames'; +import React, { memo } from 'react'; +import IconView from '../IconView'; +import './index.less'; + +const { Text, Paragraph } = Typography; + +export default memo((props: any) => { + const { className, onClick, children, icon, title, desc, hideDesc, NodeWidget, nodeMinHeight } = props; + + return ( +
+
+ + + + {/* {title} */} + + {title} + +
+
{children}
+ {/* {!hideDesc && !!desc &&
{desc}
} */} + { + NodeWidget &&
+ {NodeWidget} +
+ } + {!hideDesc && !!desc && ( + + {desc} + + )} +
+ ); +}); diff --git a/packages/x-flow/src/components/NodeEditor/index.tsx b/packages/x-flow/src/components/NodeEditor/index.tsx new file mode 100644 index 000000000..91a75d60a --- /dev/null +++ b/packages/x-flow/src/components/NodeEditor/index.tsx @@ -0,0 +1,107 @@ +import FormRender, { useForm } from 'form-render'; +import produce from 'immer'; +import { debounce } from 'lodash'; +import React, { FC, useContext, useEffect, useState } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useStore } from '../../hooks/useStore'; +import { ConfigContext } from '../../models/context'; + +interface INodeEditorProps { + data: any; + onChange: (data: any) => void; + nodeType: string; + id: string; +} + +const NodeEditor: FC = (props: any) => { + const { data, onChange, nodeType, id } = props; + const form = useForm(); + // // 1.获取节点配置信息 + const { settingMap, widgets } = useContext(ConfigContext); + const nodeSetting = settingMap[nodeType] || {}; + const [customVal, setCustomVal] = useState(data); + const CustomSettingWidget = widgets[`${nodeType}NodeSettingWidget`]; // 内置setting组件 + const NodeWidget = widgets[nodeSetting?.settingWidget]; // 自定义面板配置组件 + + const { nodes, setNodes } = useStore( + (state: any) => ({ + nodes: state.nodes, + setNodes: state.setNodes, + }), + shallow + ); + + useEffect(() => { + if (nodeSetting?.settingSchema) { + // 自定义Schema + form.resetFields(); + form.setValues(data || {}); + } else if (nodeSetting?.settingWidget) { + // 自定义组件 + setCustomVal(data); + } else { + //可能为内置schema或者是没有 + } + }, [JSON.stringify(data), id]); + + const handleNodeValueChange = debounce((data: any) => { + const newNodes = produce(nodes, draft => { + let node = null; + // 反向查询ID,因为有多个ID相同的元素 + for (let i = draft?.length - 1; i >= 0; i--) { + if (draft[i].id === id) { + node = draft[i]; + break; + } + } + if (node) { + // 更新节点的 data + node.data = { ...node.data, ...data }; + } + }); + setNodes(newNodes); + }, 100); + + const watch = { + '#': (allValues: any) => { + handleNodeValueChange({ ...allValues }); + // onChange({ id, values: { ...allValues } }); + }, + }; + + if (nodeSetting?.settingWidget && NodeWidget) { + return ( + { + setCustomVal(values); + handleNodeValueChange({ ...values }); + }} + /> + ); + } else if (nodeSetting?.settingSchema) { + return ( + + ); + } else if (CustomSettingWidget) { + // 内置节点 + return ( + { + handleNodeValueChange({ ...val }); + }} + value={data} + /> + ); + } else { + return null; + } +}; + +export default NodeEditor; diff --git a/packages/x-flow/src/components/NodesMenu/index.less b/packages/x-flow/src/components/NodesMenu/index.less new file mode 100644 index 000000000..23131e0c0 --- /dev/null +++ b/packages/x-flow/src/components/NodesMenu/index.less @@ -0,0 +1,59 @@ +.xflow-node-menu{ + min-height: 340px; + min-width: 150px; + + .menu-group-title { + padding: 0 10px; + color: #667085; + font-size: 12px; + font-weight: 500; + line-height: 24px; + } + + .menu-item { + height: 32px; + color: #101828; + padding: 0 10px; + display: flex; + align-items: center; + cursor: pointer; + } + + .menu-item:hover { + background-color: #f9fafb; + border-radius: 8px; + } + + .icon-box { + width: 20px; + height: 20px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +.xflow-node-menu-tooltip { + width: 200px; + + .title { + color: #101828; + margin-top: 3px; + } + + .description { + font-size: 12px; + font-weight: normal; + color: #101828; + } + + .icon-box-max { + width: 24px; + height: 24px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + } +} \ No newline at end of file diff --git a/packages/x-flow/src/components/NodesMenu/index.tsx b/packages/x-flow/src/components/NodesMenu/index.tsx new file mode 100644 index 000000000..d0281eff8 --- /dev/null +++ b/packages/x-flow/src/components/NodesMenu/index.tsx @@ -0,0 +1,138 @@ + +import React, { forwardRef, Ref } from 'react'; +import { Popover, Input } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import { useSet } from '../../utils/hooks'; +import IconView from '../IconView'; +import { TNodeMenu } from '../../types'; +import './index.less'; + +// 检索节点 +const searchNodeList = (query: string, list: any[]) => { + if (!query) { + return list; + } + const searchTerm = query.toLowerCase(); + + function searchList(nodes: any, preResult = []) { + if (nodes.length === 0) { + return preResult; + } + + const [currentNode, ...restNodes] = nodes; + let result: any = [...preResult]; + + if (currentNode.title.toLowerCase().includes(searchTerm)) { + result.push(currentNode); + } else if (currentNode?.type === '_group' && currentNode.items) { + const matchingItems = searchList(currentNode.items); + if (matchingItems.length > 0) { + result.push({ ...currentNode, items: matchingItems }); + } + } + return searchList(restNodes, result); + } + return searchList(list); +}; + +// 悬浮菜单项详细描述 +const MenuTooltip = ({ icon, title, description }: any) => { + return ( +
+
+ +
+
+ {title} +
+
+ {description} +
+
+ ) +}; + +// 节点菜单项 +const MenuItem = (props: any) => { + const { title, type, icon, onClick } = props; + return ( + } + placement='right' + arrow={false} + > +
+ + + + {title} +
+
+ ); +}; + +// 过滤 hidden 节点 +const filterHiddenMenu = (list: any) => { + return (list || []).filter((item: any) => !item.hidden) +} + +/** + * + * 节点菜单List + * + */ +const NodesMenu = (props: TNodeMenu, ref: Ref) => { + const { items, showSearch, onClick } = props; + + const [state, setState] = useSet({ + menuList: [...items] + }); + const { menuList } = state; + + const handleItemClick = (type: string) => (ev: React.MouseEvent) => { + ev.stopPropagation(); + onClick({ type }); + } + + const handleSearch = (ev: any) => { + setState({ menuList: searchNodeList(ev.target.value, items)}) + }; + + return ( +
+ {!!showSearch && ( +
+ } + style={{ width: '100%' }} + /> +
+ )} +
+ {filterHiddenMenu(menuList).map((item: any, index: number) => item.type === '_group' ? ( +
+
{item.title}
+ {filterHiddenMenu(item.items).map((data: any, index: number) => ( + + ))} +
+ ) : ( +
+ +
+ ))} +
+
+ ); +}; + +export default forwardRef(NodesMenu); diff --git a/packages/x-flow/src/components/NodesPopover/index.tsx b/packages/x-flow/src/components/NodesPopover/index.tsx new file mode 100644 index 000000000..726ce81f3 --- /dev/null +++ b/packages/x-flow/src/components/NodesPopover/index.tsx @@ -0,0 +1,71 @@ +import { useClickAway } from 'ahooks'; +import { Popover } from 'antd'; +import React, { + forwardRef, + useCallback, + useContext, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { ConfigContext } from '../../models/context'; +import NodesMenu from '../NodesMenu'; + +export default forwardRef((props: any, popoverRef) => { + const { addNode, children, onNodeSelectPopoverChange } = props; + + const ref = useRef(null); + const closeRef: any = useRef(null); + const [open, setOpen] = useState(false); + + const { settings, nodeSelector } = useContext(ConfigContext); + const { showSearch, popoverProps = { placement: 'top' } } = + nodeSelector || {}; + + useImperativeHandle(popoverRef, () => ({ + changeOpen: val => { + setOpen(val); + }, + })); + + useClickAway(() => { + if (closeRef.current) { + setOpen(false); + onNodeSelectPopoverChange && onNodeSelectPopoverChange(false); + closeRef.current = false; + } + }, ref); + + const handCreateNode = useCallback(({ type }) => { + addNode({ _nodeType: type }); + setOpen(false); + onNodeSelectPopoverChange && onNodeSelectPopoverChange(false); + }, []); + + return ( + { + setTimeout(() => { + closeRef.current = true; + setOpen(true); + }, 50); + }} + content={ + + } + > + {children} + + ); +}); diff --git a/packages/x-flow/src/components/PanelContainer/index.less b/packages/x-flow/src/components/PanelContainer/index.less new file mode 100644 index 000000000..7679e2fa9 --- /dev/null +++ b/packages/x-flow/src/components/PanelContainer/index.less @@ -0,0 +1,125 @@ +.custom-node-panel { + .ant-drawer-content-wrapper { + top: 54px; + bottom: 14px; + right: 12px; + border-radius: 20px; + } + + .ant-drawer-close { + display: none; + } + + .ant-drawer-header { + padding: 24px 16px 0 16px; + } + + .ant-drawer-content { + border-radius: 20px; + } + + .ant-drawer-body { + padding: 12px 16px; + } + + .title-box { + display: flex; + align-items: center; + justify-content: space-between; + .ant-input-outlined { + border-color: #fff; + font-weight: 600; + } + } + + .icon-box { + width: 24px; + height: 24px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 5px; + } + + .title-actions { + display: flex; + align-items: center; + margin-left: 24px; + } + + .desc-box { + font-size: 12px; + line-height: 32px; + font-weight: normal; + .ant-input-outlined { + border-color: #fff; + } + textarea { + margin: 12px 0; + } + } + + .ant-input-outlined:focus-within { + border-color: #3b82f6 !important; + } + + .fr-table-cell-content { + .ant-col { + padding: 0 !important; + } + } + + .ant-collapse-content-box { + padding: 0 !important; + } + + .item-collapse { + border: none; + background-color: #fff; + + .ant-collapse-header { + background: none; + padding: 6px 0; + } + + .ant-collapse-item { + border: none !important; + padding: 0; + } + + .ant-collapse-content { + border: none; + } + } + + .ant-collapse-header-text { + color: #354052; + font-weight: 600; + } + + .ant-table-thead >tr>th { + font-size: 12px; + font-weight: normal; + } + + .ant-table-tbody>tr>td { + padding: 4px !important; + } + + input, select, textarea, .ant-select-selector { + font-size: 12px !important; + min-height: 30px; + } + + input, select, .ant-select-selector { + height: 30px !important; + line-height: 30px !important; + } + + .fr-table-list { + .ant-btn { + font-size: 12px; + } + } +} \ No newline at end of file diff --git a/packages/x-flow/src/components/PanelContainer/index.tsx b/packages/x-flow/src/components/PanelContainer/index.tsx new file mode 100644 index 000000000..bc9778c59 --- /dev/null +++ b/packages/x-flow/src/components/PanelContainer/index.tsx @@ -0,0 +1,152 @@ +import { Divider, Drawer, Input, Space } from 'antd'; +import produce from 'immer'; +import { debounce } from 'lodash'; +import React, { FC, useContext, useEffect, useState } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useStore } from '../../hooks/useStore'; +import { ConfigContext } from '../../models/context'; +import IconView from '../IconView'; +import './index.less'; + +interface IPanelProps { + disabled?: boolean; // 是否禁用 ---to do:确认一下取的地方 + nodeType: string; + onClose: () => void; + node?: { id: string; _isCandidate: boolean; _nodeType: string }; + description?: string; // 业务描述---to do :确认一下取的地方---从data里面取? + children?: any; + id: string; + data: any; // data值 +} + +const getDescription = (nodeType: string, description: string) => { + if (nodeType === 'Input') { + return '工作流的起始节点,用于设定启动工作流入参信息'; + } + if (nodeType === 'Output') { + return '工作流的最终节点,用于返回工作流运行后的出参信息'; + } + return description || ''; +}; + +const Panel: FC = (props: any) => { + // disabled属性取的地方可能不对------to do + const { onClose, children, nodeType, disabled, node, description, id, data } = + props; + // 1.获取节点配置信息 + const { settingMap } = useContext(ConfigContext); + const nodeSetting = settingMap[nodeType] || {}; + const { nodes, setNodes } = useStore( + (state: any) => ({ + nodes: state.nodes, + setNodes: state.setNodes, + }), + shallow + ); + + const isDisabled = ['Input', 'Output'].includes(nodeType) || disabled; + const [descVal, setDescVal] = useState(data?.desc); + const [titleVal, setTitleVal] = useState(data?.title || nodeSetting?.title); + + // const description = getDescription(nodeType, props.description); + + const handleNodeValueChange = debounce((data: any) => { + const newNodes = produce(nodes, draft => { + let node = null; + // 反向查询ID,因为有多个ID相同的元素 + for (let i = draft?.length - 1; i >= 0; i--) { + if (draft[i].id === id) { + node = draft[i]; + break; + } + } + if (node) { + // 更新节点的 data + node.data = { ...node.data, ...data }; + } + }); + setNodes(newNodes); + }, 100); + + useEffect(() => { + setDescVal(data?.desc); + setTitleVal(data?.title || nodeSetting?.title); + }, [JSON.stringify(data), id]); + + return ( + +
+
+ + + + {isDisabled ? ( + {nodeSetting?.title} + ) : ( + { + setTitleVal(e.target.value); + handleNodeValueChange({ title: e.target.value }); + }} + /> + )} +
+
+ + {!isDisabled && ( + <> + + + + )} + {/* */} + + +
+
+
+ {isDisabled ? ( + description + ) : ( + { + setDescVal(e.target.value); + handleNodeValueChange({ desc: e.target.value }); + }} + /> + )} +
+ + } + > + {children} +
+ ); +}; + +export default React.memo(Panel); diff --git a/packages/x-flow/src/hooks/useEdges.ts b/packages/x-flow/src/hooks/useEdges.ts new file mode 100644 index 000000000..f9c5a3eba --- /dev/null +++ b/packages/x-flow/src/hooks/useEdges.ts @@ -0,0 +1,19 @@ +import type { Edge } from "@xyflow/react"; +import { shallow } from 'zustand/shallow'; + +import { useStore } from './useStore'; +import type { FlowState } from '../models/store'; + +const nodesSelector = (state: FlowState) => state.edges; + +/** + * Hook for getting the current edges from the store. + * + * @public + * @returns An array of edges + */ +export function useEdges(): Edge[] { + const nodes = useStore(nodesSelector, shallow) as EdgeType[]; + + return nodes; +} diff --git a/packages/x-flow/src/hooks/useFlow.ts b/packages/x-flow/src/hooks/useFlow.ts new file mode 100644 index 000000000..baf2c8b49 --- /dev/null +++ b/packages/x-flow/src/hooks/useFlow.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { useMemoizedFn } from 'ahooks'; +import { useStoreApi } from './useStore'; +import { useTemporalStore } from './useTemporalStore'; +import { FlowNode } from '../models/store'; +import { Edge } from '@xyflow/react'; + +export const useFlow = () => { + const storeApi = useStoreApi(); + const instance = storeApi.getState(); + const temporalStore = useTemporalStore(); + + const getNodes = useMemoizedFn(() => storeApi.getState().nodes); + const getEdges = useMemoizedFn(() => storeApi.getState().edges); + const setNodes = useMemoizedFn((nodes: FlowNode[]) => { + temporalStore.record(() => { + storeApi.getState().setNodes(nodes); + }) + }) + const addNodes = useMemoizedFn((nodes: FlowNode[]) => { + temporalStore.record(() => { + storeApi.getState().addNodes(nodes); + }) + }) + const setEdges = useMemoizedFn((edges: Edge[]) => { + storeApi.getState().setEdges(edges); + }) + const addEdges = useMemoizedFn((edges: Edge[]) => { + storeApi.getState().addEdges(edges); + }) + + return useMemo(() => ({ + setNodes, + addNodes, + setEdges, + addEdges, + getNodes, + getEdges, + }), [instance]); +}; diff --git a/packages/x-flow/src/hooks/useNodes.ts b/packages/x-flow/src/hooks/useNodes.ts new file mode 100644 index 000000000..da54e91b4 --- /dev/null +++ b/packages/x-flow/src/hooks/useNodes.ts @@ -0,0 +1,18 @@ +import { shallow } from 'zustand/shallow'; + +import { useStore } from '../hooks/useStore'; +import type { FlowNode, FlowState } from '../models/store'; + +const nodesSelector = (state: FlowState) => state.nodes; + +/** + * Hook for getting the current nodes from the store. + * + * @public + * @returns An array of nodes + */ +export function useNodes(): NodeType[] { + const nodes = useStore(nodesSelector, shallow) as NodeType[]; + + return nodes; +} diff --git a/packages/x-flow/src/hooks/useStore.ts b/packages/x-flow/src/hooks/useStore.ts new file mode 100644 index 000000000..7dc21dbb7 --- /dev/null +++ b/packages/x-flow/src/hooks/useStore.ts @@ -0,0 +1,45 @@ +import { useContext, useMemo } from 'react'; +import StoreContext from '../models/context'; +import { FlowNode, FlowState } from '../models/store'; + +import { Edge } from '@xyflow/react'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; + +const useStore = ( + selector: (state: FlowState) => T, + equalityFn?: (a: T, b: T) => boolean +) => { + const store = useContext(StoreContext); + + if (store === null) { + throw new Error( + '[XFlow]: Seems like you have not used zustand provider as an ancestor.' + ); + } + + return useStoreWithEqualityFn(store, selector, equalityFn); +}; + +const useStoreApi = < + NodeType extends FlowNode = FlowNode, + EdgeType extends Edge = Edge +>() => { + const store = useContext(StoreContext); + + if (store === null) { + throw new Error( + '[XFlow]: Seems like you have not used zustand provider as an ancestor.' + ); + } + return useMemo( + () => ({ + getState: store.getState, + setState: store.setState, + subscribe: store.subscribe, + temporal: store.temporal, + }), + [store] + ); +}; + +export { useStore, useStoreApi }; diff --git a/packages/x-flow/src/hooks/useTemporalStore.ts b/packages/x-flow/src/hooks/useTemporalStore.ts new file mode 100644 index 000000000..baa26653e --- /dev/null +++ b/packages/x-flow/src/hooks/useTemporalStore.ts @@ -0,0 +1,22 @@ +import StoreContext from '../models/context'; +import { useContext } from 'react'; + +export const useTemporalStore = () => { + const store = useContext(StoreContext); + + if (store === null) { + throw new Error( + '[XFlow]: Seems like you have not used zustand provider as an ancestor.' + ); + } + + return { + ...store.temporal.getState(), + record: (callback: () => void) => { + const temporalStore = store.temporal.getState(); + temporalStore.resume(); + callback(); + temporalStore.pause(); + }, + }; +}; diff --git a/packages/x-flow/src/index.less b/packages/x-flow/src/index.less new file mode 100644 index 000000000..540913a0b --- /dev/null +++ b/packages/x-flow/src/index.less @@ -0,0 +1,10 @@ +#xflow-container { + height: 100%; + width: 100%; + background: #F0F2F7; + position: relative; + + .react-flow__attribution { + display: none; + } +} \ No newline at end of file diff --git a/packages/x-flow/src/index.ts b/packages/x-flow/src/index.ts new file mode 100644 index 000000000..85dc9d99a --- /dev/null +++ b/packages/x-flow/src/index.ts @@ -0,0 +1,15 @@ +import XFlow from './XFlow'; +import withProvider from './withProvider'; + +import * as nodes from './nodes'; + +export type { +default as FR, +} from './types'; + +export { FlowProvider } from './components/FlowProvider'; +export { useFlow } from './hooks/useFlow'; +export { useNodes } from './hooks/useNodes'; +export { useEdges } from './hooks/useEdges'; + +export default withProvider(XFlow, nodes); diff --git a/packages/x-flow/src/models/context.ts b/packages/x-flow/src/models/context.ts new file mode 100644 index 000000000..2930f6619 --- /dev/null +++ b/packages/x-flow/src/models/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; +import { FlowStore } from './store'; + +export const ConfigContext = createContext(null); + +const StoreContext = createContext(null); +export const Provider = StoreContext.Provider; +export default StoreContext; diff --git a/packages/x-flow/src/models/event-emitter.tsx b/packages/x-flow/src/models/event-emitter.tsx new file mode 100644 index 000000000..a62cf68cf --- /dev/null +++ b/packages/x-flow/src/models/event-emitter.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useEventEmitter } from 'ahooks'; +import type { EventEmitter } from 'ahooks/lib/useEventEmitter'; +import { createContext, useContext } from 'use-context-selector'; + +const EventEmitterContext = createContext<{ eventEmitter: EventEmitter | null }>({ + eventEmitter: null, +}) + +export const useEventEmitterContextContext = () => useContext(EventEmitterContext) + +type EventEmitterContextProviderProps = { + children: React.ReactNode +} + +export const EventEmitterContextProvider = ({ + children, +}: EventEmitterContextProviderProps) => { + const eventEmitter = useEventEmitter() + + return ( + + {children} + + ) +} + +export default EventEmitterContext diff --git a/packages/x-flow/src/models/store.ts b/packages/x-flow/src/models/store.ts new file mode 100644 index 000000000..2f4833308 --- /dev/null +++ b/packages/x-flow/src/models/store.ts @@ -0,0 +1,131 @@ +import { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Edge, + Node, + OnConnect, + OnEdgesChange, + OnNodesChange, +} from '@xyflow/react'; +import isDeepEqual from 'fast-deep-equal'; +import { temporal } from 'zundo'; +import { createWithEqualityFn } from 'zustand/traditional'; + +export type FlowProps = { + nodes?: Node[]; + edges?: Edge[]; + panOnDrag?: boolean; + layout?: 'LR' | 'TB'; +}; + +export type FlowStore = ReturnType; + +export type FlowNode = Node; + +export type FlowState = { + layout?: 'LR' | 'TB'; + nodes?: FlowNode[]; + edges?: Edge[]; + panOnDrag?: boolean; + candidateNode: any; + mousePosition: any; + onNodesChange: OnNodesChange; + onEdgesChange: OnEdgesChange; + onConnect: OnConnect; + setNodes: (nodes: FlowNode[]) => void; + setEdges: (edges: Edge[]) => void; + addNodes: (nodes: FlowNode[]) => void; + addEdges: (edges: Edge[]) => void; + setLayout: (layout: 'LR' | 'TB') => void; + setCandidateNode: (candidateNode: any) => void; + setMousePosition: (mousePosition: any) => void; +}; + +const createStore = (initProps?: Partial) => { + const DEFAULT_PROPS: FlowProps = { + layout: 'LR', + panOnDrag: true, + nodes: [], + edges: [] + }; + + return createWithEqualityFn()( + temporal( + (set, get) => ({ + ...DEFAULT_PROPS, + ...initProps, + candidateNode: null, + // nodeMenus: [], + mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 }, + onNodesChange: changes => { + set({ + nodes: applyNodeChanges(changes, get().nodes), + }); + }, + onEdgesChange: changes => { + set({ + edges: applyEdgeChanges(changes, get().edges), + }); + }, + onConnect: connection => { + set({ + edges: addEdge(connection, get().edges), + }); + }, + getNodes: () => { + return get().nodes; + }, + setNodes: nodes => { + set({ nodes }); + }, + setEdges: edges => { + set({ edges }); + }, + getEdges: () => { + return get().nodes; + }, + addNodes: payload => { + const newNodes = get().nodes.concat(payload); + set({ nodes: newNodes }); + }, + addEdges: payload => { + set({ edges: get().edges.concat(payload) }); + }, + // setNodeMenus: (nodeMenus: any) => { + // set({ nodeMenus }); + // }, + setCandidateNode: candidateNode => { + set({ candidateNode }); + }, + setMousePosition: (mousePosition: any) => { + set({ mousePosition }); + }, + setLayout: (layout: 'LR' | 'TB') => { + if (!layout) { + return; + } + set({ layout }); + }, + }), + { + // nodes 和 edges 是引用类型,所以使用深比较 + equality: isDeepEqual, + // 偏函数 + partialize: state => { + const { nodes, edges } = state; + return { + edges, + nodes, + }; + }, + onSave(pastState, currentState) { + console.log('onSave', pastState, currentState); + }, + } + ), + Object.is + ); +}; + +export { createStore }; diff --git a/packages/x-flow/src/nodes/index.less b/packages/x-flow/src/nodes/index.less new file mode 100644 index 000000000..b4048db05 --- /dev/null +++ b/packages/x-flow/src/nodes/index.less @@ -0,0 +1,41 @@ +.node-container { + border: 2px solid #fff; + border-radius: 14px; + + .react-flow__edge-path, + .react-flow__connection-path { + stroke: #d0d5dc; + stroke-width: 2px; + } +} + +.node-container-selected { + border: 2px solid #296dff; + + .react-flow__handle::after { + display: none; + } +} + +.react-flow__handle { + width: 32px; + height: 32px; + background: transparent; + border-radius: 0; + border: none; + + :hover { + border: 2px solid #00a952; + transform: scale(1.25); + } +} + +.react-flow__handle::after { + content: ''; + --tw-bg-opacity: 1; + background-color: #2970ff; + width: 8px; + height: 2px; + display: block; + margin: 15px 0 0 12px; +} \ No newline at end of file diff --git a/packages/x-flow/src/nodes/index.tsx b/packages/x-flow/src/nodes/index.tsx new file mode 100644 index 000000000..18c684726 --- /dev/null +++ b/packages/x-flow/src/nodes/index.tsx @@ -0,0 +1,5 @@ +export { default as StartNode } from './node-start'; +export { default as EndNode } from './node-end'; +export { default as CommonNode } from './node-common'; +export { default as SwitchNode } from './node-switch'; +export { default as SwitchNodeSettingWidget } from './node-switch/setting'; diff --git a/packages/x-flow/src/nodes/node-common/index.less b/packages/x-flow/src/nodes/node-common/index.less new file mode 100644 index 000000000..0377a0f36 --- /dev/null +++ b/packages/x-flow/src/nodes/node-common/index.less @@ -0,0 +1,16 @@ +.custom-node-start { + width: 240px; + padding: 0 12px; + background: #fff; + border-radius: 12px; + + .title { + display: flex; + height: 50px; + align-items: center; + span { + font-weight: bold; + margin-left: 8px; + } + } +} \ No newline at end of file diff --git a/packages/x-flow/src/nodes/node-common/index.tsx b/packages/x-flow/src/nodes/node-common/index.tsx new file mode 100644 index 000000000..fa863ae3d --- /dev/null +++ b/packages/x-flow/src/nodes/node-common/index.tsx @@ -0,0 +1,26 @@ +import React, { memo, useContext } from 'react'; +import NodeContainer from '../../components/NodeContainer'; +import { ConfigContext } from '../../models/context'; + +export default memo((props: any) => { + const { type, onClick, data } = props; + const { settingMap, widgets } = useContext(ConfigContext); + const nodeSetting = settingMap[type] || {}; + const NodeWidget = widgets[nodeSetting?.nodeWidget] || undefined; + + return ( + : undefined} + /> + ); +}); diff --git a/packages/x-flow/src/nodes/node-end/index.less b/packages/x-flow/src/nodes/node-end/index.less new file mode 100644 index 000000000..0377a0f36 --- /dev/null +++ b/packages/x-flow/src/nodes/node-end/index.less @@ -0,0 +1,16 @@ +.custom-node-start { + width: 240px; + padding: 0 12px; + background: #fff; + border-radius: 12px; + + .title { + display: flex; + height: 50px; + align-items: center; + span { + font-weight: bold; + margin-left: 8px; + } + } +} \ No newline at end of file diff --git a/packages/x-flow/src/nodes/node-end/index.tsx b/packages/x-flow/src/nodes/node-end/index.tsx new file mode 100644 index 000000000..890357a49 --- /dev/null +++ b/packages/x-flow/src/nodes/node-end/index.tsx @@ -0,0 +1,26 @@ +import React, { memo, useContext } from 'react'; +import NodeContainer from '../../components/NodeContainer'; +import { ConfigContext } from '../../models/context'; + +export default memo((props: any) => { + const { onClick, type, data } = props; + const { settingMap,widgets } = useContext(ConfigContext); + const nodeSetting = settingMap[type] || {}; + const NodeWidget = widgets[nodeSetting?.nodeWidget] || undefined; + + return ( + : undefined} + /> + ); +}); diff --git a/packages/x-flow/src/nodes/node-start/index.less b/packages/x-flow/src/nodes/node-start/index.less new file mode 100644 index 000000000..0377a0f36 --- /dev/null +++ b/packages/x-flow/src/nodes/node-start/index.less @@ -0,0 +1,16 @@ +.custom-node-start { + width: 240px; + padding: 0 12px; + background: #fff; + border-radius: 12px; + + .title { + display: flex; + height: 50px; + align-items: center; + span { + font-weight: bold; + margin-left: 8px; + } + } +} \ No newline at end of file diff --git a/packages/x-flow/src/nodes/node-start/index.tsx b/packages/x-flow/src/nodes/node-start/index.tsx new file mode 100644 index 000000000..923cadfd9 --- /dev/null +++ b/packages/x-flow/src/nodes/node-start/index.tsx @@ -0,0 +1,26 @@ +import React, { memo, useContext } from 'react'; +import NodeContainer from '../../components/NodeContainer'; +import { ConfigContext } from '../../models/context'; + +export default memo((props: any) => { + const { onClick, type, data } = props; + const { settingMap, widgets } = useContext(ConfigContext); + const nodeSetting = settingMap[type] || {}; + const NodeWidget = widgets[nodeSetting?.nodeWidget] || undefined; + + return ( + : undefined} + /> + ); +}); diff --git a/packages/x-flow/src/nodes/node-switch/SwitchBuildInNodeWidget.tsx b/packages/x-flow/src/nodes/node-switch/SwitchBuildInNodeWidget.tsx new file mode 100644 index 000000000..e6efb73fa --- /dev/null +++ b/packages/x-flow/src/nodes/node-switch/SwitchBuildInNodeWidget.tsx @@ -0,0 +1,11 @@ +import { Space,Typography } from 'antd'; +import React, { memo } from 'react'; + +export default memo((props: any) => { + const { data } = props; + return + {(data?.switchData || [])?.map(item => + {item?.value} + )} + +}); diff --git a/packages/x-flow/src/nodes/node-switch/index.less b/packages/x-flow/src/nodes/node-switch/index.less new file mode 100644 index 000000000..5a2c37ce3 --- /dev/null +++ b/packages/x-flow/src/nodes/node-switch/index.less @@ -0,0 +1,22 @@ +.custom-node-start { + width: 240px; + padding: 0 12px; + background: #fff; + border-radius: 12px; + + .title { + display: flex; + height: 50px; + align-items: center; + span { + font-weight: bold; + margin-left: 8px; + } + } +} + +.custom-node-switch-setting{ + .fr-list-simple,.ant-form-item,.fr-panel,.fr-inline-container{ + width: 100%; + } +} diff --git a/packages/x-flow/src/nodes/node-switch/index.tsx b/packages/x-flow/src/nodes/node-switch/index.tsx new file mode 100644 index 000000000..53824f8de --- /dev/null +++ b/packages/x-flow/src/nodes/node-switch/index.tsx @@ -0,0 +1,33 @@ +import React, { memo, useContext } from 'react'; +import NodeContainer from '../../components/NodeContainer'; +import { ConfigContext } from '../../models/context'; +import SwitchBuildInNodeWidget from './SwitchBuildInNodeWidget'; + +export default memo((props: any) => { + const { + onClick, + type, + data, + nodeMinHeight + } = props; + const { settingMap, widgets } = useContext(ConfigContext); + const nodeSetting = settingMap[type] || {}; + const NodeWidget = widgets[nodeSetting?.nodeWidget] || undefined; + + return ( + : } + nodeMinHeight={nodeMinHeight} + /> + ); +}); diff --git a/packages/x-flow/src/nodes/node-switch/setting/index.tsx b/packages/x-flow/src/nodes/node-switch/setting/index.tsx new file mode 100644 index 000000000..c4e3f9e01 --- /dev/null +++ b/packages/x-flow/src/nodes/node-switch/setting/index.tsx @@ -0,0 +1,59 @@ +import FormRender, { Schema, useForm } from 'form-render'; +import React, { memo } from 'react'; +import '../index.less' + +interface INodeSwitchSettingPorps { + onChange: (val: any) => void; + value: any; +} + +const schema: Schema = { + type: 'object', + span: 24, + displayType: "row", + properties: { + switchData: { + type: 'array', + widget: 'simpleList', + display:"block", + props: { + hideCopy: true, + hideMove: true, + hideDelete: true, + }, + items: { + type: 'object', + properties: { + value: { + title: '条件', + type: 'string', + }, + }, + }, + }, + }, +}; + +export default memo((props: INodeSwitchSettingPorps) => { + const form = useForm(); + const { onChange, value } = props; + + const watch = { + '#': (allValues: any) => { + onChange({ ...allValues }); + }, + }; + + return ( + { + form.setValues(value || {}); + }} + className='custom-node-switch-setting' + /> + ); +}); diff --git a/packages/x-flow/src/operator/Control/index.less b/packages/x-flow/src/operator/Control/index.less new file mode 100644 index 000000000..bf99b50e9 --- /dev/null +++ b/packages/x-flow/src/operator/Control/index.less @@ -0,0 +1,48 @@ +.fai-reactflow-control { + display: flex; + align-items: center; + padding: 2px 1px; + border-radius: 8px; + border: 0.5px solid #f3f4f6; + background-color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + color: #667085; + margin-left: 10px; +} + +.control-item { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + cursor: pointer; + + &.inactive:hover { + background-color: rgba(0, 0, 0, 0.05); + color: #374151; + } + + &.active { + background-color: #ecfdf5; + color: #10b981; + } + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.icon { + width: 1rem; + height: 1rem; +} + +.separator { + margin: 0 0.75rem; + width: 1px; + height: 0.875rem; + background-color: #e5e7eb; +} diff --git a/packages/x-flow/src/operator/Control/index.tsx b/packages/x-flow/src/operator/Control/index.tsx new file mode 100644 index 000000000..cb8d9da65 --- /dev/null +++ b/packages/x-flow/src/operator/Control/index.tsx @@ -0,0 +1,79 @@ +import type { MouseEvent } from 'react'; +import React, { memo } from 'react'; +import { + RiCursorLine, + RiFunctionAddLine, + RiHand, + RiStickyNoteAddLine, +} from '@remixicon/react'; +import { Tooltip, Button } from 'antd'; +import IconView from '../../components/IconView'; +import { useEventEmitterContextContext } from '../../models/event-emitter'; +import NodeSelectPopover from '../../components/NodesPopover'; +import { useStoreApi, useStore } from '../../hooks/useStore'; + +import './index.less'; + +const Control = (props: any) => { + const { addNode } = props; + + const addNote = (e: MouseEvent) => { + e.stopPropagation(); + }; + const storeApi = useStoreApi(); + const panOnDrag = useStore(s => s.panOnDrag); + + const { eventEmitter } = useEventEmitterContextContext() + + const handleInteractionModeChange = (panOnDrag) => { + storeApi.setState({ panOnDrag }) + } + + return ( +
+ + +
+ ) +}; + +export default memo(Control); diff --git a/packages/x-flow/src/operator/UndoRedo/index.less b/packages/x-flow/src/operator/UndoRedo/index.less new file mode 100644 index 000000000..5994dc756 --- /dev/null +++ b/packages/x-flow/src/operator/UndoRedo/index.less @@ -0,0 +1,11 @@ +.fai-reactflow-undoredo { + display: flex; + align-items: center; + padding: 2px 1px; + border-radius: 8px; + border: 0.5px solid #f3f4f6; + background-color: #ffffff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + color: #667085; + margin-left: 10px; +} \ No newline at end of file diff --git a/packages/x-flow/src/operator/UndoRedo/index.tsx b/packages/x-flow/src/operator/UndoRedo/index.tsx new file mode 100644 index 000000000..2404b01b4 --- /dev/null +++ b/packages/x-flow/src/operator/UndoRedo/index.tsx @@ -0,0 +1,34 @@ +import React, { memo } from 'react'; +import { Button, Tooltip } from 'antd'; +import IconView from '../../components/IconView'; +import './index.less'; + +export type UndoRedoProps = { + handleUndo: () => void; + handleRedo: () => void; + pastStates: any[]; + futureStates: any[]; +}; + +export default memo(({ handleUndo, handleRedo, pastStates, futureStates }: UndoRedoProps) => { + return ( +
+ +
+ ); +}) diff --git a/packages/x-flow/src/operator/ZoomInOut/index.less b/packages/x-flow/src/operator/ZoomInOut/index.less new file mode 100644 index 000000000..98b4b5a6c --- /dev/null +++ b/packages/x-flow/src/operator/ZoomInOut/index.less @@ -0,0 +1,35 @@ +.fai-reactflow-zoominout { + display: flex; + align-items: center; + padding: 2px 1px; + border-radius: 8px; + border: 0.5px solid #f3f4f6; + background-color: #ffffff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + color: #667085; +} + +.fai-reactflow-zoom-select { + + .parting-line { + height: 1px; + background-color: #F3F4F6; + } + + .zoom-item { + display: flex; + align-items: center; + justify-content: space-between; + padding-left: 0.75rem; + padding-right: 0.75rem; + height: 2rem; + border-radius: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + color: #374151; + + &:hover { + background-color: #F9FAFB; + } + } +} \ No newline at end of file diff --git a/packages/x-flow/src/operator/ZoomInOut/index.tsx b/packages/x-flow/src/operator/ZoomInOut/index.tsx new file mode 100644 index 000000000..4c56c2d54 --- /dev/null +++ b/packages/x-flow/src/operator/ZoomInOut/index.tsx @@ -0,0 +1,154 @@ +import type { FC } from 'react'; +import React, { Fragment, memo } from 'react'; +import { Button, Popover, Tooltip } from 'antd'; +import { useReactFlow, useViewport } from '@xyflow/react'; +import { getKeyboardKeyNameBySystem } from '../../utils'; +import ShortcutsName from './shortcuts-name'; +import IconView from '../../components/IconView'; +import './index.less'; + +enum ZoomType { + zoomIn = 'zoomIn', + zoomOut = 'zoomOut', + zoomToFit = 'zoomToFit', + zoomTo25 = 'zoomTo25', + zoomTo50 = 'zoomTo50', + zoomTo75 = 'zoomTo75', + zoomTo100 = 'zoomTo100', + zoomTo200 = 'zoomTo200' +}; + +const ZOOM_IN_OUT_OPTIONS = [ + [ + { + key: ZoomType.zoomTo200, + text: '200%', + }, + { + key: ZoomType.zoomTo100, + text: '100%', + }, + { + key: ZoomType.zoomTo75, + text: '75%', + }, + { + key: ZoomType.zoomTo50, + text: '50%', + }, + { + key: ZoomType.zoomTo25, + text: '25%', + }, + ], + [ + { + key: ZoomType.zoomToFit, + text: '自适应视图', + }, + ] +]; + +const ZoomSelect = ({ handleZoom }: any) => { + return ( +
+ {ZOOM_IN_OUT_OPTIONS.map((options, i) => ( + + {i !== 0 &&
} +
+ { + options.map(option => ( +
handleZoom(option.key)} + > + {option.text} + { + option.key === ZoomType.zoomToFit && ( + + ) + } + { + option.key === ZoomType.zoomTo50 && ( + + ) + } + { + option.key === ZoomType.zoomTo100 && ( + + ) + } +
+ )) + } +
+ + ))} +
+ ) +}; + + +const ZoomInOut: FC = () => { + const { + zoomIn, + zoomOut, + zoomTo, + fitView, + } = useReactFlow(); + + const { zoom } = useViewport(); + + const handleZoom = (type: string) => { + if (type === ZoomType.zoomToFit) + fitView() + + if (type === ZoomType.zoomTo25) + zoomTo(0.25) + + if (type === ZoomType.zoomTo50) + zoomTo(0.5) + + if (type === ZoomType.zoomTo75) + zoomTo(0.75) + + if (type === ZoomType.zoomTo100) + zoomTo(1) + + if (type === ZoomType.zoomTo200) + zoomTo(2) + }; + + return ( +
+ +
+ ) +}; + +export default memo(ZoomInOut); diff --git a/packages/x-flow/src/operator/ZoomInOut/shortcuts-name.tsx b/packages/x-flow/src/operator/ZoomInOut/shortcuts-name.tsx new file mode 100644 index 000000000..303e43359 --- /dev/null +++ b/packages/x-flow/src/operator/ZoomInOut/shortcuts-name.tsx @@ -0,0 +1,32 @@ +import React, { memo } from 'react' +import { getKeyboardKeyNameBySystem } from '../../utils' +import cn from 'classnames' + +type ShortcutsNameProps = { + keys: string[] + className?: string +} +const ShortcutsName = ({ + keys, + className, +}: ShortcutsNameProps) => { + return ( +
+ { + keys.map(key => ( +
+ {getKeyboardKeyNameBySystem(key)} +
+ )) + } +
+ ) +} + +export default memo(ShortcutsName) diff --git a/packages/x-flow/src/operator/index.less b/packages/x-flow/src/operator/index.less new file mode 100644 index 000000000..d2cc387c0 --- /dev/null +++ b/packages/x-flow/src/operator/index.less @@ -0,0 +1,26 @@ +.fai-reactflow-operator { + position: absolute; + left: 4px; + bottom: 14px; + height: 50px; + + .mini-map { + position: absolute; + left: 12px; + bottom: 45px; + margin: 0; + border: 0.5px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 9; + } + + .operator-section { + display: flex; + align-items: center; + position: absolute; + left: 10px; + bottom: 4px; + z-index: 9; + } +} diff --git a/packages/x-flow/src/operator/index.tsx b/packages/x-flow/src/operator/index.tsx new file mode 100644 index 000000000..e30cf1b02 --- /dev/null +++ b/packages/x-flow/src/operator/index.tsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react'; +// import UndoRedo from '../header/undo-redo' +import ZoomInOut from './ZoomInOut'; +import UndoRedo from './UndoRedo'; +import Control from './Control'; + +import './index.less'; +import { useTemporalStore } from '../hooks/useTemporalStore'; + +export type OperatorProps = { + addNode: any; +} + +const Operator = ({ addNode }: OperatorProps) => { + const { undo, redo, pastStates, futureStates } = useTemporalStore(); + return ( +
+
+ + undo()} handleRedo={() => redo()} pastStates={pastStates} futureStates={futureStates} /> + +
+
+ ); +} + +export default memo(Operator) diff --git a/packages/x-flow/src/types.ts b/packages/x-flow/src/types.ts new file mode 100644 index 000000000..c23c315bc --- /dev/null +++ b/packages/x-flow/src/types.ts @@ -0,0 +1,46 @@ +import { Schema } from 'form-render'; +import React from 'react'; +export interface TNodeItem { + title: string; // 节点 title + type: string; // 节点类型 _group 比较te + description?: string; // 节点描述 + hidden?: boolean; // 是否可见 + icon: { + type: string; + bgColor: string; + }; + settingSchema?: Schema; // 节点的配置schema(弹窗) string为自定义组件 + settingWidget?: string; // 自定义组件 + hideDesc?: boolean;// 隐藏业务描述 +} + +export interface TNodeGroup { + title: string; // 节点 title + type: '_group'; + items: TNodeItem[]; +} + +export interface TNodeMenu { + ref: React.RefObject; // 可选的 ref 属性 + showSearch: boolean; // 配置是否可搜索 + items: (TNodeGroup | TNodeItem)[]; + onClick: ({}: { type: string }) => void; +} + +export interface TNodeSelector { + showSearch: boolean; // 配置是否可搜索 + items: (TNodeGroup | TNodeItem)[]; +} + +export interface FlowProps { + initialValues: { + nodes: any[]; + edges: any; + }; + layout: 'LR' | 'TB'; + widgets: any; // 自定义组件 + settings: (TNodeGroup | TNodeItem)[]; // 节点配置 + nodeSelector: TNodeSelector; +} + +export default FlowProps; diff --git a/packages/x-flow/src/utils/autoLayoutNodes.ts b/packages/x-flow/src/utils/autoLayoutNodes.ts new file mode 100644 index 000000000..ad2eab0f3 --- /dev/null +++ b/packages/x-flow/src/utils/autoLayoutNodes.ts @@ -0,0 +1,68 @@ +import produce from 'immer'; +import dagre from '@dagrejs/dagre'; +import { cloneDeep } from 'lodash-es' + +export const CUSTOM_NODE = 'custom'; +export const CUSTOM_EDGE = 'custom'; + +export const getLayoutByDagre = (originNodes: any[], originEdges: any[]) => { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + + const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) + const edges = cloneDeep(originEdges).filter(edge => !edge.data?.isInIteration) + dagreGraph.setGraph({ + ranker: 'network-simplex', // 节点分层算法,可选:'tight-tree' 'longest-path' 'network-simplex' + rankdir: 'TB', // 图的延展方向,可选: 'TB' | 'BT' | 'LR' | 'RL' + nodesep: 150, // 同层各个节点之间的间距 + ranksep: 150, // 图的各个层次之间的间距 + // align: '', // 节点对齐方式,可选:'UL' | 'UR' | 'DL' | 'DR' | undefined + }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: node.width || 204, + height: node.height || 45, + }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }); + + dagre.layout(dagreGraph); + return dagreGraph; +} + +export default (nodes: any, edges: any) => { + const layout = getLayoutByDagre(nodes, edges); + const rankMap: any = {} as Record; + + nodes.forEach((node: any) => { + if (!node.parentId && node.type === CUSTOM_NODE) { + const rank: any = layout.node(node.id).rank! + + if (!rankMap[rank]) { + rankMap[rank] = node + } + else { + if (rankMap[rank].position.y > node.position.y) + rankMap[rank] = node + } + } + }); + + const newNodes = produce(nodes, (draft: any) => { + draft.forEach((node: any) => { + if (!node.parentId && node.type === CUSTOM_NODE) { + const nodeWithPosition = layout.node(node.id) + node.position = { + x: nodeWithPosition.x - (node.width || 204) / 2, + y: nodeWithPosition.y - (node.height || 45) / 2 + (rankMap[nodeWithPosition.rank!].height || 45) / 2, + } + } + }) + }); + + return newNodes; +} diff --git a/packages/x-flow/src/utils/createIconFont.ts b/packages/x-flow/src/utils/createIconFont.ts new file mode 100644 index 000000000..f11184a35 --- /dev/null +++ b/packages/x-flow/src/utils/createIconFont.ts @@ -0,0 +1,7 @@ +import { createFromIconfontCN } from '@ant-design/icons'; + +export default (url?: string) => { + return createFromIconfontCN({ + scriptUrl: url || '//at.alicdn.com/t/a/font_2750617_sax751jyfjl.js', + }); +}; \ No newline at end of file diff --git a/packages/x-flow/src/utils/hooks.ts b/packages/x-flow/src/utils/hooks.ts new file mode 100644 index 000000000..0c0f8baaf --- /dev/null +++ b/packages/x-flow/src/utils/hooks.ts @@ -0,0 +1,43 @@ +import { useReducer, useRef, useEffect, useState } from 'react'; +import { message } from 'antd'; + +export function usePrevious(value: any) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + +// 类似于class component的setState +export const useSet = (initState: any) => { + return useReducer((state: any, newState: any) => { + return { ...state, ...newState }; + }, initState); +}; + +/** + * 业务中为了防止提交按钮会被连续点击 我们会手动的设置setLoading true 和setLoading false 非常麻烦 + * 所以写了这个hook 省事 + * @returns + */ +type TCallback = () => Promise; +type TRequestLoading = () => [boolean, (callback: TCallback) => void]; + +export const useLoadingRequest: TRequestLoading = () => { + const [loading, setLoading] = useState(false); + + const handleRequest = async (callback: TCallback) => { + try { + setLoading(true); + await callback(); + } catch (error: any) { + message.error(error); + throw error; + } finally { + setLoading(false); + } + }; + + return [loading, handleRequest]; +}; diff --git a/packages/x-flow/src/utils/index.ts b/packages/x-flow/src/utils/index.ts new file mode 100644 index 000000000..fa164c798 --- /dev/null +++ b/packages/x-flow/src/utils/index.ts @@ -0,0 +1,170 @@ +import { customAlphabet } from 'nanoid'; +export const uuid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16); + + + +import { isMatch, some, set, get, cloneDeep, has as _has, merge, mergeWith, isUndefined, omitBy } from 'lodash-es'; + +export const _set = set; +export const _get = get; +export const _cloneDeep = cloneDeep; +// export const _has = has; +export { _has }; +export const _merge = merge; +export const _mergeWith = mergeWith; +export const _isUndefined = isUndefined; +export const _omitBy = omitBy; +export const _some = some; +export const _isMatch = isMatch; + +export const isObject = (data: any) => { + const str = Object.prototype.toString.call(data); + return str.indexOf('Object') > -1; +} + +export const isArray = (data: any) => { + const str = Object.prototype.toString.call(data); + return str.indexOf('Array') > -1; +} + +export const isFunction = (data: any) => typeof data === 'function'; + +export function isUrl(string: string) { + const protocolRE = /^(?:\w+:)?\/\/(\S+)$/; + // const domainRE = /^[^\s\.]+\.\S{2,}$/; + if (typeof string !== 'string') return false; + return protocolRE.test(string); +} + +export const isNumber = (str: string | number) => !isNaN(Number(str)) + +export const getArray = (arr, defaultValue = []) => { + if (Array.isArray(arr)) return arr; + return defaultValue; +}; + +export function getFormat(format) { + let dateFormat; + switch (format) { + case 'date': + dateFormat = 'YYYY-MM-DD'; + break; + case 'time': + dateFormat = 'HH:mm:ss'; + break; + case 'dateTime': + dateFormat = 'YYYY-MM-DD HH:mm:ss'; + break; + case 'week': + dateFormat = 'YYYY-w'; + break; + case 'year': + dateFormat = 'YYYY'; + break; + case 'quarter': + dateFormat = 'YYYY-Q'; + break; + case 'month': + dateFormat = 'YYYY-MM'; + break; + default: + // dateTime + if (typeof format === 'string') { + dateFormat = format; + } else { + dateFormat = 'YYYY-MM-DD'; + } + } + return dateFormat; +} + +// TODO: to support case that item is not an object +export function isObjType(schema: any) { + //return schema?.type === 'object' && schema.properties && !schema.widget; + return schema?.type === 'object' && schema?.properties && schema?.widgetType !== 'field'; +}; + +export function isListType(schema: any) { + return schema?.type === 'array' && isObjType(schema?.items) && schema?.enum === undefined; +}; + +export function isCheckBoxType(schema: any, readOnly: boolean) { + if (readOnly) return false; + if (schema.widget === 'checkbox') return true; + if (schema && schema.type === 'boolean') { + if (schema.enum) return false; + if (schema.widget === undefined) return true; + return false; + } +} + +export const translation = (configCtx: any) => (key: string) => { + const locale = configCtx?.locale.FormRender; + return locale[key]; +}; + +export const hasFuncProperty = (obj: any) => { + return _some(obj, (value) => { + if (isFunction(value)) { + return true; + } + if (isObject(value)) { + return hasFuncProperty(value); + } + return false; + }); +}; + +/** + * 安全地获取对象的值,如果值为 null 或 undefined,则返回 defaultValue。 + * + * @param {Object} object - 要获取值的对象。 + * @param {string|Array} path - 要获取的路径,可以是字符串或数组。 + * @param {*} [defaultValue] - 如果值为 null 或 undefined,则返回 defaultValue。 + * @returns {*} - 返回获取的值,或者默认值。 + */ +export const safeGet = (object: any, path: string, defaultValue: any) => { + return get(object, path, defaultValue) ?? defaultValue; +}; + + + + + +export const isMac = () => { + return navigator.userAgent.toUpperCase().includes('MAC') +} + +const specialKeysNameMap: Record = { + ctrl: '⌘', + alt: '⌥', +} + +export const getKeyboardKeyNameBySystem = (key: string) => { + if (isMac()) + return specialKeysNameMap[key] || key + + return key +} + + +export const capitalize = (string: string) => { + if (typeof string !== 'string' || string.length === 0) { + return string; + } + return `${string.charAt(0).toUpperCase()}${string.slice(1)}`; +} + +export const transformNodes = (nodes: any[]) => { + return nodes?.map(item => { + const { type, data, ...rest } = item; + return { + type: 'custom', + data: { + ...data, + _nodeType: type, + }, + ...rest + } + }) +} \ No newline at end of file diff --git a/packages/x-flow/src/withProvider.tsx b/packages/x-flow/src/withProvider.tsx new file mode 100644 index 000000000..2f5b4a9c2 --- /dev/null +++ b/packages/x-flow/src/withProvider.tsx @@ -0,0 +1,75 @@ +import { ReactFlowProvider } from '@xyflow/react'; +import { ConfigProvider } from 'antd'; +import React, { useMemo } from 'react'; +import { FlowProviderWrapper } from './components/FlowProvider'; +import { ConfigContext } from './models/context'; +import { TNodeGroup, TNodeItem } from './types'; +interface ProviderProps { + configProvider?: any; + widgets?: any; + methods?: any; + nodeSelector?: any; + settings?: (TNodeGroup | TNodeItem)[]; + [key: string]: any; +} + +export default function withProvider( + Element: any, + defaultWidgets?: any +): React.ComponentType { + return (props: ProviderProps) => { + const { + configProvider, + widgets, + methods, + nodeSelector, + settings, + initialValues, + ...restProps + } = props; + + const settingMap = useMemo(() => { + const obj: Record = {}; + settings?.forEach((node: any) => { + if (node.type !== '_group') { + obj[node.type] = node; + } else { + node.items.forEach((item: any) => { + obj[item.type] = item; + }); + } + }); + return obj; + }, [settings]); + + const configContext = { + methods, + nodeSelector, + settings, + settingMap, + widgets: { + ...defaultWidgets, + ...widgets, + }, + }; + + return ( + + + + + + + + + + ); + }; +} diff --git a/packages/x-flow/tsconfig.json b/packages/x-flow/tsconfig.json new file mode 100644 index 000000000..3e09e8c01 --- /dev/null +++ b/packages/x-flow/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "ES2015", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "skipLibCheck": true, + // "strict": true, + "declaration": true, + "paths": { + "@/*": [ + "src/*" + ], + "@@/*": [ + "src/.umi/*" + ], + "form-render": [ + "packages/form-render/src/*" + ], + }, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "noImplicitAny": false, + "resolveJsonModule": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "typings", + "**/__test__", + "test", + "tests", + "docs", + "**/*.js" + ] +}