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

View 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 })
}

View 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,
}
}

View File

@ -5,4 +5,6 @@ pub mod menu_service;
pub mod department_service;
pub mod log_service;
// 新增岗位服务
pub mod position_service;
pub mod position_service;
pub mod flow_service;
pub mod flow_run_log_service;