From b0963e5e37ed0d1f32155d52a922bdcc75a3cfb4 Mon Sep 17 00:00:00 2001 From: ayou <550244300@qq.com> Date: Mon, 15 Sep 2025 00:27:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(flows):=20=E6=96=B0=E5=A2=9E=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E7=BC=96=E8=BE=91=E5=99=A8=E5=9F=BA=E7=A1=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(backend): 添加流程模型与服务支持 feat(frontend): 实现流程编辑器UI与交互 feat(assets): 添加流程节点图标资源 feat(plugins): 实现上下文菜单和运行时插件 feat(components): 新增基础节点和侧边栏组件 feat(routes): 添加流程相关路由配置 feat(models): 创建流程和运行日志数据模型 feat(services): 实现流程服务层逻辑 feat(migration): 添加流程相关数据库迁移 feat(config): 更新前端配置支持流程编辑器 feat(utils): 增强axios错误处理和工具函数 --- DEMO: | 1 + backend/Cargo.lock | 85 ++- backend/Cargo.toml | 8 +- backend/branch-async-create.json | 19 + backend/branch-sync-create.json | 22 + backend/flow_create.json | 3 + backend/linear-async-create.json | 15 + backend/linear-sync-create.json | 15 + backend/migration/src/lib.rs | 26 + .../src/m20220101_000001_create_users.rs | 5 +- .../src/m20220101_000011_create_workflows.rs | 16 + ...20101_000012_create_workflow_executions.rs | 16 + ...1_000013_create_workflow_execution_logs.rs | 16 + .../src/m20220101_000014_create_flows.rs | 40 ++ ...101_000015_add_code_and_remark_to_flows.rs | 56 ++ ...1_000016_add_unique_index_to_flows_code.rs | 37 + .../src/m20220101_000016_dedup_flows_code.rs | 65 ++ .../m20220101_000017_create_flow_run_logs.rs | 50 ++ ...1_000018_add_flow_code_to_flow_run_logs.rs | 37 + backend/src/db.rs | 17 + backend/src/error.rs | 8 + backend/src/flow/context.rs | 27 + backend/src/flow/domain.rs | 44 ++ backend/src/flow/dsl.rs | 263 ++++++++ backend/src/flow/engine.rs | 173 +++++ backend/src/flow/executors/db.rs | 261 +++++++ backend/src/flow/executors/http.rs | 161 +++++ backend/src/flow/executors/mod.rs | 2 + backend/src/flow/mod.rs | 7 + backend/src/flow/storage.rs | 15 + backend/src/flow/task.rs | 41 ++ backend/src/main.rs | 19 +- backend/src/middlewares/jwt.rs | 6 +- backend/src/models/flow.rs | 21 + backend/src/models/flow_run_log.rs | 25 + backend/src/models/mod.rs | 4 +- backend/src/routes/flow_run_logs.rs | 13 + backend/src/routes/flows.rs | 71 ++ backend/src/routes/mod.rs | 5 +- backend/src/services/flow_run_log_service.rs | 78 +++ backend/src/services/flow_service.rs | 438 ++++++++++++ backend/src/services/mod.rs | 4 +- backend/udmin_ai.db | Bin 0 -> 143360 bytes cookies_admin.txt | 1 - frontend/flow-fixed-layout-demo.md | 346 ++++++++++ frontend/flow-free-layout-demo.md | 346 ++++++++++ frontend/flow-free-layout-json.md | 635 ++++++++++++++++++ frontend/flow-free-layout-sj-demo.md | 412 ++++++++++++ frontend/package.json | 23 +- frontend/src/App.tsx | 10 + frontend/src/flows/app.tsx | 12 + .../src/flows/assets/icon-auto-layout.tsx | 13 + frontend/src/flows/assets/icon-break.jpg | Bin 0 -> 28182 bytes frontend/src/flows/assets/icon-cancel.tsx | 24 + frontend/src/flows/assets/icon-comment.tsx | 24 + frontend/src/flows/assets/icon-condition.svg | 9 + frontend/src/flows/assets/icon-continue.jpg | Bin 0 -> 38177 bytes frontend/src/flows/assets/icon-end.jpg | Bin 0 -> 21016 bytes frontend/src/flows/assets/icon-http.svg | 5 + frontend/src/flows/assets/icon-llm.jpg | Bin 0 -> 11089 bytes frontend/src/flows/assets/icon-loop.jpg | Bin 0 -> 25572 bytes frontend/src/flows/assets/icon-minimap.tsx | 24 + frontend/src/flows/assets/icon-mouse.tsx | 41 ++ frontend/src/flows/assets/icon-pad.tsx | 56 ++ frontend/src/flows/assets/icon-script.png | Bin 0 -> 3659 bytes frontend/src/flows/assets/icon-start.jpg | Bin 0 -> 20971 bytes frontend/src/flows/assets/icon-success.tsx | 37 + .../src/flows/assets/icon-switch-line.tsx | 15 + frontend/src/flows/assets/icon-variable.png | Bin 0 -> 3430 bytes frontend/src/flows/assets/icon-warning.tsx | 27 + .../src/flows/components/add-node/index.tsx | 29 + .../flows/components/add-node/use-add-node.ts | 115 ++++ .../src/flows/components/base-node/index.tsx | 45 ++ .../components/base-node/node-wrapper.tsx | 81 +++ .../src/flows/components/base-node/styles.tsx | 39 ++ .../src/flows/components/base-node/utils.ts | 23 + .../comment/components/blank-area.tsx | 48 ++ .../comment/components/border-area.tsx | 120 ++++ .../comment/components/container.tsx | 50 ++ .../comment/components/content-drag-area.tsx | 94 +++ .../comment/components/drag-area.tsx | 48 ++ .../components/comment/components/editor.tsx | 65 ++ .../components/comment/components/index.css | 108 +++ .../components/comment/components/index.ts | 8 + .../comment/components/more-button.tsx | 26 + .../components/comment/components/render.tsx | 83 +++ .../comment/components/resize-area.tsx | 89 +++ .../src/flows/components/comment/constant.ts | 25 + .../flows/components/comment/hooks/index.ts | 6 + .../components/comment/hooks/use-model.ts | 55 ++ .../components/comment/hooks/use-overflow.ts | 50 ++ .../components/comment/hooks/use-size.ts | 168 +++++ .../src/flows/components/comment/index.ts | 6 + .../src/flows/components/comment/model.ts | 111 +++ frontend/src/flows/components/comment/type.ts | 29 + frontend/src/flows/components/group/color.ts | 105 +++ .../group/components/background.tsx | 55 ++ .../components/group/components/color.tsx | 50 ++ .../components/group/components/header.tsx | 41 ++ .../group/components/icon-group.tsx | 52 ++ .../components/group/components/index.ts | 7 + .../group/components/node-render.tsx | 81 +++ .../group/components/tips/global-store.ts | 38 ++ .../group/components/tips/icon-close.tsx | 14 + .../group/components/tips/index.tsx | 42 ++ .../group/components/tips/is-mac-os.ts | 6 + .../components/group/components/tips/style.ts | 79 +++ .../group/components/tips/use-control.ts | 71 ++ .../components/group/components/title.tsx | 38 ++ .../components/group/components/tools.tsx | 19 + .../components/group/components/ungroup.tsx | 36 + .../src/flows/components/group/constant.ts | 12 + frontend/src/flows/components/group/index.css | 117 ++++ frontend/src/flows/components/group/index.ts | 9 + frontend/src/flows/components/index.ts | 10 + .../components/line-add-button/button.tsx | 31 + .../components/line-add-button/index.less | 13 + .../components/line-add-button/index.tsx | 128 ++++ .../components/line-add-button/use-visible.ts | 28 + .../src/flows/components/node-menu/index.tsx | 116 ++++ .../flows/components/node-panel/index.less | 60 ++ .../src/flows/components/node-panel/index.tsx | 54 ++ .../flows/components/node-panel/node-list.tsx | 102 +++ .../node-panel/node-placeholder.tsx | 31 + .../components/selector-box-popover/index.tsx | 104 +++ .../src/flows/components/sidebar/index.tsx | 7 + .../sidebar/sidebar-node-renderer.tsx | 29 + .../components/sidebar/sidebar-provider.tsx | 17 + .../components/sidebar/sidebar-renderer.tsx | 113 ++++ .../flows/components/testrun/hooks/index.ts | 8 + .../components/testrun/hooks/use-fields.ts | 50 ++ .../components/testrun/hooks/use-form-meta.ts | 62 ++ .../testrun/hooks/use-sync-default.ts | 30 + .../node-status-bar/group/index.module.less | 30 + .../testrun/node-status-bar/group/index.tsx | 61 ++ .../node-status-bar/header/index.module.less | 57 ++ .../testrun/node-status-bar/header/index.tsx | 67 ++ .../testrun/node-status-bar/index.tsx | 47 ++ .../node-status-bar/render/index.module.less | 94 +++ .../testrun/node-status-bar/render/index.tsx | 194 ++++++ .../node-status-bar/viewer/index.module.less | 142 ++++ .../testrun/node-status-bar/viewer/index.tsx | 190 ++++++ .../testrun/testrun-button/index.module.less | 16 + .../testrun/testrun-button/index.tsx | 85 +++ .../testrun/testrun-form/index.module.less | 128 ++++ .../components/testrun/testrun-form/index.tsx | 138 ++++ .../components/testrun/testrun-form/type.ts | 21 + .../testrun-json-input/index.module.less | 44 ++ .../testrun/testrun-json-input/index.tsx | 37 + .../testrun/testrun-panel/index.module.less | 139 ++++ .../testrun/testrun-panel/index.tsx | 211 ++++++ .../flows/components/tools/auto-layout.tsx | 32 + .../src/flows/components/tools/comment.tsx | 85 +++ .../src/flows/components/tools/fit-view.tsx | 23 + frontend/src/flows/components/tools/index.tsx | 211 ++++++ .../flows/components/tools/interactive.tsx | 102 +++ .../flows/components/tools/minimap-switch.tsx | 27 + .../src/flows/components/tools/minimap.tsx | 38 ++ .../components/tools/mouse-pad-selector.less | 117 ++++ .../components/tools/mouse-pad-selector.tsx | 122 ++++ .../src/flows/components/tools/readonly.tsx | 37 + frontend/src/flows/components/tools/save.tsx | 74 ++ .../src/flows/components/tools/styles.tsx | 52 ++ .../flows/components/tools/switch-line.tsx | 25 + .../flows/components/tools/zoom-select.tsx | 46 ++ frontend/src/flows/context/index.ts | 7 + .../src/flows/context/node-render-context.ts | 13 + frontend/src/flows/context/sidebar-context.ts | 14 + frontend/src/flows/editor.tsx | 112 +++ .../src/flows/form-components/feedback.tsx | 40 ++ .../form-components/form-content/index.tsx | 29 + .../form-components/form-content/styles.tsx | 26 + .../form-components/form-header/index.tsx | 73 ++ .../form-components/form-header/styles.tsx | 41 ++ .../form-header/title-input.tsx | 50 ++ .../form-components/form-header/utils.tsx | 15 + .../form-components/form-inputs/index.tsx | 68 ++ .../form-components/form-inputs/styles.tsx | 8 + .../flows/form-components/form-item/index.css | 14 + .../flows/form-components/form-item/index.tsx | 95 +++ frontend/src/flows/form-components/index.ts | 10 + frontend/src/flows/hooks/index.ts | 9 + frontend/src/flows/hooks/use-editor-props.tsx | 508 ++++++++++++++ frontend/src/flows/hooks/use-is-sidebar.ts | 12 + .../flows/hooks/use-node-render-context.ts | 12 + frontend/src/flows/hooks/use-port-click.ts | 94 +++ frontend/src/flows/index.ts | 6 + frontend/src/flows/initial-data.ts | 581 ++++++++++++++++ .../src/flows/nodes/block-end/form-meta.tsx | 40 ++ frontend/src/flows/nodes/block-end/index.ts | 46 ++ .../src/flows/nodes/block-start/form-meta.tsx | 40 ++ frontend/src/flows/nodes/block-start/index.ts | 46 ++ frontend/src/flows/nodes/break/form-meta.tsx | 33 + frontend/src/flows/nodes/break/index.ts | 44 ++ .../src/flows/nodes/code/components/code.tsx | 36 + .../flows/nodes/code/components/inputs.tsx | 38 ++ .../flows/nodes/code/components/outputs.tsx | 44 ++ frontend/src/flows/nodes/code/form-meta.tsx | 32 + frontend/src/flows/nodes/code/index.tsx | 86 +++ frontend/src/flows/nodes/code/types.tsx | 20 + frontend/src/flows/nodes/comment/index.tsx | 26 + .../condition/condition-inputs/index.tsx | 85 +++ .../condition/condition-inputs/styles.tsx | 12 + .../src/flows/nodes/condition/form-meta.tsx | 35 + frontend/src/flows/nodes/condition/index.ts | 50 ++ frontend/src/flows/nodes/constants.ts | 21 + .../src/flows/nodes/continue/form-meta.tsx | 33 + frontend/src/flows/nodes/continue/index.ts | 44 ++ frontend/src/flows/nodes/db/form-meta.tsx | 214 ++++++ frontend/src/flows/nodes/db/index.tsx | 45 ++ .../src/flows/nodes/default-form-meta.tsx | 79 +++ frontend/src/flows/nodes/end/form-meta.tsx | 61 ++ frontend/src/flows/nodes/end/index.ts | 38 ++ frontend/src/flows/nodes/group/index.tsx | 66 ++ .../src/flows/nodes/http/components/api.tsx | 60 ++ .../src/flows/nodes/http/components/body.tsx | 94 +++ .../flows/nodes/http/components/headers.tsx | 39 ++ .../flows/nodes/http/components/params.tsx | 39 ++ .../flows/nodes/http/components/timeout.tsx | 51 ++ frontend/src/flows/nodes/http/form-meta.tsx | 45 ++ frontend/src/flows/nodes/http/index.tsx | 53 ++ frontend/src/flows/nodes/http/types.tsx | 36 + frontend/src/flows/nodes/index.ts | 40 ++ frontend/src/flows/nodes/llm/index.ts | 97 +++ frontend/src/flows/nodes/loop/form-meta.tsx | 96 +++ frontend/src/flows/nodes/loop/index.ts | 103 +++ frontend/src/flows/nodes/start/form-meta.tsx | 67 ++ frontend/src/flows/nodes/start/index.ts | 39 ++ .../src/flows/nodes/variable/form-meta.tsx | 36 + frontend/src/flows/nodes/variable/index.tsx | 48 ++ frontend/src/flows/nodes/variable/types.tsx | 15 + .../context-menu-layer.tsx | 84 +++ .../context-menu-plugin.ts | 28 + .../plugins/context-menu-plugin/index.ts | 6 + frontend/src/flows/plugins/index.ts | 8 + .../runtime-plugin/client/base-client.ts | 22 + .../client/browser-client/index.ts | 46 ++ .../plugins/runtime-plugin/client/index.ts | 8 + .../client/server-client/constant.ts | 12 + .../client/server-client/index.ts | 151 +++++ .../client/server-client/type.ts | 9 + .../runtime-plugin/create-runtime-plugin.ts | 34 + .../src/flows/plugins/runtime-plugin/index.ts | 7 + .../runtime-plugin/runtime-service/index.ts | 218 ++++++ .../src/flows/plugins/runtime-plugin/type.ts | 21 + .../components/full-variable-list.tsx | 13 + .../components/global-variable-editor.tsx | 45 ++ .../components/index.module.less | 44 ++ .../components/variable-panel.tsx | 46 ++ .../plugins/variable-panel-plugin/index.ts | 6 + .../variable-panel-layer.tsx | 27 + .../variable-panel-plugin.ts | 40 ++ frontend/src/flows/services/custom-service.ts | 99 +++ frontend/src/flows/services/index.ts | 6 + .../src/flows/shortcuts/collapse/index.ts | 35 + frontend/src/flows/shortcuts/constants.ts | 22 + frontend/src/flows/shortcuts/copy/index.ts | 255 +++++++ frontend/src/flows/shortcuts/delete/index.ts | 128 ++++ frontend/src/flows/shortcuts/expand/index.ts | 35 + frontend/src/flows/shortcuts/index.ts | 7 + frontend/src/flows/shortcuts/paste/index.ts | 234 +++++++ .../src/flows/shortcuts/paste/traverse.ts | 189 ++++++ .../flows/shortcuts/paste/unique-workflow.ts | 113 ++++ .../src/flows/shortcuts/select-all/index.ts | 34 + frontend/src/flows/shortcuts/shortcuts.ts | 28 + frontend/src/flows/shortcuts/type.ts | 27 + frontend/src/flows/shortcuts/zoom-in/index.ts | 32 + .../src/flows/shortcuts/zoom-out/index.ts | 32 + frontend/src/flows/styles/index.css | 80 +++ frontend/src/flows/type.d.ts | 9 + frontend/src/flows/typings/index.ts | 7 + frontend/src/flows/typings/json-schema.ts | 9 + frontend/src/flows/typings/node.ts | 77 +++ frontend/src/flows/utils/index.ts | 7 + frontend/src/flows/utils/on-drag-line-end.ts | 99 +++ .../src/flows/utils/toggle-loop-expanded.ts | 62 ++ frontend/src/flows/utils/yaml.ts | 119 ++++ frontend/src/layouts/MainLayout.tsx | 164 +++-- frontend/src/main.tsx | 37 + frontend/src/pages/FlowList.tsx | 314 +++++++++ frontend/src/pages/FlowRunLogs.tsx | 184 +++++ frontend/src/pages/Login.tsx | 8 +- frontend/src/pages/Menus.tsx | 6 +- frontend/src/pages/Users.tsx | 10 +- frontend/src/utils/axios.ts | 7 + frontend/src/utils/config.ts | 4 + frontend/src/utils/react18-polyfill.ts | 67 ++ frontend/tsconfig.json | 2 + frontend/tsconfig.tsbuildinfo | 2 +- frontend/vite.config.ts | 14 +- scripts/setup_demo.sh | 112 ++- 291 files changed, 17947 insertions(+), 86 deletions(-) create mode 100644 DEMO: create mode 100644 backend/branch-async-create.json create mode 100644 backend/branch-sync-create.json create mode 100644 backend/flow_create.json create mode 100644 backend/linear-async-create.json create mode 100644 backend/linear-sync-create.json create mode 100644 backend/migration/src/m20220101_000011_create_workflows.rs create mode 100644 backend/migration/src/m20220101_000012_create_workflow_executions.rs create mode 100644 backend/migration/src/m20220101_000013_create_workflow_execution_logs.rs create mode 100644 backend/migration/src/m20220101_000014_create_flows.rs create mode 100644 backend/migration/src/m20220101_000015_add_code_and_remark_to_flows.rs create mode 100644 backend/migration/src/m20220101_000016_add_unique_index_to_flows_code.rs create mode 100644 backend/migration/src/m20220101_000016_dedup_flows_code.rs create mode 100644 backend/migration/src/m20220101_000017_create_flow_run_logs.rs create mode 100644 backend/migration/src/m20220101_000018_add_flow_code_to_flow_run_logs.rs create mode 100644 backend/src/flow/context.rs create mode 100644 backend/src/flow/domain.rs create mode 100644 backend/src/flow/dsl.rs create mode 100644 backend/src/flow/engine.rs create mode 100644 backend/src/flow/executors/db.rs create mode 100644 backend/src/flow/executors/http.rs create mode 100644 backend/src/flow/executors/mod.rs create mode 100644 backend/src/flow/mod.rs create mode 100644 backend/src/flow/storage.rs create mode 100644 backend/src/flow/task.rs create mode 100644 backend/src/models/flow.rs create mode 100644 backend/src/models/flow_run_log.rs create mode 100644 backend/src/routes/flow_run_logs.rs create mode 100644 backend/src/routes/flows.rs create mode 100644 backend/src/services/flow_run_log_service.rs create mode 100644 backend/src/services/flow_service.rs create mode 100644 backend/udmin_ai.db create mode 100644 frontend/flow-free-layout-json.md create mode 100644 frontend/flow-free-layout-sj-demo.md create mode 100644 frontend/src/flows/app.tsx create mode 100644 frontend/src/flows/assets/icon-auto-layout.tsx create mode 100644 frontend/src/flows/assets/icon-break.jpg create mode 100644 frontend/src/flows/assets/icon-cancel.tsx create mode 100644 frontend/src/flows/assets/icon-comment.tsx create mode 100644 frontend/src/flows/assets/icon-condition.svg create mode 100644 frontend/src/flows/assets/icon-continue.jpg create mode 100644 frontend/src/flows/assets/icon-end.jpg create mode 100644 frontend/src/flows/assets/icon-http.svg create mode 100644 frontend/src/flows/assets/icon-llm.jpg create mode 100644 frontend/src/flows/assets/icon-loop.jpg create mode 100644 frontend/src/flows/assets/icon-minimap.tsx create mode 100644 frontend/src/flows/assets/icon-mouse.tsx create mode 100644 frontend/src/flows/assets/icon-pad.tsx create mode 100644 frontend/src/flows/assets/icon-script.png create mode 100644 frontend/src/flows/assets/icon-start.jpg create mode 100644 frontend/src/flows/assets/icon-success.tsx create mode 100644 frontend/src/flows/assets/icon-switch-line.tsx create mode 100644 frontend/src/flows/assets/icon-variable.png create mode 100644 frontend/src/flows/assets/icon-warning.tsx create mode 100644 frontend/src/flows/components/add-node/index.tsx create mode 100644 frontend/src/flows/components/add-node/use-add-node.ts create mode 100644 frontend/src/flows/components/base-node/index.tsx create mode 100644 frontend/src/flows/components/base-node/node-wrapper.tsx create mode 100644 frontend/src/flows/components/base-node/styles.tsx create mode 100644 frontend/src/flows/components/base-node/utils.ts create mode 100644 frontend/src/flows/components/comment/components/blank-area.tsx create mode 100644 frontend/src/flows/components/comment/components/border-area.tsx create mode 100644 frontend/src/flows/components/comment/components/container.tsx create mode 100644 frontend/src/flows/components/comment/components/content-drag-area.tsx create mode 100644 frontend/src/flows/components/comment/components/drag-area.tsx create mode 100644 frontend/src/flows/components/comment/components/editor.tsx create mode 100644 frontend/src/flows/components/comment/components/index.css create mode 100644 frontend/src/flows/components/comment/components/index.ts create mode 100644 frontend/src/flows/components/comment/components/more-button.tsx create mode 100644 frontend/src/flows/components/comment/components/render.tsx create mode 100644 frontend/src/flows/components/comment/components/resize-area.tsx create mode 100644 frontend/src/flows/components/comment/constant.ts create mode 100644 frontend/src/flows/components/comment/hooks/index.ts create mode 100644 frontend/src/flows/components/comment/hooks/use-model.ts create mode 100644 frontend/src/flows/components/comment/hooks/use-overflow.ts create mode 100644 frontend/src/flows/components/comment/hooks/use-size.ts create mode 100644 frontend/src/flows/components/comment/index.ts create mode 100644 frontend/src/flows/components/comment/model.ts create mode 100644 frontend/src/flows/components/comment/type.ts create mode 100644 frontend/src/flows/components/group/color.ts create mode 100644 frontend/src/flows/components/group/components/background.tsx create mode 100644 frontend/src/flows/components/group/components/color.tsx create mode 100644 frontend/src/flows/components/group/components/header.tsx create mode 100644 frontend/src/flows/components/group/components/icon-group.tsx create mode 100644 frontend/src/flows/components/group/components/index.ts create mode 100644 frontend/src/flows/components/group/components/node-render.tsx create mode 100644 frontend/src/flows/components/group/components/tips/global-store.ts create mode 100644 frontend/src/flows/components/group/components/tips/icon-close.tsx create mode 100644 frontend/src/flows/components/group/components/tips/index.tsx create mode 100644 frontend/src/flows/components/group/components/tips/is-mac-os.ts create mode 100644 frontend/src/flows/components/group/components/tips/style.ts create mode 100644 frontend/src/flows/components/group/components/tips/use-control.ts create mode 100644 frontend/src/flows/components/group/components/title.tsx create mode 100644 frontend/src/flows/components/group/components/tools.tsx create mode 100644 frontend/src/flows/components/group/components/ungroup.tsx create mode 100644 frontend/src/flows/components/group/constant.ts create mode 100644 frontend/src/flows/components/group/index.css create mode 100644 frontend/src/flows/components/group/index.ts create mode 100644 frontend/src/flows/components/index.ts create mode 100644 frontend/src/flows/components/line-add-button/button.tsx create mode 100644 frontend/src/flows/components/line-add-button/index.less create mode 100644 frontend/src/flows/components/line-add-button/index.tsx create mode 100644 frontend/src/flows/components/line-add-button/use-visible.ts create mode 100644 frontend/src/flows/components/node-menu/index.tsx create mode 100644 frontend/src/flows/components/node-panel/index.less create mode 100644 frontend/src/flows/components/node-panel/index.tsx create mode 100644 frontend/src/flows/components/node-panel/node-list.tsx create mode 100644 frontend/src/flows/components/node-panel/node-placeholder.tsx create mode 100644 frontend/src/flows/components/selector-box-popover/index.tsx create mode 100644 frontend/src/flows/components/sidebar/index.tsx create mode 100644 frontend/src/flows/components/sidebar/sidebar-node-renderer.tsx create mode 100644 frontend/src/flows/components/sidebar/sidebar-provider.tsx create mode 100644 frontend/src/flows/components/sidebar/sidebar-renderer.tsx create mode 100644 frontend/src/flows/components/testrun/hooks/index.ts create mode 100644 frontend/src/flows/components/testrun/hooks/use-fields.ts create mode 100644 frontend/src/flows/components/testrun/hooks/use-form-meta.ts create mode 100644 frontend/src/flows/components/testrun/hooks/use-sync-default.ts create mode 100644 frontend/src/flows/components/testrun/node-status-bar/group/index.module.less create mode 100644 frontend/src/flows/components/testrun/node-status-bar/group/index.tsx create mode 100644 frontend/src/flows/components/testrun/node-status-bar/header/index.module.less create mode 100644 frontend/src/flows/components/testrun/node-status-bar/header/index.tsx create mode 100644 frontend/src/flows/components/testrun/node-status-bar/index.tsx create mode 100644 frontend/src/flows/components/testrun/node-status-bar/render/index.module.less create mode 100644 frontend/src/flows/components/testrun/node-status-bar/render/index.tsx create mode 100644 frontend/src/flows/components/testrun/node-status-bar/viewer/index.module.less create mode 100644 frontend/src/flows/components/testrun/node-status-bar/viewer/index.tsx create mode 100644 frontend/src/flows/components/testrun/testrun-button/index.module.less create mode 100644 frontend/src/flows/components/testrun/testrun-button/index.tsx create mode 100644 frontend/src/flows/components/testrun/testrun-form/index.module.less create mode 100644 frontend/src/flows/components/testrun/testrun-form/index.tsx create mode 100644 frontend/src/flows/components/testrun/testrun-form/type.ts create mode 100644 frontend/src/flows/components/testrun/testrun-json-input/index.module.less create mode 100644 frontend/src/flows/components/testrun/testrun-json-input/index.tsx create mode 100644 frontend/src/flows/components/testrun/testrun-panel/index.module.less create mode 100644 frontend/src/flows/components/testrun/testrun-panel/index.tsx create mode 100644 frontend/src/flows/components/tools/auto-layout.tsx create mode 100644 frontend/src/flows/components/tools/comment.tsx create mode 100644 frontend/src/flows/components/tools/fit-view.tsx create mode 100644 frontend/src/flows/components/tools/index.tsx create mode 100644 frontend/src/flows/components/tools/interactive.tsx create mode 100644 frontend/src/flows/components/tools/minimap-switch.tsx create mode 100644 frontend/src/flows/components/tools/minimap.tsx create mode 100644 frontend/src/flows/components/tools/mouse-pad-selector.less create mode 100644 frontend/src/flows/components/tools/mouse-pad-selector.tsx create mode 100644 frontend/src/flows/components/tools/readonly.tsx create mode 100644 frontend/src/flows/components/tools/save.tsx create mode 100644 frontend/src/flows/components/tools/styles.tsx create mode 100644 frontend/src/flows/components/tools/switch-line.tsx create mode 100644 frontend/src/flows/components/tools/zoom-select.tsx create mode 100644 frontend/src/flows/context/index.ts create mode 100644 frontend/src/flows/context/node-render-context.ts create mode 100644 frontend/src/flows/context/sidebar-context.ts create mode 100644 frontend/src/flows/editor.tsx create mode 100644 frontend/src/flows/form-components/feedback.tsx create mode 100644 frontend/src/flows/form-components/form-content/index.tsx create mode 100644 frontend/src/flows/form-components/form-content/styles.tsx create mode 100644 frontend/src/flows/form-components/form-header/index.tsx create mode 100644 frontend/src/flows/form-components/form-header/styles.tsx create mode 100644 frontend/src/flows/form-components/form-header/title-input.tsx create mode 100644 frontend/src/flows/form-components/form-header/utils.tsx create mode 100644 frontend/src/flows/form-components/form-inputs/index.tsx create mode 100644 frontend/src/flows/form-components/form-inputs/styles.tsx create mode 100644 frontend/src/flows/form-components/form-item/index.css create mode 100644 frontend/src/flows/form-components/form-item/index.tsx create mode 100644 frontend/src/flows/form-components/index.ts create mode 100644 frontend/src/flows/hooks/index.ts create mode 100644 frontend/src/flows/hooks/use-editor-props.tsx create mode 100644 frontend/src/flows/hooks/use-is-sidebar.ts create mode 100644 frontend/src/flows/hooks/use-node-render-context.ts create mode 100644 frontend/src/flows/hooks/use-port-click.ts create mode 100644 frontend/src/flows/index.ts create mode 100644 frontend/src/flows/initial-data.ts create mode 100644 frontend/src/flows/nodes/block-end/form-meta.tsx create mode 100644 frontend/src/flows/nodes/block-end/index.ts create mode 100644 frontend/src/flows/nodes/block-start/form-meta.tsx create mode 100644 frontend/src/flows/nodes/block-start/index.ts create mode 100644 frontend/src/flows/nodes/break/form-meta.tsx create mode 100644 frontend/src/flows/nodes/break/index.ts create mode 100644 frontend/src/flows/nodes/code/components/code.tsx create mode 100644 frontend/src/flows/nodes/code/components/inputs.tsx create mode 100644 frontend/src/flows/nodes/code/components/outputs.tsx create mode 100644 frontend/src/flows/nodes/code/form-meta.tsx create mode 100644 frontend/src/flows/nodes/code/index.tsx create mode 100644 frontend/src/flows/nodes/code/types.tsx create mode 100644 frontend/src/flows/nodes/comment/index.tsx create mode 100644 frontend/src/flows/nodes/condition/condition-inputs/index.tsx create mode 100644 frontend/src/flows/nodes/condition/condition-inputs/styles.tsx create mode 100644 frontend/src/flows/nodes/condition/form-meta.tsx create mode 100644 frontend/src/flows/nodes/condition/index.ts create mode 100644 frontend/src/flows/nodes/constants.ts create mode 100644 frontend/src/flows/nodes/continue/form-meta.tsx create mode 100644 frontend/src/flows/nodes/continue/index.ts create mode 100644 frontend/src/flows/nodes/db/form-meta.tsx create mode 100644 frontend/src/flows/nodes/db/index.tsx create mode 100644 frontend/src/flows/nodes/default-form-meta.tsx create mode 100644 frontend/src/flows/nodes/end/form-meta.tsx create mode 100644 frontend/src/flows/nodes/end/index.ts create mode 100644 frontend/src/flows/nodes/group/index.tsx create mode 100644 frontend/src/flows/nodes/http/components/api.tsx create mode 100644 frontend/src/flows/nodes/http/components/body.tsx create mode 100644 frontend/src/flows/nodes/http/components/headers.tsx create mode 100644 frontend/src/flows/nodes/http/components/params.tsx create mode 100644 frontend/src/flows/nodes/http/components/timeout.tsx create mode 100644 frontend/src/flows/nodes/http/form-meta.tsx create mode 100644 frontend/src/flows/nodes/http/index.tsx create mode 100644 frontend/src/flows/nodes/http/types.tsx create mode 100644 frontend/src/flows/nodes/index.ts create mode 100644 frontend/src/flows/nodes/llm/index.ts create mode 100644 frontend/src/flows/nodes/loop/form-meta.tsx create mode 100644 frontend/src/flows/nodes/loop/index.ts create mode 100644 frontend/src/flows/nodes/start/form-meta.tsx create mode 100644 frontend/src/flows/nodes/start/index.ts create mode 100644 frontend/src/flows/nodes/variable/form-meta.tsx create mode 100644 frontend/src/flows/nodes/variable/index.tsx create mode 100644 frontend/src/flows/nodes/variable/types.tsx create mode 100644 frontend/src/flows/plugins/context-menu-plugin/context-menu-layer.tsx create mode 100644 frontend/src/flows/plugins/context-menu-plugin/context-menu-plugin.ts create mode 100644 frontend/src/flows/plugins/context-menu-plugin/index.ts create mode 100644 frontend/src/flows/plugins/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/base-client.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/browser-client/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/server-client/constant.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/server-client/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/client/server-client/type.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/create-runtime-plugin.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/runtime-service/index.ts create mode 100644 frontend/src/flows/plugins/runtime-plugin/type.ts create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/components/full-variable-list.tsx create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/components/global-variable-editor.tsx create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/components/index.module.less create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/components/variable-panel.tsx create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/index.ts create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/variable-panel-layer.tsx create mode 100644 frontend/src/flows/plugins/variable-panel-plugin/variable-panel-plugin.ts create mode 100644 frontend/src/flows/services/custom-service.ts create mode 100644 frontend/src/flows/services/index.ts create mode 100644 frontend/src/flows/shortcuts/collapse/index.ts create mode 100644 frontend/src/flows/shortcuts/constants.ts create mode 100644 frontend/src/flows/shortcuts/copy/index.ts create mode 100644 frontend/src/flows/shortcuts/delete/index.ts create mode 100644 frontend/src/flows/shortcuts/expand/index.ts create mode 100644 frontend/src/flows/shortcuts/index.ts create mode 100644 frontend/src/flows/shortcuts/paste/index.ts create mode 100644 frontend/src/flows/shortcuts/paste/traverse.ts create mode 100644 frontend/src/flows/shortcuts/paste/unique-workflow.ts create mode 100644 frontend/src/flows/shortcuts/select-all/index.ts create mode 100644 frontend/src/flows/shortcuts/shortcuts.ts create mode 100644 frontend/src/flows/shortcuts/type.ts create mode 100644 frontend/src/flows/shortcuts/zoom-in/index.ts create mode 100644 frontend/src/flows/shortcuts/zoom-out/index.ts create mode 100644 frontend/src/flows/styles/index.css create mode 100644 frontend/src/flows/type.d.ts create mode 100644 frontend/src/flows/typings/index.ts create mode 100644 frontend/src/flows/typings/json-schema.ts create mode 100644 frontend/src/flows/typings/node.ts create mode 100644 frontend/src/flows/utils/index.ts create mode 100644 frontend/src/flows/utils/on-drag-line-end.ts create mode 100644 frontend/src/flows/utils/toggle-loop-expanded.ts create mode 100644 frontend/src/flows/utils/yaml.ts create mode 100644 frontend/src/pages/FlowList.tsx create mode 100644 frontend/src/pages/FlowRunLogs.tsx create mode 100644 frontend/src/utils/react18-polyfill.ts diff --git a/DEMO: b/DEMO: new file mode 100644 index 0000000..0741a26 --- /dev/null +++ b/DEMO: @@ -0,0 +1 @@ +CHECK = SYS: FLOWLOG: diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e1597b3..f283d4f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -524,7 +524,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -1852,6 +1852,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2384,6 +2390,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.21.12", + "rustls-native-certs", "rustls-pemfile", "serde", "serde_json", @@ -2397,7 +2404,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.4", "winreg", ] @@ -2598,6 +2604,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2658,6 +2676,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2862,6 +2889,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" @@ -3803,10 +3853,12 @@ dependencies = [ "chrono", "config", "dotenvy", + "futures", "hyper 1.7.0", "jsonwebtoken", "migration", "once_cell", + "percent-encoding", "petgraph", "rand", "redis", @@ -4120,12 +4172,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "0.26.11" @@ -4193,7 +4239,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -4226,13 +4272,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -4241,7 +4293,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -4280,6 +4332,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4317,7 +4378,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 06f27da..305a0a6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,7 +14,7 @@ bytes = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = "3.14.0" -sea-orm = { version = "1.1.14", features = ["sqlx-mysql", "sqlx-sqlite", "runtime-tokio-rustls", "macros"] } +sea-orm = { version = "1.1.14", features = ["sqlx-mysql", "sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } jsonwebtoken = "9.3.1" argon2 = "0.5.3" # 或升级到 3.0.0(注意 API 可能不兼容) uuid = { version = "1.11.0", features = ["serde", "v4"] } @@ -32,13 +32,13 @@ sha2 = "0.10" rand = "0.8" async-trait = "0.1" redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } - -# 流程管理相关依赖 petgraph = "0.6" rhai = { version = "1.17", features = ["serde", "metadata", "internals"] } serde_yaml = "0.9" regex = "1.10" -reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.11", features = ["json", "rustls-tls-native-roots"], default-features = false } +futures = "0.3" +percent-encoding = "2.3" [dependencies.migration] path = "migration" diff --git a/backend/branch-async-create.json b/backend/branch-async-create.json new file mode 100644 index 0000000..d84ba87 --- /dev/null +++ b/backend/branch-async-create.json @@ -0,0 +1,19 @@ +{ + "name": "Branch A->COND->B/C (async)", + "code": "branch_async", + "design_json": { + "name": "Branch A->COND->B/C (async)", + "execution_mode": "async", + "nodes": [ + { "id": "A", "kind": "http", "name": "http A", "task": "http", "config": { "url": "https://httpbin.org/get", "method": "GET" } }, + { "id": "COND", "kind": "condition", "name": "cond", "task": "condition", "config": { "expression": { "left": { "type": "constant", "value": true }, "operator": "is_true", "right": { "type": "constant", "value": true } } }, "ports": { "yes": { "id": "yes" }, "no": { "id": "no" } } }, + { "id": "B", "kind": "http", "name": "http B", "task": "http", "config": { "url": "https://httpbin.org/anything/B", "method": "GET" } }, + { "id": "C", "kind": "http", "name": "http C", "task": "http", "config": { "url": "https://httpbin.org/anything/C", "method": "GET" } } + ], + "edges": [ + { "from": "A", "to": "COND" }, + { "from": "COND", "to": "B", "condition": { "left": { "type": "constant", "value": true }, "operator": "is_true", "right": { "type": "constant", "value": true } } }, + { "from": "COND", "to": "C", "condition": { "left": { "type": "constant", "value": true }, "operator": "is_false", "right": { "type": "constant", "value": true } } } + ] + } +} \ No newline at end of file diff --git a/backend/branch-sync-create.json b/backend/branch-sync-create.json new file mode 100644 index 0000000..8790531 --- /dev/null +++ b/backend/branch-sync-create.json @@ -0,0 +1,22 @@ +{ + "name": "branch-sync", + "code": "branch_sync_1", + "design_json": { + "name": "branch-sync", + "executionMode": "sync", + "nodes": [ + { "id": "A", "type": "http", "data": { "title": "A-GET-x", "api": { "method": "GET", "url": "https://httpbin.org/get?x=hello" } } }, + { "id": "COND", "type": "condition", "data": { "title": "COND", "conditions": [ + { "key": "yes", "value": { "left": { "type": "constant", "content": true }, "operator": "is_true" } }, + { "key": "no", "value": { "left": { "type": "constant", "content": false }, "operator": "is_true" } } + ] } }, + { "id": "B", "type": "http", "data": { "title": "B-UUID", "api": { "method": "GET", "url": "https://httpbin.org/uuid" } } }, + { "id": "C", "type": "http", "data": { "title": "C-Delay1s", "api": { "method": "GET", "url": "https://httpbin.org/delay/1" } } } + ], + "edges": [ + { "sourceNodeID": "A", "targetNodeID": "COND" }, + { "sourceNodeID": "COND", "targetNodeID": "B", "sourcePortID": "yes" }, + { "sourceNodeID": "COND", "targetNodeID": "C", "sourcePortID": "no" } + ] + } +} \ No newline at end of file diff --git a/backend/flow_create.json b/backend/flow_create.json new file mode 100644 index 0000000..572ee55 --- /dev/null +++ b/backend/flow_create.json @@ -0,0 +1,3 @@ +{ + "yaml": "# demo flow\nnodes:\n - { id: start, kind: start, name: 开始 }\n - { id: assign, kind: custom, name: 赋值, script: ctx.x = ctx.x + 1; }\n - { id: cond, kind: custom, name: 条件 }\n - { id: end, kind: end, name: 结束 }\nedges:\n - { from: start, to: assign }\n - { from: assign, to: cond }\n - { from: cond, to: end, condition: ctx.x >= 1 }\n - { from: cond, to: end }\n" +} \ No newline at end of file diff --git a/backend/linear-async-create.json b/backend/linear-async-create.json new file mode 100644 index 0000000..fb8f07e --- /dev/null +++ b/backend/linear-async-create.json @@ -0,0 +1,15 @@ +{ + "name": "linear-async", + "code": "linear_async_1", + "design_json": { + "name": "linear-async", + "executionMode": "async", + "nodes": [ + { "id": "N1", "type": "http", "data": { "title": "A-GET", "api": { "method": "GET", "url": "https://httpbin.org/delay/1" } } }, + { "id": "N2", "type": "http", "data": { "title": "B-UUID", "api": { "method": "GET", "url": "https://httpbin.org/uuid" } } } + ], + "edges": [ + { "sourceNodeID": "N1", "targetNodeID": "N2" } + ] + } +} \ No newline at end of file diff --git a/backend/linear-sync-create.json b/backend/linear-sync-create.json new file mode 100644 index 0000000..7b8a0a9 --- /dev/null +++ b/backend/linear-sync-create.json @@ -0,0 +1,15 @@ +{ + "name": "linear-sync", + "code": "linear_sync_1", + "design_json": { + "name": "linear-sync", + "executionMode": "sync", + "nodes": [ + { "id": "N1", "type": "http", "data": { "title": "A-GET", "api": { "method": "GET", "url": "https://httpbin.org/get" } } }, + { "id": "N2", "type": "http", "data": { "title": "B-UUID", "api": { "method": "GET", "url": "https://httpbin.org/uuid" } } } + ], + "edges": [ + { "sourceNodeID": "N1", "targetNodeID": "N2" } + ] + } +} \ No newline at end of file diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 702dcf6..d7b6bc9 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -11,6 +11,18 @@ mod m20220101_000008_add_keep_alive_to_menus; mod m20220101_000009_create_request_logs; // 新增岗位与用户岗位关联 mod m20220101_000010_create_positions; +// 占位:历史上已应用但缺失的工作流相关迁移 +mod m20220101_000011_create_workflows; +mod m20220101_000012_create_workflow_executions; +mod m20220101_000013_create_workflow_execution_logs; +mod m20220101_000014_create_flows; +// 新增 flows 的 code 与 remark 列 +mod m20220101_000015_add_code_and_remark_to_flows; +mod m20220101_000016_dedup_flows_code; +mod m20220101_000016_add_unique_index_to_flows_code; +mod m20220101_000017_create_flow_run_logs; +// 新增:为 flow_run_logs 添加 flow_code 列 +mod m20220101_000018_add_flow_code_to_flow_run_logs; pub struct Migrator; @@ -29,6 +41,20 @@ impl MigratorTrait for Migrator { Box::new(m20220101_000009_create_request_logs::Migration), // 注册岗位迁移 Box::new(m20220101_000010_create_positions::Migration), + // 占位:历史上已应用但缺失的工作流相关迁移 + Box::new(m20220101_000011_create_workflows::Migration), + Box::new(m20220101_000012_create_workflow_executions::Migration), + Box::new(m20220101_000013_create_workflow_execution_logs::Migration), + // 新增 flows 表 + Box::new(m20220101_000014_create_flows::Migration), + // 新增 flows 的 code 与 remark 列 + Box::new(m20220101_000015_add_code_and_remark_to_flows::Migration), + // 先去重再建唯一索引 + Box::new(m20220101_000016_dedup_flows_code::Migration), + Box::new(m20220101_000016_add_unique_index_to_flows_code::Migration), + Box::new(m20220101_000017_create_flow_run_logs::Migration), + // 新增:为 flow_run_logs 添加 flow_code 列 + Box::new(m20220101_000018_add_flow_code_to_flow_run_logs::Migration), ] } } \ No newline at end of file diff --git a/backend/migration/src/m20220101_000001_create_users.rs b/backend/migration/src/m20220101_000001_create_users.rs index 5107c30..6e33ef3 100644 --- a/backend/migration/src/m20220101_000001_create_users.rs +++ b/backend/migration/src/m20220101_000001_create_users.rs @@ -24,9 +24,10 @@ impl MigrationTrait for Migration { ) .await?; - // seed admin user (cross-DB) + // seed admin user for all DB backends + // NOTE: test default password is fixed to '123456' for local/dev testing let salt = SaltString::generate(&mut rand::thread_rng()); - let hash = Argon2::default().hash_password("Admin@123".as_bytes(), &salt).unwrap().to_string(); + let hash = Argon2::default().hash_password("123456".as_bytes(), &salt).unwrap().to_string(); let backend = manager.get_database_backend(); let conn = manager.get_connection(); match backend { diff --git a/backend/migration/src/m20220101_000011_create_workflows.rs b/backend/migration/src/m20220101_000011_create_workflows.rs new file mode 100644 index 0000000..6418a5c --- /dev/null +++ b/backend/migration/src/m20220101_000011_create_workflows.rs @@ -0,0 +1,16 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // 占位迁移:历史上已应用,但当前代码库缺失实际文件 + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000012_create_workflow_executions.rs b/backend/migration/src/m20220101_000012_create_workflow_executions.rs new file mode 100644 index 0000000..6418a5c --- /dev/null +++ b/backend/migration/src/m20220101_000012_create_workflow_executions.rs @@ -0,0 +1,16 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // 占位迁移:历史上已应用,但当前代码库缺失实际文件 + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000013_create_workflow_execution_logs.rs b/backend/migration/src/m20220101_000013_create_workflow_execution_logs.rs new file mode 100644 index 0000000..6418a5c --- /dev/null +++ b/backend/migration/src/m20220101_000013_create_workflow_execution_logs.rs @@ -0,0 +1,16 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // 占位迁移:历史上已应用,但当前代码库缺失实际文件 + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000014_create_flows.rs b/backend/migration/src/m20220101_000014_create_flows.rs new file mode 100644 index 0000000..9905512 --- /dev/null +++ b/backend/migration/src/m20220101_000014_create_flows.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Flows::Table) + .if_not_exists() + .col(ColumnDef::new(Flows::Id).string_len(64).not_null().primary_key()) + .col(ColumnDef::new(Flows::Name).string().null()) + .col(ColumnDef::new(Flows::Yaml).text().null()) + .col(ColumnDef::new(Flows::DesignJson).text().null()) + .col(ColumnDef::new(Flows::CreatedAt).timestamp().not_null().default(Expr::current_timestamp())) + .col(ColumnDef::new(Flows::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp())) + .to_owned() + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(Flows::Table).to_owned()).await + } +} + +#[derive(Iden)] +enum Flows { + Table, + Id, + Name, + Yaml, + DesignJson, + CreatedAt, + UpdatedAt, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000015_add_code_and_remark_to_flows.rs b/backend/migration/src/m20220101_000015_add_code_and_remark_to_flows.rs new file mode 100644 index 0000000..2fbf695 --- /dev/null +++ b/backend/migration/src/m20220101_000015_add_code_and_remark_to_flows.rs @@ -0,0 +1,56 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite 不支持单条 ALTER 语句包含多个操作,拆分为两次执行 + manager + .alter_table( + Table::alter() + .table(Flows::Table) + .add_column(ColumnDef::new(Flows::Code).string().null()) + .to_owned() + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Flows::Table) + .add_column(ColumnDef::new(Flows::Remark).string().null()) + .to_owned() + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 与 up 相反顺序,依然需要拆分为两次执行 + manager + .alter_table( + Table::alter() + .table(Flows::Table) + .drop_column(Flows::Remark) + .to_owned() + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Flows::Table) + .drop_column(Flows::Code) + .to_owned() + ) + .await + } +} + +#[derive(DeriveIden)] +enum Flows { + Table, + Code, + Remark, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000016_add_unique_index_to_flows_code.rs b/backend/migration/src/m20220101_000016_add_unique_index_to_flows_code.rs new file mode 100644 index 0000000..790cc74 --- /dev/null +++ b/backend/migration/src/m20220101_000016_add_unique_index_to_flows_code.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_index( + Index::create() + .name("idx-unique-flows-code") + .table(Flows::Table) + .col(Flows::Code) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx-unique-flows-code") + .table(Flows::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Flows { + Table, + Code, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000016_dedup_flows_code.rs b/backend/migration/src/m20220101_000016_dedup_flows_code.rs new file mode 100644 index 0000000..676d272 --- /dev/null +++ b/backend/migration/src/m20220101_000016_dedup_flows_code.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::Statement; +use sea_orm_migration::sea_orm::DatabaseBackend; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + match manager.get_database_backend() { + DatabaseBackend::MySql => { + // 将重复的 code 置为 NULL,仅保留每组中 id 最小的一条 + let sql = r#" + UPDATE flows f + JOIN ( + SELECT code, MIN(id) AS min_id + FROM flows + WHERE code IS NOT NULL + GROUP BY code + HAVING COUNT(*) > 1 + ) t ON f.code = t.code AND f.id <> t.min_id + SET f.code = NULL; + "#; + db.execute(Statement::from_string(DatabaseBackend::MySql, sql.to_string())).await?; + Ok(()) + } + DatabaseBackend::Postgres => { + let sql = r#" + WITH d AS ( + SELECT id, ROW_NUMBER() OVER(PARTITION BY code ORDER BY id) AS rn + FROM flows + WHERE code IS NOT NULL + ) + UPDATE flows AS f + SET code = NULL + FROM d + WHERE f.id = d.id AND d.rn > 1; + "#; + db.execute(Statement::from_string(DatabaseBackend::Postgres, sql.to_string())).await?; + Ok(()) + } + DatabaseBackend::Sqlite => { + let sql = r#" + WITH d AS ( + SELECT id, ROW_NUMBER() OVER(PARTITION BY code ORDER BY id) AS rn + FROM flows + WHERE code IS NOT NULL + ) + UPDATE flows + SET code = NULL + WHERE id IN (SELECT id FROM d WHERE rn > 1); + "#; + db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql.to_string())).await?; + Ok(()) + } + } + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // 数据清洗不可逆 + Ok(()) + } +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000017_create_flow_run_logs.rs b/backend/migration/src/m20220101_000017_create_flow_run_logs.rs new file mode 100644 index 0000000..be0d928 --- /dev/null +++ b/backend/migration/src/m20220101_000017_create_flow_run_logs.rs @@ -0,0 +1,50 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FlowRunLogs::Table) + .if_not_exists() + .col(ColumnDef::new(FlowRunLogs::Id).big_integer().not_null().auto_increment().primary_key()) + .col(ColumnDef::new(FlowRunLogs::FlowId).string_len(64).not_null()) + .col(ColumnDef::new(FlowRunLogs::Input).text().null()) + .col(ColumnDef::new(FlowRunLogs::Output).text().null()) + .col(ColumnDef::new(FlowRunLogs::Ok).boolean().not_null().default(false)) + .col(ColumnDef::new(FlowRunLogs::Logs).text().null()) + .col(ColumnDef::new(FlowRunLogs::UserId).big_integer().null()) + .col(ColumnDef::new(FlowRunLogs::Username).string().null()) + .col(ColumnDef::new(FlowRunLogs::StartedAt).timestamp().not_null().default(Expr::current_timestamp())) + .col(ColumnDef::new(FlowRunLogs::DurationMs).big_integer().not_null().default(0)) + .col(ColumnDef::new(FlowRunLogs::CreatedAt).timestamp().not_null().default(Expr::current_timestamp())) + .to_owned() + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(FlowRunLogs::Table).to_owned()).await + } +} + +#[derive(Iden)] +enum FlowRunLogs { + Table, + Id, + FlowId, + Input, + Output, + Ok, + Logs, + UserId, + Username, + StartedAt, + DurationMs, + CreatedAt, +} \ No newline at end of file diff --git a/backend/migration/src/m20220101_000018_add_flow_code_to_flow_run_logs.rs b/backend/migration/src/m20220101_000018_add_flow_code_to_flow_run_logs.rs new file mode 100644 index 0000000..fa25bf2 --- /dev/null +++ b/backend/migration/src/m20220101_000018_add_flow_code_to_flow_run_logs.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(FlowRunLogs::Table) + .add_column(ColumnDef::new(FlowRunLogs::FlowCode).string().null()) + .to_owned() + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(FlowRunLogs::Table) + .drop_column(FlowRunLogs::FlowCode) + .to_owned() + ) + .await + } +} + +#[derive(DeriveIden)] +enum FlowRunLogs { + Table, + #[sea_orm(iden = "flow_run_logs")] + __N, // dummy to ensure table name when not default; but using Table alias is standard + FlowCode, +} \ No newline at end of file diff --git a/backend/src/db.rs b/backend/src/db.rs index 143a063..e95a810 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,5 +1,7 @@ use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use std::time::Duration; +use once_cell::sync::OnceCell; +use crate::error::AppError; pub type Db = DatabaseConnection; @@ -12,4 +14,19 @@ pub async fn init_db() -> anyhow::Result { .sqlx_logging(false); let conn = Database::connect(opt).await?; Ok(conn) +} + +// ===== Global DB connection (OnceCell) ===== +static GLOBAL_DB: OnceCell = OnceCell::new(); + +pub fn set_db(conn: Db) -> Result<(), AppError> { + GLOBAL_DB + .set(conn) + .map_err(|_| AppError::Anyhow(anyhow::anyhow!("Db already initialized"))) +} + +pub fn get_db() -> Result<&'static Db, AppError> { + GLOBAL_DB + .get() + .ok_or_else(|| AppError::Anyhow(anyhow::anyhow!("Db not initialized"))) } \ No newline at end of file diff --git a/backend/src/error.rs b/backend/src/error.rs index 66451ea..1dbc687 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -8,7 +8,10 @@ pub enum AppError { #[error("forbidden")] Forbidden, #[error("forbidden: {0}")] ForbiddenMsg(String), #[error("bad request: {0}")] BadRequest(String), + #[error("conflict: {0}")] Conflict(String), #[error("not found")] NotFound, + // 新增:允许在个别接口(如同步执行运行)明确返回后端的错误信息 + #[error("internal error: {0}")] InternalMsg(String), #[error(transparent)] Db(#[from] sea_orm::DbErr), #[error(transparent)] Jwt(#[from] jsonwebtoken::errors::Error), #[error(transparent)] Anyhow(#[from] anyhow::Error), @@ -22,7 +25,12 @@ impl IntoResponse for AppError { AppError::Forbidden => (StatusCode::FORBIDDEN, 403, "forbidden".to_string()), AppError::ForbiddenMsg(m) => (StatusCode::FORBIDDEN, 403, m.clone()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, 400, m.clone()), + AppError::Conflict(m) => (StatusCode::CONFLICT, 409, m.clone()), AppError::NotFound => (StatusCode::NOT_FOUND, 404, "not found".into()), + // Treat JWT decode/validation errors as 401 to allow frontend to refresh tokens + AppError::Jwt(_) => (StatusCode::UNAUTHORIZED, 401, "unauthorized".to_string()), + // 新增:对 InternalMsg 直接以 500 返回详细消息 + AppError::InternalMsg(m) => (StatusCode::INTERNAL_SERVER_ERROR, 500, m.clone()), _ => (StatusCode::INTERNAL_SERVER_ERROR, 500, "internal error".into()), }; (status, Json(ApiResponse:: { code, message: msg, data: None })).into_response() diff --git a/backend/src/flow/context.rs b/backend/src/flow/context.rs new file mode 100644 index 0000000..cc12fc5 --- /dev/null +++ b/backend/src/flow/context.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FlowContext { + #[serde(default)] + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ExecutionMode { + #[serde(rename = "sync")] Sync, + #[serde(rename = "async")] AsyncFireAndForget, +} + +impl Default for ExecutionMode { fn default() -> Self { ExecutionMode::Sync } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DriveOptions { + #[serde(default)] + pub max_steps: usize, + #[serde(default)] + pub execution_mode: ExecutionMode, +} + +impl Default for DriveOptions { + fn default() -> Self { Self { max_steps: 10_000, execution_mode: ExecutionMode::Sync } } +} \ No newline at end of file diff --git a/backend/src/flow/domain.rs b/backend/src/flow/domain.rs new file mode 100644 index 0000000..cd12aea --- /dev/null +++ b/backend/src/flow/domain.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct NodeId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum NodeKind { + Start, + End, + Task, + Decision, +} + +impl Default for NodeKind { + fn default() -> Self { Self::Task } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NodeDef { + pub id: NodeId, + #[serde(default)] + pub kind: NodeKind, + #[serde(default)] + pub name: String, + #[serde(default)] + pub task: Option, // 绑定的任务组件标识 +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LinkDef { + pub from: NodeId, + pub to: NodeId, + #[serde(default)] + pub condition: Option, // 条件脚本,返回 bool +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChainDef { + #[serde(default)] + pub name: String, + pub nodes: Vec, + #[serde(default)] + pub links: Vec, +} \ No newline at end of file diff --git a/backend/src/flow/dsl.rs b/backend/src/flow/dsl.rs new file mode 100644 index 0000000..bedb7c9 --- /dev/null +++ b/backend/src/flow/dsl.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowDSL { + #[serde(default)] + pub name: String, + #[serde(default, alias = "executionMode")] + pub execution_mode: Option, + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeDSL { + pub id: String, + #[serde(default)] + pub kind: String, // start / end / task / decision + #[serde(default)] + pub name: String, + #[serde(default)] + pub task: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdgeDSL { + #[serde(alias = "source", alias = "from", rename = "from")] + pub from: String, + #[serde(alias = "target", alias = "to", rename = "to")] + pub to: String, + #[serde(default)] + pub condition: Option, +} + +impl From for super::domain::ChainDef { + fn from(v: FlowDSL) -> Self { + super::domain::ChainDef { + name: v.name, + nodes: v + .nodes + .into_iter() + .map(|n| super::domain::NodeDef { + id: super::domain::NodeId(n.id), + kind: match n.kind.to_lowercase().as_str() { + "start" => super::domain::NodeKind::Start, + "end" => super::domain::NodeKind::End, + "decision" => super::domain::NodeKind::Decision, + _ => super::domain::NodeKind::Task, + }, + name: n.name, + task: n.task, + }) + .collect(), + links: v + .edges + .into_iter() + .map(|e| super::domain::LinkDef { + from: super::domain::NodeId(e.from), + to: super::domain::NodeId(e.to), + condition: e.condition, + }) + .collect(), + } + } +} + +// ===== New: Parse design_json (free layout JSON) to ChainDef and build execution context ===== + +/// Build ChainDef from design_json (front-end flow JSON) +pub fn chain_from_design_json(design: &Value) -> anyhow::Result { + use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef}; + + // Accept both JSON object and stringified JSON + let parsed: Option = match design { + Value::String(s) => serde_json::from_str::(s).ok(), + _ => None, + }; + let design = parsed.as_ref().unwrap_or(design); + + let name = design + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let nodes_arr = design.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + + let mut nodes: Vec = Vec::new(); + for n in &nodes_arr { + let id = n.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + let t = n.get("type").and_then(|v| v.as_str()).unwrap_or("task"); + let name_field = n + .get("data") + .and_then(|d| d.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let kind = match t { + "start" => NodeKind::Start, + "end" => NodeKind::End, + "condition" => NodeKind::Decision, + _ => NodeKind::Task, + }; + // Map type to task executor id (only for executable nodes). Others will be None. + let task = match t { + "http" => Some("http".to_string()), + "db" => Some("db".to_string()), + _ => None, + }; + nodes.push(NodeDef { id: NodeId(id.to_string()), kind, name: name_field, task }); + } + + let mut links: Vec = Vec::new(); + if let Some(arr) = design.get("edges").and_then(|v| v.as_array()) { + for e in arr { + let from = e + .get("sourceNodeID") + .or_else(|| e.get("source")) + .or_else(|| e.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let to = e + .get("targetNodeID") + .or_else(|| e.get("target")) + .or_else(|| e.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + // Try build structured condition for edges from a condition node via sourcePortID mapping + let mut cond: Option = None; + if let Some(spid) = e.get("sourcePortID").and_then(|v| v.as_str()) { + // find source node + if let Some(src_node) = nodes_arr.iter().find(|n| n.get("id").and_then(|v| v.as_str()) == Some(from.as_str())) { + if src_node.get("type").and_then(|v| v.as_str()) == Some("condition") { + if let Some(conds) = src_node.get("data").and_then(|d| d.get("conditions")).and_then(|v| v.as_array()) { + if let Some(item) = conds.iter().find(|c| c.get("key").and_then(|v| v.as_str()) == Some(spid)) { + if let Some(val) = item.get("value") { + // store JSON string for engine to interpret at runtime + if let Ok(s) = serde_json::to_string(val) { cond = Some(s); } + } + } + } + } + } + } + + links.push(LinkDef { from: NodeId(from), to: NodeId(to), condition: cond }); + } + } + + Ok(ChainDef { name, nodes, links }) +} + +/// Trim whitespace and strip wrapping quotes/backticks if present +fn sanitize_wrapped(s: &str) -> String { + let mut t = s.trim(); + if t.len() >= 2 { + let bytes = t.as_bytes(); + let first = bytes[0] as char; + let last = bytes[t.len() - 1] as char; + if (first == '`' && last == '`') || (first == '"' && last == '"') || (first == '\'' && last == '\'') { + t = &t[1..t.len() - 1]; + t = t.trim(); + // Handle stray trailing backslash left by an attempted escape of the closing quote/backtick + if t.ends_with('\\') { + t = &t[..t.len() - 1]; + } + } + } + t.to_string() +} + +/// Build ctx supplement from design_json: fill node-scope configs for executors, e.g., nodes..http +pub fn ctx_from_design_json(design: &Value) -> Value { + use serde_json::json; + + // Accept both JSON object and stringified JSON + let parsed: Option = match design { + Value::String(s) => serde_json::from_str::(s).ok(), + _ => None, + }; + let design = parsed.as_ref().unwrap_or(design); + + let mut nodes_map = serde_json::Map::new(); + + if let Some(arr) = design.get("nodes").and_then(|v| v.as_array()) { + for n in arr { + let id = match n.get("id").and_then(|v| v.as_str()) { + Some(s) => s, + None => continue, + }; + let node_type = n.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let mut node_cfg = serde_json::Map::new(); + + match node_type { + "http" => { + // Extract http config: method, url, headers, query, body + let data = n.get("data"); + let api = data.and_then(|d| d.get("api")); + let method = api.and_then(|a| a.get("method")).and_then(|v| v.as_str()).unwrap_or("GET").to_string(); + let url_val = api.and_then(|a| a.get("url")); + let raw_url = match url_val { + Some(Value::String(s)) => s.clone(), + Some(Value::Object(obj)) => obj.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(), + _ => String::new(), + }; + let url = sanitize_wrapped(&raw_url); + if !url.is_empty() { + let mut http_obj = serde_json::Map::new(); + http_obj.insert("method".into(), Value::String(method)); + http_obj.insert("url".into(), Value::String(url)); + // Optionally: headers/query/body + if let Some(hs) = api.and_then(|a| a.get("headers")).and_then(|v| v.as_object()) { + let mut heads = serde_json::Map::new(); + for (k, v) in hs.iter() { + if let Some(s) = v.as_str() { heads.insert(k.clone(), Value::String(s.to_string())); } + } + if !heads.is_empty() { http_obj.insert("headers".into(), Value::Object(heads)); } + } + if let Some(qs) = api.and_then(|a| a.get("query")).and_then(|v| v.as_object()) { + let mut query = serde_json::Map::new(); + for (k, v) in qs.iter() { query.insert(k.clone(), v.clone()); } + if !query.is_empty() { http_obj.insert("query".into(), Value::Object(query)); } + } + if let Some(body_obj) = data.and_then(|d| d.get("body")).and_then(|v| v.as_object()) { + // try body.content or body.json + if let Some(Value::Object(json_body)) = body_obj.get("json") { http_obj.insert("body".into(), Value::Object(json_body.clone())); } + else if let Some(Value::String(s)) = body_obj.get("content") { http_obj.insert("body".into(), Value::String(s.clone())); } + } + node_cfg.insert("http".into(), Value::Object(http_obj)); + } + } + "db" => { + // Extract db config: sql, params, outputKey + let data = n.get("data"); + if let Some(db_cfg) = data.and_then(|d| d.get("db")).and_then(|v| v.as_object()) { + let mut db_obj = serde_json::Map::new(); + // sql can be string or object with content + let raw_sql = db_cfg.get("sql"); + let sql = match raw_sql { + Some(Value::String(s)) => sanitize_wrapped(s), + Some(Value::Object(o)) => o.get("content").and_then(|v| v.as_str()).map(sanitize_wrapped).unwrap_or_default(), + _ => String::new(), + }; + if !sql.is_empty() { db_obj.insert("sql".into(), Value::String(sql)); } + if let Some(p) = db_cfg.get("params") { db_obj.insert("params".into(), p.clone()); } + if let Some(Value::String(k)) = db_cfg.get("outputKey") { db_obj.insert("outputKey".into(), Value::String(k.clone())); } + if let Some(conn) = db_cfg.get("connection") { db_obj.insert("connection".into(), conn.clone()); } + if !db_obj.is_empty() { node_cfg.insert("db".into(), Value::Object(db_obj)); } + } + } + _ => {} + } + + if !node_cfg.is_empty() { nodes_map.insert(id.to_string(), Value::Object(node_cfg)); } + } + } + + json!({ "nodes": Value::Object(nodes_map) }) +} \ No newline at end of file diff --git a/backend/src/flow/engine.rs b/backend/src/flow/engine.rs new file mode 100644 index 0000000..7f45751 --- /dev/null +++ b/backend/src/flow/engine.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; + +use rhai::Engine; +use tracing::info; + +use super::{context::{DriveOptions, ExecutionMode}, domain::{ChainDef, NodeKind}, task::TaskRegistry}; + +pub struct FlowEngine { + pub tasks: TaskRegistry, +} + +impl FlowEngine { + pub fn new(tasks: TaskRegistry) -> Self { Self { tasks } } + + pub async fn drive(&self, chain: &ChainDef, mut ctx: serde_json::Value, opts: DriveOptions) -> anyhow::Result<(serde_json::Value, Vec)> { + let mut logs = Vec::new(); + + // 查找 start:优先 Start 节点;否则选择入度为 0 的第一个节点;再否则回退第一个节点 + let start = if let Some(n) = chain + .nodes + .iter() + .find(|n| matches!(n.kind, NodeKind::Start)) + { + n.id.0.clone() + } else { + // 计算入度 + let mut indeg: HashMap<&str, usize> = HashMap::new(); + for n in &chain.nodes { indeg.entry(n.id.0.as_str()).or_insert(0); } + for l in &chain.links { *indeg.entry(l.to.0.as_str()).or_insert(0) += 1; } + if let Some(n) = chain.nodes.iter().find(|n| indeg.get(n.id.0.as_str()).copied().unwrap_or(0) == 0) { + n.id.0.clone() + } else { + chain + .nodes + .first() + .ok_or_else(|| anyhow::anyhow!("empty chain"))? + .id + .0 + .clone() + } + }; + + // 邻接表(按 links 的原始顺序保序) + let mut adj: HashMap<&str, Vec<&super::domain::LinkDef>> = HashMap::new(); + for l in &chain.links { adj.entry(&l.from.0).or_default().push(l); } + let node_map: HashMap<&str, &super::domain::NodeDef> = chain.nodes.iter().map(|n| (n.id.0.as_str(), n)).collect(); + + let mut current = start; + let mut steps = 0usize; + while steps < opts.max_steps { + steps += 1; + let node = node_map.get(current.as_str()).ok_or_else(|| anyhow::anyhow!("node not found"))?; + logs.push(format!("enter node: {}", node.id.0)); + info!(target: "udmin.flow", "enter node: {}", node.id.0); + + // 任务执行 + if let Some(task_name) = &node.task { + if let Some(task) = self.tasks.get(task_name) { + match opts.execution_mode { + ExecutionMode::Sync => { + if let serde_json::Value::Object(obj) = &mut ctx { obj.insert("__current_node_id".to_string(), serde_json::Value::String(node.id.0.clone())); } + task.execute(&mut ctx).await?; + logs.push(format!("exec task: {} (sync)", task_name)); + info!(target: "udmin.flow", "exec task: {} (sync)", task_name); + } + ExecutionMode::AsyncFireAndForget => { + // fire-and-forget: 复制一份上下文供该任务使用,主流程不等待 + let mut task_ctx = ctx.clone(); + if let serde_json::Value::Object(obj) = &mut task_ctx { obj.insert("__current_node_id".to_string(), serde_json::Value::String(node.id.0.clone())); } + let task_arc = task.clone(); + let name_for_log = task_name.clone(); + tokio::spawn(async move { + let _ = task_arc.execute(&mut task_ctx).await; + info!(target: "udmin.flow", "exec task done (async): {}", name_for_log); + }); + logs.push(format!("spawn task: {} (async)", task_name)); + info!(target: "udmin.flow", "spawn task: {} (async)", task_name); + } + } + } else { + logs.push(format!("task not found: {} (skip)", task_name)); + info!(target: "udmin.flow", "task not found: {} (skip)", task_name); + } + } + + if matches!(node.kind, NodeKind::End) { break; } + + // 选择下一条 link:优先有条件的且为真;否则保序选择第一条无条件边 + let mut next: Option = None; + if let Some(links) = adj.get(node.id.0.as_str()) { + // 先检测条件边 + for link in links.iter() { + if let Some(cond_str) = &link.condition { + // 两种情况: + // 1) 前端序列化的 JSON,形如 { left: {type, content}, operator, right? } + // 2) 直接是 rhai 表达式字符串 + let ok = if cond_str.trim_start().starts_with('{') { + match serde_json::from_str::(cond_str) { + Ok(v) => eval_condition_json(&ctx, &v).unwrap_or(false), + Err(_) => false, + } + } else { + let mut scope = rhai::Scope::new(); + scope.push("ctx", rhai::serde::to_dynamic(ctx.clone()).map_err(|e| anyhow::anyhow!(e.to_string()))?); + let engine = Engine::new(); + engine.eval_with_scope::(&mut scope, cond_str).unwrap_or(false) + }; + if ok { next = Some(link.to.0.clone()); break; } + } + } + // 若没有命中条件边,则取第一条无条件边 + if next.is_none() { + for link in links.iter() { + if link.condition.is_none() { next = Some(link.to.0.clone()); break; } + } + } + } + match next { Some(n) => current = n, None => break } + } + + Ok((ctx, logs)) + } +} + +fn eval_condition_json(ctx: &serde_json::Value, cond: &serde_json::Value) -> anyhow::Result { + // 目前支持前端 Condition 组件导出的: { left:{type, content}, operator, right? } + let left = cond.get("left").ok_or_else(|| anyhow::anyhow!("missing left"))?; + let op = cond.get("operator").and_then(|v| v.as_str()).unwrap_or(""); + let right = cond.get("right"); + + let lval = resolve_value(ctx, left)?; + let rval = match right { Some(v) => Some(resolve_value(ctx, v)?), None => None }; + + use serde_json::Value as V; + let res = match (op, &lval, &rval) { + ("contains", V::String(s), Some(V::String(t))) => s.contains(t), + ("equals", V::String(s), Some(V::String(t))) => s == t, + ("equals", V::Number(a), Some(V::Number(b))) => a == b, + ("is_true", V::Bool(b), _) => *b, + ("is_false", V::Bool(b), _) => !*b, + ("gt", V::Number(a), Some(V::Number(b))) => a.as_f64().unwrap_or(0.0) > b.as_f64().unwrap_or(0.0), + ("lt", V::Number(a), Some(V::Number(b))) => a.as_f64().unwrap_or(0.0) < b.as_f64().unwrap_or(0.0), + _ => false, + }; + Ok(res) +} + +fn resolve_value(ctx: &serde_json::Value, v: &serde_json::Value) -> anyhow::Result { + use serde_json::Value as V; + let t = v.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match t { + "constant" => Ok(v.get("content").cloned().unwrap_or(V::Null)), + "ref" => { + // content: [nodeId, field] + if let Some(arr) = v.get("content").and_then(|v| v.as_array()) { + if arr.len() >= 2 { + if let (Some(node), Some(field)) = (arr[0].as_str(), arr[1].as_str()) { + let val = ctx + .get("nodes") + .and_then(|n| n.get(node)) + .and_then(|m| m.get(field)) + .cloned() + .or_else(|| ctx.get(field).cloned()) + .unwrap_or(V::Null); + return Ok(val); + } + } + } + Ok(V::Null) + } + _ => Ok(V::Null), + } +} \ No newline at end of file diff --git a/backend/src/flow/executors/db.rs b/backend/src/flow/executors/db.rs new file mode 100644 index 0000000..2e8410b --- /dev/null +++ b/backend/src/flow/executors/db.rs @@ -0,0 +1,261 @@ +use async_trait::async_trait; +use serde_json::{json, Value}; +use tracing::info; + +use crate::flow::task::TaskComponent; + +#[derive(Default)] +pub struct DbTask; + +#[async_trait] +impl TaskComponent for DbTask { + async fn execute(&self, ctx: &mut Value) -> anyhow::Result<()> { + // 1) 获取当前节点ID + let node_id_opt = ctx + .get("__current_node_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 2) 读取 db 配置:仅节点级 db,不再回退到全局 ctx.db,避免误用项目数据库 + let cfg = match (&node_id_opt, ctx.get("nodes")) { + (Some(node_id), Some(nodes)) => nodes.get(&node_id).and_then(|n| n.get("db")).cloned(), + _ => None, + }; + + let Some(cfg) = cfg else { + info!(target = "udmin.flow", "db task: no config found, skip"); + return Ok(()); + }; + + // 3) 解析配置(包含可选连接信息) + let (sql, params, output_key, conn, mode_from_db) = parse_db_config(cfg)?; + // 提前读取结果模式,优先 connection.mode,其次 db.output.mode/db.outputMode/db.mode + let result_mode = get_result_mode_from_conn(&conn).or(mode_from_db); + info!(target = "udmin.flow", "db task: exec sql: {}", sql); + + // 4) 获取连接:必须显式声明 db.connection,禁止回退到项目全局数据库,避免安全风险 + let db: std::borrow::Cow<'_, crate::db::Db>; + let tmp_conn; // 用于在本作用域内持有临时连接 + use sea_orm::{Statement, ConnectionTrait}; + + let conn_cfg = conn.ok_or_else(|| anyhow::anyhow!("db task: connection config is required (db.connection)"))?; + // 构造 URL 并建立临时连接 + let url = extract_connection_url(conn_cfg)?; + use sea_orm::{ConnectOptions, Database}; + use std::time::Duration; + let mut opt = ConnectOptions::new(url); + opt.max_connections(20) + .min_connections(1) + .connect_timeout(Duration::from_secs(8)) + .idle_timeout(Duration::from_secs(120)) + .sqlx_logging(true); + tmp_conn = Database::connect(opt).await?; + db = std::borrow::Cow::Owned(tmp_conn); + + // 判定是否为 SELECT:简单判断前缀,允许前导空白与括号 + let is_select = { + let s = sql.trim_start(); + let s = s.trim_start_matches('('); + s.to_uppercase().starts_with("SELECT") + }; + + // 构建参数列表(支持位置和命名两种形式) + let params_vec: Vec = match params { + None => vec![], + Some(Value::Array(arr)) => arr.into_iter().map(json_to_db_value).collect::>()?, + Some(Value::Object(obj)) => { + // 对命名参数对象,保持插入顺序不可控,这里仅将值收集为位置绑定,建议 SQL 使用 `?` 占位 + obj.into_iter().map(|(_, v)| json_to_db_value(v)).collect::>()? + } + Some(v) => { + // 其它类型:当作单个位置参数 + vec![json_to_db_value(v)?] + } + }; + + let stmt = Statement::from_sql_and_values(db.get_database_backend(), &sql, params_vec); + + let result = if is_select { + let rows = db.query_all(stmt).await?; + // 将 QueryResult 转换为 JSON 数组 + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let mut obj = serde_json::Map::new(); + // 读取列名列表 + let cols = row.column_names(); + for (idx, col_name) in cols.iter().enumerate() { + let key = col_name.to_string(); + // 尝试以通用 JSON 值提取(优先字符串、数值、布尔、二进制、null) + let val = try_get_as_json(&row, idx, &key); + obj.insert(key, val); + } + out.push(Value::Object(obj)); + } + // 默认 rows 模式:直接返回数组 + match result_mode.as_deref() { + // 返回首行字段对象(无则 Null) + Some("fields") | Some("first") => { + if let Some(Value::Object(m)) = out.get(0) { Value::Object(m.clone()) } else { Value::Null } + } + // 默认与显式 rows 都返回数组 + _ => Value::Array(out), + } + } else { + let exec = db.execute(stmt).await?; + // 非 SELECT 默认返回受影响行数 + match result_mode.as_deref() { + // 如显式要求 rows,则返回空数组 + Some("rows") => json!([]), + _ => json!(exec.rows_affected()), + } + }; + + // 5) 写回 ctx(并对敏感信息脱敏) + let write_key = output_key.unwrap_or_else(|| "db_response".to_string()); + if let (Some(node_id), Some(obj)) = (node_id_opt, ctx.as_object_mut()) { + if let Some(nodes) = obj.get_mut("nodes").and_then(|v| v.as_object_mut()) { + if let Some(target) = nodes.get_mut(&node_id).and_then(|v| v.as_object_mut()) { + // 写入结果 + target.insert(write_key, result); + // 对密码字段脱敏(保留其它配置不变) + if let Some(dbv) = target.get_mut("db") { + if let Some(dbo) = dbv.as_object_mut() { + if let Some(connv) = dbo.get_mut("connection") { + match connv { + Value::Object(m) => { + if let Some(pw) = m.get_mut("password") { + *pw = Value::String("***".to_string()); + } + if let Some(Value::String(url)) = m.get_mut("url") { + *url = "***".to_string(); + } + } + Value::String(s) => { + *s = "***".to_string(); + } + _ => {} + } + } + } + } + return Ok(()); + } + } + } + if let Value::Object(map) = ctx { map.insert(write_key, result); } + Ok(()) + } +} + +fn parse_db_config(cfg: Value) -> anyhow::Result<(String, Option, Option, Option, Option)> { + match cfg { + Value::String(sql) => Ok((sql, None, None, None, None)), + Value::Object(mut m) => { + let sql = m + .remove("sql") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .ok_or_else(|| anyhow::anyhow!("db config missing sql"))?; + let params = m.remove("params"); + let output_key = m.remove("outputKey").and_then(|v| v.as_str().map(|s| s.to_string())); + // 在移除 connection 前,从 db 层读取可能的输出模式 + let mode_from_db = { + // db.output.mode + let from_output = m.get("output").and_then(|v| v.as_object()).and_then(|o| o.get("mode")).and_then(|v| v.as_str()).map(|s| s.to_string()); + // db.outputMode 或 db.mode + let from_flat = m.get("outputMode").and_then(|v| v.as_str()).map(|s| s.to_string()) + .or_else(|| m.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string())); + from_output.or(from_flat) + }; + let conn = m.remove("connection"); + // 安全策略:必须显式声明连接,禁止默认落到全局数据库 + if conn.is_none() { + return Err(anyhow::anyhow!("db config missing connection (db.connection is required)")); + } + Ok((sql, params, output_key, conn, mode_from_db)) + } + _ => Err(anyhow::anyhow!("invalid db config")), + } +} + +fn extract_connection_url(cfg: Value) -> anyhow::Result { + match cfg { + Value::String(url) => Ok(url), + Value::Object(mut m) => { + if let Some(url) = m.remove("url").and_then(|v| v.as_str().map(|s| s.to_string())) { + return Ok(url); + } + let driver = m + .remove("driver") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "mysql".to_string()); + // sqlite 特殊处理:仅需要 database(文件路径或 :memory:) + if driver == "sqlite" { + let database = m.remove("database").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.database is required for sqlite unless url provided"))?; + return Ok(format!("sqlite://{}", database)); + } + + let host = m.remove("host").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "localhost".to_string()); + let port = m.remove("port").map(|v| match v { Value::Number(n) => n.to_string(), Value::String(s) => s, _ => String::new() }); + let database = m.remove("database").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.database is required unless url provided"))?; + let username = m.remove("username").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.username is required unless url provided"))?; + let password = m.remove("password").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default(); + let port_part = port.filter(|s| !s.is_empty()).map(|s| format!(":{}", s)).unwrap_or_default(); + let url = format!( + "{}://{}:{}@{}{}{}", + driver, + percent_encoding::utf8_percent_encode(&username, percent_encoding::NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&password, percent_encoding::NON_ALPHANUMERIC), + host, + port_part, + format!("/{}", database) + ); + Ok(url) + } + _ => Err(anyhow::anyhow!("invalid connection config")), + } +} + +fn get_result_mode_from_conn(conn: &Option) -> Option { + match conn { + Some(Value::Object(m)) => m.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string()), + _ => None, + } +} + +fn json_to_db_value(v: Value) -> anyhow::Result { + use sea_orm::Value as DbValue; + let dv = match v { + Value::Null => DbValue::String(None), + Value::Bool(b) => DbValue::Bool(Some(b)), + Value::Number(n) => { + if let Some(i) = n.as_i64() { DbValue::BigInt(Some(i)) } + else if let Some(u) = n.as_u64() { DbValue::BigUnsigned(Some(u)) } + else if let Some(f) = n.as_f64() { DbValue::Double(Some(f)) } + else { DbValue::String(None) } + } + Value::String(s) => DbValue::String(Some(Box::new(s))), + Value::Array(arr) => { + // 无通用跨库数组类型:存为 JSON 字符串 + let s = serde_json::to_string(&Value::Array(arr))?; + DbValue::String(Some(Box::new(s))) + } + Value::Object(obj) => { + let s = serde_json::to_string(&Value::Object(obj))?; + DbValue::String(Some(Box::new(s))) + } + }; + Ok(dv) +} + +fn try_get_as_json(row: &sea_orm::QueryResult, idx: usize, col_name: &str) -> Value { + use sea_orm::TryGetable; + // 尝试多种基础类型 + if let Ok(v) = row.try_get::>("", col_name) { return v.map(Value::String).unwrap_or(Value::Null); } + if let Ok(v) = row.try_get::>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); } + if let Ok(v) = row.try_get::>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); } + if let Ok(v) = row.try_get::>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); } + if let Ok(v) = row.try_get::>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); } + // 回退:按索引读取成字符串 + if let Ok(v) = row.try_get_by_index::>(idx) { return v.map(Value::String).unwrap_or(Value::Null); } + Value::Null +} \ No newline at end of file diff --git a/backend/src/flow/executors/http.rs b/backend/src/flow/executors/http.rs new file mode 100644 index 0000000..c0a020a --- /dev/null +++ b/backend/src/flow/executors/http.rs @@ -0,0 +1,161 @@ +use async_trait::async_trait; +use serde_json::{Value, json, Map}; +use tracing::info; +use std::collections::HashMap; +use std::time::Duration; +use reqwest::Certificate; + +use crate::flow::task::TaskComponent; + +#[derive(Default)] +pub struct HttpTask; + +#[derive(Default, Clone)] +struct HttpOpts { + timeout_ms: Option, + insecure: bool, + ca_pem: Option, + http1_only: bool, +} + +#[async_trait] +impl TaskComponent for HttpTask { + async fn execute(&self, ctx: &mut Value) -> anyhow::Result<()> { + // 1) 读取当前节点ID(由引擎在执行前写入 ctx.__current_node_id) + let node_id_opt = ctx + .get("__current_node_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 2) 从 ctx 中提取 http 配置 + // 优先 nodes..http,其次全局 http + let cfg = match (&node_id_opt, ctx.get("nodes")) { + (Some(node_id), Some(nodes)) => nodes.get(&node_id).and_then(|n| n.get("http")).cloned(), + _ => None, + }.or_else(|| ctx.get("http").cloned()); + + let Some(cfg) = cfg else { + // 未提供配置,直接跳过(也可选择返回错误) + info!(target = "udmin.flow", "http task: no config found, skip"); + return Ok(()); + }; + + // 3) 解析配置 + let (method, url, headers, query, body, opts) = parse_http_config(cfg)?; + info!(target = "udmin.flow", "http task: {} {}", method, url); + + // 4) 发送请求(支持 HTTPS 相关选项) + let client = { + let mut builder = reqwest::Client::builder(); + if let Some(ms) = opts.timeout_ms { builder = builder.timeout(Duration::from_millis(ms)); } + if opts.insecure { builder = builder.danger_accept_invalid_certs(true); } + if opts.http1_only { builder = builder.http1_only(); } + if let Some(pem) = opts.ca_pem { + if let Ok(cert) = Certificate::from_pem(pem.as_bytes()) { + builder = builder.add_root_certificate(cert); + } + } + builder.build()? + }; + let mut req = client.request(method.parse()?, url); + + if let Some(hs) = headers { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + let mut map = HeaderMap::new(); + for (k, v) in hs { + if let (Ok(name), Ok(value)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(&v)) { + map.insert(name, value); + } + } + req = req.headers(map); + } + + if let Some(qs) = query { + // 将查询参数转成 (String, String) 列表,便于 reqwest 序列化 + let mut pairs: Vec<(String, String)> = Vec::new(); + for (k, v) in qs { + let s = match v { + Value::String(s) => s, + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + other => other.to_string(), + }; + pairs.push((k, s)); + } + req = req.query(&pairs); + } + + if let Some(b) = body { + req = req.json(&b); + } + + let resp = req.send().await?; + let status = resp.status().as_u16(); + let headers_out: Map = resp + .headers() + .iter() + .map(|(k, v)| (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))) + .collect(); + + // 尝试以 JSON 解析,否则退回文本 + let text = resp.text().await?; + let parsed_body: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)); + + // 5) 将结果写回 ctx + let result = json!({ + "status": status, + "headers": headers_out, + "body": parsed_body, + }); + + // 优先写 nodes..http_response,否则写入全局 http_response + if let (Some(node_id), Some(obj)) = (node_id_opt, ctx.as_object_mut()) { + if let Some(nodes) = obj.get_mut("nodes").and_then(|v| v.as_object_mut()) { + if let Some(target) = nodes.get_mut(&node_id).and_then(|v| v.as_object_mut()) { + target.insert("http_response".to_string(), result); + return Ok(()); + } + } + } + // 退回:写入全局 + if let Value::Object(map) = ctx { + map.insert("http_response".to_string(), result); + } + Ok(()) + } +} + +fn parse_http_config(cfg: Value) -> anyhow::Result<( + String, + String, + Option>, + Option>, + Option, + HttpOpts, +)> { + // 支持两种配置: + // 1) 字符串:视为 URL,方法 GET + // 2) 对象:{ method, url, headers, query, body } + match cfg { + Value::String(url) => Ok(("GET".into(), url, None, None, None, HttpOpts::default())), + Value::Object(mut m) => { + let method = m.remove("method").and_then(|v| v.as_str().map(|s| s.to_uppercase())).unwrap_or_else(|| "GET".into()); + let url = m.remove("url").and_then(|v| v.as_str().map(|s| s.to_string())) + .ok_or_else(|| anyhow::anyhow!("http config missing url"))?; + let headers = m.remove("headers").and_then(|v| v.as_object().cloned()).map(|obj| { + obj.into_iter().filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))).collect::>() + }); + let query = m.remove("query").and_then(|v| v.as_object().cloned()); + let body = m.remove("body"); + + // 可选 HTTPS/超时/HTTP 版本配置 + let timeout_ms = m.remove("timeout_ms").and_then(|v| v.as_u64()); + let insecure = m.remove("insecure").and_then(|v| v.as_bool()).unwrap_or(false); + let http1_only = m.remove("http1_only").and_then(|v| v.as_bool()).unwrap_or(false); + let ca_pem = m.remove("ca_pem").and_then(|v| v.as_str().map(|s| s.to_string())); + let opts = HttpOpts { timeout_ms, insecure, ca_pem, http1_only }; + Ok((method, url, headers, query, body, opts)) + } + _ => Err(anyhow::anyhow!("invalid http config")), + } +} \ No newline at end of file diff --git a/backend/src/flow/executors/mod.rs b/backend/src/flow/executors/mod.rs new file mode 100644 index 0000000..b8b589e --- /dev/null +++ b/backend/src/flow/executors/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod db; \ No newline at end of file diff --git a/backend/src/flow/mod.rs b/backend/src/flow/mod.rs new file mode 100644 index 0000000..9366ce2 --- /dev/null +++ b/backend/src/flow/mod.rs @@ -0,0 +1,7 @@ +pub mod domain; +pub mod context; +pub mod task; +pub mod engine; +pub mod dsl; +pub mod storage; +pub mod executors; \ No newline at end of file diff --git a/backend/src/flow/storage.rs b/backend/src/flow/storage.rs new file mode 100644 index 0000000..db25c74 --- /dev/null +++ b/backend/src/flow/storage.rs @@ -0,0 +1,15 @@ +use once_cell::sync::Lazy; +use std::collections::HashMap; +use std::sync::Mutex; + +static STORE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +pub fn list() -> Vec<(String, String)> { + STORE.lock().unwrap().iter().map(|(k, v)| (k.clone(), v.clone())).collect() +} + +pub fn get(id: &str) -> Option { STORE.lock().unwrap().get(id).cloned() } + +pub fn put(id: String, yaml: String) { STORE.lock().unwrap().insert(id, yaml); } + +pub fn del(id: &str) -> Option { STORE.lock().unwrap().remove(id) } \ No newline at end of file diff --git a/backend/src/flow/task.rs b/backend/src/flow/task.rs new file mode 100644 index 0000000..ee16bd4 --- /dev/null +++ b/backend/src/flow/task.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; +use serde_json::Value; + +#[async_trait] +pub trait TaskComponent: Send + Sync { + async fn execute(&self, ctx: &mut Value) -> anyhow::Result<()>; +} + +pub type TaskRegistry = std::collections::HashMap>; + +use std::sync::{Arc, RwLock, OnceLock}; + +pub fn default_registry() -> TaskRegistry { + let mut reg: TaskRegistry = TaskRegistry::new(); + reg.insert("http".into(), Arc::new(crate::flow::executors::http::HttpTask::default())); + reg.insert("db".into(), Arc::new(crate::flow::executors::db::DbTask::default())); + reg +} + +// ===== Global registry (for DI/registry center) ===== +static GLOBAL_TASK_REGISTRY: OnceLock> = OnceLock::new(); + +/// Get a snapshot of current registry (clone of HashMap). If not initialized, it will be filled with default_registry(). +pub fn get_registry() -> TaskRegistry { + let lock = GLOBAL_TASK_REGISTRY.get_or_init(|| RwLock::new(default_registry())); + lock.read().expect("lock poisoned").clone() +} + +/// Register/override a single task into global registry. +pub fn register_global_task(name: impl Into, task: Arc) { + let lock = GLOBAL_TASK_REGISTRY.get_or_init(|| RwLock::new(default_registry())); + let mut w = lock.write().expect("lock poisoned"); + w.insert(name.into(), task); +} + +/// Initialize or mutate the global registry with a custom initializer. +pub fn init_global_registry_with(init: impl FnOnce(&mut TaskRegistry)) { + let lock = GLOBAL_TASK_REGISTRY.get_or_init(|| RwLock::new(default_registry())); + let mut w = lock.write().expect("lock poisoned"); + init(&mut w); +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index ad3c01d..6e21108 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -7,7 +7,7 @@ pub mod models; pub mod services; pub mod routes; pub mod utils; -//pub mod workflow; +pub mod flow; use axum::Router; use axum::http::{HeaderValue, Method}; @@ -15,6 +15,16 @@ use tower_http::cors::{CorsLayer, Any, AllowOrigin}; use migration::MigratorTrait; use axum::middleware; +// 自定义日志时间格式:YYYY-MM-DD HH:MM:SS.ssssss(不带 T 和 Z) +struct LocalTimeFmt; + +impl tracing_subscriber::fmt::time::FormatTime for LocalTimeFmt { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer) -> std::fmt::Result { + let now = chrono::Local::now(); + w.write_str(&now.format("%Y-%m-%d %H:%M:%S%.6f").to_string()) + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // 增强:支持通过 ENV_FILE 指定要加载的环境文件,并记录实际加载的文件 @@ -41,9 +51,14 @@ async fn main() -> anyhow::Result<()> { } }; - tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_timer(LocalTimeFmt) + .init(); let db = db::init_db().await?; + // set global DB for tasks + db::set_db(db.clone()).expect("db set failure"); // initialize Redis connection let redis_pool = redis::init_redis().await?; diff --git a/backend/src/middlewares/jwt.rs b/backend/src/middlewares/jwt.rs index 91d902b..eb2af4d 100644 --- a/backend/src/middlewares/jwt.rs +++ b/backend/src/middlewares/jwt.rs @@ -20,8 +20,10 @@ pub fn encode_token(claims: &Claims, secret: &str) -> Result { pub fn decode_token(token: &str, secret: &str) -> Result { let key = DecodingKey::from_secret(secret.as_bytes()); - let data = jsonwebtoken::decode::(token, &key, &Validation::default())?; - Ok(data.claims) + match jsonwebtoken::decode::(token, &key, &Validation::default()) { + Ok(data) => Ok(data.claims), + Err(_) => Err(AppError::Unauthorized), + } } #[derive(Clone, Debug)] diff --git a/backend/src/models/flow.rs b/backend/src/models/flow.rs new file mode 100644 index 0000000..698b7c2 --- /dev/null +++ b/backend/src/models/flow.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "flows")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + pub name: Option, + pub yaml: Option, + pub design_json: Option, + // 新增:流程编号与备注 + pub code: Option, + pub remark: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/backend/src/models/flow_run_log.rs b/backend/src/models/flow_run_log.rs new file mode 100644 index 0000000..ce336cd --- /dev/null +++ b/backend/src/models/flow_run_log.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, serde::Serialize, serde::Deserialize)] +#[sea_orm(table_name = "flow_run_logs")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub flow_id: String, + // 新增:流程编码(可空) + pub flow_code: Option, + pub input: Option, + pub output: Option, + pub ok: bool, + pub logs: Option, + pub user_id: Option, + pub username: Option, + pub started_at: DateTimeWithTimeZone, + pub duration_ms: i64, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bed7e2a..e0deac4 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -9,4 +9,6 @@ pub mod user_department; pub mod request_log; // 新增岗位与用户岗位关联模型 pub mod position; -pub mod user_position; \ No newline at end of file +pub mod user_position; +pub mod flow; +pub mod flow_run_log; \ No newline at end of file diff --git a/backend/src/routes/flow_run_logs.rs b/backend/src/routes/flow_run_logs.rs new file mode 100644 index 0000000..87ea764 --- /dev/null +++ b/backend/src/routes/flow_run_logs.rs @@ -0,0 +1,13 @@ +use axum::{Router, routing::get, extract::{State, Query}, Json}; +use crate::{db::Db, response::ApiResponse, services::flow_run_log_service}; + +pub fn router() -> Router { + Router::new().route("/flow_run_logs", get(list)) +} + +async fn list(State(db): State, Query(p): Query) -> Json>> { + match flow_run_log_service::list(&db, p).await { + Ok(res) => Json(ApiResponse::ok(res)), + Err(e) => Json(ApiResponse::err(500, format!("{}", e))), + } +} \ No newline at end of file diff --git a/backend/src/routes/flows.rs b/backend/src/routes/flows.rs new file mode 100644 index 0000000..cfaceb2 --- /dev/null +++ b/backend/src/routes/flows.rs @@ -0,0 +1,71 @@ +use axum::{Router, routing::{post, get}, extract::{State, Path, Query}, Json}; +use crate::{db::Db, response::ApiResponse, services::flow_service, error::AppError}; +use serde::Deserialize; +use tracing::{info, error}; +use crate::middlewares::jwt::AuthUser; + +pub fn router() -> Router { + Router::new() + .route("/flows", post(create).get(list)) + .route("/flows/{id}", get(get_one).put(update).delete(remove)) + .route("/flows/{id}/run", post(run)) +} + +#[derive(Deserialize)] +struct PageParams { page: Option, page_size: Option, keyword: Option } + +async fn list(State(db): State, Query(p): Query) -> Result>>, AppError> { + let page = p.page.unwrap_or(1); + let page_size = p.page_size.unwrap_or(10); + let res = flow_service::list(&db, page, page_size, p.keyword).await.map_err(flow_service::ae)?; + Ok(Json(ApiResponse::ok(res))) +} + +#[derive(Deserialize)] +struct CreateReq { yaml: Option, name: Option, design_json: Option, code: Option, remark: Option } + +#[derive(Deserialize)] +struct UpdateReq { yaml: Option, design_json: Option, name: Option, code: Option, remark: Option } + + async fn create(State(db): State, Json(req): Json) -> Result>, AppError> { + info!(target = "udmin", "routes.flows.create: start"); + let res = match flow_service::create(&db, flow_service::FlowCreateReq { yaml: req.yaml, name: req.name, design_json: req.design_json, code: req.code, remark: req.remark }).await { + Ok(r) => { info!(target = "udmin", id = %r.id, "routes.flows.create: ok"); r } + Err(e) => { + error!(target = "udmin", error = ?e, "routes.flows.create: failed"); + // 将错误恢复为统一映射,避免对外暴露内部细节 + return Err(flow_service::ae(e)); + } + }; + Ok(Json(ApiResponse::ok(res))) + } + + async fn update(State(db): State, Path(id): Path, Json(req): Json) -> Result>, AppError> { + let res = flow_service::update(&db, &id, flow_service::FlowUpdateReq { yaml: req.yaml, design_json: req.design_json, name: req.name, code: req.code, remark: req.remark }).await.map_err(flow_service::ae)?; + Ok(Json(ApiResponse::ok(res))) + } + +async fn get_one(State(db): State, Path(id): Path) -> Result>, AppError> { + let res = flow_service::get(&db, &id).await.map_err(flow_service::ae)?; + Ok(Json(ApiResponse::ok(res))) +} + +async fn remove(State(db): State, Path(id): Path) -> Result>, AppError> { + flow_service::delete(&db, &id).await.map_err(flow_service::ae)?; + Ok(Json(ApiResponse::ok(serde_json::json!({"deleted": true})))) +} + +#[derive(Deserialize)] +struct RunReq { #[serde(default)] input: serde_json::Value } + +async fn run(State(db): State, user: AuthUser, Path(id): Path, Json(req): Json) -> Result>, AppError> { + match flow_service::run(&db, &id, flow_service::RunReq { input: req.input }, Some((user.uid, user.username))).await { + Ok(r) => Ok(Json(ApiResponse::ok(r))), + Err(e) => { + // 同步执行:直接把后端错误详细信息返回给前端 + let mut full = e.to_string(); + for cause in e.chain().skip(1) { full.push_str(" | "); full.push_str(&cause.to_string()); } + Err(AppError::InternalMsg(full)) + } + } +} \ No newline at end of file diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 08e2621..8492cd3 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -6,6 +6,8 @@ pub mod departments; pub mod logs; // 新增岗位 pub mod positions; +pub mod flows; +pub mod flow_run_logs; use axum::Router; use crate::db::Db; @@ -18,6 +20,7 @@ pub fn api_router() -> Router { .merge(menus::router()) .merge(departments::router()) .merge(logs::router()) - // 合并岗位路由 + .merge(flows::router()) .merge(positions::router()) + .merge(flow_run_logs::router()) } \ No newline at end of file diff --git a/backend/src/services/flow_run_log_service.rs b/backend/src/services/flow_run_log_service.rs new file mode 100644 index 0000000..47f2fd3 --- /dev/null +++ b/backend/src/services/flow_run_log_service.rs @@ -0,0 +1,78 @@ +use crate::{db::Db, models::flow_run_log}; +use sea_orm::{ActiveModelTrait, Set, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, ColumnTrait}; +use chrono::{DateTime, FixedOffset, Utc}; + +#[derive(serde::Serialize)] +pub struct PageResp { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +#[derive(serde::Deserialize)] +pub struct ListParams { pub page: Option, pub page_size: Option, pub flow_id: Option, pub flow_code: Option, pub user: Option, pub ok: Option } + +#[derive(serde::Serialize)] +pub struct RunLogItem { + pub id: i64, + pub flow_id: String, + pub flow_code: Option, + pub input: Option, + pub output: Option, + pub ok: bool, + pub logs: Option, + pub user_id: Option, + pub username: Option, + pub started_at: chrono::DateTime, + pub duration_ms: i64, +} +impl From for RunLogItem { + fn from(m: flow_run_log::Model) -> Self { + Self { id: m.id, flow_id: m.flow_id, flow_code: m.flow_code, input: m.input, output: m.output, ok: m.ok, logs: m.logs, user_id: m.user_id, username: m.username, started_at: m.started_at, duration_ms: m.duration_ms } + } +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct CreateRunLogInput { + pub flow_id: String, + pub flow_code: Option, + pub input: Option, + pub output: Option, + pub ok: bool, + pub logs: Option, + pub user_id: Option, + pub username: Option, + pub started_at: DateTime, + pub duration_ms: i64, +} + +pub async fn create(db: &Db, input: CreateRunLogInput) -> anyhow::Result { + let am = flow_run_log::ActiveModel { + id: Default::default(), + flow_id: Set(input.flow_id), + flow_code: Set(input.flow_code), + input: Set(input.input), + output: Set(input.output), + ok: Set(input.ok), + logs: Set(input.logs), + user_id: Set(input.user_id), + username: Set(input.username), + started_at: Set(input.started_at), + duration_ms: Set(input.duration_ms), + created_at: Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap())), + }; + let m = am.insert(db).await?; + Ok(m.id) +} + +pub async fn list(db: &Db, p: ListParams) -> anyhow::Result> { + let page = p.page.unwrap_or(1); let page_size = p.page_size.unwrap_or(10); + let mut selector = flow_run_log::Entity::find(); + if let Some(fid) = p.flow_id { selector = selector.filter(flow_run_log::Column::FlowId.eq(fid)); } + if let Some(fcode) = p.flow_code { selector = selector.filter(flow_run_log::Column::FlowCode.eq(fcode)); } + if let Some(u) = p.user { + let like = format!("%{}%", u); + selector = selector.filter(flow_run_log::Column::Username.like(like)); + } + if let Some(ok) = p.ok { selector = selector.filter(flow_run_log::Column::Ok.eq(ok)); } + let paginator = selector.order_by_desc(flow_run_log::Column::Id).paginate(db, page_size); + let total = paginator.num_items().await? as u64; + let models = paginator.fetch_page(if page>0 { page-1 } else { 0 }).await?; + Ok(PageResp { items: models.into_iter().map(Into::into).collect(), total, page, page_size }) +} \ No newline at end of file diff --git a/backend/src/services/flow_service.rs b/backend/src/services/flow_service.rs new file mode 100644 index 0000000..165551a --- /dev/null +++ b/backend/src/services/flow_service.rs @@ -0,0 +1,438 @@ +// removed unused: use std::collections::HashMap; +// removed unused: use std::sync::Mutex; +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::flow::{self, dsl::FlowDSL, engine::FlowEngine, context::{DriveOptions, ExecutionMode}}; +use crate::db::Db; +use crate::models::flow as db_flow; +use crate::models::request_log; // 新增:查询最近修改人 +use crate::services::flow_run_log_service; +use crate::services::flow_run_log_service::CreateRunLogInput; +use sea_orm::{EntityTrait, ActiveModelTrait, Set, DbErr, ColumnTrait, QueryFilter, PaginatorTrait, QueryOrder}; +use sea_orm::entity::prelude::DateTimeWithTimeZone; // 新增:时间类型 +use chrono::{Utc, FixedOffset}; +use tracing::{info, error}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowSummary { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub remark: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[serde(skip_serializing_if = "Option::is_none")] pub last_modified_by: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowDoc { pub id: String, pub yaml: String, #[serde(skip_serializing_if = "Option::is_none")] pub design_json: Option } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowCreateReq { pub yaml: Option, pub name: Option, pub design_json: Option, pub code: Option, pub remark: Option } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowUpdateReq { pub yaml: Option, pub design_json: Option, pub name: Option, pub code: Option, pub remark: Option } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunReq { #[serde(default)] pub input: serde_json::Value } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunResult { pub ok: bool, pub ctx: serde_json::Value, pub logs: Vec } + +#[derive(Clone, Debug, serde::Serialize)] +pub struct PageResp { pub items: Vec, pub total: u64, pub page: u64, pub page_size: u64 } + +// list flows from database with pagination & keyword +pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option) -> anyhow::Result> { + let mut selector = db_flow::Entity::find(); + if let Some(k) = keyword.filter(|s| !s.is_empty()) { + let like = format!("%{}%", k); + selector = selector.filter( + db_flow::Column::Name.like(like.clone()) + .or(db_flow::Column::Id.like(like)) + ); + } + let paginator = selector.order_by_desc(db_flow::Column::CreatedAt).paginate(db, page_size); + let total = paginator.num_items().await? as u64; + let models = paginator.fetch_page(if page > 0 { page - 1 } else { 0 }).await?; + let mut items: Vec = Vec::with_capacity(models.len()); + for row in models.into_iter() { + let id = row.id.clone(); + let name = row + .name + .clone() + .or_else(|| row.yaml.as_deref().and_then(extract_name)) + .unwrap_or_else(|| { + let prefix: String = id.chars().take(8).collect(); + format!("flow_{}", prefix) + }); + // 最近修改人:从请求日志中查找最近一次对该flow的PUT请求 + let last_modified_by = request_log::Entity::find() + .filter(request_log::Column::Path.like(format!("/api/flows/{}%", id))) + .filter(request_log::Column::Method.eq("PUT")) + .order_by_desc(request_log::Column::RequestTime) + .one(db) + .await? + .and_then(|m| m.username); + items.push(FlowSummary { + id, + name, + code: row.code.clone(), + remark: row.remark.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + last_modified_by, + }); + } + Ok(PageResp { items, total, page, page_size }) +} + +// create new flow with yaml or just name +pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result { + info!(target: "udmin", "flow.create: start"); + if let Some(yaml) = &req.yaml { + let _parsed: FlowDSL = serde_yaml::from_str(yaml).context("invalid flow yaml")?; + info!(target: "udmin", "flow.create: yaml parsed ok"); + } + let id = uuid::Uuid::new_v4().to_string(); + let name = req + .name + .clone() + .or_else(|| req.yaml.as_deref().and_then(extract_name)); + let now = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()); + let design_json_str = match &req.design_json { Some(v) => serde_json::to_string(v).ok(), None => None }; + let am = db_flow::ActiveModel { + id: Set(id.clone()), + name: Set(name), + yaml: Set(req.yaml.clone()), + design_json: Set(design_json_str), + // 新增: code 与 remark 入库 + code: Set(req.code.clone()), + remark: Set(req.remark.clone()), + created_at: Set(now), + updated_at: Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap())), + ..Default::default() + }; + info!(target: "udmin", "flow.create: inserting into db id={}", id); + // Use exec() instead of insert() returning Model to avoid RecordNotInserted on non-AI PK + match db_flow::Entity::insert(am).exec(db).await { + Ok(_) => { + info!(target: "udmin", "flow.create: insert ok id={}", id); + Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json }) + } + Err(DbErr::RecordNotInserted) => { + // Workaround for MySQL + non-auto-increment PK: verify by reading back + error!(target: "udmin", "flow.create: insert returned RecordNotInserted, verifying by select id={}", id); + match db_flow::Entity::find_by_id(id.clone()).one(db).await { + Ok(Some(_)) => { + info!(target: "udmin", "flow.create: found inserted row by id={}, treating as success", id); + Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json }) + } + Ok(None) => Err(anyhow::anyhow!("insert flow failed").context("verify inserted row not found")), + Err(e) => Err(anyhow::Error::new(e).context("insert flow failed")), + } + } + Err(e) => { + error!(target: "udmin", error = ?e, "flow.create: insert failed"); + Err(anyhow::Error::new(e).context("insert flow failed")) + } + } +} + +pub async fn get(db: &Db, id: &str) -> anyhow::Result { + let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?; + let row = row.ok_or_else(|| anyhow::anyhow!("not found"))?; + let yaml = row.yaml.unwrap_or_default(); + let design_json = row.design_json.and_then(|s| serde_json::from_str::(&s).ok()); + Ok(FlowDoc { id: row.id, yaml, design_json }) +} + +pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result { + if let Some(yaml) = &req.yaml { + let _parsed: FlowDSL = serde_yaml::from_str(yaml).context("invalid flow yaml")?; + } + let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?; + let Some(row) = row else { return Err(anyhow::anyhow!("not found")); }; + let mut am: db_flow::ActiveModel = row.into(); + + if let Some(yaml) = req.yaml { + let next_name = req + .name + .or_else(|| extract_name(&yaml)); + if let Some(n) = next_name { am.name = Set(Some(n)); } + am.yaml = Set(Some(yaml.clone())); + } else if let Some(n) = req.name { am.name = Set(Some(n)); } + + if let Some(dj) = req.design_json { + let s = serde_json::to_string(&dj)?; + am.design_json = Set(Some(s)); + } + if let Some(c) = req.code { am.code = Set(Some(c)); } + if let Some(r) = req.remark { am.remark = Set(Some(r)); } + + // update timestamp + am.updated_at = Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap())); + + am.update(db).await?; + // return latest yaml + let got = db_flow::Entity::find_by_id(id.to_string()).one(db).await?.unwrap(); + let dj = got.design_json.as_deref().and_then(|s| serde_json::from_str::(&s).ok()); + Ok(FlowDoc { id: id.to_string(), yaml: got.yaml.unwrap_or_default(), design_json: dj }) +} + +pub async fn delete(db: &Db, id: &str) -> anyhow::Result<()> { + let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?; + let Some(row) = row else { return Err(anyhow::anyhow!("not found")); }; + let am: db_flow::ActiveModel = row.into(); + am.delete(db).await?; + Ok(()) +} + +pub async fn run(db: &Db, id: &str, req: RunReq, operator: Option<(i64, String)>) -> anyhow::Result { + info!(target = "udmin", "flow.run: start id={}", id); + let start = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()); + // 获取流程编码,便于写入运行日志 + let flow_code: Option = match db_flow::Entity::find_by_id(id.to_string()).one(db).await { + Ok(Some(row)) => row.code, + _ => None, + }; + // 获取流程文档并记录失败原因 + let doc = match get(db, id).await { + Ok(d) => d, + Err(e) => { + error!(target = "udmin", error = ?e, "flow.run: get doc failed id={}", id); + // 记录失败日志 + let (user_id, username) = operator.map(|(u, n)| (Some(u), Some(n))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some(format!("get doc failed: {}", e)), + user_id, + username, + started_at: start, + duration_ms: 0, + }).await; + return Err(e); + } + }; + + // 记录文档基本信息,便于判断走 JSON 还是 YAML + info!(target = "udmin", "flow.run: doc loaded id={} has_design_json={} yaml_len={}", id, doc.design_json.is_some(), doc.yaml.len()); + + // Prefer design_json if present; otherwise fall back to YAML + let mut exec_mode: ExecutionMode = ExecutionMode::Sync; + let (mut chain, mut ctx) = if let Some(design) = &doc.design_json { + info!(target = "udmin", "flow.run: building chain from design_json id={}", id); + let chain_from_json = match flow::dsl::chain_from_design_json(design) { + Ok(c) => c, + Err(e) => { + error!(target = "udmin", error = ?e, "flow.run: build chain from design_json failed id={}", id); + // 记录失败日志 + let (user_id, username) = operator.as_ref().map(|(u, n)| (Some(*u), Some(n.clone()))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some(format!("build chain from design_json failed: {}", e)), + user_id, + username, + started_at: start, + duration_ms: 0, + }).await; + return Err(e); + } + }; + let mut ctx = req.input.clone(); + // Merge node-scoped configs into ctx under ctx.nodes + let supplement = flow::dsl::ctx_from_design_json(design); + merge_json(&mut ctx, &supplement); + // 解析 executionMode / execution_mode + let mode_str = design.get("executionMode").and_then(|v| v.as_str()) + .or_else(|| design.get("execution_mode").and_then(|v| v.as_str())) + .unwrap_or("sync"); + exec_mode = parse_execution_mode(mode_str); + info!(target = "udmin", "flow.run: ctx prepared from design_json id={} execution_mode={:?}", id, exec_mode); + (chain_from_json, ctx) + } else { + info!(target = "udmin", "flow.run: parsing YAML id={}", id); + let dsl = match serde_yaml::from_str::(&doc.yaml) { + Ok(d) => d, + Err(e) => { + error!(target = "udmin", error = ?e, "flow.run: parse YAML failed id={}", id); + // 记录失败日志 + let (user_id, username) = operator.as_ref().map(|(u, n)| (Some(*u), Some(n.clone()))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some(format!("parse YAML failed: {}", e)), + user_id, + username, + started_at: start, + duration_ms: 0, + }).await; + return Err(anyhow::Error::new(e).context("invalid flow yaml")); + } + }; + // 从 YAML 读取执行模式 + if let Some(m) = dsl.execution_mode.as_deref() { exec_mode = parse_execution_mode(m); } + (dsl.into(), req.input.clone()) + }; + + // 若 design_json 解析出的 chain 为空,兜底回退到 YAML + if chain.nodes.is_empty() { + info!(target = "udmin", "flow.run: empty chain from design_json, fallback to YAML id={}", id); + if !doc.yaml.trim().is_empty() { + match serde_yaml::from_str::(&doc.yaml) { + Ok(dsl) => { + chain = dsl.clone().into(); + // YAML 分支下 ctx = req.input,不再追加 design_json 的补充 + ctx = req.input.clone(); + if let Some(m) = dsl.execution_mode.as_deref() { exec_mode = parse_execution_mode(m); } + info!(target = "udmin", "flow.run: fallback YAML parsed id={} execution_mode={:?}", id, exec_mode); + } + Err(e) => { + error!(target = "udmin", error = ?e, "flow.run: fallback parse YAML failed id={}", id); + // 保留原空 chain,稍后 drive 会再次报错,但这里先返回更明确的错误 + let (user_id, username) = operator.as_ref().map(|(u, n)| (Some(*u), Some(n.clone()))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some(format!("fallback parse YAML failed: {}", e)), + user_id, + username, + started_at: start, + duration_ms: 0, + }).await; + return Err(anyhow::anyhow!("empty chain: design_json produced no nodes and YAML parse failed")); + } + } + } else { + // YAML 也为空 + let (user_id, username) = operator.as_ref().map(|(u, n)| (Some(*u), Some(n.clone()))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some("empty chain: both design_json and yaml are empty".to_string()), + user_id, + username, + started_at: start, + duration_ms: 0, + }).await; + return Err(anyhow::anyhow!("empty chain: both design_json and yaml are empty")); + } + } + + // 从全局注册中心获取任务(若未初始化则返回默认注册表) + let tasks: flow::task::TaskRegistry = flow::task::get_registry(); + let engine = FlowEngine::new(tasks); + + info!(target = "udmin", "flow.run: driving engine id={} nodes={} links={} execution_mode={:?}", id, chain.nodes.len(), chain.links.len(), exec_mode); + // 执行 + let drive_res = engine + .drive(&chain, ctx, DriveOptions { execution_mode: exec_mode.clone(), ..Default::default() }) + .await; + let (ctx, logs) = match drive_res { + Ok(r) => r, + Err(e) => { + error!(target = "udmin", error = ?e, "flow.run: engine drive failed id={}", id); + let dur = (Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()) - start).num_milliseconds() as i64; + let (user_id, username) = operator.as_ref().map(|(u, n)| (Some(*u), Some(n.clone()))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: None, + ok: false, + logs: Some(format!("engine drive failed: {}", e)), + user_id, + username, + started_at: start, + duration_ms: dur, + }).await; + return Err(e); + } + }; + + // 调试:打印处理后的 ctx + //info!(target = "udmin", "flow.run: result ctx={}", serde_json::to_string(&ctx).unwrap_or_else(|_| "".to_string())); + + info!(target = "udmin", "flow.run: done id={}", id); + // 写入成功日志 + let dur = (Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()) - start).num_milliseconds() as i64; + let (user_id, username) = operator.map(|(u, n)| (Some(u), Some(n))).unwrap_or((None, None)); + let _ = flow_run_log_service::create(db, CreateRunLogInput { + flow_id: id.to_string(), + flow_code: flow_code.clone(), + input: Some(serde_json::to_string(&req.input).unwrap_or_default()), + output: Some(serde_json::to_string(&ctx).unwrap_or_default()), + ok: true, + logs: Some(serde_json::to_string(&logs).unwrap_or_default()), + user_id, + username, + started_at: start, + duration_ms: dur, + }).await; + Ok(RunResult { ok: true, ctx, logs }) +} + +fn extract_name(yaml: &str) -> Option { + for line in yaml.lines() { + let lt = line.trim(); + if lt.starts_with("#") && lt.len() > 1 { return Some(lt.trim_start_matches('#').trim().to_string()); } + if lt.starts_with("name:") { + let name = lt.trim_start_matches("name:").trim(); + if !name.is_empty() { return Some(name.to_string()); } + } + } + None +} + +pub fn ae>(e: E) -> AppError { + let err: anyhow::Error = e.into(); + let mut full = err.to_string(); + for cause in err.chain().skip(1) { + full.push_str(" | "); + full.push_str(&cause.to_string()); + } + // MySQL duplicate key example: "Database error: Duplicate entry 'xxx' for key 'idx-unique-flows-code'" + // 也兼容包含唯一索引名/关键字的报错信息 + if full.contains("Duplicate entry") || full.contains("idx-unique-flows-code") || (full.contains("code") && full.contains("unique")) { + return AppError::Conflict("流程编码已存在".to_string()); + } + AppError::Anyhow(anyhow::anyhow!(full)) +} + +// shallow merge json objects: a <- b +fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) { + use serde_json::Value as V; + match (a, b) { + (V::Object(ao), V::Object(bo)) => { + for (k, v) in bo.iter() { + match ao.get_mut(k) { + Some(av) => merge_json(av, v), + None => { ao.insert(k.clone(), v.clone()); } + } + } + } + (a_slot, b_val) => { *a_slot = b_val.clone(); } + } +} + +// parse execution mode string +fn parse_execution_mode(s: &str) -> ExecutionMode { + match s.to_ascii_lowercase().as_str() { + "async" | "async_fire_and_forget" | "fire_and_forget" => ExecutionMode::AsyncFireAndForget, + _ => ExecutionMode::Sync, + } +} \ No newline at end of file diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 6bf0d9b..107a698 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -5,4 +5,6 @@ pub mod menu_service; pub mod department_service; pub mod log_service; // 新增岗位服务 -pub mod position_service; \ No newline at end of file +pub mod position_service; +pub mod flow_service; +pub mod flow_run_log_service; \ No newline at end of file diff --git a/backend/udmin_ai.db b/backend/udmin_ai.db new file mode 100644 index 0000000000000000000000000000000000000000..073abbc509f19e4080a412d9aee022417c92a1ac GIT binary patch literal 143360 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 literal 0 HcmV?d00001 diff --git a/cookies_admin.txt b/cookies_admin.txt index f64a6e2..c31d989 100644 --- a/cookies_admin.txt +++ b/cookies_admin.txt @@ -2,4 +2,3 @@ # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. -#HttpOnly_localhost FALSE / FALSE 1756997825 refresh_token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MSwiaXNzIjoidWRtaW4iLCJleHAiOjE3NTY5OTc4MjUsInR5cCI6InJlZnJlc2gifQ.2w3R0i3ShF-ypbya6UN5ZZ19kBNv1hf_whP-RXGn3cc diff --git a/frontend/flow-fixed-layout-demo.md b/frontend/flow-fixed-layout-demo.md index e69de29..6cb0c00 100644 --- a/frontend/flow-fixed-layout-demo.md +++ b/frontend/flow-fixed-layout-demo.md @@ -0,0 +1,346 @@ +# 创建自由布局画布 + +本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见: + + + +文件结构: + +``` +- hooks + - use-editor-props.ts # 画布配置 +- components + - base-node.tsx # 节点渲染 + - tools.tsx # 画布工具栏 +- initial-data.ts # 初始化数据 +- node-registries.ts # 节点配置 +- app.tsx # 画布入口 +``` + +### 1. 画布入口 + +* `FreeLayoutEditorProvider`: 画布配置器, 内部会生成 react-context 供子组件消费 +* `EditorRenderer`: 为最终渲染的画布,可以包装在其他组件下边方便定制画布位置 + +```tsx pure title="app.tsx" + +import { + FreeLayoutEditorProvider, + EditorRenderer, +} from '@flowgram.ai/free-layout-editor'; + +import '@flowgram.ai/free-layout-editor/index.css'; // 加载样式 + +import { useEditorProps } from './use-editor-props' // 画布详细的 props 配置 +import { Tools } from './components/tools' // 画布工具 + +function App() { + const editorProps = useEditorProps() + return ( + + + + + ); +} +``` + +### 2. 配置画布 + +画布配置采用声明式,提供 数据、渲染、事件、插件相关配置 + +```tsx pure title="hooks/use-editor-props.tsx" +import { useMemo } from 'react'; +import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor'; +import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin'; + +import { initialData } from './initial-data' // 初始化数据 +import { nodeRegistries } from './node-registries' // 节点声明配置 +import { BaseNode } from './components/base-node' // 节点渲染 + +export function useEditorProps( +): FreeLayoutProps { + return useMemo( + () => ({ + /** + * 初始化数据 + */ + initialData, + /** + * 画布节点定义 + */ + nodeRegistries, + /** + * 物料 + */ + materials: { + renderDefaultNode: BaseNode, // 节点渲染组件 + }, + /** + * 节点引擎, 用于渲染节点表单 + */ + nodeEngine: { + enable: true, + }, + /** + * 画布历史记录, 用于控制 redo/undo + */ + history: { + enable: true, + enableChangeNode: true, // 用于监听节点表单数据变化 + }, + /** + * 画布初始化回调 + */ + onInit: ctx => { + // 如果要动态加载数据,可以通过如下方法异步执行 + // ctx.docuemnt.fromJSON(initialData) + }, + /** + * 画布第一次渲染完整回调 + */ + onAllLayersRendered: (ctx) => {}, + /** + * 画布销毁回调 + */ + onDispose: () => { }, + plugins: () => [ + /** + * 缩略图插件 + */ + createMinimapPlugin({}), + ], + }), + [], + ); +} + +``` + +### 3. 配置数据 + +画布文档数据采用树形结构,支持嵌套 + +:::note 文档数据基本结构: + +* nodes `array` 节点列表, 支持嵌套 +* edges `array` 边列表 + +::: + +:::note 节点数据基本结构: + +* id: `string` 节点唯一标识, 必须保证唯一 +* meta: `object` 节点的 ui 配置信息,如自由布局的 `position` 信息放这里 +* type: `string | number` 节点类型,会和 `nodeRegistries` 中的 `type` 对应 +* data: `object` 节点表单数据, 业务可自定义 +* blocks: `array` 节点的分支, 采用 `block` 更贴近 `Gramming`, 目前会存子画布的节点 +* edges: `array` 子画布的边数据 + +::: + +:::note 边数据基本结构: + +* sourceNodeID: `string` 开始节点 id +* targetNodeID: `string` 目标节点 id +* sourcePortID?: `string | number` 开始端口 id, 缺省则采用开始节点的默认端口 +* targetPortID?: `string | number` 目标端口 id, 缺省则采用目标节点的默认端口 + +::: + +```tsx pure title="initial-data.ts" +import { WorkflowJSON } from '@flowgram.ai/free-layout-editor'; + +export const initialData: WorkflowJSON = { + nodes: [ + { + id: 'start_0', + type: 'start', + meta: { + position: { x: 0, y: 0 }, + }, + data: { + title: 'Start', + content: 'Start content' + }, + }, + { + id: 'node_0', + type: 'custom', + meta: { + position: { x: 400, y: 0 }, + }, + data: { + title: 'Custom', + content: 'Custom node content' + }, + }, + { + id: 'end_0', + type: 'end', + meta: { + position: { x: 800, y: 0 }, + }, + data: { + title: 'End', + content: 'End content' + }, + }, + ], + edges: [ + { + sourceNodeID: 'start_0', + targetNodeID: 'node_0', + }, + { + sourceNodeID: 'node_0', + targetNodeID: 'end_0', + }, + ], +}; + + +``` + +### 4. 声明节点 + +声明节点可以用于确定节点的类型及渲染方式 + +```tsx pure title="node-registries.tsx" +import { WorkflowNodeRegistry, ValidateTrigger } from '@flowgram.ai/free-layout-editor'; + +/** + * You can customize your own node registry + * 你可以自定义节点的注册器 + */ +export const nodeRegistries: WorkflowNodeRegistry[] = [ + { + type: 'start', + meta: { + isStart: true, // 标记为开始节点 + deleteDisable: true, // 开始节点不能删除 + copyDisable: true, // 开始节点不能复制 + defaultPorts: [{ type: 'output' }], // 用于定义节点的输入和输出端口, 开始节点只有输出端口 + // useDynamicPort: true, // 用于动态端口,会寻找 data-port-id 和 data-port-type 属性的 dom 作为端口 + }, + /** + * 配置节点表单的校验及渲染, + * 注:validate 采用数据和渲染分离,保证节点即使不渲染也能对数据做校验 + */ + formMeta: { + validateTrigger: ValidateTrigger.onChange, + validate: { + title: ({ value }) => (value ? undefined : 'Title is required'), + }, + /** + * Render form + */ + render: () => ( + <> + + {({ field }) =>
{field.value}
} +
+ + {({ field }) => } + + + ) + }, + }, + { + type: 'end', + meta: { + deleteDisable: true, + copyDisable: true, + defaultPorts: [{ type: 'input' }], + }, + formMeta: { + // ... + } + }, + { + type: 'custom', + meta: { + }, + formMeta: { + // ... + }, + defaultPorts: [{ type: 'output' }, { type: 'input' }], // 普通节点有两个端口 + }, +]; + +``` + +### 5. 渲染节点 + +渲染节点用于添加样式、事件及表单渲染的位置 + +```tsx pure title="components/base-node.tsx" +import { useNodeRender, WorkflowNodeRenderer } from '@flowgram.ai/free-layout-editor'; + +export const BaseNode = () => { + /** + * 提供节点渲染相关的方法 + */ + const { form } = useNodeRender() + /** + * WorkflowNodeRenderer 会添加节点拖拽事件及 端口渲染,如果要深度定制,可以看该组件源代码: + * https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx + */ + return ( + + { + // 表单渲染通过 formMeta 生成 + form?.render() + } + + ) +}; + +``` + +### 6. 添加工具 + +工具主要用于控制画布缩放等操作, 工具汇总在 `usePlaygroundTools` 中, 而 `useClientContext` 用于获取画布的上下文, 里边包含画布的核心模块如 `history` + +```tsx pure title="components/tools.tsx" +import { useEffect, useState } from 'react' +import { usePlaygroundTools, useClientContext } from '@flowgram.ai/free-layout-editor'; + +export function Tools() { + const { history } = useClientContext(); + const tools = usePlaygroundTools(); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + useEffect(() => { + const disposable = history.undoRedoService.onChange(() => { + setCanUndo(history.canUndo()); + setCanRedo(history.canRedo()); + }); + return () => disposable.dispose(); + }, [history]); + + return
+ + + + + + + {Math.floor(tools.zoom * 100)}% +
+} +``` + +### 7. 效果 + +import { FreeLayoutSimple } from '../../../../components'; + +
+ +
diff --git a/frontend/flow-free-layout-demo.md b/frontend/flow-free-layout-demo.md index e69de29..6cb0c00 100644 --- a/frontend/flow-free-layout-demo.md +++ b/frontend/flow-free-layout-demo.md @@ -0,0 +1,346 @@ +# 创建自由布局画布 + +本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见: + + + +文件结构: + +``` +- hooks + - use-editor-props.ts # 画布配置 +- components + - base-node.tsx # 节点渲染 + - tools.tsx # 画布工具栏 +- initial-data.ts # 初始化数据 +- node-registries.ts # 节点配置 +- app.tsx # 画布入口 +``` + +### 1. 画布入口 + +* `FreeLayoutEditorProvider`: 画布配置器, 内部会生成 react-context 供子组件消费 +* `EditorRenderer`: 为最终渲染的画布,可以包装在其他组件下边方便定制画布位置 + +```tsx pure title="app.tsx" + +import { + FreeLayoutEditorProvider, + EditorRenderer, +} from '@flowgram.ai/free-layout-editor'; + +import '@flowgram.ai/free-layout-editor/index.css'; // 加载样式 + +import { useEditorProps } from './use-editor-props' // 画布详细的 props 配置 +import { Tools } from './components/tools' // 画布工具 + +function App() { + const editorProps = useEditorProps() + return ( + + + + + ); +} +``` + +### 2. 配置画布 + +画布配置采用声明式,提供 数据、渲染、事件、插件相关配置 + +```tsx pure title="hooks/use-editor-props.tsx" +import { useMemo } from 'react'; +import { type FreeLayoutProps } from '@flowgram.ai/free-layout-editor'; +import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin'; + +import { initialData } from './initial-data' // 初始化数据 +import { nodeRegistries } from './node-registries' // 节点声明配置 +import { BaseNode } from './components/base-node' // 节点渲染 + +export function useEditorProps( +): FreeLayoutProps { + return useMemo( + () => ({ + /** + * 初始化数据 + */ + initialData, + /** + * 画布节点定义 + */ + nodeRegistries, + /** + * 物料 + */ + materials: { + renderDefaultNode: BaseNode, // 节点渲染组件 + }, + /** + * 节点引擎, 用于渲染节点表单 + */ + nodeEngine: { + enable: true, + }, + /** + * 画布历史记录, 用于控制 redo/undo + */ + history: { + enable: true, + enableChangeNode: true, // 用于监听节点表单数据变化 + }, + /** + * 画布初始化回调 + */ + onInit: ctx => { + // 如果要动态加载数据,可以通过如下方法异步执行 + // ctx.docuemnt.fromJSON(initialData) + }, + /** + * 画布第一次渲染完整回调 + */ + onAllLayersRendered: (ctx) => {}, + /** + * 画布销毁回调 + */ + onDispose: () => { }, + plugins: () => [ + /** + * 缩略图插件 + */ + createMinimapPlugin({}), + ], + }), + [], + ); +} + +``` + +### 3. 配置数据 + +画布文档数据采用树形结构,支持嵌套 + +:::note 文档数据基本结构: + +* nodes `array` 节点列表, 支持嵌套 +* edges `array` 边列表 + +::: + +:::note 节点数据基本结构: + +* id: `string` 节点唯一标识, 必须保证唯一 +* meta: `object` 节点的 ui 配置信息,如自由布局的 `position` 信息放这里 +* type: `string | number` 节点类型,会和 `nodeRegistries` 中的 `type` 对应 +* data: `object` 节点表单数据, 业务可自定义 +* blocks: `array` 节点的分支, 采用 `block` 更贴近 `Gramming`, 目前会存子画布的节点 +* edges: `array` 子画布的边数据 + +::: + +:::note 边数据基本结构: + +* sourceNodeID: `string` 开始节点 id +* targetNodeID: `string` 目标节点 id +* sourcePortID?: `string | number` 开始端口 id, 缺省则采用开始节点的默认端口 +* targetPortID?: `string | number` 目标端口 id, 缺省则采用目标节点的默认端口 + +::: + +```tsx pure title="initial-data.ts" +import { WorkflowJSON } from '@flowgram.ai/free-layout-editor'; + +export const initialData: WorkflowJSON = { + nodes: [ + { + id: 'start_0', + type: 'start', + meta: { + position: { x: 0, y: 0 }, + }, + data: { + title: 'Start', + content: 'Start content' + }, + }, + { + id: 'node_0', + type: 'custom', + meta: { + position: { x: 400, y: 0 }, + }, + data: { + title: 'Custom', + content: 'Custom node content' + }, + }, + { + id: 'end_0', + type: 'end', + meta: { + position: { x: 800, y: 0 }, + }, + data: { + title: 'End', + content: 'End content' + }, + }, + ], + edges: [ + { + sourceNodeID: 'start_0', + targetNodeID: 'node_0', + }, + { + sourceNodeID: 'node_0', + targetNodeID: 'end_0', + }, + ], +}; + + +``` + +### 4. 声明节点 + +声明节点可以用于确定节点的类型及渲染方式 + +```tsx pure title="node-registries.tsx" +import { WorkflowNodeRegistry, ValidateTrigger } from '@flowgram.ai/free-layout-editor'; + +/** + * You can customize your own node registry + * 你可以自定义节点的注册器 + */ +export const nodeRegistries: WorkflowNodeRegistry[] = [ + { + type: 'start', + meta: { + isStart: true, // 标记为开始节点 + deleteDisable: true, // 开始节点不能删除 + copyDisable: true, // 开始节点不能复制 + defaultPorts: [{ type: 'output' }], // 用于定义节点的输入和输出端口, 开始节点只有输出端口 + // useDynamicPort: true, // 用于动态端口,会寻找 data-port-id 和 data-port-type 属性的 dom 作为端口 + }, + /** + * 配置节点表单的校验及渲染, + * 注:validate 采用数据和渲染分离,保证节点即使不渲染也能对数据做校验 + */ + formMeta: { + validateTrigger: ValidateTrigger.onChange, + validate: { + title: ({ value }) => (value ? undefined : 'Title is required'), + }, + /** + * Render form + */ + render: () => ( + <> + + {({ field }) =>
{field.value}
} +
+ + {({ field }) => } + + + ) + }, + }, + { + type: 'end', + meta: { + deleteDisable: true, + copyDisable: true, + defaultPorts: [{ type: 'input' }], + }, + formMeta: { + // ... + } + }, + { + type: 'custom', + meta: { + }, + formMeta: { + // ... + }, + defaultPorts: [{ type: 'output' }, { type: 'input' }], // 普通节点有两个端口 + }, +]; + +``` + +### 5. 渲染节点 + +渲染节点用于添加样式、事件及表单渲染的位置 + +```tsx pure title="components/base-node.tsx" +import { useNodeRender, WorkflowNodeRenderer } from '@flowgram.ai/free-layout-editor'; + +export const BaseNode = () => { + /** + * 提供节点渲染相关的方法 + */ + const { form } = useNodeRender() + /** + * WorkflowNodeRenderer 会添加节点拖拽事件及 端口渲染,如果要深度定制,可以看该组件源代码: + * https://github.com/bytedance/flowgram.ai/blob/main/packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx + */ + return ( + + { + // 表单渲染通过 formMeta 生成 + form?.render() + } + + ) +}; + +``` + +### 6. 添加工具 + +工具主要用于控制画布缩放等操作, 工具汇总在 `usePlaygroundTools` 中, 而 `useClientContext` 用于获取画布的上下文, 里边包含画布的核心模块如 `history` + +```tsx pure title="components/tools.tsx" +import { useEffect, useState } from 'react' +import { usePlaygroundTools, useClientContext } from '@flowgram.ai/free-layout-editor'; + +export function Tools() { + const { history } = useClientContext(); + const tools = usePlaygroundTools(); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + useEffect(() => { + const disposable = history.undoRedoService.onChange(() => { + setCanUndo(history.canUndo()); + setCanRedo(history.canRedo()); + }); + return () => disposable.dispose(); + }, [history]); + + return
+ + + + + + + {Math.floor(tools.zoom * 100)}% +
+} +``` + +### 7. 效果 + +import { FreeLayoutSimple } from '../../../../components'; + +
+ +
diff --git a/frontend/flow-free-layout-json.md b/frontend/flow-free-layout-json.md new file mode 100644 index 0000000..447ac55 --- /dev/null +++ b/frontend/flow-free-layout-json.md @@ -0,0 +1,635 @@ +{ + "nodes": [ + { + "id": "start_0", + "type": "start", + "meta": { + "position": { + "x": 180, + "y": 573.7 + } + }, + "data": { + "title": "Start", + "outputs": { + "type": "object", + "properties": { + "query": { + "type": "string", + "default": "Hello Flow." + }, + "enable": { + "type": "boolean", + "default": true + }, + "array_obj": { + "type": "array", + "items": { + "type": "object", + "properties": { + "int": { + "type": "number" + }, + "str": { + "type": "string" + } + } + } + } + } + } + } + }, + { + "id": "condition_0", + "type": "condition", + "meta": { + "position": { + "x": 1100, + "y": 510.20000000000005 + } + }, + "data": { + "title": "Condition", + "conditions": [ + { + "key": "if_0", + "value": { + "left": { + "type": "ref", + "content": [ + "start_0", + "query" + ] + }, + "operator": "contains", + "right": { + "type": "constant", + "content": "Hello Flow." + } + } + }, + { + "key": "if_f0rOAt", + "value": { + "left": { + "type": "ref", + "content": [ + "start_0", + "enable" + ] + }, + "operator": "is_true" + } + } + ] + } + }, + { + "id": "end_0", + "type": "end", + "meta": { + "position": { + "x": 3008, + "y": 573.7 + } + }, + "data": { + "title": "End", + "inputsValues": { + "success": { + "type": "constant", + "content": true, + "schema": { + "type": "boolean" + } + }, + "query": { + "type": "ref", + "content": [ + "start_0", + "query" + ] + } + }, + "inputs": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "query": { + "type": "string" + } + } + } + } + }, + { + "id": "159623", + "type": "comment", + "meta": { + "position": { + "x": 180, + "y": 756.7 + } + }, + "data": { + "size": { + "width": 240, + "height": 150 + }, + "note": "hi ~\n\nthis is a comment node\n\n- flowgram.ai" + } + }, + { + "id": "http_rDGIH", + "type": "http", + "meta": { + "position": { + "x": 640, + "y": 447.35 + } + }, + "data": { + "title": "HTTP_1", + "outputs": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "statusCode": { + "type": "integer" + } + } + }, + "api": { + "method": "GET", + "url": { + "type": "template", + "content": "" + } + }, + "body": { + "bodyType": "JSON" + }, + "timeout": { + "timeout": 10000, + "retryTimes": 1 + } + } + }, + { + "id": "loop_Ycnsk", + "type": "loop", + "meta": { + "position": { + "x": 1480, + "y": 90.00000000000003 + } + }, + "data": { + "title": "Loop_1", + "loopFor": { + "type": "ref", + "content": [ + "start_0", + "array_obj" + ] + }, + "loopOutputs": { + "acm": { + "type": "ref", + "content": [ + "llm_6aSyo", + "result" + ] + } + } + }, + "blocks": [ + { + "id": "llm_6aSyo", + "type": "llm", + "meta": { + "position": { + "x": 344, + "y": 0 + } + }, + "data": { + "title": "LLM_3", + "inputsValues": { + "modelName": { + "type": "constant", + "content": "gpt-3.5-turbo" + }, + "apiKey": { + "type": "constant", + "content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "apiHost": { + "type": "constant", + "content": "https://mock-ai-url/api/v3" + }, + "temperature": { + "type": "constant", + "content": 0.5 + }, + "systemPrompt": { + "type": "template", + "content": "# Role\nYou are an AI assistant.\n" + }, + "prompt": { + "type": "template", + "content": "" + } + }, + "inputs": { + "type": "object", + "required": [ + "modelName", + "apiKey", + "apiHost", + "temperature", + "prompt" + ], + "properties": { + "modelName": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "apiHost": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "systemPrompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + }, + "prompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + } + } + }, + "outputs": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + }, + { + "id": "llm_ZqKlP", + "type": "llm", + "meta": { + "position": { + "x": 804, + "y": 0 + } + }, + "data": { + "title": "LLM_4", + "inputsValues": { + "modelName": { + "type": "constant", + "content": "gpt-3.5-turbo" + }, + "apiKey": { + "type": "constant", + "content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "apiHost": { + "type": "constant", + "content": "https://mock-ai-url/api/v3" + }, + "temperature": { + "type": "constant", + "content": 0.5 + }, + "systemPrompt": { + "type": "template", + "content": "# Role\nYou are an AI assistant.\n" + }, + "prompt": { + "type": "template", + "content": "" + } + }, + "inputs": { + "type": "object", + "required": [ + "modelName", + "apiKey", + "apiHost", + "temperature", + "prompt" + ], + "properties": { + "modelName": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "apiHost": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "systemPrompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + }, + "prompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + } + } + }, + "outputs": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + }, + { + "id": "block_start_PUDtS", + "type": "block-start", + "meta": { + "position": { + "x": 32, + "y": 163.1 + } + }, + "data": {} + }, + { + "id": "block_end_leBbs", + "type": "block-end", + "meta": { + "position": { + "x": 1116, + "y": 163.1 + } + }, + "data": {} + } + ], + "edges": [ + { + "sourceNodeID": "block_start_PUDtS", + "targetNodeID": "llm_6aSyo" + }, + { + "sourceNodeID": "llm_6aSyo", + "targetNodeID": "llm_ZqKlP" + }, + { + "sourceNodeID": "llm_ZqKlP", + "targetNodeID": "block_end_leBbs" + } + ] + }, + { + "id": "group_nYl6D", + "type": "group", + "meta": { + "position": { + "x": 1644, + "y": 730.2 + } + }, + "data": { + "parentID": "root", + "blockIDs": [ + "llm_8--A3", + "llm_vTyMa" + ] + } + }, + { + "id": "llm_8--A3", + "type": "llm", + "meta": { + "position": { + "x": 180, + "y": 0 + } + }, + "data": { + "title": "LLM_1", + "inputsValues": { + "modelName": { + "type": "constant", + "content": "gpt-3.5-turbo" + }, + "apiKey": { + "type": "constant", + "content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "apiHost": { + "type": "constant", + "content": "https://mock-ai-url/api/v3" + }, + "temperature": { + "type": "constant", + "content": 0.5 + }, + "systemPrompt": { + "type": "template", + "content": "# Role\nYou are an AI assistant.\n" + }, + "prompt": { + "type": "template", + "content": "# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}" + } + }, + "inputs": { + "type": "object", + "required": [ + "modelName", + "apiKey", + "apiHost", + "temperature", + "prompt" + ], + "properties": { + "modelName": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "apiHost": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "systemPrompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + }, + "prompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + } + } + }, + "outputs": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + }, + { + "id": "llm_vTyMa", + "type": "llm", + "meta": { + "position": { + "x": 640, + "y": 10 + } + }, + "data": { + "title": "LLM_2", + "inputsValues": { + "modelName": { + "type": "constant", + "content": "gpt-3.5-turbo" + }, + "apiKey": { + "type": "constant", + "content": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "apiHost": { + "type": "constant", + "content": "https://mock-ai-url/api/v3" + }, + "temperature": { + "type": "constant", + "content": 0.5 + }, + "systemPrompt": { + "type": "template", + "content": "# Role\nYou are an AI assistant.\n" + }, + "prompt": { + "type": "template", + "content": "# LLM Input\nresult:{{llm_8--A3.result}}" + } + }, + "inputs": { + "type": "object", + "required": [ + "modelName", + "apiKey", + "apiHost", + "temperature", + "prompt" + ], + "properties": { + "modelName": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "apiHost": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "systemPrompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + }, + "prompt": { + "type": "string", + "extra": { + "formComponent": "prompt-editor" + } + } + } + }, + "outputs": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + } + ], + "edges": [ + { + "sourceNodeID": "start_0", + "targetNodeID": "http_rDGIH" + }, + { + "sourceNodeID": "http_rDGIH", + "targetNodeID": "condition_0" + }, + { + "sourceNodeID": "condition_0", + "targetNodeID": "llm_8--A3", + "sourcePortID": "if_f0rOAt" + }, + { + "sourceNodeID": "condition_0", + "targetNodeID": "loop_Ycnsk", + "sourcePortID": "if_0" + }, + { + "sourceNodeID": "llm_vTyMa", + "targetNodeID": "end_0" + }, + { + "sourceNodeID": "loop_Ycnsk", + "targetNodeID": "end_0" + }, + { + "sourceNodeID": "llm_8--A3", + "targetNodeID": "llm_vTyMa" + } + ] +} \ No newline at end of file diff --git a/frontend/flow-free-layout-sj-demo.md b/frontend/flow-free-layout-sj-demo.md new file mode 100644 index 0000000..e1f7c9b --- /dev/null +++ b/frontend/flow-free-layout-sj-demo.md @@ -0,0 +1,412 @@ +# 最佳实践 + +import { FreeFeatureOverview } from '../../../../components'; + + + +## 安装 + +```shell +npx @flowgram.ai/create-app@latest free-layout +``` + +## 源码 + +https://github.com/bytedance/flowgram.ai/tree/main/apps/demo-free-layout + +## 项目概览 + +### 核心技术栈 + +* **前端框架**: React 18 + TypeScript +* **构建工具**: Rsbuild (基于 Rspack 的现代构建工具) +* **样式方案**: Less + Styled Components + CSS Variables +* **UI 组件库**: Semi Design (@douyinfe/semi-ui) +* **状态管理**: 基于 Flowgram 自研的编辑器框架 +* **依赖注入**: Inversify + +### 核心依赖包 + +* **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖 +* **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件 +* **@flowgram.ai/free-lines-plugin**: 连线渲染插件 +* **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件 +* **@flowgram.ai/minimap-plugin**: 缩略图插件 +* **@flowgram.ai/free-container-plugin**: 子画布插件 +* **@flowgram.ai/free-group-plugin**: 分组插件 +* **@flowgram.ai/form-materials**: 表单物料 +* **@flowgram.ai/runtime-interface**: 运行时接口 +* **@flowgram.ai/runtime-js**: js 运行时模块 + +## 代码说明 + +### 目录结构 + +``` +src/ +├── app.tsx # 应用入口文件 +├── editor.tsx # 编辑器主组件 +├── initial-data.ts # 初始化数据配置 +├── assets/ # 静态资源 +├── components/ # 组件库 +│ ├── index.ts +│ ├── add-node/ # 添加节点组件 +│ ├── base-node/ # 基础节点组件 +│ ├── comment/ # 注释组件 +│ ├── group/ # 分组组件 +│ ├── line-add-button/ # 连线添加按钮 +│ ├── node-menu/ # 节点菜单 +│ ├── node-panel/ # 节点添加面板 +│ ├── selector-box-popover/ # 选择框弹窗 +│ ├── sidebar/ # 侧边栏 +│ ├── testrun/ # 测试运行组件 +│ │ ├── hooks/ # 测试运行钩子 +│ │ ├── node-status-bar/ # 节点状态栏 +│ │ ├── testrun-button/ # 测试运行按钮 +│ │ ├── testrun-form/ # 测试运行表单 +│ │ ├── testrun-json-input/ # JSON输入组件 +│ │ └── testrun-panel/ # 测试运行面板 +│ └── tools/ # 工具组件 +├── context/ # React Context +│ ├── node-render-context.ts # 当前渲染节点 Context +│ ├── sidebar-context # 侧边栏 Context +├── form-components/ # 表单组件库 +│ ├── form-content/ # 表单内容 +│ ├── form-header/ # 表单头部 +│ ├── form-inputs/ # 表单输入 +│ └── form-item/ # 表单项 +│ └── feedback.tsx # 表单校验错误渲染 +├── hooks/ +│ ├── index.ts +│ ├── use-editor-props.tsx # 编辑器属性钩子 +│ ├── use-is-sidebar.ts # 侧边栏状态钩子 +│ ├── use-node-render-context.ts # 节点渲染上下文钩子 +│ └── use-port-click.ts # 端口点击钩子 +├── nodes/ # 节点定义 +│ ├── index.ts +│ ├── constants.ts # 节点常量定义 +│ ├── default-form-meta.ts # 默认表单元数据 +│ ├── block-end/ # 块结束节点 +│ ├── block-start/ # 块开始节点 +│ ├── break/ # 中断节点 +│ ├── code/ # 代码节点 +│ ├── comment/ # 注释节点 +│ ├── condition/ # 条件节点 +│ ├── continue/ # 继续节点 +│ ├── end/ # 结束节点 +│ ├── group/ # 分组节点 +│ ├── http/ # HTTP节点 +│ ├── llm/ # LLM节点 +│ ├── loop/ # 循环节点 +│ ├── start/ # 开始节点 +│ └── variable/ # 变量节点 +├── plugins/ # 插件系统 +│ ├── index.ts +│ ├── context-menu-plugin/ # 右键菜单插件 +│ ├── runtime-plugin/ # 运行时插件 +│ │ ├── client/ # 客户端 +│ │ │ ├── browser-client/ # 浏览器客户端 +│ │ │ └── server-client/ # 服务器客户端 +│ │ └── runtime-service/ # 运行时服务 +│ └── variable-panel-plugin/ # 变量面板插件 +│ └── components/ # 变量面板组件 +├── services/ # 服务层 +│ ├── index.ts +│ └── custom-service.ts # 自定义服务 +├── shortcuts/ # 快捷键系统 +│ ├── index.ts +│ ├── constants.ts # 快捷键常量 +│ ├── shortcuts.ts # 快捷键定义 +│ ├── type.ts # 类型定义 +│ ├── collapse/ # 折叠快捷键 +│ ├── copy/ # 复制快捷键 +│ ├── delete/ # 删除快捷键 +│ ├── expand/ # 展开快捷键 +│ ├── paste/ # 粘贴快捷键 +│ ├── select-all/ # 全选快捷键 +│ ├── zoom-in/ # 放大快捷键 +│ └── zoom-out/ # 缩小快捷键 +├── styles/ # 样式文件 +├── typings/ # 类型定义 +│ ├── index.ts +│ ├── json-schema.ts # JSON Schema类型 +│ └── node.ts # 节点类型定义 +└── utils/ # 工具函数 + ├── index.ts + └── on-drag-line-end.ts # 拖拽连线结束处理 +``` + +### 关键目录功能说明 + +#### 1. `/components` - 组件库 + +* **base-node**: 所有节点的基础渲染组件 +* **testrun**: 完整的测试运行功能模块,包含状态栏、表单、面板等 +* **sidebar**: 侧边栏组件,提供工具和属性面板 +* **node-panel**: 节点添加面板,支持拖拽添加新节点 + +#### 2. `/nodes` - 节点系统 + +每个节点类型都有独立的目录,包含: + +* 节点注册信息 (`index.ts`) +* 表单元数据定义 (`form-meta.ts`) +* 节点特定的组件和逻辑 + +#### 3. `/plugins` - 插件系统 + +* **runtime-plugin**: 支持浏览器和服务器两种运行模式 +* **context-menu-plugin**: 右键菜单功能 +* **variable-panel-plugin**: 变量管理面板 + +#### 4. `/shortcuts` - 快捷键系统 + +完整的快捷键支持,包括: + +* 基础操作:复制、粘贴、删除、全选 +* 视图操作:放大、缩小、折叠、展开 +* 每个快捷键都有独立的实现模块 + +## 应用架构设计 + +### 核心设计模式 + +#### 1. 插件化架构 (Plugin Architecture) + +应用采用高度模块化的插件系统,每个功能都作为独立插件存在: + +```typescript +plugins: () => [ + createFreeLinesPlugin({ renderInsideLine: LineAddButton }), + createMinimapPlugin({ /* 配置 */ }), + createFreeSnapPlugin({ /* 对齐配置 */ }), + createFreeNodePanelPlugin({ renderer: NodePanel }), + createContainerNodePlugin({}), + createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }), + createContextMenuPlugin({}), + createRuntimePlugin({ mode: 'browser' }), + createVariablePanelPlugin({}) +] +``` + +#### 2. 节点注册系统 (Node Registry Pattern) + +通过注册表模式管理不同类型的工作流节点: + +```typescript +export const nodeRegistries: FlowNodeRegistry[] = [ + ConditionNodeRegistry, // 条件节点 + StartNodeRegistry, // 开始节点 + EndNodeRegistry, // 结束节点 + LLMNodeRegistry, // LLM节点 + LoopNodeRegistry, // 循环节点 + CommentNodeRegistry, // 注释节点 + HTTPNodeRegistry, // HTTP节点 + CodeNodeRegistry, // 代码节点 + // ... 更多节点类型 +]; +``` + +#### 3. 依赖注入模式 (Dependency Injection) + +使用 Inversify 框架实现服务的依赖注入: + +```typescript +onBind: ({ bind }) => { + bind(CustomService).toSelf().inSingletonScope(); +} +``` + +## 核心功能分析 + +### 1. 编辑器配置系统 + +`useEditorProps` 是整个编辑器的配置中心,包含: + +```typescript +export function useEditorProps( + initialData: FlowDocumentJSON, + nodeRegistries: FlowNodeRegistry[] +): FreeLayoutProps { + return useMemo(() => ({ + background: true, // 背景网格 + readonly: false, // 是否只读 + initialData, // 初始数据 + nodeRegistries, // 节点注册表 + + // 核心功能配置 + playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ }, + nodeEngine: { enable: true }, + variableEngine: { enable: true }, + history: { enable: true, enableChangeNode: true }, + + // 业务逻辑配置 + canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ }, + canDeleteLine: (ctx, line) => { /* 删除连线规则 */ }, + canDeleteNode: (ctx, node) => { /* 删除节点规则 */ }, + canDropToNode: (ctx, params) => { /* 拖拽规则 */ }, + + // 插件配置 + plugins: () => [/* 插件列表 */], + + // 事件处理 + onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000), + onInit: (ctx) => { /* 初始化 */ }, + onAllLayersRendered: (ctx) => { /* 渲染完成 */ } + }), []); +} +``` + +### 2. 节点类型系统 + +应用支持多种工作流节点类型: + +```typescript +export enum WorkflowNodeType { + Start = 'start', // 开始节点 + End = 'end', // 结束节点 + LLM = 'llm', // 大语言模型节点 + HTTP = 'http', // HTTP请求节点 + Code = 'code', // 代码执行节点 + Variable = 'variable', // 变量节点 + Condition = 'condition', // 条件判断节点 + Loop = 'loop', // 循环节点 + BlockStart = 'block-start', // 子画布开始节点 + BlockEnd = 'block-end', // 子画布结束节点 + Comment = 'comment', // 注释节点 + Continue = 'continue', // 继续节点 + Break = 'break', // 中断节点 +} +``` + +每个节点都遵循统一的注册模式: + +```typescript +export const StartNodeRegistry: FlowNodeRegistry = { + type: WorkflowNodeType.Start, + meta: { + isStart: true, + deleteDisable: true, // 不可删除 + copyDisable: true, // 不可复制 + nodePanelVisible: false, // 不在节点面板显示 + defaultPorts: [{ type: 'output' }], + size: { width: 360, height: 211 } + }, + info: { + icon: iconStart, + description: '工作流的起始节点,用于设置启动工作流所需的信息。' + }, + formMeta, // 表单配置 + canAdd() { return false; } // 不允许添加多个开始节点 +}; +``` + +### 3. 插件化架构 + +应用的功能通过插件系统实现模块化: + +#### 核心插件列表 + +1. **FreeLinesPlugin** - 连线渲染和交互 +2. **MinimapPlugin** - 缩略图导航 +3. **FreeSnapPlugin** - 自动对齐和辅助线 +4. **FreeNodePanelPlugin** - 节点添加面板 +5. **ContainerNodePlugin** - 容器节点(如循环节点) +6. **FreeGroupPlugin** - 节点分组功能 +7. **ContextMenuPlugin** - 右键菜单 +8. **RuntimePlugin** - 工作流运行时 +9. **VariablePanelPlugin** - 变量管理面板 + +### 4. 运行时系统 + +应用支持两种运行模式: + +```typescript +createRuntimePlugin({ + mode: 'browser', // 浏览器模式 + // mode: 'server', // 服务器模式 + // serverConfig: { + // domain: 'localhost', + // port: 4000, + // protocol: 'http', + // }, +}) +``` + +## 设计理念与架构优势 + +### 1. 高度模块化 + +* **插件化架构**: 每个功能都是独立插件,易于扩展和维护 +* **节点注册系统**: 新节点类型可以轻松添加,无需修改核心代码 +* **组件化设计**: UI组件高度复用,职责清晰 + +### 2. 类型安全 + +* **完整的TypeScript支持**: 从配置到运行时的全链路类型保护 +* **JSON Schema集成**: 节点数据结构通过Schema验证 +* **强类型的插件接口**: 插件开发有明确的类型约束 + +### 3. 用户体验优化 + +* **实时预览**: 支持工作流的实时运行和调试 +* **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验 +* **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈 + +### 4. 扩展性设计 + +* **开放的插件系统**: 第三方可以轻松开发自定义插件 +* **灵活的节点系统**: 支持自定义节点类型和表单配置 +* **多运行时支持**: 浏览器和服务器双模式运行 + +### 5. 性能优化 + +* **按需加载**: 组件和插件支持按需加载 +* **防抖处理**: 自动保存等高频操作的性能优化 + +## 技术亮点 + +### 1. 自研编辑器框架 + +基于 `@flowgram.ai/free-layout-editor` 自研框架,提供: + +* 自由布局的画布系统 +* 完整的撤销/重做功能 +* 节点和连线的生命周期管理 +* 变量引擎和表达式系统 + +### 2. 先进的构建配置 + +使用 Rsbuild 作为构建工具: + +```typescript +export default defineConfig({ + plugins: [pluginReact(), pluginLess()], + source: { + entry: { index: './src/app.tsx' }, + decorators: { version: 'legacy' } // 支持装饰器 + }, + tools: { + rspack: { + ignoreWarnings: [/Critical dependency/] // 忽略特定警告 + } + } +}); +``` + +### 3. 国际化支持 + +内置多语言支持: + +```typescript +i18n: { + locale: navigator.language, + languages: { + 'zh-CN': { + 'Never Remind': '不再提示', + 'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出', + }, + 'en-US': {}, + } +} +``` diff --git a/frontend/package.json b/frontend/package.json index 5b6f82e..c7c1c86 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,18 +10,39 @@ }, "dependencies": { "@ant-design/icons": "^5.4.0", + "@douyinfe/semi-icons": "^2.80.0", + "@douyinfe/semi-ui": "^2.80.0", + "@flowgram.ai/free-container-plugin": "^0.4.7", + "@flowgram.ai/free-group-plugin": "^0.4.7", + "@flowgram.ai/free-layout-editor": "^0.4.7", + "@flowgram.ai/free-lines-plugin": "^0.4.7", + "@flowgram.ai/free-node-panel-plugin": "^0.4.7", + "@flowgram.ai/free-snap-plugin": "^0.4.7", + "@flowgram.ai/form-materials": "^0.4.7", + "@flowgram.ai/minimap-plugin": "^0.4.7", + "@flowgram.ai/runtime-interface": "^0.4.7", + "@flowgram.ai/runtime-js": "^0.4.7", "antd": "^5.17.0", "axios": "^1.7.2", + "classnames": "^2.5.1", "highlight.js": "^11.11.1", + "js-yaml": "^4.1.0", + "lodash-es": "^4.17.21", + "nanoid": "^4.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-json-view-lite": "^2.4.2", - "react-router-dom": "^6.26.2" + "react-router-dom": "^6.26.2", + "styled-components": "^5.3.11" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.3.24", "@types/react-dom": "^18.3.7", + "@types/styled-components": "^5.1.34", "@vitejs/plugin-react": "^4.2.0", + "less": "^4.2.0", "typescript": "^5.4.0", "vite": "^5.2.0" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9b07ac..88515f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,10 @@ import Logs from './pages/Logs' // 移除不存在的 Layout/RequireAuth 组件导入 // 新增 import Positions from './pages/Positions' +import FlowList from './pages/FlowList' +// 引入流程编辑器 +import { Flows } from './flows' +import FlowRunLogs from './pages/FlowRunLogs' function RequireAuth({ children }: { children: any }) { const token = getToken() @@ -33,6 +37,12 @@ export default function App() { } /> {/* 新增 */} } /> + {/* 将 /flows 映射为流程列表 */} + } /> + {/* 编辑器:新增与编辑都跳转到此路由,使用查询参数 id 作为标识 */} + } /> + {/* 流程运行日志 */} + } /> } /> diff --git a/frontend/src/flows/app.tsx b/frontend/src/flows/app.tsx new file mode 100644 index 0000000..4e74a31 --- /dev/null +++ b/frontend/src/flows/app.tsx @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { createRoot } from 'react-dom/client'; + +import { Editor } from './editor'; + +const app = createRoot(document.getElementById('root')!); + +app.render(); diff --git a/frontend/src/flows/assets/icon-auto-layout.tsx b/frontend/src/flows/assets/icon-auto-layout.tsx new file mode 100644 index 0000000..420b4b3 --- /dev/null +++ b/frontend/src/flows/assets/icon-auto-layout.tsx @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +export const IconAutoLayout = ( + + + +); diff --git a/frontend/src/flows/assets/icon-break.jpg b/frontend/src/flows/assets/icon-break.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c06b1abeae17dac9d207d4d6c07866d5a7929d1e GIT binary patch literal 28182 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 M|LJL2$9~NH4+%g8XaE2J literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-cancel.tsx b/frontend/src/flows/assets/icon-cancel.tsx new file mode 100644 index 0000000..a1505da --- /dev/null +++ b/frontend/src/flows/assets/icon-cancel.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +interface Props { + className?: string; + style?: React.CSSProperties; +} + +export const IconCancel = ({ className, style }: Props) => ( + + + + +); diff --git a/frontend/src/flows/assets/icon-comment.tsx b/frontend/src/flows/assets/icon-comment.tsx new file mode 100644 index 0000000..cc5d8dc --- /dev/null +++ b/frontend/src/flows/assets/icon-comment.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { CSSProperties, FC } from 'react'; + +interface IconCommentProps { + style?: CSSProperties; +} + +export const IconComment: FC = ({ style }) => ( + + + + +); diff --git a/frontend/src/flows/assets/icon-condition.svg b/frontend/src/flows/assets/icon-condition.svg new file mode 100644 index 0000000..be9c2eb --- /dev/null +++ b/frontend/src/flows/assets/icon-condition.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/flows/assets/icon-continue.jpg b/frontend/src/flows/assets/icon-continue.jpg new file mode 100644 index 0000000000000000000000000000000000000000..095acb2910aede2240a430c77d1ccfb708ddb268 GIT binary patch literal 38177 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 E0rJppy8r+H literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-end.jpg b/frontend/src/flows/assets/icon-end.jpg new file mode 100644 index 0000000000000000000000000000000000000000..46bc47dd344943235af3e58c5c5379cbf0323734 GIT binary patch literal 21016 zcmeFYcT`htv?d-!M4CwNVxd>*MI@j20TKix^e!MJ^ePd7C|#-op%)QC=#gFwAYHn2 zDFO*nq$Y?EAS9FT-kG_xX71d%zu){jCo4HCIcJ@d{hs~q{XEa!FZnxp6>tNjt*Z^V zbomnCJLLr+F9IF`F8yC_|6Jt%@)DOWk-q?#uU)!z=@!)`X250UOH|C4$h`mo0N@h! zzg`>gU$;w_sjg68rMX5+M^8DS@dn`XB`T`RSE#6|uUw&=9Y#40xWY`$a$EM{RaPSh znmc}Ma&O)jUAwDU+rD3TC@LwdsH#2sTT5H#u`bBi z#MI2(!qUp=xwDI_8^k>zFz7`v^kqn7RCG*i+}rrnv~+kzW>$92$Kn!1DYC4*qOQK7 zv8lPGwXM6Sx37QT>)_Dj)bz~k-1qqf40i43`o`wguWkI{@1sA*Cxp|pf9$#hp!)Aa z_OFKhPj*oT;nL+RSE#Pg{A1Up%fXa474sG9+p65=1AqWL9T|Y^!QJ?9 z`86Ft6!8Cc|8EHX%?qyQc9ZDvnMnuQ*{hwj(A>VWx8lWyxQ@t*ofAZ1)a5Bq!{@vV zbvid}b`m%~eZuI_E{cJv&6IRYUsuYNndoL5yCm z3BiBY^`c!90lbjXF?^78jv|N@?$x`}8!GS4;2%x|z#Au=I&!03?|bdmcuVs}-|~uE zkN+^-Ch>AQJe73WJO|0dt^$&KG25hO0d=1aDvv{ea%L)*-G>gtcc0IoV^u&0(!Io@ z5zPju){x}KPxza0cp5(VtKH8$S&sWULLT*LwH=wX}+ z2#iYTU-M)8)bd2?n#5SsT~@omtqBBmRfS2O1AC=gnWm`_m!@3a&&kAFc?jWSqj>$M zGr^khui>LGoE9v_p3R{SFVKJCGPi0NUBj}l&(HDdvAQ0f*QhC72bd`x?Nw&=tAIn^ zy8iD`l)F#$uS|QPlW2>6d?Dt5T_c~w@0$cVwFf-&fX3!d!mZ>7rmBs_F^P&6s#6;= z4(5#JoVjHtAc*t#$E&h|VY?c>>BV<<1?NO|4|A z+ohI-FA%xNfCvjx-^x|+0#+va8}Z59hXw0iAg8wzzF`th1<~NonH~Out5bonc=*uu z_*eDFe@ebp*u@uggvKu>JDa5w+K_b4Gcum_5#@TfqFB9m&JEZr@mHpc-GzPv$`;lA~}r51IA#Fta2w>ph56{mWJaM!n$Nh7r_=pJs%-)4PlwTiXSoVo+E zjypl()8YQPuC3QJ^Gw9L??o)!i%&E!aZI%rbT_~K@GoirRJjj0AAJ0RFwfo{3MRxYKQ!+rEY^)evQ<{ zrSSVn>w+?6q-hv)9L3l5HJ9RR4CCRU6>aglT4r6-Amw^K}Xh`ldyzy9DLTAi>q@ zQsQaGF}zG5I1e1(vi&Y!UFw>KRS;g$_q&Y_0=^FaZ3FIk>Q1XQR&6P~2Fih`Dm13& zy5Ea{47N|3AOv=)j-Ot{Xi$;9hVc1?YTyVn=zw6yj1ECUAyx&`jt_4dVXj$i$n|-T z8D+72|0IMXCS!J#PieG#xYE9RA#nxklL&h(!>sN;BBUWSQa~TOg%z@vz)02@7o|R9 z@i%cdL&6|AP8eE6$$@)ts|w?aPw%=grJufCSW680TAHvF8Rpc-o60ImZjZMay8HU< zY7T3StP72h22efY7XOwPFTa*m*TocGCk$)OsLNn~&_Bj2$Nc$hRyV7_|H`0YrXk5- zVyNDF1u{2b=n=$mq(iG(=k@rWZ$rOr#_x{Y%Wl%oa`Mlcx@2kI<)ZpRxQU53 zs+w)GGPW19Y0xNk*s3+es^1SzKtG>~PyaO=RX!6XZk*GAQ7P}%YbX|+vMVD6!Mss~ zyRUv_oQ?O{&vT4z94-hnZikQoU1IyvGKyE?+8!U^hPMAMSbKUHIVy%Ls0=u+zoLIL zvdnI&u^?9D%U>4C9?*~kAJjHNm=qy(C)x|j(}=YoM~!4kB#u`}k-uK$VSFSJ_5O(|R1 zHE@Jkv%c=1psbP(K zBA!Ce$6A`_B*Hblo<8kTbjC^57CwFtv^-aMeFf$^Y@Ru`v~;o;)cA)KBSHM8fund8 z)B6r7(lW44xr=*$R~?2Q?|SO-;(o?XPXXWO7Cb6$56RtEy^(~W5A*)5-x^LgP@&@- z{rvs>ouGho!>eKZM9A89pdX(q<~YpwQd~VE$0Z%|Q`OeWCuCZ7{)Y`UgQ?K0g|4IY zbTQi|C;Pf%J-YJhlE<1``9MhEVzrUR7+d-}CrR-0Zs>iiJwe)V_kD?;R7W&MAgM$0 z`3sBvU9=3wRzg3UjcHjjXD+F#QStiYYjqI1@7HaFZ|I(rOwWO#uKnHY@(eq#>zqZA z;)=gR*SO=OatSuR+|;3WArj4e9#(x?Lx+C&3cmu=tJ7jm;-~#^(LOR@HKx z#Bvj0;Oh48rl{UPNVAt}#iLujAo1fFa7NDj!nk7pe9)inHSm7%!_L-O`|Kn8o^gW2 z-0p+5#Vko5joTh8*AKjHbglqz5N}uh${1OXNC8@Yo9nd``1FK-!%N4k;f)FZWTk`Q zhPwWdIGzlM6*K95imCeiwhWx2sEZp5j_8{)0v*h+2GC@Vg zGfgvhmqu^t?p99bnUDD%r?2gZAvVHepz;G9G=!v&53z=^Bw1W%vigI7UY?bx#yy!{ zHIt;obn6S@?fu0OyAcO_+7WP)f(@KLz<%VAdig(C>y)C6q)+!X&^gpiF*GUkI zC9+EB6Hbvk0(>3Rzc_O22N=~BtPfbBE~9rFJ`oySy*M^+U;Qc-8pdFR?J@F^mSG08 zc~@b7&buph{^-;)M7)sb$upquAp>sIHr9q-imznQFwU7v-*HrtYqwkw8~klj-2)eq z!1@m7ymazmk4ULA#m#g)ifi##*&64Kh)7lv8$u|S_~}Z8!h?>T8x3PZ7W7WF(Dmpk zlyt*|=Ilm4sej|u{T<$~LXDJP8{r>X8{3-l!5*)2U%tl_(7qRnH*x!E8gvU*Ya;Qj z?`iSd9y3H-kE56|Dz^7&IbDOqw@PX-++7jUYFr7bR@DyN)&xgkghZQiTsNdtnz==| zwHl`M6{hO-9~c{I7nH**ji>=^fTx!zQ0PC}g&Cd;gwHIiT%>f|gOCBN#JfH>8%u2# zG$PkvG`36a)v*a^i6B28zaV7ekdEq9qn=)Zo6M2EgVbgi@adZ?$0u#hW5uN_;g8mQ zBGJG#pDS3Xo?5y3RcW}}hU(Q|?Q{=EIU8(}T6(#osff4-dsMYS^%Er^wRrm4+a8lN zq&ZUpBD&AvLb6i6IvuopKYZbFVUz3^2kk=|v~$_Auu9<`w6!)~2tCaLp#s@wFDP$b+`IlkFob(#)fC ze2$39EYAs9VqxBFLLNf}A}8_*yGxzH!k@bX>|Ckv!z8+$bAeF7N$K3~f_u4Z_6eHI z4w%B&j1w^AK;r6Q$u!ZmY~dm1vjoewS@2Yi;;0$B#2vS(Vlki&h79=D+`lZ}KWW*o zd!1^qHFH+pTeuRWlGUX+&L^xDU2Uv@idzpx@|j9@(kSIQCnE(XdwBaS4LD@xO`$`b zE(QF`yhj_=g5_l=>j9}GZPljfBylA!4OFb~^Vc|?>nA55IS%J8lySvdwHTRbwk^j`mK5ND1^FdZ z_AcpzVw{qao~NZBj_~~=16+UN!5b!s$et>Hgp70C0JeIBL?esk8kY-61nh4dtem}6jKrn50iIbXtA_b`ZgHxQjCCasR z8$}x{APuu+lQrOo*T6g!K{9q%jKRo`G*?I$2u^f~kV#g%o(uTiADlefgSBn1yD_xk z8u?nd!p z9d-jn*_R>>sebFd>+kYf>qaEQsS^B6U2`xFGm@TnKL6s=v};PThP^1Z?|o0)m^%vz z{Xd7ie+!2+Iv7J5X!AhMIx;4d2?M)980ZZ?C-r@R+G|e-UAXN2{dwIZOdizKhR$~U z%g!vB!{QyMe+4Z{Ms@7an}2x6WuD}f@%*KoK;{m2vQKjN`gb1ngABSF8E&(rYpTOP zBGRYwauSWb`_s!!QHlmhYH)WwOtcCpApCv2*J4eM0F1y&qLn=lgl;S>oOZA*Lgl)D z1^4-_b#g+T9=002yVm;c<1~t>_q<1N7d_#%^cqyY1{@_mIvPIH@V&5V3F;3DSkmW& zOk#F3l|~@S9;BUn1)PjexiQYkUs z&TxC?4e+zCPEt4e&6S0g299AunBLT{B%3Iq#OOGh`|v-i#tCLJVA2=jx1x zSt)i(=Y7IZJs+J9dt^0;J|wRqFr#U8mtOHqL;|EXWDzVLy16U#_tTszw!6<|<5)8OT#*A{cGKcmpDy?p;Oqh|NvkDn3SV~4XAHBvRV zgX7(v>oR9s@K92DErHM5JgMKgHG`G;^@to%Ka}G>bwaVah6HUwk13o_o0nCy$_bnl z!tS6J1Tjf;rR_IL)mI3wrcC*};JjiL199IxtAD0^zmbP_4y3ffZK_5yu{!wC1kv#0 zHi-{^J=e8rOO2ErR_tM=U3}T*yL*Tk(o3qK2)VrYl_6l^$HJ&2Ywv31z+JJ`IT!nX z2x2(B!mz9c7xkpLm{OBfhl!ORnc7X4G!|Zp{58C#}u`t~(Kr-@(4QzAlh(2zA1Z-&wm>p%CD$ z>ujSR@!?{G^Ts+J$q?#5->k=!7`5e+B@RwsH>67GF)jxO-BuMy*yIkZLx*`v*A(LK z{eCqTwT}s&eF~Y%)fG|EElJZEY&^fc9{nGJuKytdZ5QrdA_J~`#TV{*PK0XX*N+jn z^Hu%)oyD43AMZ*mOkio@>%_7JRQmpRq59@2&kgOyX|(Ap>L2j-)v){ewKFKGKe8oc zz^yPr4frW%BweU%PfMr_3pCa)${o!Js$N%D1vQwIIm-W{;K)C+rWhe>`m}H5+U+1l zM?Z^1e!483#52#7SP7SmUi0*_XX({rnFkV$cyy2>OA#zTK0n_68++1p<=8ZWU0z&rHEYNg+C;T=aIp zbb2Wnpx?FXn?(o8V2KhPFmPo*7)3RLy?^JSfBtAPv${EG&kz(1&{4Rz^hm0`*J#{F z-G>49BtcUCk$8HBn4|BYp#NPtH8psUkj0J16;YcY-*N+gB*n8StXqV(+t>YRo(Knm zTU!?-mtY_zoa3f1oC8sq`GA4y8GB!)L}45^0#A zv2g=_NNTZ1*K48ig_hXFn8+8VzR>MM(|}MR`o(jg97_Dt$6`II#2#?5AtNw-OvRc$ zDRJjnF@v;BxJ8Z41J{M8@r|m$&4~JtF*&b6n0V)nZ~^Ez#ypYu2zP?zjBRA1BMfx6 zMYXxK7H#`o+xL=oqTAZq8mIn|zr@78QCMa<{2&J5@Sy!7y00(4R~~py)nlXg-Y-Tg z!%v3M1?stScNF)nZ~ZieC3LzH3||u+k~5fE!Og4S zXti)f+%mJK-4tzx-!dc8*XTbiW3&09Ttk0h|N&m(Wu9IqiBmXyj@K zIw!nS5TvZX%EY_00BM#RVNuRmKk{K8zzj;U)%CbKReGFw|0Pv;kn%y~EmJI7Yn1q4 zz}#pUx+4wEYmyH!#y*LFdzy+q90IWwCl9XYXxHnGb=;-Nn27hDCG5+6Irn6g^yKV} zER@`vjdtZ~I_m&+jQwD9H7XnmgMEtK_(!|`xA^eCrEg&r@^Iz%#k-nLiqw#zbd!4q zyuX4;iCAsMoA+KA%#5(`vr9)=!D6cRUlfX%;7zw5Br2h8{f8XZz~q zb(7yVo{*s(MlJD{Y$*YuJs!vr+(xgaVAw?aV$F{Q&XZEr;W+0|eoA zrU8dg$K)b@HQf+ughxli>vViPARaz^zFk*jOZ>Fs<30c+i0lJv`1zXqoY@;tv;k0d z0PEo-`+e8hJuA^nhCQxayb;e8|HoMzUTIb?W`NKg0_T-1det9uX|iE>0@t`SnT?|E zq&so-S!#>9rCJF#Jf5!6jQH0Q64O^hf`sy}9+C(n=#_;+a2oF~6P*dVAD6a`A5=+7 z6Mb+edV2YfEOARaoTI@NZnY0)TQM23qrQg&Wp2K=y<9k(NV8`=gp;?pl|C+>(FB1~5S{dL@Ih#g8D=T$fnzF6LYz^P`q90`VxiYzi%H}_{ z$U_2$@T~?8T8Nhpe&0i~Lh4Ev!p9m_rjOIb5#!noVl$D(QC73jAzAK6wQPk_AjR8e zOy}QHQ#%9qT$&&aN3JKExD>4RTYM}gn4*lthx0qDDW?$cejwBg38g=ob+VZHfG#x1 z??#^4Wd;Z?1Z~UR?F;r$Fo@e40&a39saOki@!9s~-Ofh-+2&Z>76bz zWH(UG-1iGCW-z4{1IZU#<*kWn>vk^rw!}H&rezgKbVo&|63a_?{l3$_X3qK2l)q6~ zYD}_(8Qh8?p$8X>b?qKCa!@L~q0WT0r+b+u-ZsCqqozNTAM-I6-faWPJhXOKs*7@o z*_h>i=c9$HTGbHi#p&?ikenF48E;-JL#4{k(uJV%XMX7(*n4&LDT?ZLHx+#wivlaa z)hfCRPd54;))Kn0zLSlW6Xl?T;yj+jwjFLo!83(yQn>Q5a*pkv{p>R9K?n?KyYzj< z>WR{*$4iL(;?m*}dM}l`7`pTvlO9jOh*e5=Vq};eamG~geD^fvu}FO2cE7ut?pqSb z^x0WILRH%+%vcBGpOBdMJSW{cG3|%K?$mEXO@!yIf2VgLXJKLEcw>2NZ28sa&#l;| z#>S?2-Fp3qGLCK=UW~sW|Mr^f!O|xoowhD6;IG;xWGFI(AzW#j$Rbd?lxA%cruf~lXqwo>)X7Uu=N+3$8yVvKq#-Z1*8 zkSly@`97XAQbsXX1?50+Ugc^i3)T79@PXohcar^jjgQrH}#gG(?rTV~Qz7^=m66j&Mrz%%+zP(S*K# z<%|^?KnBD}o|6F|-;n{}L)7UxMIh;#hMi^sG5wvj2N#Tm6svIcICC=KzB>^6!A^-{ zWNuF(QPy?kf-Uwe6Al|+3MO4ICIdzWe!%Kdc7t&V$ErBgTeKMN10m;FhSLKNjkKLb zJC*%vtLY39swuo)i2T**IoaSvHw{s96#6lmR9g#i{5%t+Yqz1*@)$!3!6)?ZE8=@p zhU$}LEzNGel(toGmih8L%hQZWx|r^JPF9|Q#CIpz`6TnizQGAZTtzJ%YjI?^rozWx zAmighG?G4jvRsdTPq1S8I4vKdZOUt8l<0Hqq5pt6`*7g8NLb)mMW?`nd;e|vU2$fh z`@~R1SNG}4y}MU#KyM{qQ)i~I5J(=BC632vixGHIel@8A_E?h0XlXMDzS!< zHjKT5p~GI6x}glC{FzE2@jvy6@r_T4c@t1Bla*QHG<;)2!)d%U!}Nzbc+cNgKdF|= zFtHCCF1)hR^(;37Roy`sjqYAN8-FKp+%p73r4a6n_d?K+bM8f`fl{nfxJj5K?EtS<$UANJ<3LSE!ek{*Q!e`v0aAr^;StkjRtgIKfyN^y-P(Tx{QZv6`wEH zn0l;Mm}H)m>@EOV#~|X|H4gfowWbL61x4X)tBSU*#UAtETNlNsd$_Fk-zILwOlh5lJ$)l&%b@h zfY&7*I@lku7lpTAPhhye7UxTFOndyL^#b(^&`!+7u3pQkewkZ`V8jdhZyfq^-#Pd` znbJhFlZ?YB@*b$*1)>QhnT^${e!@4shxQA2`Qg*0sLvpawkB(dre@czyz48UM($3S zeweE&G6G%*349ZR<;MP?2Ef=lai*VDc7Z+z3v}0TrUx4Q%Y4#Z8}7hsM5;2q{*6JF zA1W^6WAkB^pWVoSmm0A_>v3l)P%p4K+RaaqIv5sA-=C)w6Vqhn4^D!&&$#_qIKY!c zY+5%SNE3p|fHw|qx+F>BA?jCT*S}M0Whw$X+o!0d?2c0^W*Ag|0zKr^-i-=^T+mUsfrt4 z{4_0@pbQyWOS=qts<(+YxRw$1ZHJwnWu&r8<}Sg;l)?F$-f{tNbe3t!)ah0Vp+g5?Hb5o$ zPiW{|R7)^O1&7>?-`&P(_Us7B)C%(#-Yo0&;Lcxs%=%TrN+?~dgMP?l2}O@N+6KPd zA?|8?7o{RlGHt|%9oe1E=FC0jS9}qsk-c_26A4uB1aSwi)rBypPUBT_zf$P0_g&kw zKZu=>ppxv|I>5kbwYsK<JXg`Rac}=dj2@g zDB%gZDfa2(3`l4RIyo@zvx0k7fW1sFk-2NhBNh>jC2?R#!bJ}UF_;2p6~6K9F$1~0 zPg8y0D%BNAG1p%{g{$goi77Rx-6NpQSPU#YwLH}ed$*?ZH9YK=hNQ%-_UGDDD9|MK zP546Rkv|#00mLB+$$&W2SSQvRO+yA0n|5^)+>vC!Ab;m#>%awrpFKe~mZ(mDJVQB{ zNvNe1iX_PZqLu(&YMF|HnJQLSh>FH!z|Y9@U=<38Nv2@GtM~q$TNIOoK7WCoDcbN- zAWa2~_zvd1DR3m)2V3nIz~N*T%}L4j*Z~q-JsE&!gY`B;naBX`%19V)#*hq1W!hU> zBgLqoh+I9So7Cs-lsgX-Ehr!>14g=&LdZCvD0o{lzCI+Lax&oX;l<}X*tY=+o{Z#z z^?d2{vOiOWB?u6}<77ZD1*x(8SOs42)G$(jpDB>^7}y+;a>j-2#5pk$pq&3mPC7Dp zCo+J}k_;fk1OMldp*i07qhMA(a}t9C8NhV;q7k;WPLhOS^nhdlvkp-&_%+4%+*_2% z07ers;Pe*h#~5(SiNZv>5-)yH{7{{=&>>0K`AF0xJiur6pQR*(i$GoQp%9FhYp;1B zWHyM>y0A^}h1^~Ud9v5`lVEI-ybwGj88P15v?Gwso;S*LI&-vy6A;Fo2=v(ae3`$> zD}+&>jr>|4dfoET{4@XE6NYW9uSK?|_;iNf8@QFGT2x@o$0?tGW9x!qO+h;T*s2hcBDs#E$>F3c{C?LJYuk#@JoM`}6|2R%)^_ zK352@%IWQ`GMJ`~*1V*7K;9!fZ zV2`?;PoWD`9Vy*Yi7@6&Mln1PfG8k#%esw^;XAtiPm6ir6^hq%t14%aF~#Q0W;P9F zQ~A2X4bSrOGtKs%H+v7dkFUC}#rD~Htff#Y$#G-=r}|&j97sG>-@^D?o>2rVqx8`6 z-dNhlzI(|sN{f#s!NWR@@88?*RXIZB)+vFhSOc(1q@i>xbQ3u<8r4M@f41;dn>`I& zmhQNj7~Yza$A=s=zt^v>t<+$W=VZ>T$g3sT$<&WUCiP)uO%>oF_2nKbaNYAyhI(b% z-wYd!*5Q9PWiKc$(GbaKmv5Tcp%`|>hF z6K-tpyCbH8Jxm5VRLD*E({JiK<77?Mlh*BSkRslnhSj5uSNFj8&y9pn zYNvO9nl;Y^N?BcjF6`*cDa?BZwj2C-9&gsr_P)Bbx$Uv^ozv?NR>KAG0#WrH4A@Ya z*}B97TFD3Ab{5!saO{lDO^1kb(LwNlGocLJhY?p9qiYncXxN^FgN%Q5VRxSSDb!lL z^q)aUI}_`yL9Dh8lnxFl{(_|YiWhC+gPo1RJ@yN081HO6&6k|$>W+Hqc0_YBnpx^+ zfJetwJllI#jqI{+F!#n(jbQxCKq)V;wYu4=Vu-`x{@%fE6&dhloLikAi=rlc=IjZ5 z6jN+6=mYDT5=-{IivHpjgfDW+*2_QnQe8b*C-*nyd!sqow1&U1~Yd-P&T%ZON{Nwaz%XViY8?O-D>W`WId8Qq3b^BEM!KsPH=_S)nT&3$`%Fk1+nwH;6Sx;Od*220dq;d75l~4cY z@=jNo2%eS2a85ThWG>m{tOG~`o>ggo`zr4CGJ>r{}C!gwgXZCJP-IA@Uq_*-sgwT1_fn1Tw zWHL9(1OCj}Ecteoi#&5|S2(2il<1eVPEh^rP$V%|EfC8LmiZASV5P9osWc;;meP7{ z2p02)^tzBMO!u6dG#1jlr+5B{K!tgb-`?Y9ZWZsbtTBD-H+!H@>>1zj$xWe-RA{!< zf=A0uekQ%lkgIcwVbYA-E)2lC`0-w%K$IOfUVv+^T;GMwcXGHTfYBI^4rXy-+XfGP z!Xp)CER4#zH@I3X8#7mH;gYA47m~#?1n96m?8^-jPr8Q2;t?biyB!YkgTq@*gsk- zXi#WE=%du^T@4V$LFIIC0>_4gIXtbj#&~7V>dp!y)Hc&Elf=@=&4z1^-FVCXe&rna zbmrrXE8RADtb4=e4L54OBPEoP(#`6$s=))7@rWo0exx`$RTq1#p^eDhc$$}!?(`>5 z={i)+s}E_-4B|k)U*ELoEO&+uoixmF8f3yetWQi9D7s8(3Gw4RQF!ixULgKIqDlXw zj!sh@%1+qC>m7&SXJUKpqrxRSahY$c(yd3O%5|$0!xWJb*fp)WhT-VcVT+0VpUu%V zk}?jdKsnKx+xLDvyn+vPy$KEL))2xSi^Ql*3HP|F>Id)LrbAETRV2YkFkCX+HlcJa zuFI*czgkO=S<^35a?>F6$lU~~QFT+9^)-r5o1?1S#7e0#aV>C3gmZt45dj@k49uz5 zMg0;ureKPQ#ZYd1V^T)+xsF?K=QXq?-wT{Rww+}hjMwSebMyy`Nn0%%Hhsneqkm@T zEK4G5=NfjPcQYWwN1TJyu#9E#u*)htw|mV;)MZ9Q{Yv%4Ov)Jw1?=h5{e-XPBUC0$ z{S@8SRT~_*4$)fb+P3lK;HOAK$-?%h7c|mqd_vEQvL0f@=S9ruOhxs#P;N-GG@so@ zu$b@h=i22 zuDg$T!v~aF`s(K)zxeaT$Aro?9U|NCu_8eX@*+qkVL>J(XgIVaoReUz*qowh{kGRo zWS-)7pf)lf%E|CO=voO+XPg2VAnt0ha-G}!B>Bpj7<7H0v8+ml+KCV>Qwh;iUb|+B zS>hM>_DgB72z3_N^L%0zia*P+e!CfEQw*K&yvyBdsm-Y2X(KTT8?KTt-B|JHJ6Gu_ zAg;L<5r2ozui#9_6FVD@3o9sn1tT;){ zq}(_W2`*Wu)N}qtnEt1OCcR`b5$x6UBURUZF+{M@lk}C6KC(JLosk9$X@BBHl6F5( z$iWI;ZF1>a3hjz%b$nKy@%;5te!*dS-nIBHjm_}uh^EP5lj4^k=qL}lbX}LknUEa6F zOtUQk!ldEuHhAjUhLE;t+qtL@9qozrbW?JO@Y=QJvtau{~oJ4RLiux!xg_9E&P*`6jz zihm*lDhlg2duX54Q8ac}`EJTGn^nKc zmj(P+Fs1R9;B|hNGzH^4f}(YBi>t1^D0YJXDk}slA;92%!;SOY5>5uxH&zn%6h{B< z-!?OH3i5UIlzd~EE-=!9>`$R3i14eU;zJ)|YzpN|C#y3Ypu#N{#C5QMHn5wlmue0I~sH3zG|}Ly?49 zTpE_eqwWeRPZ53iZ;G>9R=v|~*-nZc<^rw#JGtFkTk!#wEi zBD{x8#X$Fb>hfE8r<{eM>tS7A{~Oc3)QTx&fTr~4kkT-Q*S4&h-=g(Vc0HwAZC9fm z0xI0ks)?J-R+QvCpg1~m5TU8Xd!u6PyOzt%aF1N)mmJnfWAar!H0kS>LA2P~(`1LY zh^Ve-xTBd(Yv#8oQ82|=}fBsD~;gPBZ_kmQSNT3xpTUyEVf~}-Ju#7aODhMY>0G| z*jU_ZHcxweMqv;Xi2`JgsD`m>)PD)*pX*yyp<@>^R2p^W$$ApZWsrA-+(iuA)AZoe zO+iqrp#llnP`KZbo2G0RYD(o*%q&$Vcd}7|Y~>^}NYo*XvQ)%u>nDoK>+0eL(5;g2 z)0VDvAoe1)4GKm`UenN$@l21Z3lG6m0!-eq=w$W-dF}vKq6OzCL@lm;w zi2m;TKR^}>%R`>wZ!zXErz;73LGN+eTAVzmrMLC?lhcLNR1J(D_P@0rk{^EdLTVc_-4MkxG zQq(Q+A_F{W+ayVRX>%BACvzMvv1~VF|FtP;`H_#4^R@V4!-ne<^WPU27y8ayo-&>B zV7z$-3Cgkl-W(XBq_3rtRwm|fx08uinTHGNe-5fkbgXcc7%%7$uBWUP)3Rzr9_Saw zJIb>AnQmhx^m3{pLnSMD1;ekj#V6u_v87J%>A&i~{eQ0f)9j##EHcOUai~2J3Ljnu z60#9i6!=AAeDu*)7n7uUTPYXY$?Y*|-|@zw;;*stS05otXIiAnO2}`)rnab+d9^U2 zGLP5#I=Zv3P+x}kcM83Vyf|izt>0|81)1!cQp~W(2y~4fJXCK}fo@nN3TbhQa}k}N zcni=(_I5w5j)|RG?erS4oS1A!CT z($*8x5+G!NZN@Rq>LFuw)xAY)>qq?U?d@m?0|!nsf$v@qgL`csgHgo*pI^ntI~5ht z0CPe;7y+6QH>(B*xzFTPm{x+p`5?H6h}d+ws)fG(DwG{fO z>zTUsJ{QZxvZs9fF)8Of1PibWI4 z*1Kg-MO%RKA8R92Yb-VWz?6C|NJ?R@0>}t-XCmAY=vm>makrDW$PP6rWKb;cZ3g0M zh-M2hwX3|9A6pL_l7biy-l8QKCcQHr*WAL}=BP)|)7y#bI(O zbLq-IkoIttc}^fN>F33EdfM`+sqLL&ULis@{uPP#*STXTdrK-B~7|;p&AcgozzRI8~fcu9(wl!mv}sK zTQSXVfG-uwUVl2(5L_FhUEP-N^|R;n-Ml`9C?VnZQ;%p8`V{#zc-mi3s;4tzqcY}flXhMuZS{ZTk6{#CFG`xANORtje}G9m zrEhWa-D75!yPG+~{dVeYkthCltldIx7GK1cCX@~LPPvl=W1mhr_l$q|!R8S_jK%6q zjd$;JA?#_tWIch!siKXe)sBA6eI>j|G7OP}+w|P)fw?*6i;0dtqJ~fRI2HvN%9iAm zb!1X?oyOcQcM@V?ms;>LtC>C1WcitNe6lU3bwE>lK_5N>tQFWjIT!k+DG74v zaFbFjj3bGKA;Y6}XX^prHYU zuPMnd_`L|kq)k@ueNvZ6ugJQ||7B1@)3585RikW6mbCX1Z%EvLlUN1ZLAzaC{#fro zZYlQ?y#KqqdBqGet{gI#vp4>{h;vq9?b>JIDfi9UKZLGrYJjsx4rPT{5~nF%Ag)o5 zpY`FlhVxNt%UQ+ySyc^tGEb&fUsW!i@3hX}ZfC(J@I@aB+HLn;mYSJX-r* z9VOZADEm)7EsFfI`Y9c0&z{hSb=QV7XxvOv6q~8Gs#>G-jqpz|N&S1w7fEYUF4}z~ zA#YQd(m+%>1ortm)!eJF-;l@_e7rGD26zJFjpm`=dOws`3ww5Q7iD746u;ciud#c) z;yN7~T=j+hx%Gv+N^t_Hw#AjyI{_ADQBkD7aM$ZRd9>Aj6xI#B+bxI6>5q{}fo5df z>|}8&?}RXPM42~q;h zJ&l|g-4p+9{aar`TV^#T1Dt+vI$%FdGyb0vwZ;=jGh97p}BPMQ=Tyr`aV_DUa5*1~_Mz zI%VqR7Uo_`>RnwQnM{D={y;ih+2AL2pyR&{;A(}vNZO+l3^ye$`3OI=BuWtMkp`Jo zupZbYiyzvi#jW)?SZuYcwvOeEl5dc~JMi}`TH;?SKYr>Fy*q*G=d*r`{4ecX=Tp;Z z9@ljcQ4tZ5CT5fXp*N{gRz2w^6ltLuFq9y=0fK-?^pGYcCv{;VS%eTG!JrswNQ4Af z5~UXruo^zQn4+twyUyy*^fh)Y!6syUdi%j z?x@A*PRT2rn}YG~^p%RB(Q{{Mc;lviO9Snk70s))OWbeOElI`GYl6_U}FlFv>=9wxVVvXMg7RI0dQllBWr!Ak`qDk`R4dt5r}hO#qL7keYFG$3?07Rz>j zI}5zOgYuT=`hKlv_gJaBJ}3!ag61uS4{tXI!#3c|da0uSiVKDu^13=^Og$c9@%@=S zUsOEP_AJmBm0(?8ISC^zCInO1+iF`pIl=OEBi zZjYxXkP7M;NRLkyo?Njf^DUb>MN{MOoOiHa{o($T+D?(dq(mezUy|py8?JC!9Y;66 zrIH)lVG9ZX-ccG2e|s|-W@ykfoVH8i7xi0t)nv}V#A;lBonM?W@Dmjl5TQjED~ViI zF)=A@2;S4J`wrESe}PcbW4qW+-p4i-^$RT@9m5w1;pl~KuYHT~#!>u+O*!b0 zsHvbGB$H^0;DgfeI>2@|U58ALP`=Mnq!rdzIdh&5whL+$s&ceiqZW%|-Q`!8=ii#T zunb>GZF;4fMcYg}kICesO|BQunlq$%(nTTfEq2cOU*x%vm(eF5lWeJ41&Uj{)nh5o zUCCy~>f1391MHz}KPxzf>@;0wPVU_H%8ZS8oZoHQNm-EY^^nO+&DoVz6Bnc%bijrW%e#QTK1L#t2ut3#i0yj zT3H@Y4?WM_=!7^%NN0FCN!mK}*zDc63cePJ(dzA8CxNlrSQ=radz~w2ALF;|q*1D# zPPG{>ys$I^`dD|P!>1*s1+QHvCoY4j)=X-=-UaJcN>4QANK;F)6V7R9Rk0mAMmWsC z?StY}3pu#sQ^@Q-bc~1g*pp5B!P>H5KE@oeFr1|V)8#hv73}ohbQNf{#Gr!aGTB4k zX}eY7rTl1A#u!0WzIBM@-aivWBP?2 zDzkB{hfPMfjRmA%D9hYNdJ2vzHL_H;7^&;^Y;!(g0O9M)mesBRA~R%!|9vMD0#r`n zNjq#DftC$})sWQkYX{LgYIKrxyXtgBO^p%;Zy5l%R5ufYjgX z29q3N9Q`WXK%WR%0`O1ohdQ6K89I40u#P3_(>qcbbS2$Zl8O5<&= zK9jsqgIU$qlcrcbw0gg3w|0qp{KYum%<8Yb1c7J#@m5)I!C;MDdiAZH1Ppk~HPw)Q zCVK?V`yGN#b@kezN~b<}K$J5RmWg}NmQD+Cq!`!^WPAD2jF@fRw@PaeFOBy|Bo9p& zDsc|BSvpiM=$$jo=8cG0HbMHV)~+>T%YW}jWG%%mWx~O=YkR*hN8BV}F)y~zE2~Q* zQFo$t)7nF+*>*qQELn{V%%2vC)&x9Zzcedy^ZjJ&HwH6k3H-@)#Ry0lyN%0)$$1Ad zR1VZ_EW_uov!&;%K5mp!9G8!r!+SlCzs5Gv{f@6N=>N-q&gyj?BEZ>$cbeUVeB-zy2hF31F93XKp)_NBIn^ zo>wJaS)@nPS!eVt*u8SwWO@?vEI}%zCenO5xqHa1t$C16zYSAD@G;5lBhU9>(q@BC z9_(s-^>K(dzq|*^4FuUtRS{@wD`Jw91uulx?*0%5-OQjX0}T>F*GRS~d>a?N%O!Y22m5 zG3EF#5}rDyM|YfGq+YNbbfi&=xWP?0<8F0@8LJfaHf_V0AYmz0^OZkeGe_?wpkxr_ zlMxc+Q=FliVGhpg0dF|Wmh9(zU$4QG+yQWdLySGZuKM>5pFfkj{QoI`Vg~ + + diff --git a/frontend/src/flows/assets/icon-llm.jpg b/frontend/src/flows/assets/icon-llm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4db9e0b7785f08a25601b3ea7c55103d34026fde GIT binary patch literal 11089 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 literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-minimap.tsx b/frontend/src/flows/assets/icon-minimap.tsx new file mode 100644 index 0000000..ae45379 --- /dev/null +++ b/frontend/src/flows/assets/icon-minimap.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +export const IconMinimap = () => ( + + + + + + +); diff --git a/frontend/src/flows/assets/icon-mouse.tsx b/frontend/src/flows/assets/icon-mouse.tsx new file mode 100644 index 0000000..17d879a --- /dev/null +++ b/frontend/src/flows/assets/icon-mouse.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +export function IconMouse(props: { width?: number; height?: number }) { + const { width, height } = props; + return ( + + + + ); +} + +export const IconMouseTool = () => ( + + + +); diff --git a/frontend/src/flows/assets/icon-pad.tsx b/frontend/src/flows/assets/icon-pad.tsx new file mode 100644 index 0000000..45cf741 --- /dev/null +++ b/frontend/src/flows/assets/icon-pad.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +export function IconPad(props: { width?: number; height?: number }) { + const { width, height } = props; + return ( + + + + + ); +} + +export const IconPadTool = () => ( + + + + +); diff --git a/frontend/src/flows/assets/icon-script.png b/frontend/src/flows/assets/icon-script.png new file mode 100644 index 0000000000000000000000000000000000000000..0c5beb7a192c5af02314aca1407d8395245c671f GIT binary patch literal 3659 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 Cbfdul literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-start.jpg b/frontend/src/flows/assets/icon-start.jpg new file mode 100644 index 0000000000000000000000000000000000000000..72a5a48e60a4b93f4cea42c9e2e45bd9c1a3f01f GIT binary patch literal 20971 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 Q`RC+6A^02G^pDX$0Rx_1qW}N^ literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-success.tsx b/frontend/src/flows/assets/icon-success.tsx new file mode 100644 index 0000000..028a450 --- /dev/null +++ b/frontend/src/flows/assets/icon-success.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +interface Props { + className?: string; + style?: React.CSSProperties; +} + +export const IconSuccessFill = ({ className, style }: Props) => ( + + + + + + + + + + + +); diff --git a/frontend/src/flows/assets/icon-switch-line.tsx b/frontend/src/flows/assets/icon-switch-line.tsx new file mode 100644 index 0000000..53ba1ff --- /dev/null +++ b/frontend/src/flows/assets/icon-switch-line.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +export const IconSwitchLine = ( + + + +); diff --git a/frontend/src/flows/assets/icon-variable.png b/frontend/src/flows/assets/icon-variable.png new file mode 100644 index 0000000000000000000000000000000000000000..558ede149c154d661e3afc2d331b4c69180b1822 GIT binary patch literal 3430 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 literal 0 HcmV?d00001 diff --git a/frontend/src/flows/assets/icon-warning.tsx b/frontend/src/flows/assets/icon-warning.tsx new file mode 100644 index 0000000..d6c18ab --- /dev/null +++ b/frontend/src/flows/assets/icon-warning.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +interface Props { + className?: string; + style?: React.CSSProperties; +} + +export const IconWarningFill = ({ className, style }: Props) => ( + + + +); diff --git a/frontend/src/flows/components/add-node/index.tsx b/frontend/src/flows/components/add-node/index.tsx new file mode 100644 index 0000000..5313777 --- /dev/null +++ b/frontend/src/flows/components/add-node/index.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { Button } from '@douyinfe/semi-ui'; +import { IconPlus } from '@douyinfe/semi-icons'; +import { I18n } from '@flowgram.ai/free-layout-editor'; + +import { useAddNode } from './use-add-node'; + +export const AddNode = (props: { disabled: boolean }) => { + const addNode = useAddNode(); + return ( + + ); +}; diff --git a/frontend/src/flows/components/add-node/use-add-node.ts b/frontend/src/flows/components/add-node/use-add-node.ts new file mode 100644 index 0000000..486c018 --- /dev/null +++ b/frontend/src/flows/components/add-node/use-add-node.ts @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ +import { useCallback } from 'react'; + +import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin'; +import { + useService, + WorkflowDocument, + usePlayground, + PositionSchema, + WorkflowNodeEntity, + WorkflowSelectService, + WorkflowNodeJSON, + getAntiOverlapPosition, + WorkflowNodeMeta, + FlowNodeBaseType, +} from '@flowgram.ai/free-layout-editor'; +// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook +const useGetPanelPosition = () => { + const playground = usePlayground(); + return useCallback( + (targetBoundingRect: DOMRect): PositionSchema => + // convert mouse position to canvas position - 将鼠标位置转换为画布位置 + playground.config.getPosFromMouseEvent({ + clientX: targetBoundingRect.left + 64, + clientY: targetBoundingRect.top - 7, + }), + [playground] + ); +}; +// hook to handle node selection - 处理节点选择的 hook +const useSelectNode = () => { + const selectService = useService(WorkflowSelectService); + return useCallback( + (node?: WorkflowNodeEntity) => { + if (!node) { + return; + } + // select the target node - 选择目标节点 + selectService.selectNode(node); + }, + [selectService] + ); +}; + +const getContainerNode = (selectService: WorkflowSelectService) => { + const { activatedNode } = selectService; + if (!activatedNode) { + return; + } + const { isContainer } = activatedNode.getNodeMeta(); + if (isContainer) { + return activatedNode; + } + const parentNode = activatedNode.parent; + if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) { + return; + } + return parentNode; +}; + +// main hook for adding new nodes - 添加新节点的主 hook +export const useAddNode = () => { + const workflowDocument = useService(WorkflowDocument); + const nodePanelService = useService(WorkflowNodePanelService); + const selectService = useService(WorkflowSelectService); + const playground = usePlayground(); + const getPanelPosition = useGetPanelPosition(); + const select = useSelectNode(); + + return useCallback( + async (targetBoundingRect: DOMRect): Promise => { + // calculate panel position based on target element - 根据目标元素计算面板位置 + const panelPosition = getPanelPosition(targetBoundingRect); + const containerNode = getContainerNode(selectService); + await new Promise((resolve) => { + // call the node panel service to show the panel - 调用节点面板服务来显示面板 + nodePanelService.callNodePanel({ + position: panelPosition, + enableMultiAdd: true, + containerNode, + panelProps: {}, + // handle node selection from panel - 处理从面板中选择节点 + onSelect: async (panelParams?: NodePanelResult) => { + if (!panelParams) { + return; + } + const { nodeType, nodeJSON } = panelParams; + const position = Boolean(containerNode) + ? getAntiOverlapPosition(workflowDocument, { + x: 0, + y: 200, + }) + : undefined; + // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点 + const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType( + nodeType, + position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点 + nodeJSON ?? ({} as WorkflowNodeJSON), + containerNode?.id + ); + select(node); + }, + // handle panel close - 处理面板关闭 + onClose: () => { + resolve(); + }, + }); + }); + }, + [getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select] + ); +}; diff --git a/frontend/src/flows/components/base-node/index.tsx b/frontend/src/flows/components/base-node/index.tsx new file mode 100644 index 0000000..d356c15 --- /dev/null +++ b/frontend/src/flows/components/base-node/index.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { useCallback } from 'react'; + +import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor'; +import { ConfigProvider } from '@douyinfe/semi-ui'; + +import { NodeStatusBar } from '../testrun/node-status-bar'; +import { NodeRenderContext } from '../../context'; +import { ErrorIcon } from './styles'; +import { NodeWrapper } from './node-wrapper'; + +export const BaseNode = ({ node }: { node: FlowNodeEntity }) => { + /** + * Provides methods related to node rendering + * 提供节点渲染相关的方法 + */ + const nodeRender = useNodeRender(); + /** + * It can only be used when nodeEngine is enabled + * 只有在节点引擎开启时候才能使用表单 + */ + const form = nodeRender.form; + + /** + * Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library + * 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现 + */ + const getPopupContainer = useCallback(() => node.renderData.node || document.body, []); + + return ( + + + + {form?.state.invalid && } + {form?.render()} + + + + + ); +}; diff --git a/frontend/src/flows/components/base-node/node-wrapper.tsx b/frontend/src/flows/components/base-node/node-wrapper.tsx new file mode 100644 index 0000000..55d9c45 --- /dev/null +++ b/frontend/src/flows/components/base-node/node-wrapper.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import React, { useState, useContext } from 'react'; + +import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor'; +import { useClientContext } from '@flowgram.ai/free-layout-editor'; + +import { FlowNodeMeta } from '../../typings'; +import { useNodeRenderContext, usePortClick } from '../../hooks'; +import { SidebarContext } from '../../context'; +import { scrollToView } from './utils'; +import { NodeWrapperStyle } from './styles'; + +export interface NodeWrapperProps { + isScrollToView?: boolean; + children: React.ReactNode; +} + +/** + * Used for drag-and-drop/click events and ports rendering of nodes + * 用于节点的拖拽/点击事件和点位渲染 + */ +export const NodeWrapper: React.FC = (props) => { + const { children, isScrollToView = false } = props; + const nodeRender = useNodeRenderContext(); + const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } = + nodeRender; + const [isDragging, setIsDragging] = useState(false); + const sidebar = useContext(SidebarContext); + const form = nodeRender.form; + const ctx = useClientContext(); + const onPortClick = usePortClick(); + const meta = node.getNodeMeta(); + + const portsRender = ports.map((p) => ( + + )); + + return ( + <> + { + startDrag(e); + setIsDragging(true); + }} + onTouchStart={(e) => { + startDrag(e as unknown as React.MouseEvent); + setIsDragging(true); + }} + onClick={(e) => { + selectNode(e); + if (!isDragging) { + sidebar.setNodeId(nodeRender.node.id); + // 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间 + // Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected. + if (isScrollToView) { + scrollToView(ctx, nodeRender.node); + } + } + }} + onMouseUp={() => setIsDragging(false)} + onFocus={onFocus} + onBlur={onBlur} + data-node-selected={String(selected)} + style={{ + ...meta.wrapperStyle, + outline: form?.state.invalid ? '1px solid red' : 'none', + }} + > + {children} + + {portsRender} + + ); +}; diff --git a/frontend/src/flows/components/base-node/styles.tsx b/frontend/src/flows/components/base-node/styles.tsx new file mode 100644 index 0000000..f90a6d2 --- /dev/null +++ b/frontend/src/flows/components/base-node/styles.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import styled from 'styled-components'; +import { IconInfoCircle } from '@douyinfe/semi-icons'; + +export const NodeWrapperStyle = styled.div` + align-items: flex-start; + background-color: #fff; + border: 1px solid rgba(6, 7, 9, 0.15); + border-radius: 8px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02); + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + width: 360px; + height: auto; + + &.selected { + border: 1px solid #4e40e5; + } +`; + +export const ErrorIcon = () => ( + +); diff --git a/frontend/src/flows/components/base-node/utils.ts b/frontend/src/flows/components/base-node/utils.ts new file mode 100644 index 0000000..b9d4737 --- /dev/null +++ b/frontend/src/flows/components/base-node/utils.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor'; + +export function scrollToView( + ctx: FreeLayoutPluginContext, + node: FlowNodeEntity, + sidebarWidth = 448 +) { + const bounds = node.transform.bounds; + ctx.playground.scrollToView({ + bounds, + scrollDelta: { + x: sidebarWidth / 2, + y: 0, + }, + zoom: 1, + scrollToCenter: true, + }); +} diff --git a/frontend/src/flows/components/comment/components/blank-area.tsx b/frontend/src/flows/components/comment/components/blank-area.tsx new file mode 100644 index 0000000..3a4d6fc --- /dev/null +++ b/frontend/src/flows/components/comment/components/blank-area.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import type { FC } from 'react'; + +import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; + +import type { CommentEditorModel } from '../model'; +import { DragArea } from './drag-area'; + +interface IBlankArea { + model: CommentEditorModel; +} + +export const BlankArea: FC = (props) => { + const { model } = props; + const playground = usePlayground(); + const { selectNode } = useNodeRender(); + + return ( +

+ ); +}; diff --git a/frontend/src/flows/components/comment/components/border-area.tsx b/frontend/src/flows/components/comment/components/border-area.tsx new file mode 100644 index 0000000..f353a8e --- /dev/null +++ b/frontend/src/flows/components/comment/components/border-area.tsx @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { type FC } from 'react'; + +import type { CommentEditorModel } from '../model'; +import { ResizeArea } from './resize-area'; +import { DragArea } from './drag-area'; + +interface IBorderArea { + model: CommentEditorModel; + overflow: boolean; + onResize?: () => { + resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void; + resizeEnd: () => void; + }; +} + +export const BorderArea: FC = (props) => { + const { model, overflow, onResize } = props; + + return ( +
+ {/* 左边 */} + + {/* 右边 */} + + {/* 上边 */} + + {/* 下边 */} + + {/** 左上角 */} + ({ top: y, right: 0, bottom: 0, left: x })} + onResize={onResize} + /> + {/** 右上角 */} + ({ top: y, right: x, bottom: 0, left: 0 })} + onResize={onResize} + /> + {/** 右下角 */} + ({ top: 0, right: x, bottom: y, left: 0 })} + onResize={onResize} + /> + {/** 左下角 */} + ({ top: 0, right: 0, bottom: y, left: x })} + onResize={onResize} + /> +
+ ); +}; diff --git a/frontend/src/flows/components/comment/components/container.tsx b/frontend/src/flows/components/comment/components/container.tsx new file mode 100644 index 0000000..c823d98 --- /dev/null +++ b/frontend/src/flows/components/comment/components/container.tsx @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import type { ReactNode, FC, CSSProperties } from 'react'; + +interface ICommentContainer { + focused: boolean; + children?: ReactNode; + style?: React.CSSProperties; +} + +export const CommentContainer: FC = (props) => { + const { focused, children, style } = props; + + const scrollbarStyle = { + // 滚动条样式 + scrollbarWidth: 'thin', + scrollbarColor: 'rgb(159 159 158 / 65%) transparent', + // 针对 WebKit 浏览器(如 Chrome、Safari)的样式 + '&:WebkitScrollbar': { + width: '4px', + }, + '&::WebkitScrollbarTrack': { + background: 'transparent', + }, + '&::WebkitScrollbarThumb': { + backgroundColor: 'rgb(159 159 158 / 65%)', + borderRadius: '20px', + border: '2px solid transparent', + }, + } as unknown as CSSProperties; + + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/flows/components/comment/components/content-drag-area.tsx b/frontend/src/flows/components/comment/components/content-drag-area.tsx new file mode 100644 index 0000000..065a32d --- /dev/null +++ b/frontend/src/flows/components/comment/components/content-drag-area.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { type FC, useState, useEffect, type WheelEventHandler } from 'react'; + +import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; + +import type { CommentEditorModel } from '../model'; +import { DragArea } from './drag-area'; + +interface IContentDragArea { + model: CommentEditorModel; + focused: boolean; + overflow: boolean; +} + +export const ContentDragArea: FC = (props) => { + const { model, focused, overflow } = props; + const playground = usePlayground(); + const { selectNode } = useNodeRender(); + + const [active, setActive] = useState(false); + + useEffect(() => { + // 当编辑器失去焦点时,取消激活状态 + if (!focused) { + setActive(false); + } + }, [focused]); + + const handleWheel: WheelEventHandler = (e) => { + const editorElement = model.element; + if (active || !overflow || !editorElement) { + return; + } + e.stopPropagation(); + const maxScroll = editorElement.scrollHeight - editorElement.clientHeight; + const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll); + editorElement.scroll(0, newScrollTop); + }; + + const handleMouseDown = (mouseDownEvent: React.MouseEvent) => { + if (active) { + return; + } + mouseDownEvent.preventDefault(); + mouseDownEvent.stopPropagation(); + model.setFocus(false); + selectNode(mouseDownEvent); + playground.node.focus(); // 防止节点无法被删除 + + const startX = mouseDownEvent.clientX; + const startY = mouseDownEvent.clientY; + + const handleMouseUp = (mouseMoveEvent: MouseEvent) => { + const deltaX = mouseMoveEvent.clientX - startX; + const deltaY = mouseMoveEvent.clientY - startY; + // 判断是拖拽还是点击 + const delta = 5; + if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) { + // 点击后隐藏 + setActive(true); + } + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('click', handleMouseUp); + }; + + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('click', handleMouseUp); + }; + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/flows/components/comment/components/drag-area.tsx b/frontend/src/flows/components/comment/components/drag-area.tsx new file mode 100644 index 0000000..0a2fbe6 --- /dev/null +++ b/frontend/src/flows/components/comment/components/drag-area.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react'; + +import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor'; + +import { type CommentEditorModel } from '../model'; + +interface IDragArea { + model: CommentEditorModel; + stopEvent?: boolean; + style?: CSSProperties; +} + +export const DragArea: FC = (props) => { + const { model, stopEvent = true, style } = props; + + const playground = usePlayground(); + + const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender(); + + const handleDrag = (e: MouseEvent | TouchEvent) => { + if (stopEvent) { + e.preventDefault(); + e.stopPropagation(); + } + model.setFocus(false); + onStartDrag(e as MouseEvent); + selectNode(e as MouseEvent); + playground.node.focus(); // 防止节点无法被删除 + }; + + return ( +
+ ); +}; diff --git a/frontend/src/flows/components/comment/components/editor.tsx b/frontend/src/flows/components/comment/components/editor.tsx new file mode 100644 index 0000000..95ce6da --- /dev/null +++ b/frontend/src/flows/components/comment/components/editor.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + */ + +import { type FC, type CSSProperties, useEffect, useRef, useState, useMemo } from 'react'; + +import { usePlayground } from '@flowgram.ai/free-layout-editor'; + +import { CommentEditorModel } from '../model'; +import { CommentEditorEvent } from '../constant'; + +interface ICommentEditor { + model: CommentEditorModel; + style?: CSSProperties; + value?: string; + onChange?: (value: string) => void; +} + +export const CommentEditor: FC = (props) => { + const { model, style, onChange } = props; + const playground = usePlayground(); + const editorRef = useRef(null); + const placeholder = model.value || model.focused ? undefined : 'Enter a comment...'; + + // 同步编辑器内部值变化 + useEffect(() => { + const disposer = model.on((params) => { + if (params.type !== CommentEditorEvent.Change) { + return; + } + onChange?.(model.value); + }); + return () => disposer.dispose(); + }, [model, onChange]); + + useEffect(() => { + if (!editorRef.current) { + return; + } + model.element = editorRef.current; + }, [editorRef]); + + return ( +
+

{placeholder}

+