feat(flows): 新增流程编辑器基础功能与相关组件
feat(backend): 添加流程模型与服务支持 feat(frontend): 实现流程编辑器UI与交互 feat(assets): 添加流程节点图标资源 feat(plugins): 实现上下文菜单和运行时插件 feat(components): 新增基础节点和侧边栏组件 feat(routes): 添加流程相关路由配置 feat(models): 创建流程和运行日志数据模型 feat(services): 实现流程服务层逻辑 feat(migration): 添加流程相关数据库迁移 feat(config): 更新前端配置支持流程编辑器 feat(utils): 增强axios错误处理和工具函数
85
backend/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
19
backend/branch-async-create.json
Normal file
@ -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 } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
22
backend/branch-sync-create.json
Normal file
@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
3
backend/flow_create.json
Normal file
@ -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"
|
||||
}
|
||||
15
backend/linear-async-create.json
Normal file
@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
15
backend/linear-sync-create.json
Normal file
@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
16
backend/migration/src/m20220101_000011_create_workflows.rs
Normal file
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
40
backend/migration/src/m20220101_000014_create_flows.rs
Normal file
@ -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,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
65
backend/migration/src/m20220101_000016_dedup_flows_code.rs
Normal file
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -13,3 +15,18 @@ pub async fn init_db() -> anyhow::Result<Db> {
|
||||
let conn = Database::connect(opt).await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// ===== Global DB connection (OnceCell) =====
|
||||
static GLOBAL_DB: OnceCell<Db> = 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")))
|
||||
}
|
||||
@ -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::<serde_json::Value> { code, message: msg, data: None })).into_response()
|
||||
|
||||
27
backend/src/flow/context.rs
Normal file
@ -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 } }
|
||||
}
|
||||
44
backend/src/flow/domain.rs
Normal file
@ -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<String>, // 绑定的任务组件标识
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LinkDef {
|
||||
pub from: NodeId,
|
||||
pub to: NodeId,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>, // 条件脚本,返回 bool
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ChainDef {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
pub nodes: Vec<NodeDef>,
|
||||
#[serde(default)]
|
||||
pub links: Vec<LinkDef>,
|
||||
}
|
||||
263
backend/src/flow/dsl.rs
Normal file
@ -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<String>,
|
||||
pub nodes: Vec<NodeDSL>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<EdgeDSL>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
impl From<FlowDSL> 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<super::domain::ChainDef> {
|
||||
use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef};
|
||||
|
||||
// Accept both JSON object and stringified JSON
|
||||
let parsed: Option<Value> = match design {
|
||||
Value::String(s) => serde_json::from_str::<Value>(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<NodeDef> = 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<LinkDef> = 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<String> = 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.<id>.http
|
||||
pub fn ctx_from_design_json(design: &Value) -> Value {
|
||||
use serde_json::json;
|
||||
|
||||
// Accept both JSON object and stringified JSON
|
||||
let parsed: Option<Value> = match design {
|
||||
Value::String(s) => serde_json::from_str::<Value>(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) })
|
||||
}
|
||||
173
backend/src/flow/engine.rs
Normal file
@ -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<String>)> {
|
||||
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<String> = 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::<serde_json::Value>(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::<bool>(&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<bool> {
|
||||
// 目前支持前端 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<serde_json::Value> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
261
backend/src/flow/executors/db.rs
Normal file
@ -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<sea_orm::Value> = match params {
|
||||
None => vec![],
|
||||
Some(Value::Array(arr)) => arr.into_iter().map(json_to_db_value).collect::<anyhow::Result<_>>()?,
|
||||
Some(Value::Object(obj)) => {
|
||||
// 对命名参数对象,保持插入顺序不可控,这里仅将值收集为位置绑定,建议 SQL 使用 `?` 占位
|
||||
obj.into_iter().map(|(_, v)| json_to_db_value(v)).collect::<anyhow::Result<_>>()?
|
||||
}
|
||||
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<Value>, Option<String>, Option<Value>, Option<String>)> {
|
||||
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<String> {
|
||||
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<Value>) -> Option<String> {
|
||||
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<sea_orm::Value> {
|
||||
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::<Option<String>>("", col_name) { return v.map(Value::String).unwrap_or(Value::Null); }
|
||||
if let Ok(v) = row.try_get::<Option<i64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
|
||||
if let Ok(v) = row.try_get::<Option<u64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
|
||||
if let Ok(v) = row.try_get::<Option<f64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
|
||||
if let Ok(v) = row.try_get::<Option<bool>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
|
||||
// 回退:按索引读取成字符串
|
||||
if let Ok(v) = row.try_get_by_index::<Option<String>>(idx) { return v.map(Value::String).unwrap_or(Value::Null); }
|
||||
Value::Null
|
||||
}
|
||||
161
backend/src/flow/executors/http.rs
Normal file
@ -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<u64>,
|
||||
insecure: bool,
|
||||
ca_pem: Option<String>,
|
||||
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.<node_id>.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<String, Value> = 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.<node_id>.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<HashMap<String, String>>,
|
||||
Option<Map<String, Value>>,
|
||||
Option<Value>,
|
||||
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::<HashMap<String, String>>()
|
||||
});
|
||||
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")),
|
||||
}
|
||||
}
|
||||
2
backend/src/flow/executors/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod db;
|
||||
7
backend/src/flow/mod.rs
Normal file
@ -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;
|
||||
15
backend/src/flow/storage.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static STORE: Lazy<Mutex<HashMap<String, String>>> = 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<String> { 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<String> { STORE.lock().unwrap().remove(id) }
|
||||
41
backend/src/flow/task.rs
Normal file
@ -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<String, std::sync::Arc<dyn TaskComponent>>;
|
||||
|
||||
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<RwLock<TaskRegistry>> = 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<String>, task: Arc<dyn TaskComponent>) {
|
||||
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);
|
||||
}
|
||||
@ -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?;
|
||||
|
||||
@ -20,8 +20,10 @@ pub fn encode_token(claims: &Claims, secret: &str) -> Result<String, AppError> {
|
||||
|
||||
pub fn decode_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let data = jsonwebtoken::decode::<Claims>(token, &key, &Validation::default())?;
|
||||
Ok(data.claims)
|
||||
match jsonwebtoken::decode::<Claims>(token, &key, &Validation::default()) {
|
||||
Ok(data) => Ok(data.claims),
|
||||
Err(_) => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
21
backend/src/models/flow.rs
Normal file
@ -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<String>,
|
||||
pub yaml: Option<String>,
|
||||
pub design_json: Option<String>,
|
||||
// 新增:流程编号与备注
|
||||
pub code: Option<String>,
|
||||
pub remark: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
25
backend/src/models/flow_run_log.rs
Normal file
@ -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<String>,
|
||||
pub input: Option<String>,
|
||||
pub output: Option<String>,
|
||||
pub ok: bool,
|
||||
pub logs: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
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 {}
|
||||
@ -10,3 +10,5 @@ pub mod request_log;
|
||||
// 新增岗位与用户岗位关联模型
|
||||
pub mod position;
|
||||
pub mod user_position;
|
||||
pub mod flow;
|
||||
pub mod flow_run_log;
|
||||
13
backend/src/routes/flow_run_logs.rs
Normal file
@ -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<Db> {
|
||||
Router::new().route("/flow_run_logs", get(list))
|
||||
}
|
||||
|
||||
async fn list(State(db): State<Db>, Query(p): Query<flow_run_log_service::ListParams>) -> Json<ApiResponse<flow_run_log_service::PageResp<flow_run_log_service::RunLogItem>>> {
|
||||
match flow_run_log_service::list(&db, p).await {
|
||||
Ok(res) => Json(ApiResponse::ok(res)),
|
||||
Err(e) => Json(ApiResponse::err(500, format!("{}", e))),
|
||||
}
|
||||
}
|
||||
71
backend/src/routes/flows.rs
Normal file
@ -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<Db> {
|
||||
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<u64>, page_size: Option<u64>, keyword: Option<String> }
|
||||
|
||||
async fn list(State(db): State<Db>, Query(p): Query<PageParams>) -> Result<Json<ApiResponse<flow_service::PageResp<flow_service::FlowSummary>>>, 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<String>, name: Option<String>, design_json: Option<serde_json::Value>, code: Option<String>, remark: Option<String> }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateReq { yaml: Option<String>, design_json: Option<serde_json::Value>, name: Option<String>, code: Option<String>, remark: Option<String> }
|
||||
|
||||
async fn create(State(db): State<Db>, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<flow_service::FlowDoc>>, 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<Db>, Path(id): Path<String>, Json(req): Json<UpdateReq>) -> Result<Json<ApiResponse<flow_service::FlowDoc>>, 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<Db>, Path(id): Path<String>) -> Result<Json<ApiResponse<flow_service::FlowDoc>>, 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<Db>, Path(id): Path<String>) -> Result<Json<ApiResponse<serde_json::Value>>, 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<Db>, user: AuthUser, Path(id): Path<String>, Json(req): Json<RunReq>) -> Result<Json<ApiResponse<flow_service::RunResult>>, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Db> {
|
||||
.merge(menus::router())
|
||||
.merge(departments::router())
|
||||
.merge(logs::router())
|
||||
// 合并岗位路由
|
||||
.merge(flows::router())
|
||||
.merge(positions::router())
|
||||
.merge(flow_run_logs::router())
|
||||
}
|
||||
78
backend/src/services/flow_run_log_service.rs
Normal file
@ -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<T> { pub items: Vec<T>, pub total: u64, pub page: u64, pub page_size: u64 }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ListParams { pub page: Option<u64>, pub page_size: Option<u64>, pub flow_id: Option<String>, pub flow_code: Option<String>, pub user: Option<String>, pub ok: Option<bool> }
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct RunLogItem {
|
||||
pub id: i64,
|
||||
pub flow_id: String,
|
||||
pub flow_code: Option<String>,
|
||||
pub input: Option<String>,
|
||||
pub output: Option<String>,
|
||||
pub ok: bool,
|
||||
pub logs: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub started_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
pub duration_ms: i64,
|
||||
}
|
||||
impl From<flow_run_log::Model> 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<String>,
|
||||
pub input: Option<String>,
|
||||
pub output: Option<String>,
|
||||
pub ok: bool,
|
||||
pub logs: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub started_at: DateTime<FixedOffset>,
|
||||
pub duration_ms: i64,
|
||||
}
|
||||
|
||||
pub async fn create(db: &Db, input: CreateRunLogInput) -> anyhow::Result<i64> {
|
||||
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<PageResp<RunLogItem>> {
|
||||
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 })
|
||||
}
|
||||
438
backend/src/services/flow_service.rs
Normal file
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub remark: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[serde(skip_serializing_if = "Option::is_none")] pub last_modified_by: Option<String>,
|
||||
}
|
||||
#[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<serde_json::Value> }
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowCreateReq { pub yaml: Option<String>, pub name: Option<String>, pub design_json: Option<serde_json::Value>, pub code: Option<String>, pub remark: Option<String> }
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowUpdateReq { pub yaml: Option<String>, pub design_json: Option<serde_json::Value>, pub name: Option<String>, pub code: Option<String>, pub remark: Option<String> }
|
||||
#[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<String> }
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct PageResp<T> { pub items: Vec<T>, 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<String>) -> anyhow::Result<PageResp<FlowSummary>> {
|
||||
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<FlowSummary> = 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<FlowDoc> {
|
||||
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<FlowDoc> {
|
||||
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::<serde_json::Value>(&s).ok());
|
||||
Ok(FlowDoc { id: row.id, yaml, design_json })
|
||||
}
|
||||
|
||||
pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<FlowDoc> {
|
||||
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::<serde_json::Value>(&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<RunResult> {
|
||||
info!(target = "udmin", "flow.run: start id={}", id);
|
||||
let start = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
|
||||
// 获取流程编码,便于写入运行日志
|
||||
let flow_code: Option<String> = 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::<FlowDSL>(&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::<FlowDSL>(&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(|_| "<serialize ctx failed>".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<String> {
|
||||
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: Into<anyhow::Error>>(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,
|
||||
}
|
||||
}
|
||||
@ -6,3 +6,5 @@ pub mod department_service;
|
||||
pub mod log_service;
|
||||
// 新增岗位服务
|
||||
pub mod position_service;
|
||||
pub mod flow_service;
|
||||
pub mod flow_run_log_service;
|
||||
BIN
backend/udmin_ai.db
Normal file
@ -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
|
||||
|
||||
@ -0,0 +1,346 @@
|
||||
# 创建自由布局画布
|
||||
|
||||
本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见:
|
||||
|
||||
<div className="rs-tip">
|
||||
<a className="rs-link" href="/examples/free-layout/free-layout-simple.html">
|
||||
自由布局基础用法
|
||||
</a>
|
||||
</div>
|
||||
|
||||
文件结构:
|
||||
|
||||
```
|
||||
- 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 (
|
||||
<FreeLayoutEditorProvider {...editorProps}>
|
||||
<EditorRenderer className="demo-editor" />
|
||||
<Tools />
|
||||
</FreeLayoutEditorProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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<FreeLayoutProps>(
|
||||
() => ({
|
||||
/**
|
||||
* 初始化数据
|
||||
*/
|
||||
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 name="title">
|
||||
{({ field }) => <div className="demo-free-node-title">{field.value}</div>}
|
||||
</Field>
|
||||
<Field name="content">
|
||||
{({ field }) => <input onChange={field.onChange} value={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 (
|
||||
<WorkflowNodeRenderer className="demo-free-node" node={props.node}>
|
||||
{
|
||||
// 表单渲染通过 formMeta 生成
|
||||
form?.render()
|
||||
}
|
||||
</WorkflowNodeRenderer>
|
||||
)
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### 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 <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 226, display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => tools.zoomin()}>ZoomIn</button>
|
||||
<button onClick={() => tools.zoomout()}>ZoomOut</button>
|
||||
<button onClick={() => tools.fitView()}>Fitview</button>
|
||||
<button onClick={() => tools.autoLayout()}>AutoLayout</button>
|
||||
<button onClick={() => history.undo()} disabled={!canUndo}>Undo</button>
|
||||
<button onClick={() => history.redo()} disabled={!canRedo}>Redo</button>
|
||||
<span>{Math.floor(tools.zoom * 100)}%</span>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 效果
|
||||
|
||||
import { FreeLayoutSimple } from '../../../../components';
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: '600px'}}>
|
||||
<FreeLayoutSimple />
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,346 @@
|
||||
# 创建自由布局画布
|
||||
|
||||
本案例可通过 `npx @flowgram.ai/create-app@latest free-layout-simple` 安装,完整代码及效果见:
|
||||
|
||||
<div className="rs-tip">
|
||||
<a className="rs-link" href="/examples/free-layout/free-layout-simple.html">
|
||||
自由布局基础用法
|
||||
</a>
|
||||
</div>
|
||||
|
||||
文件结构:
|
||||
|
||||
```
|
||||
- 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 (
|
||||
<FreeLayoutEditorProvider {...editorProps}>
|
||||
<EditorRenderer className="demo-editor" />
|
||||
<Tools />
|
||||
</FreeLayoutEditorProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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<FreeLayoutProps>(
|
||||
() => ({
|
||||
/**
|
||||
* 初始化数据
|
||||
*/
|
||||
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 name="title">
|
||||
{({ field }) => <div className="demo-free-node-title">{field.value}</div>}
|
||||
</Field>
|
||||
<Field name="content">
|
||||
{({ field }) => <input onChange={field.onChange} value={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 (
|
||||
<WorkflowNodeRenderer className="demo-free-node" node={props.node}>
|
||||
{
|
||||
// 表单渲染通过 formMeta 生成
|
||||
form?.render()
|
||||
}
|
||||
</WorkflowNodeRenderer>
|
||||
)
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### 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 <div style={{ position: 'absolute', zIndex: 10, bottom: 16, left: 226, display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => tools.zoomin()}>ZoomIn</button>
|
||||
<button onClick={() => tools.zoomout()}>ZoomOut</button>
|
||||
<button onClick={() => tools.fitView()}>Fitview</button>
|
||||
<button onClick={() => tools.autoLayout()}>AutoLayout</button>
|
||||
<button onClick={() => history.undo()} disabled={!canUndo}>Undo</button>
|
||||
<button onClick={() => history.redo()} disabled={!canRedo}>Redo</button>
|
||||
<span>{Math.floor(tools.zoom * 100)}%</span>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 效果
|
||||
|
||||
import { FreeLayoutSimple } from '../../../../components';
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: '600px'}}>
|
||||
<FreeLayoutSimple />
|
||||
</div>
|
||||
|
||||
635
frontend/flow-free-layout-json.md
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
412
frontend/flow-free-layout-sj-demo.md
Normal file
@ -0,0 +1,412 @@
|
||||
# 最佳实践
|
||||
|
||||
import { FreeFeatureOverview } from '../../../../components';
|
||||
|
||||
<FreeFeatureOverview />
|
||||
|
||||
## 安装
|
||||
|
||||
```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<FreeLayoutProps>(() => ({
|
||||
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': {},
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
{/* 新增 */}
|
||||
<Route path="/positions" element={<Positions />} />
|
||||
{/* 将 /flows 映射为流程列表 */}
|
||||
<Route path="/flows" element={<FlowList />} />
|
||||
{/* 编辑器:新增与编辑都跳转到此路由,使用查询参数 id 作为标识 */}
|
||||
<Route path="/flows/editor" element={<Flows />} />
|
||||
{/* 流程运行日志 */}
|
||||
<Route path="/flow-run-logs" element={<FlowRunLogs />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
12
frontend/src/flows/app.tsx
Normal file
@ -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(<Editor />);
|
||||
13
frontend/src/flows/assets/icon-auto-layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconAutoLayout = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
BIN
frontend/src/flows/assets/icon-break.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
24
frontend/src/flows/assets/icon-cancel.tsx
Normal file
@ -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) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z"></path>
|
||||
<path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z"></path>
|
||||
</svg>
|
||||
);
|
||||
24
frontend/src/flows/assets/icon-comment.tsx
Normal file
@ -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<IconCommentProps> = ({ style }) => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={style}
|
||||
>
|
||||
<path d="M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z"></path>
|
||||
<path d="M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z"></path>
|
||||
</svg>
|
||||
);
|
||||
9
frontend/src/flows/assets/icon-condition.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3370FF"/>
|
||||
<stop offset="0.997908" stop-color="#33A9FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/flows/assets/icon-continue.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/src/flows/assets/icon-end.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
5
frontend/src/flows/assets/icon-http.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M20.006 4C22.145 4 23.9853 7.39855 24.7651 12.241H15.2469C16.0267 7.39855 17.867 4 20.006 4ZM15.021 20.9119C14.908 19.8023 14.8424 18.6486 14.8424 17.4436C14.8424 16.2421 14.908 15.0848 15.021 13.9752H24.9837C25.0966 15.0848 25.1623 16.2421 25.1623 17.4436C25.1623 18.645 25.0966 19.7987 24.9837 20.9119H15.021ZM23.8044 4.56199C27.6525 5.71199 30.7644 8.55942 32.3022 12.2409H26.4936C26.0199 9.15463 25.1162 6.39537 23.8044 4.56199ZM16.1971 4.56199C12.3563 5.71199 9.23701 8.55942 7.70652 12.2409H13.5151C13.9815 9.15463 14.8852 6.39537 16.1971 4.56199ZM26.7119 13.9752H32.8776C33.1691 15.0848 33.3368 16.2421 33.3368 17.4436C33.3368 18.645 33.1691 19.7987 32.874 20.9119H26.7119C26.8249 19.7766 26.8906 18.6083 26.8906 17.4436C26.8906 16.2789 26.8249 15.1142 26.7119 13.9752ZM13.122 17.4436C13.122 16.2789 13.1876 15.1105 13.3006 13.9752H7.13127C6.83975 15.0885 6.66848 16.2421 6.66848 17.4436C6.66848 18.645 6.83975 19.8023 7.13127 20.912H13.2933C13.1876 19.7767 13.122 18.6119 13.122 17.4436ZM4 25.3373C4 23.8005 5.24582 22.5547 6.78261 22.5547H33.2174C34.7542 22.5547 36 23.8005 36 25.3373V33.2174C36 34.7542 34.7542 36 33.2174 36H6.78261C5.24582 36 4 34.7542 4 33.2174V25.3373ZM10.9109 28.1569H8.48666V25.9161H6.66848V32.6388H8.48666V29.8376H10.9109V32.6388H12.7291V25.9161H10.9109V28.1569ZM13.9412 27.5968H15.7594V32.6388H17.5776V27.5968H19.3958V25.9161H13.9412V27.5968ZM20.6079 27.5968H22.426V32.6388H24.2442V27.5968H26.0625V25.9161H20.6079V27.5968ZM31.5169 25.9161H27.2746V32.6388H29.0927V30.3979H31.5169C32.5472 30.3979 33.3351 29.6696 33.3351 28.7172V27.5968C33.3351 26.6445 32.5472 25.9161 31.5169 25.9161ZM31.5169 28.7172H29.0927V27.5968H31.5169V28.7172Z"
|
||||
fill="#3370FF" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src/flows/assets/icon-llm.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src/flows/assets/icon-loop.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
frontend/src/flows/assets/icon-minimap.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconMinimap = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g1">
|
||||
<path
|
||||
id="path1"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z"
|
||||
/>
|
||||
<path
|
||||
id="path2"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
41
frontend/src/flows/assets/icon-mouse.tsx
Normal file
@ -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 (
|
||||
<svg
|
||||
width={width || 34}
|
||||
height={height || 52}
|
||||
viewBox="0 0 34 52"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconMouseTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
56
frontend/src/flows/assets/icon-pad.tsx
Normal file
@ -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 (
|
||||
<svg
|
||||
width={width || 48}
|
||||
height={height || 38}
|
||||
viewBox="0 0 48 38"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="1.83317"
|
||||
y="1.49998"
|
||||
width="44.3333"
|
||||
height="35"
|
||||
rx="3.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
/>
|
||||
<path
|
||||
d="M14.6665 30.6667H33.3332"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.8"
|
||||
strokeWidth="2.33333"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconPadTool = () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
|
||||
></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
BIN
frontend/src/flows/assets/icon-script.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/src/flows/assets/icon-start.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
37
frontend/src/flows/assets/icon-success.tsx
Normal file
@ -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) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g clipPath="url(#icon-workflow-run-success_svg__a)">
|
||||
<path
|
||||
fill="#3EC254"
|
||||
d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="icon-workflow-run-success_svg__a">
|
||||
<path fill="#fff" d="M0 0h20v20H0z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
15
frontend/src/flows/assets/icon-switch-line.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const IconSwitchLine = (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
id="switch-line"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
BIN
frontend/src/flows/assets/icon-variable.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
27
frontend/src/flows/assets/icon-warning.tsx
Normal file
@ -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) => (
|
||||
<svg
|
||||
className={className}
|
||||
style={style}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
29
frontend/src/flows/components/add-node/index.tsx
Normal file
@ -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 (
|
||||
<Button
|
||||
data-testid="flow.add-node"
|
||||
icon={<IconPlus />}
|
||||
color="highlight"
|
||||
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
|
||||
disabled={props.disabled}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
addNode(rect);
|
||||
}}
|
||||
>
|
||||
{I18n.t('Add Node')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
115
frontend/src/flows/components/add-node/use-add-node.ts
Normal file
@ -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<WorkflowNodeMeta>();
|
||||
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>(WorkflowNodePanelService);
|
||||
const selectService = useService(WorkflowSelectService);
|
||||
const playground = usePlayground();
|
||||
const getPanelPosition = useGetPanelPosition();
|
||||
const select = useSelectNode();
|
||||
|
||||
return useCallback(
|
||||
async (targetBoundingRect: DOMRect): Promise<void> => {
|
||||
// calculate panel position based on target element - 根据目标元素计算面板位置
|
||||
const panelPosition = getPanelPosition(targetBoundingRect);
|
||||
const containerNode = getContainerNode(selectService);
|
||||
await new Promise<void>((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]
|
||||
);
|
||||
};
|
||||
45
frontend/src/flows/components/base-node/index.tsx
Normal file
@ -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 (
|
||||
<ConfigProvider getPopupContainer={getPopupContainer}>
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
<NodeWrapper>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
{form?.render()}
|
||||
</NodeWrapper>
|
||||
<NodeStatusBar />
|
||||
</NodeRenderContext.Provider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
81
frontend/src/flows/components/base-node/node-wrapper.tsx
Normal file
@ -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<NodeWrapperProps> = (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<FlowNodeMeta>();
|
||||
|
||||
const portsRender = ports.map((p) => (
|
||||
<WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeWrapperStyle
|
||||
className={selected ? 'selected' : ''}
|
||||
ref={nodeRef}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
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}
|
||||
</NodeWrapperStyle>
|
||||
{portsRender}
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
frontend/src/flows/components/base-node/styles.tsx
Normal file
@ -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 = () => (
|
||||
<IconInfoCircle
|
||||
style={{
|
||||
position: 'absolute',
|
||||
color: 'red',
|
||||
left: -6,
|
||||
top: -6,
|
||||
zIndex: 1,
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
23
frontend/src/flows/components/base-node/utils.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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<IBlankArea> = (props) => {
|
||||
const { model } = props;
|
||||
const playground = usePlayground();
|
||||
const { selectNode } = useNodeRender();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-blank-area h-full w-full"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
model.setFocus(false);
|
||||
selectNode(e);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
}}
|
||||
onClick={(e) => {
|
||||
model.setFocus(true);
|
||||
model.selectEnd();
|
||||
}}
|
||||
>
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
model={model}
|
||||
stopEvent={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
frontend/src/flows/components/comment/components/border-area.tsx
Normal file
@ -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<IBorderArea> = (props) => {
|
||||
const { model, overflow, onResize } = props;
|
||||
|
||||
return (
|
||||
<div style={{ zIndex: 999 }}>
|
||||
{/* 左边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -10,
|
||||
top: 10,
|
||||
width: 20,
|
||||
height: 'calc(100% - 20px)',
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 右边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -10,
|
||||
top: 10,
|
||||
height: 'calc(100% - 20px)',
|
||||
width: overflow ? 10 : 20, // 防止遮挡滚动条
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 上边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: 10,
|
||||
width: 'calc(100% - 20px)',
|
||||
height: 20,
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/* 下边 */}
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -10,
|
||||
left: 10,
|
||||
width: 'calc(100% - 20px)',
|
||||
height: 20,
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
{/** 左上角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
cursor: 'nwse-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 右上角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
cursor: 'nesw-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 右下角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
cursor: 'nwse-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
{/** 左下角 */}
|
||||
<ResizeArea
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
cursor: 'nesw-resize',
|
||||
}}
|
||||
model={model}
|
||||
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
|
||||
onResize={onResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<ICommentContainer> = (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 (
|
||||
<div
|
||||
className="workflow-comment-container"
|
||||
data-flow-editor-selectable="false"
|
||||
style={{
|
||||
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
|
||||
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
|
||||
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
|
||||
...scrollbarStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<IContentDragArea> = (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<HTMLDivElement> = (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 (
|
||||
<div
|
||||
className="workflow-comment-content-drag-area"
|
||||
onMouseDown={handleMouseDown}
|
||||
onWheel={handleWheel}
|
||||
style={{
|
||||
display: active ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<DragArea
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
model={model}
|
||||
stopEvent={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<IDragArea> = (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 (
|
||||
<div
|
||||
className="workflow-comment-drag-area"
|
||||
data-flow-editor-selectable="false"
|
||||
draggable={true}
|
||||
style={style}
|
||||
onMouseDown={handleDrag}
|
||||
onTouchStart={handleDrag}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
};
|
||||
65
frontend/src/flows/components/comment/components/editor.tsx
Normal file
@ -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<ICommentEditor> = (props) => {
|
||||
const { model, style, onChange } = props;
|
||||
const playground = usePlayground();
|
||||
const editorRef = useRef<HTMLTextAreaElement | null>(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 (
|
||||
<div className="workflow-comment-editor">
|
||||
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
|
||||
<textarea
|
||||
className="workflow-comment-editor-textarea"
|
||||
ref={editorRef}
|
||||
style={style}
|
||||
readOnly={playground.config.readonly}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
model.setValue(value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
model.setFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
model.setFocus(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
frontend/src/flows/components/comment/components/index.css
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
.workflow-comment {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 120px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.workflow-comment-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
outline: 1px solid;
|
||||
padding: 6px 2px 6px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-comment-drag-area {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.workflow-comment-content-drag-area {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: calc(100% - 22px);
|
||||
}
|
||||
|
||||
.workflow-comment-resize-area {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.workflow-comment-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workflow-comment-editor-placeholder {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
color: rgba(55, 67, 106, 0.38);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-comment-editor-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .semi-button {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .semi-button:hover {
|
||||
color: #ffa100;
|
||||
background: #fbf2d2cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button-focused > .semi-button:hover {
|
||||
color: #ff811a;
|
||||
background: #ffe3cecc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button > .semi-button:active {
|
||||
color: #f2b600;
|
||||
background: #ede5c7cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.workflow-comment-more-button-focused > .semi-button:active {
|
||||
color: #ff811a;
|
||||
background: #eed5c1cc;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
export { CommentRender } from './render';
|
||||
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { NodeMenu } from '../../node-menu';
|
||||
|
||||
interface IMoreButton {
|
||||
node: WorkflowNodeEntity;
|
||||
focused: boolean;
|
||||
deleteNode: () => void;
|
||||
}
|
||||
|
||||
export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
|
||||
<div
|
||||
className={`workflow-comment-more-button ${
|
||||
focused ? 'workflow-comment-more-button-focused' : ''
|
||||
}`}
|
||||
>
|
||||
<NodeMenu node={node} deleteNode={deleteNode} />
|
||||
</div>
|
||||
);
|
||||
83
frontend/src/flows/components/comment/components/render.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldRenderProps,
|
||||
FlowNodeFormData,
|
||||
Form,
|
||||
FormModelV2,
|
||||
useNodeRender,
|
||||
WorkflowNodeEntity,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { useOverflow } from '../hooks/use-overflow';
|
||||
import { useModel } from '../hooks/use-model';
|
||||
import { useSize } from '../hooks';
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
import { MoreButton } from './more-button';
|
||||
import { CommentEditor } from './editor';
|
||||
import { ContentDragArea } from './content-drag-area';
|
||||
import { CommentContainer } from './container';
|
||||
import { BorderArea } from './border-area';
|
||||
|
||||
export const CommentRender: FC<{
|
||||
node: WorkflowNodeEntity;
|
||||
}> = (props) => {
|
||||
const { node } = props;
|
||||
const model = useModel();
|
||||
|
||||
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
|
||||
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formControl = formModel?.formControl;
|
||||
|
||||
const { width, height, onResize } = useSize();
|
||||
const { overflow, updateOverflow } = useOverflow({ model, height });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
ref={nodeRef}
|
||||
data-node-selected={String(focused)}
|
||||
onMouseEnter={updateOverflow}
|
||||
onMouseDown={(e) => {
|
||||
setTimeout(() => {
|
||||
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
|
||||
selectNode(e);
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
|
||||
}, 20);
|
||||
}}
|
||||
>
|
||||
<Form control={formControl}>
|
||||
<>
|
||||
{/* 背景 */}
|
||||
<CommentContainer focused={focused} style={{ height }}>
|
||||
<Field name={CommentEditorFormField.Note}>
|
||||
{({ field }: FieldRenderProps<string>) => (
|
||||
<>
|
||||
{/** 编辑器 */}
|
||||
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
|
||||
{/* 内容拖拽区域(点击后隐藏) */}
|
||||
<ContentDragArea model={model} focused={focused} overflow={overflow} />
|
||||
{/* 更多按钮 */}
|
||||
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</CommentContainer>
|
||||
{/* 边框 */}
|
||||
<BorderArea model={model} overflow={overflow} onResize={onResize} />
|
||||
</>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { CSSProperties, type FC } from 'react';
|
||||
|
||||
import { MouseTouchEvent, useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import type { CommentEditorModel } from '../model';
|
||||
|
||||
interface IResizeArea {
|
||||
model: CommentEditorModel;
|
||||
onResize?: () => {
|
||||
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
|
||||
resizeEnd: () => void;
|
||||
};
|
||||
getDelta?: (delta: { x: number; y: number }) => {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ResizeArea: FC<IResizeArea> = (props) => {
|
||||
const { model, onResize, getDelta, style } = props;
|
||||
|
||||
const playground = usePlayground();
|
||||
|
||||
const { selectNode } = useNodeRender();
|
||||
|
||||
const handleResizeStart = (
|
||||
startResizeEvent: React.MouseEvent | React.TouchEvent | MouseEvent
|
||||
) => {
|
||||
MouseTouchEvent.preventDefault(startResizeEvent);
|
||||
startResizeEvent.stopPropagation();
|
||||
if (!onResize) {
|
||||
return;
|
||||
}
|
||||
const { resizing, resizeEnd } = onResize();
|
||||
model.setFocus(false);
|
||||
selectNode(startResizeEvent as React.MouseEvent);
|
||||
playground.node.focus(); // 防止节点无法被删除
|
||||
|
||||
const { clientX: startX, clientY: startY } = MouseTouchEvent.getEventCoord(
|
||||
startResizeEvent as MouseEvent
|
||||
);
|
||||
|
||||
const handleResizing = (mouseMoveEvent: MouseEvent | TouchEvent) => {
|
||||
const { clientX: moveX, clientY: moveY } = MouseTouchEvent.getEventCoord(mouseMoveEvent);
|
||||
const deltaX = moveX - startX;
|
||||
const deltaY = moveY - startY;
|
||||
const delta = getDelta?.({ x: deltaX, y: deltaY });
|
||||
if (!delta || !resizing) {
|
||||
return;
|
||||
}
|
||||
resizing(delta);
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
resizeEnd();
|
||||
document.removeEventListener('mousemove', handleResizing);
|
||||
document.removeEventListener('mouseup', handleResizeEnd);
|
||||
document.removeEventListener('click', handleResizeEnd);
|
||||
document.removeEventListener('touchmove', handleResizing);
|
||||
document.removeEventListener('touchend', handleResizeEnd);
|
||||
document.removeEventListener('touchcancel', handleResizeEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResizing);
|
||||
document.addEventListener('mouseup', handleResizeEnd);
|
||||
document.addEventListener('click', handleResizeEnd);
|
||||
document.addEventListener('touchmove', handleResizing, { passive: false });
|
||||
document.addEventListener('touchend', handleResizeEnd);
|
||||
document.addEventListener('touchcancel', handleResizeEnd);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-comment-resize-area"
|
||||
style={style}
|
||||
data-flow-editor-selectable="false"
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
/>
|
||||
);
|
||||
};
|
||||
25
frontend/src/flows/components/comment/constant.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- enum */
|
||||
|
||||
export enum CommentEditorFormField {
|
||||
Size = 'size',
|
||||
Note = 'note',
|
||||
}
|
||||
|
||||
/** 编辑器事件 */
|
||||
export enum CommentEditorEvent {
|
||||
/** 内容变更事件 */
|
||||
Change = 'change',
|
||||
/** 多选事件 */
|
||||
MultiSelect = 'multiSelect',
|
||||
/** 单选事件 */
|
||||
Select = 'select',
|
||||
/** 失焦事件 */
|
||||
Blur = 'blur',
|
||||
}
|
||||
|
||||
export const CommentEditorDefaultValue = '';
|
||||
6
frontend/src/flows/components/comment/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useSize } from './use-size';
|
||||
55
frontend/src/flows/components/comment/hooks/use-model.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeFormData,
|
||||
FormModelV2,
|
||||
useEntityFromContext,
|
||||
useNodeRender,
|
||||
WorkflowNodeEntity,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
|
||||
export const useModel = () => {
|
||||
const node = useEntityFromContext<WorkflowNodeEntity>();
|
||||
const { selected: focused } = useNodeRender();
|
||||
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
|
||||
const model = useMemo(() => new CommentEditorModel(), []);
|
||||
|
||||
// 同步失焦状态
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
return;
|
||||
}
|
||||
model.setFocus(focused);
|
||||
}, [focused, model]);
|
||||
|
||||
// 同步表单值初始化
|
||||
useEffect(() => {
|
||||
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
|
||||
model.setValue(value); // 设置初始值
|
||||
model.selectEnd(); // 设置初始化光标位置
|
||||
}, [formModel, model]);
|
||||
|
||||
// 同步表单外部值变化:undo/redo/协同
|
||||
useEffect(() => {
|
||||
const disposer = formModel.onFormValuesChange(({ name }) => {
|
||||
if (name !== CommentEditorFormField.Note) {
|
||||
return;
|
||||
}
|
||||
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
|
||||
model.setValue(value);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [formModel, model]);
|
||||
|
||||
return model;
|
||||
};
|
||||
50
frontend/src/flows/components/comment/hooks/use-overflow.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { usePlayground } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorModel } from '../model';
|
||||
import { CommentEditorEvent } from '../constant';
|
||||
|
||||
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
|
||||
const { model, height } = params;
|
||||
const playground = usePlayground();
|
||||
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
const isOverflow = useCallback((): boolean => {
|
||||
if (!model.element) {
|
||||
return false;
|
||||
}
|
||||
return model.element.scrollHeight > model.element.clientHeight;
|
||||
}, [model, height, playground]);
|
||||
|
||||
// 更新 overflow
|
||||
const updateOverflow = useCallback(() => {
|
||||
setOverflow(isOverflow());
|
||||
}, [isOverflow]);
|
||||
|
||||
// 监听高度变化
|
||||
useEffect(() => {
|
||||
updateOverflow();
|
||||
}, [height, updateOverflow]);
|
||||
|
||||
// 监听 change 事件
|
||||
useEffect(() => {
|
||||
const changeDisposer = model.on((params) => {
|
||||
if (params.type !== CommentEditorEvent.Change) {
|
||||
return;
|
||||
}
|
||||
updateOverflow();
|
||||
});
|
||||
return () => {
|
||||
changeDisposer.dispose();
|
||||
};
|
||||
}, [model, updateOverflow]);
|
||||
|
||||
return { overflow, updateOverflow };
|
||||
};
|
||||
168
frontend/src/flows/components/comment/hooks/use-size.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeFormData,
|
||||
FormModelV2,
|
||||
FreeOperationType,
|
||||
HistoryService,
|
||||
TransformData,
|
||||
useCurrentEntity,
|
||||
usePlayground,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorFormField } from '../constant';
|
||||
|
||||
export const useSize = () => {
|
||||
const node = useCurrentEntity();
|
||||
const nodeMeta = node.getNodeMeta();
|
||||
const playground = usePlayground();
|
||||
const historyService = useService(HistoryService);
|
||||
const { size = { width: 240, height: 150 } } = nodeMeta;
|
||||
const transform = node.getData(TransformData);
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
|
||||
const [width, setWidth] = useState(formSize?.width ?? size.width);
|
||||
const [height, setHeight] = useState(formSize?.height ?? size.height);
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
const initSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
if (!initSize) {
|
||||
formModel.setValueIn(CommentEditorFormField.Size, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}, [formModel, width, height]);
|
||||
|
||||
// 同步表单外部值变化:初始化/undo/redo/协同
|
||||
useEffect(() => {
|
||||
const disposer = formModel.onFormValuesChange(({ name }) => {
|
||||
if (name !== CommentEditorFormField.Size) {
|
||||
return;
|
||||
}
|
||||
const newSize = formModel.getValueIn<{ width: number; height: number }>(
|
||||
CommentEditorFormField.Size
|
||||
);
|
||||
if (!newSize) {
|
||||
return;
|
||||
}
|
||||
setWidth(newSize.width);
|
||||
setHeight(newSize.height);
|
||||
});
|
||||
return () => disposer.dispose();
|
||||
}, [formModel]);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
const resizeState = {
|
||||
width,
|
||||
height,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { zoom } = playground.config;
|
||||
|
||||
const top = delta.top / zoom;
|
||||
const right = delta.right / zoom;
|
||||
const bottom = delta.bottom / zoom;
|
||||
const left = delta.left / zoom;
|
||||
|
||||
const minWidth = 120;
|
||||
const minHeight = 80;
|
||||
|
||||
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
|
||||
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
|
||||
|
||||
// 如果宽度或高度小于最小值,则不更新偏移量
|
||||
const newOffsetX =
|
||||
(left > 0 || right < 0) && newWidth <= minWidth
|
||||
? resizeState.offsetX
|
||||
: left / 2 + right / 2;
|
||||
const newOffsetY =
|
||||
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
|
||||
|
||||
const newPositionX = resizeState.positionX + newOffsetX;
|
||||
const newPositionY = resizeState.positionY + newOffsetY;
|
||||
|
||||
resizeState.width = newWidth;
|
||||
resizeState.height = newHeight;
|
||||
resizeState.offsetX = newOffsetX;
|
||||
resizeState.offsetY = newOffsetY;
|
||||
|
||||
// 更新状态
|
||||
setWidth(newWidth);
|
||||
setHeight(newHeight);
|
||||
|
||||
// 更新偏移量
|
||||
transform.update({
|
||||
position: {
|
||||
x: newPositionX,
|
||||
y: newPositionY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resizeEnd = () => {
|
||||
historyService.transact(() => {
|
||||
historyService.pushOperation(
|
||||
{
|
||||
type: FreeOperationType.dragNodes,
|
||||
value: {
|
||||
ids: [node.id],
|
||||
value: [
|
||||
{
|
||||
x: resizeState.positionX + resizeState.offsetX,
|
||||
y: resizeState.positionY + resizeState.offsetY,
|
||||
},
|
||||
],
|
||||
oldValue: [
|
||||
{
|
||||
x: resizeState.positionX,
|
||||
y: resizeState.positionY,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
noApply: true,
|
||||
}
|
||||
);
|
||||
formModel.setValueIn(CommentEditorFormField.Size, {
|
||||
width: resizeState.width,
|
||||
height: resizeState.height,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
resizeEnd,
|
||||
};
|
||||
}, [node, width, height, transform, playground, formModel, historyService]);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
onResize,
|
||||
};
|
||||
};
|
||||
6
frontend/src/flows/components/comment/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { CommentRender } from './components';
|
||||
111
frontend/src/flows/components/comment/model.ts
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Emitter } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { CommentEditorEventParams } from './type';
|
||||
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
|
||||
|
||||
export class CommentEditorModel {
|
||||
private innerValue: string = CommentEditorDefaultValue;
|
||||
|
||||
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
|
||||
|
||||
private editor: HTMLTextAreaElement;
|
||||
|
||||
/** 注册事件 */
|
||||
public on = this.emitter.event;
|
||||
|
||||
/** 获取当前值 */
|
||||
public get value(): string {
|
||||
return this.innerValue;
|
||||
}
|
||||
|
||||
/** 外部设置模型值 */
|
||||
public setValue(value: string = CommentEditorDefaultValue): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (value === this.innerValue) {
|
||||
return;
|
||||
}
|
||||
this.innerValue = value;
|
||||
this.syncEditorValue();
|
||||
this.emitter.fire({
|
||||
type: CommentEditorEvent.Change,
|
||||
value: this.innerValue,
|
||||
});
|
||||
}
|
||||
|
||||
public set element(el: HTMLTextAreaElement) {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.editor = el;
|
||||
}
|
||||
|
||||
/** 获取编辑器 DOM 节点 */
|
||||
public get element(): HTMLTextAreaElement | null {
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
/** 编辑器聚焦/失焦 */
|
||||
public setFocus(focused: boolean): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (focused && !this.focused) {
|
||||
this.editor.focus();
|
||||
} else if (!focused && this.focused) {
|
||||
this.editor.blur();
|
||||
this.deselect();
|
||||
this.emitter.fire({
|
||||
type: CommentEditorEvent.Blur,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择末尾 */
|
||||
public selectEnd(): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
// 获取文本长度
|
||||
const length = this.editor.value.length;
|
||||
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
|
||||
this.editor.setSelectionRange(length, length);
|
||||
}
|
||||
|
||||
/** 获取聚焦状态 */
|
||||
public get focused(): boolean {
|
||||
return document.activeElement === this.editor;
|
||||
}
|
||||
|
||||
/** 取消选择文本 */
|
||||
private deselect(): void {
|
||||
const selection: Selection | null = window.getSelection();
|
||||
|
||||
// 清除所有选择区域
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否初始化 */
|
||||
private get initialized(): boolean {
|
||||
return Boolean(this.editor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步编辑器实例内容
|
||||
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
|
||||
*/
|
||||
private syncEditorValue(): void {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.editor.value = this.innerValue;
|
||||
}
|
||||
}
|
||||
29
frontend/src/flows/components/comment/type.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { CommentEditorEvent } from './constant';
|
||||
|
||||
interface CommentEditorChangeEvent {
|
||||
type: CommentEditorEvent.Change;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CommentEditorMultiSelectEvent {
|
||||
type: CommentEditorEvent.MultiSelect;
|
||||
}
|
||||
|
||||
interface CommentEditorSelectEvent {
|
||||
type: CommentEditorEvent.Select;
|
||||
}
|
||||
|
||||
interface CommentEditorBlurEvent {
|
||||
type: CommentEditorEvent.Blur;
|
||||
}
|
||||
|
||||
export type CommentEditorEventParams =
|
||||
| CommentEditorChangeEvent
|
||||
| CommentEditorMultiSelectEvent
|
||||
| CommentEditorSelectEvent
|
||||
| CommentEditorBlurEvent;
|
||||
105
frontend/src/flows/components/group/color.ts
Normal file
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
type GroupColor = {
|
||||
'50': string;
|
||||
'300': string;
|
||||
'400': string;
|
||||
};
|
||||
|
||||
export const defaultColor = 'Blue';
|
||||
|
||||
export const groupColors: Record<string, GroupColor> = {
|
||||
Red: {
|
||||
'50': '#fef2f2',
|
||||
'300': '#fca5a5',
|
||||
'400': '#f87171',
|
||||
},
|
||||
Orange: {
|
||||
'50': '#fff7ed',
|
||||
'300': '#fdba74',
|
||||
'400': '#fb923c',
|
||||
},
|
||||
Amber: {
|
||||
'50': '#fffbeb',
|
||||
'300': '#fcd34d',
|
||||
'400': '#fbbf24',
|
||||
},
|
||||
Yellow: {
|
||||
'50': '#fef9c3',
|
||||
'300': '#fde047',
|
||||
'400': '#facc15',
|
||||
},
|
||||
Lime: {
|
||||
'50': '#f7fee7',
|
||||
'300': '#bef264',
|
||||
'400': '#a3e635',
|
||||
},
|
||||
Green: {
|
||||
'50': '#f0fdf4',
|
||||
'300': '#86efac',
|
||||
'400': '#4ade80',
|
||||
},
|
||||
Emerald: {
|
||||
'50': '#ecfdf5',
|
||||
'300': '#6ee7b7',
|
||||
'400': '#34d399',
|
||||
},
|
||||
Teal: {
|
||||
'50': '#f0fdfa',
|
||||
'300': '#5eead4',
|
||||
'400': '#2dd4bf',
|
||||
},
|
||||
Cyan: {
|
||||
'50': '#ecfeff',
|
||||
'300': '#67e8f9',
|
||||
'400': '#22d3ee',
|
||||
},
|
||||
Sky: {
|
||||
'50': '#ecfeff',
|
||||
'300': '#7dd3fc',
|
||||
'400': '#38bdf8',
|
||||
},
|
||||
Blue: {
|
||||
'50': '#eff6ff',
|
||||
'300': '#93c5fd',
|
||||
'400': '#60a5fa',
|
||||
},
|
||||
Indigo: {
|
||||
'50': '#eef2ff',
|
||||
'300': '#a5b4fc',
|
||||
'400': '#818cf8',
|
||||
},
|
||||
Violet: {
|
||||
'50': '#f5f3ff',
|
||||
'300': '#c4b5fd',
|
||||
'400': '#a78bfa',
|
||||
},
|
||||
Purple: {
|
||||
'50': '#faf5ff',
|
||||
'300': '#d8b4fe',
|
||||
'400': '#c084fc',
|
||||
},
|
||||
Fuchsia: {
|
||||
'50': '#fdf4ff',
|
||||
'300': '#f0abfc',
|
||||
'400': '#e879f9',
|
||||
},
|
||||
Pink: {
|
||||
'50': '#fdf2f8',
|
||||
'300': '#f9a8d4',
|
||||
'400': '#f472b6',
|
||||
},
|
||||
Rose: {
|
||||
'50': '#fff1f2',
|
||||
'300': '#fda4af',
|
||||
'400': '#fb7185',
|
||||
},
|
||||
Gray: {
|
||||
'50': '#f9fafb',
|
||||
'300': '#d1d5db',
|
||||
'400': '#9ca3af',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { CSSProperties, FC, useEffect } from 'react';
|
||||
|
||||
import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
interface GroupBackgroundProps {
|
||||
node: WorkflowNodeEntity;
|
||||
style?: CSSProperties;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style, selected }) => {
|
||||
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
|
||||
const color = groupColors[colorName];
|
||||
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
|
||||
// 使用独特的选择器
|
||||
const styleContent = `
|
||||
.workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
|
||||
border: 1px solid ${color['300']};
|
||||
}
|
||||
|
||||
.workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
|
||||
border: 1px solid #4e40e5;
|
||||
}
|
||||
`;
|
||||
|
||||
styleElement.textContent = styleContent;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return () => {
|
||||
styleElement.remove();
|
||||
};
|
||||
}, [color]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-group-background"
|
||||
data-flow-editor-selectable="true"
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: `${color['300']}${selected ? '40' : '29'}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
50
frontend/src/flows/components/group/components/color.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Field } from '@flowgram.ai/free-layout-editor';
|
||||
import { Popover, Tooltip } from '@douyinfe/semi-ui';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
export const GroupColor: FC = () => (
|
||||
<Field<string> name={GroupField.Color}>
|
||||
{({ field }) => {
|
||||
const colorName = field.value ?? defaultColor;
|
||||
return (
|
||||
<Popover
|
||||
position="top"
|
||||
mouseLeaveDelay={300}
|
||||
content={
|
||||
<div className="workflow-group-color-palette">
|
||||
{Object.entries(groupColors).map(([name, color]) => (
|
||||
<Tooltip content={name} key={name} mouseEnterDelay={300}>
|
||||
<span
|
||||
className="workflow-group-color-item"
|
||||
key={name}
|
||||
style={{
|
||||
backgroundColor: color['300'],
|
||||
borderColor: name === colorName ? color['400'] : '#fff',
|
||||
}}
|
||||
onClick={() => field.onChange(name)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="workflow-group-color"
|
||||
style={{
|
||||
backgroundColor: groupColors[colorName]['300'],
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
);
|
||||
41
frontend/src/flows/components/group/components/header.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';
|
||||
|
||||
import { useWatch } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
onDrag: (e: MouseEvent | TouchEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
children: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {
|
||||
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
|
||||
const color = groupColors[colorName];
|
||||
return (
|
||||
<div
|
||||
className="workflow-group-header"
|
||||
data-flow-editor-selectable="false"
|
||||
onMouseDown={onDrag}
|
||||
onTouchStart={onDrag}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: color['50'],
|
||||
borderColor: color['300'],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
interface IconGroupProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const IconGroup: FC<IconGroupProps> = ({ size }) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
id="group"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconUngroup: FC<IconGroupProps> = ({ size }) => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
id="ungroup"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
stroke="none"
|
||||
d="M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||