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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<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)]
|
||||
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.<id>.scripts.js 指定的脚本文件路径
|
||||
// 1) 文件脚本优先:nodes.<id>.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.<id>,再回退到根级
|
||||
let cfg: Option<String> = 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(())
|
||||
}
|
||||
|
||||
@ -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<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" => {
|
||||
// frontend IFlowValue ref: content is [nodeId, key1, key2, ...] or [topKey, ...]
|
||||
let parts: Vec<String> = v
|
||||
|
||||
@ -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<String>, name: Option<String>, design_json: Opti
|
||||
#[derive(Deserialize)]
|
||||
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");
|
||||
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<String>, design_json: Option<serde_json::Value>,
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #7f92cd40;
|
||||
width: 348px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100% !important;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,10 +102,12 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ 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<TestRunSidePanelProps> = ({ visible, onCancel
|
||||
mask={false}
|
||||
motion={false}
|
||||
onCancel={onClose}
|
||||
width={400}
|
||||
width={500}
|
||||
headerStyle={{
|
||||
display: 'none',
|
||||
}}
|
||||
|
||||
@ -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<VariableNodeJSON>) => {
|
||||
<>
|
||||
<FormHeader />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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<ApiResp<{ saved: boolean }>>(`/flows/${id}`, { yaml, design_json });
|
||||
if (data?.code === 0) {
|
||||
if (!silent) Toast.success(I18n.t('Saved'));
|
||||
|
||||
Reference in New Issue
Block a user