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:
2025-09-20 17:35:36 +08:00
parent baa787934a
commit 296f0ae9f6
11 changed files with 357 additions and 41 deletions

28
backend/Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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");

View File

@ -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

View File

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

View 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`)。
- 查看结果:
- 在变量节点右侧“输出”面板或下游节点输入位置查看结果。
## 十一、版本信息
- 新增:在“常量”输入框中支持表达式/引用快捷直输。
- 兼容:不影响既有流程配置与执行。

View File

@ -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;

View File

@ -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;
} }
} }

View File

@ -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',
}} }}

View File

@ -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.name2) 使 {"${path.to.value}"}
</Typography.Text>
<AssignRows name="assign" />
</>
) : (
<DisplayOutputs displayFromScope />
)}
</FormContent> </FormContent>
</> </>
); );

View File

@ -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'));