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错误处理和工具函数
This commit is contained in:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

263
backend/src/flow/dsl.rs Normal file
View 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) })
}