diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f283d4f..256a73e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2492,6 +2492,33 @@ dependencies = [ "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]] name = "rsa" version = "0.9.8" @@ -3865,6 +3892,7 @@ dependencies = [ "regex", "reqwest", "rhai", + "rquickjs", "sea-orm", "serde", "serde_json", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 305a0a6..b75f29d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -39,6 +39,8 @@ regex = "1.10" reqwest = { version = "0.11", features = ["json", "rustls-tls-native-roots"], default-features = false } futures = "0.3" percent-encoding = "2.3" +# 新增: QuickJS 运行时用于 JS 执行器(不启用额外特性) +rquickjs = "0.8" [dependencies.migration] path = "migration" diff --git a/backend/src/flow/executors/script_js.rs b/backend/src/flow/executors/script_js.rs index 8050e15..ce2cd95 100644 --- a/backend/src/flow/executors/script_js.rs +++ b/backend/src/flow/executors/script_js.rs @@ -1,7 +1,9 @@ -use async_trait::async_trait; +use async_trait::async_trait +; use serde_json::Value; use tracing::{debug, info}; use std::time::Instant; +use std::fs; use crate::flow::task::Executor; use crate::flow::domain::{NodeDef, NodeId}; @@ -20,34 +22,136 @@ fn truncate_str(s: &str, max: usize) -> String { if s.len() <= max { s } else { format!("{}…", &s[..max]) } } +fn shallow_diff(before: &Value, after: &Value) -> (Vec, Vec, Vec) { + 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("".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> = 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)] pub struct ScriptJsTask; #[async_trait] impl Executor for ScriptJsTask { async fn execute(&self, node_id: &NodeId, _node: &NodeDef, ctx: &mut Value) -> anyhow::Result<()> { - let start = Instant::now(); - - // 优先 nodes..scripts.js 指定的脚本文件路径 + // 1) 文件脚本优先:nodes..scripts.js -> 执行文件 if let Some(path) = read_node_script_file(ctx, &node_id.0, "js") { 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)"); - return Ok(()); + debug!(target = "udmin.flow", node=%node_id.0, file=%preview, "script_js task: will execute JS file"); + return exec_js_file(node_id, &path, ctx); } - - // 兼容 inline 配置(暂不执行,仅提示) - let inline = ctx.get("script") - .or_else(|| ctx.get("expr")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - if let Some(code) = inline { - let preview = truncate_str(&code, 200); - debug!(target = "udmin.flow", node=%node_id.0, preview=%preview, "script_js task: inline script provided, but execution not implemented"); - let _elapsed = start.elapsed().as_millis(); - info!(target = "udmin.flow", node=%node_id.0, "script_js task: JavaScript execution not implemented yet (skipped)"); - return Ok(()); + + // 2) inline 脚本(支持 String 或 { script | expr }),优先读取 nodes.,再回退到根级 + let cfg: Option = ctx.get("nodes") + .and_then(|nodes| nodes.get(&node_id.0)) + .and_then(|n| n.get("script").or_else(|| n.get("expr"))) + .and_then(|v| match v { + Value::String(s) => Some(s.clone()), + Value::Object(m) => m.get("script").or_else(|| m.get("expr")).and_then(|x| x.as_str()).map(|s| s.to_string()), + _ => None, + }) + .or_else(|| ctx.get("script").and_then(|v| v.as_str()).map(|s| s.to_string())) + .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"); Ok(()) } diff --git a/backend/src/flow/executors/variable.rs b/backend/src/flow/executors/variable.rs index a1ecbbc..6e2b134 100644 --- a/backend/src/flow/executors/variable.rs +++ b/backend/src/flow/executors/variable.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use serde_json::Value; +use serde_json::{Value, json}; use tracing::info; 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(""); 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 = 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" => { // frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...] let parts: Vec = v diff --git a/backend/src/routes/flows.rs b/backend/src/routes/flows.rs index cfaceb2..ffa01da 100644 --- a/backend/src/routes/flows.rs +++ b/backend/src/routes/flows.rs @@ -1,5 +1,5 @@ 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 tracing::{info, error}; use crate::middlewares::jwt::AuthUser; @@ -27,7 +27,7 @@ struct CreateReq { yaml: Option, name: Option, design_json: Opti #[derive(Deserialize)] struct UpdateReq { yaml: Option, design_json: Option, name: Option, code: Option, remark: Option } - async fn create(State(db): State, Json(req): Json) -> Result>, AppError> { + async fn create(State(db): State, user: AuthUser, Json(req): Json) -> Result>, 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 } @@ -37,6 +37,20 @@ struct UpdateReq { yaml: Option, design_json: Option, 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))) } diff --git a/docs/variable-node-usage.md b/docs/variable-node-usage.md new file mode 100644 index 0000000..a0a1ab9 --- /dev/null +++ b/docs/variable-node-usage.md @@ -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..<字段>}` 的规范形式。 + - 避免模糊/非规范路径,便于协作和排障。 + +## 六、歧义与转义 +- 如需输入“字面量字符串”而非触发快捷语法: + - 对于以 `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.`)。 + - 表达式报错多为语法或空值访问,建议在“表达式”类型中尝试以便定位。 +- 表达式返回类型: + - 应返回可序列化 JSON 值(字符串、数值、布尔、对象、数组或 `null`)。 +- 查看结果: + - 在变量节点右侧“输出”面板或下游节点输入位置查看结果。 + +## 十一、版本信息 +- 新增:在“常量”输入框中支持表达式/引用快捷直输。 +- 兼容:不影响既有流程配置与执行。 \ No newline at end of file diff --git a/frontend/src/flows/components/testrun/testrun-json-input/index.module.less b/frontend/src/flows/components/testrun/testrun-json-input/index.module.less index 4292371..ee0f6ed 100644 --- a/frontend/src/flows/components/testrun/testrun-json-input/index.module.less +++ b/frontend/src/flows/components/testrun/testrun-json-input/index.module.less @@ -10,7 +10,8 @@ padding: 8px 8px 8px 4px; border-radius: 8px; border: 1px solid #7f92cd40; - width: 348px; + width: 100%; + box-sizing: border-box; :global(.cm-editor) { height: 100% !important; diff --git a/frontend/src/flows/components/testrun/testrun-panel/index.module.less b/frontend/src/flows/components/testrun/testrun-panel/index.module.less index 1fa45c9..92e11ea 100644 --- a/frontend/src/flows/components/testrun/testrun-panel/index.module.less +++ b/frontend/src/flows/components/testrun/testrun-panel/index.module.less @@ -27,8 +27,8 @@ } .code-editor-container { - min-height: 200px; - max-height: 400px; + min-height: 240px; + max-height: 520px; background: #fff; padding: 8px 8px 8px 4px; border-radius: 4px; @@ -40,13 +40,13 @@ } :global(.cm-scroller) { - min-height: 200px !important; - max-height: 400px !important; + min-height: 240px !important; + max-height: 520px !important; } :global(.cm-content) { - min-height: 200px !important; - max-height: 400px !important; + min-height: 240px !important; + max-height: 520px !important; } } } @@ -67,9 +67,10 @@ .button { border-radius: 8px; - width: 358px; + width: 100%; height: 40px; - margin: 16px; + margin: 0; + box-sizing: border-box; &.running { background-color: rgba(87, 104, 161, 0.08) !important; // override semi style @@ -93,6 +94,7 @@ display: flex; flex-direction: column; overflow: hidden; + position: relative; .testrun-panel-header { background: var(#fcfcff); @@ -129,11 +131,15 @@ .testrun-panel-footer { border-top: 1px solid rgba(82, 100, 154, 0.13); - height: 40px; - position: fixed; + position: absolute; background: #fbfbfb; height: 72px; - bottom: 16px; + left: 0; + right: 0; + bottom: 0; border-radius: 0 0 8px 8px; + padding: 8px 16px; + display: flex; + align-items: center; } } diff --git a/frontend/src/flows/components/testrun/testrun-panel/index.tsx b/frontend/src/flows/components/testrun/testrun-panel/index.tsx index fc6b865..463a85e 100644 --- a/frontend/src/flows/components/testrun/testrun-panel/index.tsx +++ b/frontend/src/flows/components/testrun/testrun-panel/index.tsx @@ -102,10 +102,12 @@ export const TestRunSidePanel: FC = ({ visible, onCancel onCancel(); }; - // sidebar effect + // 当测试运行面板打开时,自动关闭右侧节点编辑侧栏,避免两个 SideSheet 重叠 useEffect(() => { - setNodeId(undefined); - }, []); + if (visible) { + setNodeId(undefined); + } + }, [visible]); useEffect(() => { if (sidebarNodeId) { @@ -177,7 +179,7 @@ export const TestRunSidePanel: FC = ({ visible, onCancel mask={false} motion={false} onCancel={onClose} - width={400} + width={500} headerStyle={{ display: 'none', }} diff --git a/frontend/src/flows/nodes/variable/form-meta.tsx b/frontend/src/flows/nodes/variable/form-meta.tsx index 38c66d9..e7990d9 100644 --- a/frontend/src/flows/nodes/variable/form-meta.tsx +++ b/frontend/src/flows/nodes/variable/form-meta.tsx @@ -5,6 +5,7 @@ import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor'; import { AssignRows, createInferAssignPlugin, DisplayOutputs } from '@flowgram.ai/form-materials'; +import { Typography } from '@douyinfe/semi-ui'; import { FormHeader, FormContent } from '../../form-components'; import { VariableNodeJSON } from './types'; @@ -18,7 +19,17 @@ export const FormRender = ({ form }: FormRenderProps) => { <> - {isSidebar ? : } + {isSidebar ? ( + <> + + 支持三种取值方式:常量、引用与表达式。快捷语法: + 1) 直接输入表达式,如 ctx["user_n"], ctx.user.profile.name;2) 使用 {"${path.to.value}"} 作为引用路径(等价于选择“引用”并逐级点选)。 + + + + ) : ( + + )} ); diff --git a/frontend/src/flows/services/custom-service.ts b/frontend/src/flows/services/custom-service.ts index 1f170bf..eaee12e 100644 --- a/frontend/src/flows/services/custom-service.ts +++ b/frontend/src/flows/services/custom-service.ts @@ -31,6 +31,24 @@ function getFlowIdFromUrl(): string { 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() export class CustomService { @inject(FreeLayoutPluginContext) ctx!: FreeLayoutPluginContext; @@ -52,7 +70,9 @@ export class CustomService { } const json = this.document.toJSON() as any; 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>(`/flows/${id}`, { yaml, design_json }); if (data?.code === 0) { if (!silent) Toast.success(I18n.t('Saved'));