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 0000000..073abbc Binary files /dev/null and b/backend/udmin_ai.db differ 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 0000000..c06b1ab Binary files /dev/null and b/frontend/src/flows/assets/icon-break.jpg differ 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 0000000..095acb2 Binary files /dev/null and b/frontend/src/flows/assets/icon-continue.jpg differ diff --git a/frontend/src/flows/assets/icon-end.jpg b/frontend/src/flows/assets/icon-end.jpg new file mode 100644 index 0000000..46bc47d Binary files /dev/null and b/frontend/src/flows/assets/icon-end.jpg differ diff --git a/frontend/src/flows/assets/icon-http.svg b/frontend/src/flows/assets/icon-http.svg new file mode 100644 index 0000000..724a425 --- /dev/null +++ b/frontend/src/flows/assets/icon-http.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/flows/assets/icon-llm.jpg b/frontend/src/flows/assets/icon-llm.jpg new file mode 100644 index 0000000..4db9e0b Binary files /dev/null and b/frontend/src/flows/assets/icon-llm.jpg differ diff --git a/frontend/src/flows/assets/icon-loop.jpg b/frontend/src/flows/assets/icon-loop.jpg new file mode 100644 index 0000000..dc26db1 Binary files /dev/null and b/frontend/src/flows/assets/icon-loop.jpg differ 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 0000000..0c5beb7 Binary files /dev/null and b/frontend/src/flows/assets/icon-script.png differ diff --git a/frontend/src/flows/assets/icon-start.jpg b/frontend/src/flows/assets/icon-start.jpg new file mode 100644 index 0000000..72a5a48 Binary files /dev/null and b/frontend/src/flows/assets/icon-start.jpg differ 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 0000000..558ede1 Binary files /dev/null and b/frontend/src/flows/assets/icon-variable.png differ 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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + model.setFocus(false); + selectNode(e); + playground.node.focus(); // 防止节点无法被删除 + }} + onClick={(e) => { + model.setFocus(true); + model.selectEnd(); + }} + > + +
+ ); +}; 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}

+