feat(backend): 新增 QuickJS 运行时支持 JavaScript 执行器
refactor(backend): 重构 script_js 执行器实现 JavaScript 文件/内联脚本执行 feat(backend): 变量节点支持表达式/引用快捷语法输入 docs: 添加变量节点使用文档说明快捷语法功能 style(frontend): 调整测试面板样式和布局 fix(frontend): 修复测试面板打开时自动关闭节点编辑侧栏 build(backend): 添加 rquickjs 依赖用于 JavaScript 执行
This commit is contained in:
28
backend/Cargo.lock
generated
28
backend/Cargo.lock
generated
@ -2492,6 +2492,33 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
|
||||||
|
dependencies = [
|
||||||
|
"rquickjs-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs-core"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
|
||||||
|
dependencies = [
|
||||||
|
"rquickjs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs-sys"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@ -3865,6 +3892,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rhai",
|
"rhai",
|
||||||
|
"rquickjs",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -39,6 +39,8 @@ regex = "1.10"
|
|||||||
reqwest = { version = "0.11", features = ["json", "rustls-tls-native-roots"], default-features = false }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls-native-roots"], default-features = false }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
percent-encoding = "2.3"
|
percent-encoding = "2.3"
|
||||||
|
# 新增: QuickJS 运行时用于 JS 执行器(不启用额外特性)
|
||||||
|
rquickjs = "0.8"
|
||||||
|
|
||||||
[dependencies.migration]
|
[dependencies.migration]
|
||||||
path = "migration"
|
path = "migration"
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait
|
||||||
|
;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
use crate::flow::task::Executor;
|
use crate::flow::task::Executor;
|
||||||
use crate::flow::domain::{NodeDef, NodeId};
|
use crate::flow::domain::{NodeDef, NodeId};
|
||||||
@ -20,32 +22,134 @@ fn truncate_str(s: &str, max: usize) -> String {
|
|||||||
if s.len() <= max { s } else { format!("{}…", &s[..max]) }
|
if s.len() <= max { s } else { format!("{}…", &s[..max]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shallow_diff(before: &Value, after: &Value) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
let mut modified = Vec::new();
|
||||||
|
let (Some(bm), Some(am)) = (before.as_object(), after.as_object()) else {
|
||||||
|
if before != after { modified.push("<root>".to_string()); }
|
||||||
|
return (added, removed, modified);
|
||||||
|
};
|
||||||
|
let bkeys: BTreeSet<_> = bm.keys().cloned().collect();
|
||||||
|
let akeys: BTreeSet<_> = am.keys().cloned().collect();
|
||||||
|
for k in akeys.difference(&bkeys) { added.push((*k).to_string()); }
|
||||||
|
for k in bkeys.difference(&akeys) { removed.push((*k).to_string()); }
|
||||||
|
for k in akeys.intersection(&bkeys) {
|
||||||
|
let key = (*k).to_string();
|
||||||
|
if bm.get(&key) != am.get(&key) { modified.push(key); }
|
||||||
|
}
|
||||||
|
(added, removed, modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_js_script(node_id: &NodeId, script: &str, ctx: &mut Value) -> anyhow::Result<()> {
|
||||||
|
use rquickjs::{Runtime, Context as JsContext, Ctx, FromJs, Value as JsValue};
|
||||||
|
|
||||||
|
if script.trim().is_empty() {
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, "script_js task: empty script, skip");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let preview = truncate_str(script, 200);
|
||||||
|
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: will execute JavaScript script");
|
||||||
|
|
||||||
|
let before_ctx = ctx.clone();
|
||||||
|
|
||||||
|
// QuickJS 运行
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
let qctx = JsContext::full(&rt)?;
|
||||||
|
|
||||||
|
// 在 JS 中执行并取回 ctx
|
||||||
|
let res: anyhow::Result<Option<Value>> = qctx.with(|q: Ctx<'_>| {
|
||||||
|
// 将当前 ctx 作为 JSON 字符串注入到 JS 全局对象
|
||||||
|
let global = q.globals();
|
||||||
|
let ctx_json = serde_json::to_string(&before_ctx).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
global.set("__CTX_JSON", ctx_json)?;
|
||||||
|
|
||||||
|
// 包装脚本,确保返回序列化后的 ctx 字符串(用字符串拼接避免 format! 花括号转义问题)
|
||||||
|
let mut wrapped = String::new();
|
||||||
|
wrapped.push_str("(function(){ try { var ctx = JSON.parse(globalThis.__CTX_JSON); ");
|
||||||
|
wrapped.push_str(script);
|
||||||
|
wrapped.push_str(" ; return (typeof ctx === 'undefined') ? undefined : JSON.stringify(ctx); } catch (e) { throw e; } })()");
|
||||||
|
|
||||||
|
// 执行并获取返回值
|
||||||
|
let v: JsValue = q.eval(wrapped)?;
|
||||||
|
|
||||||
|
// 如果结果为 undefined/null,则认为未修改
|
||||||
|
if v.is_null() || v.is_undefined() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
// 将返回的 JS 值转换为 Rust String,然后解析为 serde_json::Value
|
||||||
|
let s: String = String::from_js(&q, v)?;
|
||||||
|
let new_ctx: Value = serde_json::from_str(&s)?;
|
||||||
|
Ok(Some(new_ctx))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let dur_ms = start.elapsed().as_millis();
|
||||||
|
match res {
|
||||||
|
Ok(Some(new_ctx)) => {
|
||||||
|
let (added, removed, modified) = shallow_diff(&before_ctx, &new_ctx);
|
||||||
|
*ctx = new_ctx;
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, added=%added.len(), removed=%removed.len(), modified=%modified.len(), "script_js task: executed and ctx updated");
|
||||||
|
if !(added.is_empty() && removed.is_empty() && modified.is_empty()) {
|
||||||
|
debug!(target = "udmin.flow", node=%node_id.0, ?added, ?removed, ?modified, "script_js task: ctx shallow diff");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, preview=%preview, "script_js task: script returned no ctx, ctx unchanged");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, ms=%dur_ms, err=%e.to_string(), preview=%preview, "script_js task: execution failed, ctx unchanged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_js_file(node_id: &NodeId, path: &str, ctx: &mut Value) -> anyhow::Result<()> {
|
||||||
|
let code = match fs::read_to_string(path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, err=%e.to_string(), "script_js task: failed to read JS file");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if code.trim().is_empty() {
|
||||||
|
info!(target = "udmin.flow", node=%node_id.0, "script_js task: empty JS file, skip");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
exec_js_script(node_id, &code, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ScriptJsTask;
|
pub struct ScriptJsTask;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executor for ScriptJsTask {
|
impl Executor for ScriptJsTask {
|
||||||
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> {
|
||||||
let start = Instant::now();
|
// 1) 文件脚本优先:nodes.<id>.scripts.js -> 执行文件
|
||||||
|
|
||||||
// 优先 nodes.<id>.scripts.js 指定的脚本文件路径
|
|
||||||
if let Some(path) = read_node_script_file(ctx, &node_id.0, "js") {
|
if let Some(path) = read_node_script_file(ctx, &node_id.0, "js") {
|
||||||
let preview = truncate_str(&path, 120);
|
let preview = truncate_str(&path, 120);
|
||||||
info!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: JavaScript file execution not implemented yet (skipped)");
|
debug!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: will execute JS file");
|
||||||
return Ok(());
|
return exec_js_file(node_id, &path, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容 inline 配置(暂不执行,仅提示)
|
// 2) inline 脚本(支持 String 或 { script | expr }),优先读取 nodes.<id>,再回退到根级
|
||||||
let inline = ctx.get("script")
|
let cfg: Option<String> = ctx.get("nodes")
|
||||||
.or_else(|| ctx.get("expr"))
|
.and_then(|nodes| nodes.get(&node_id.0))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|n| n.get("script").or_else(|| n.get("expr")))
|
||||||
.map(|s| s.to_string());
|
.and_then(|v| match v {
|
||||||
if let Some(code) = inline {
|
Value::String(s) => Some(s.clone()),
|
||||||
let preview = truncate_str(&code, 200);
|
Value::Object(m) => m.get("script").or_else(|| m.get("expr")).and_then(|x| x.as_str()).map(|s| s.to_string()),
|
||||||
debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: inline script provided, but execution not implemented");
|
_ => None,
|
||||||
let _elapsed = start.elapsed().as_millis();
|
})
|
||||||
info!(target = "udmin.flow", node=%node_id.0, "script_js task: JavaScript execution not implemented yet (skipped)");
|
.or_else(|| ctx.get("script").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||||
return Ok(());
|
.or_else(|| ctx.get("expr").and_then(|v| v.as_str()).map(|s| s.to_string()));
|
||||||
|
|
||||||
|
if let Some(script) = cfg {
|
||||||
|
return exec_js_script(node_id, &script, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(target = "udmin.flow", node=%node_id.0, "script_js task: no script found, skip");
|
info!(target = "udmin.flow", node=%node_id.0, "script_js task: no script found, skip");
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::{Value, json};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::flow::task::Executor;
|
use crate::flow::task::Executor;
|
||||||
@ -26,7 +26,39 @@ fn resolve_assign_value(ctx: &Value, v: &Value) -> Value {
|
|||||||
|
|
||||||
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
let t = v.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
match t {
|
match t {
|
||||||
"constant" => v.get("content").cloned().unwrap_or(V::Null),
|
"constant" => {
|
||||||
|
// 支持两种扩展写法:
|
||||||
|
// 1) 若常量为字符串且形如 ctx[...] / ctx. 开头,则按 Rhai 表达式求值
|
||||||
|
// 2) 若常量为字符串且形如 ${path.a.b},则等价于 ref: { content: ["path","a","b"] }
|
||||||
|
if let Some(s) = v.get("content").and_then(|c| c.as_str()) {
|
||||||
|
let s_trim = s.trim();
|
||||||
|
// ${a.b.c} -> ref
|
||||||
|
if s_trim.starts_with("${") && s_trim.ends_with('}') {
|
||||||
|
let inner = s_trim[2..s_trim.len()-1].trim();
|
||||||
|
// 解析为路径段,支持 a.b.c 或 a["b"]["c"] 的简化处理
|
||||||
|
let normalized = inner
|
||||||
|
.replace('[', ".")
|
||||||
|
.replace(']', "")
|
||||||
|
.replace('"', "")
|
||||||
|
.replace('\'', "");
|
||||||
|
let parts: Vec<String> = normalized
|
||||||
|
.trim_matches('.')
|
||||||
|
.split('.')
|
||||||
|
.filter(|seg| !seg.is_empty())
|
||||||
|
.map(|seg| seg.to_string())
|
||||||
|
.collect();
|
||||||
|
if !parts.is_empty() {
|
||||||
|
let ref_json = json!({"type":"ref","content": parts});
|
||||||
|
return resolve_assign_value(ctx, &ref_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ctx[...] / ctx. 前缀 -> 表达式求值
|
||||||
|
if s_trim.starts_with("ctx[") || s_trim.starts_with("ctx.") {
|
||||||
|
return eval_rhai_expr_json(s_trim, ctx).unwrap_or(V::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.get("content").cloned().unwrap_or(V::Null)
|
||||||
|
}
|
||||||
"ref" => {
|
"ref" => {
|
||||||
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
|
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
|
||||||
let parts: Vec<String> = v
|
let parts: Vec<String> = v
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use axum::{Router, routing::{post, get}, extract::{State, Path, Query}, Json};
|
use axum::{Router, routing::{post, get}, extract::{State, Path, Query}, Json};
|
||||||
use crate::{db::Db, response::ApiResponse, services::flow_service, error::AppError};
|
use crate::{db::Db, response::ApiResponse, services::{flow_service, log_service}, error::AppError};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{info, error};
|
use tracing::{info, error};
|
||||||
use crate::middlewares::jwt::AuthUser;
|
use crate::middlewares::jwt::AuthUser;
|
||||||
@ -27,7 +27,7 @@ struct CreateReq { yaml: Option<String>, name: Option<String>, design_json: Opti
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UpdateReq { yaml: Option<String>, design_json: Option<serde_json::Value>, name: Option<String>, code: Option<String>, remark: Option<String> }
|
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> {
|
async fn create(State(db): State<Db>, user: AuthUser, Json(req): Json<CreateReq>) -> Result<Json<ApiResponse<flow_service::FlowDoc>>, AppError> {
|
||||||
info!(target = "udmin", "routes.flows.create: start");
|
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 {
|
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 }
|
Ok(r) => { info!(target = "udmin", id = %r.id, "routes.flows.create: ok"); r }
|
||||||
@ -37,6 +37,20 @@ struct UpdateReq { yaml: Option<String>, design_json: Option<serde_json::Value>,
|
|||||||
return Err(flow_service::ae(e));
|
return Err(flow_service::ae(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 新建成功后,补一条 PUT 请求日志,使“最近修改人”默认为创建人
|
||||||
|
if let Err(e) = log_service::create(&db, log_service::CreateLogInput {
|
||||||
|
path: format!("/api/flows/{}", res.id),
|
||||||
|
method: "PUT".to_string(),
|
||||||
|
request_params: None,
|
||||||
|
response_params: None,
|
||||||
|
status_code: 200,
|
||||||
|
user_id: Some(user.uid),
|
||||||
|
username: Some(user.username.clone()),
|
||||||
|
request_time: chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()),
|
||||||
|
duration_ms: 0,
|
||||||
|
}).await {
|
||||||
|
error!(target = "udmin", error = ?e, "routes.flows.create: write put log failed");
|
||||||
|
}
|
||||||
Ok(Json(ApiResponse::ok(res)))
|
Ok(Json(ApiResponse::ok(res)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
docs/variable-node-usage.md
Normal file
96
docs/variable-node-usage.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# 变量赋值节点:表达式/引用直输 使用文档
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
- 变量节点的右值支持三种取值方式:
|
||||||
|
- 常量(constant):数字、字符串、对象、数组等,原样作为值。
|
||||||
|
- 引用(ref):通过路径引用上下文或其他节点的输出。
|
||||||
|
- 表达式(expression):使用 Rhai 表达式,运行时计算出值。
|
||||||
|
- 新增“快捷语法”直输能力:即便右值选择为“常量”,也可直接在输入框里写:
|
||||||
|
- 表达式直输:以 `ctx.` 或 `ctx[` 开头的字符串按 Rhai 表达式求值。
|
||||||
|
- 引用直输:以 `${...}` 包裹的字符串按“引用路径”解析。
|
||||||
|
|
||||||
|
## 二、使用入口
|
||||||
|
- 打开画布,选择“变量”节点,在右侧侧边栏的“赋值行”进行编辑。
|
||||||
|
- 侧边栏顶部会有一段提示文字,说明三种取值方式与两种快捷语法。
|
||||||
|
- 相关代码位置:
|
||||||
|
- 前端:frontend/src/flows/nodes/variable/form-meta.tsx
|
||||||
|
- 后端:backend/src/flow/executors/variable.rs
|
||||||
|
|
||||||
|
## 三、如何输入右值
|
||||||
|
- 方式一:常量(默认)
|
||||||
|
- 直接输入数字、字符串、对象、数组,例如:`0`、`"hello"`、`{"a":1}`、`[1,2,3]`。
|
||||||
|
- 支持“快捷语法”,见下文。
|
||||||
|
- 方式二:引用(ref)
|
||||||
|
- 切换右值类型为“引用”,通过路径选择器选择 ctx 中已有字段或其他节点输出。
|
||||||
|
- 推荐用于团队协作时提升可读性。
|
||||||
|
- 方式三:表达式(expression)
|
||||||
|
- 切换右值类型为“表达式”,输入 Rhai 表达式,运行时计算。
|
||||||
|
- 适合需要计算或条件逻辑的场景。
|
||||||
|
|
||||||
|
## 四、快捷语法(在“常量”输入框中也可直输)
|
||||||
|
- 表达式直输(运行时按 Rhai 表达式执行)
|
||||||
|
- 触发规则:字符串以 `ctx.` 或 `ctx[` 开头。
|
||||||
|
- 示例:
|
||||||
|
- `ctx["user_n"]`
|
||||||
|
- `ctx.user.profile.name`
|
||||||
|
- `(ctx["score"] ?? 0) + 1`
|
||||||
|
- 引用直输(运行时按引用路径取值)
|
||||||
|
- 触发规则:字符串以 `${` 开始且以 `}` 结束。
|
||||||
|
- 示例:
|
||||||
|
- `${user_n}`(引用顶层 `ctx.user_n`)
|
||||||
|
- `${nodes.order_node.result.id}`(引用某节点的输出字段)
|
||||||
|
- `${orders[0].id}`(支持数组下标)
|
||||||
|
|
||||||
|
## 五、路径与表达式说明
|
||||||
|
- ctx 注入
|
||||||
|
- 表达式求值时,会自动注入 `ctx`(流程上下文),可通过点号或中括号访问。
|
||||||
|
- 支持典型的运算与空值处理(如 `??`)、算术、比较等。
|
||||||
|
- 引用路径(`${...}`)
|
||||||
|
- 推荐使用规范路径:顶层字段如 `${a.b}`,节点输出如 `${nodes.node_id.some_field}`。
|
||||||
|
- 支持数组访问:`${arr[0].name}`。
|
||||||
|
- 仅在完整匹配以 `${` 开始并以 `}` 结束时触发引用直输。
|
||||||
|
- 节点输出引用建议
|
||||||
|
- 使用 `${nodes.<nodeId>.<字段>}` 的规范形式。
|
||||||
|
- 避免模糊/非规范路径,便于协作和排障。
|
||||||
|
|
||||||
|
## 六、歧义与转义
|
||||||
|
- 如需输入“字面量字符串”而非触发快捷语法:
|
||||||
|
- 对于以 `ctx.` 或 `ctx[` 开头的文本:在前面加引号或任意非 `c` 字符,例如 `'ctx[1]'` 或前面加一个空格。
|
||||||
|
- 对于包含 `${...}` 的文本:只要整串文本不同时满足“以 `${` 开头且以 `}` 结束”,就不会触发引用直输;或者在外层再包一层引号,如 `"abc ${x} def"`。
|
||||||
|
- 简单记忆:只有“整串文本”完全匹配触发条件,才会被识别为快捷语法;否则按原样作为字符串。
|
||||||
|
|
||||||
|
## 七、常见示例
|
||||||
|
- 从上下文读取用户名:
|
||||||
|
- 左值:`user_name`;右值(常量输入框直输):`ctx["user_n"]`。
|
||||||
|
- 从节点输出读取订单 ID:
|
||||||
|
- 左值:`first_order_id`;右值(常量输入框直输):`${nodes.order_node.result.orders[0].id}`。
|
||||||
|
- 进行加一计算:
|
||||||
|
- 左值:`score_plus_one`;右值(常量输入框直输):`(ctx["score"] ?? 0) + 1`。
|
||||||
|
- 传统方式(非快捷语法):
|
||||||
|
- 切换到“引用”类型,选择路径 `a.b`。
|
||||||
|
- 切换到“表达式”类型,输入 `(ctx["a"] ?? 0) + 1`。
|
||||||
|
|
||||||
|
## 八、最佳实践
|
||||||
|
- 简单取值:优先用“引用”类型或 `${...}` 快捷语法。
|
||||||
|
- 复杂逻辑:使用“表达式”类型,便于维护与问题定位。
|
||||||
|
- 协作/导出可读性:更倾向使用“引用/表达式”类型(而非快捷语法),或在保存时做规范化(如后续启用)。
|
||||||
|
|
||||||
|
## 九、导出/保存行为
|
||||||
|
- 本次为“运行时增强”:
|
||||||
|
- 在“常量”输入框中写的 `ctx[...]` 或 `${...}`,保存/导出时仍表现为普通字符串。
|
||||||
|
- 运行时自动识别并解析执行。
|
||||||
|
- 如需“保存即规范化”(将快捷语法转换为标准的 ref/expression 类型),可作为后续可选功能启用。
|
||||||
|
|
||||||
|
## 十、常见问题排查
|
||||||
|
- 变量值为空或错误:
|
||||||
|
- 检查 `ctx` 中是否存在对应字段(例如 `ctx.user_n` 是否在上游节点或初始上下文中设定)。
|
||||||
|
- 检查节点 ID 与输出字段拼写(如 `nodes.<nodeId>`)。
|
||||||
|
- 表达式报错多为语法或空值访问,建议在“表达式”类型中尝试以便定位。
|
||||||
|
- 表达式返回类型:
|
||||||
|
- 应返回可序列化 JSON 值(字符串、数值、布尔、对象、数组或 `null`)。
|
||||||
|
- 查看结果:
|
||||||
|
- 在变量节点右侧“输出”面板或下游节点输入位置查看结果。
|
||||||
|
|
||||||
|
## 十一、版本信息
|
||||||
|
- 新增:在“常量”输入框中支持表达式/引用快捷直输。
|
||||||
|
- 兼容:不影响既有流程配置与执行。
|
||||||
@ -10,7 +10,8 @@
|
|||||||
padding: 8px 8px 8px 4px;
|
padding: 8px 8px 8px 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #7f92cd40;
|
border: 1px solid #7f92cd40;
|
||||||
width: 348px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
:global(.cm-editor) {
|
:global(.cm-editor) {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
|
|||||||
@ -27,8 +27,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-container {
|
.code-editor-container {
|
||||||
min-height: 200px;
|
min-height: 240px;
|
||||||
max-height: 400px;
|
max-height: 520px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 8px 8px 8px 4px;
|
padding: 8px 8px 8px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -40,13 +40,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.cm-scroller) {
|
:global(.cm-scroller) {
|
||||||
min-height: 200px !important;
|
min-height: 240px !important;
|
||||||
max-height: 400px !important;
|
max-height: 520px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.cm-content) {
|
:global(.cm-content) {
|
||||||
min-height: 200px !important;
|
min-height: 240px !important;
|
||||||
max-height: 400px !important;
|
max-height: 520px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,9 +67,10 @@
|
|||||||
|
|
||||||
.button {
|
.button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 358px;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
margin: 16px;
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
background-color: rgba(87, 104, 161, 0.08) !important; // override semi style
|
background-color: rgba(87, 104, 161, 0.08) !important; // override semi style
|
||||||
@ -93,6 +94,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.testrun-panel-header {
|
.testrun-panel-header {
|
||||||
background: var(#fcfcff);
|
background: var(#fcfcff);
|
||||||
@ -129,11 +131,15 @@
|
|||||||
|
|
||||||
.testrun-panel-footer {
|
.testrun-panel-footer {
|
||||||
border-top: 1px solid rgba(82, 100, 154, 0.13);
|
border-top: 1px solid rgba(82, 100, 154, 0.13);
|
||||||
height: 40px;
|
position: absolute;
|
||||||
position: fixed;
|
|
||||||
background: #fbfbfb;
|
background: #fbfbfb;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
bottom: 16px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
border-radius: 0 0 8px 8px;
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,10 +102,12 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
|||||||
onCancel();
|
onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
// sidebar effect
|
// 当测试运行面板打开时,自动关闭右侧节点编辑侧栏,避免两个 SideSheet 重叠
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodeId(undefined);
|
if (visible) {
|
||||||
}, []);
|
setNodeId(undefined);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sidebarNodeId) {
|
if (sidebarNodeId) {
|
||||||
@ -177,7 +179,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
|
|||||||
mask={false}
|
mask={false}
|
||||||
motion={false}
|
motion={false}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={400}
|
width={500}
|
||||||
headerStyle={{
|
headerStyle={{
|
||||||
display: 'none',
|
display: 'none',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';
|
import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';
|
||||||
import { AssignRows, createInferAssignPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';
|
import { AssignRows, createInferAssignPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import { FormHeader, FormContent } from '../../form-components';
|
import { FormHeader, FormContent } from '../../form-components';
|
||||||
import { VariableNodeJSON } from './types';
|
import { VariableNodeJSON } from './types';
|
||||||
@ -18,7 +19,17 @@ export const FormRender = ({ form }: FormRenderProps<VariableNodeJSON>) => {
|
|||||||
<>
|
<>
|
||||||
<FormHeader />
|
<FormHeader />
|
||||||
<FormContent>
|
<FormContent>
|
||||||
{isSidebar ? <AssignRows name="assign" /> : <DisplayOutputs displayFromScope />}
|
{isSidebar ? (
|
||||||
|
<>
|
||||||
|
<Typography.Text type="tertiary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
支持三种取值方式:常量、引用与表达式。快捷语法:
|
||||||
|
1) 直接输入表达式,如 ctx["user_n"], ctx.user.profile.name;2) 使用 {"${path.to.value}"} 作为引用路径(等价于选择“引用”并逐级点选)。
|
||||||
|
</Typography.Text>
|
||||||
|
<AssignRows name="assign" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DisplayOutputs displayFromScope />
|
||||||
|
)}
|
||||||
</FormContent>
|
</FormContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,6 +31,24 @@ function getFlowIdFromUrl(): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新增:针对后端的 design_json 兼容性转换
|
||||||
|
// - 将前端 UI 的 type: 'code' 映射为后端可识别并映射到 script_js 执行器的 'javascript'
|
||||||
|
// - 其余字段保持不变
|
||||||
|
function transformDesignJsonForBackend(json: any): any {
|
||||||
|
try {
|
||||||
|
const clone = JSON.parse(JSON.stringify(json));
|
||||||
|
clone.nodes = (clone.nodes || []).map((n: any) => {
|
||||||
|
if (n && n.type === 'code') {
|
||||||
|
return { ...n, type: 'javascript' };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
return clone;
|
||||||
|
} catch {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class CustomService {
|
export class CustomService {
|
||||||
@inject(FreeLayoutPluginContext) ctx!: FreeLayoutPluginContext;
|
@inject(FreeLayoutPluginContext) ctx!: FreeLayoutPluginContext;
|
||||||
@ -52,7 +70,9 @@ export class CustomService {
|
|||||||
}
|
}
|
||||||
const json = this.document.toJSON() as any;
|
const json = this.document.toJSON() as any;
|
||||||
const yaml = stringifyFlowDoc(json);
|
const yaml = stringifyFlowDoc(json);
|
||||||
const design_json = JSON.stringify(json);
|
// 使用转换后的 design_json,以便后端将 code 节点识别为 javascript 并选择 script_js 执行器
|
||||||
|
const designForBackend = transformDesignJsonForBackend(json);
|
||||||
|
const design_json = JSON.stringify(designForBackend);
|
||||||
const { data } = await api.put<ApiResp<{ saved: boolean }>>(`/flows/${id}`, { yaml, design_json });
|
const { data } = await api.put<ApiResp<{ saved: boolean }>>(`/flows/${id}`, { yaml, design_json });
|
||||||
if (data?.code === 0) {
|
if (data?.code === 0) {
|
||||||
if (!silent) Toast.success(I18n.t('Saved'));
|
if (!silent) Toast.success(I18n.t('Saved'));
|
||||||
|
|||||||
Reference in New Issue
Block a user