lan-low-code-cli 2.8.1 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -350,13 +350,17 @@ llc flow delete --id <flowId> --yes
350
350
 
351
351
  **布局**由编译器生成,不要手写 `drawAttr.bounds`:
352
352
 
353
- - `--layout ide`(默认):主链以 `--center-x`(默认 679)居中;`BranchNode` 为 56×56,其余节点 180×38;
354
- 分支后继与分支节点同层、泳道 ±1 左右对称;汇聚下移一层;层距 = 该层最高节点 + 52。
355
- 这与平台 mxGraph 编辑器「向下插入 / 左右插入」累积出的编排一致。
356
- - `--layout layered`:分支后继放到下一层,可读性更强但与平台手工习惯略有差异。
353
+ - `--layout ide`(默认):**dagre 分层布局**(Sugiyama:分层 → 层内排序 → 坐标分配)。
354
+ 主链以 `--center-x`(默认 679)居中,`BranchNode` 56×56、其余 180×38;分支后继位于下一层,
355
+ 父节点居中于子节点之上,汇聚节点居中于多个父节点下方;层间距 `--rank-padding`(默认 52),
356
+ 同层间距 60。嵌套分支场景实测交叉数 0、线段总长比旧算法短约 16%。
357
+ - `--layout layered`:旧版「rank + lane」算法,复刻 IDE 手工「左右插入」习惯(分支后继与分支节点同层、
358
+ 左右对称)。保留用于兼容历史流程,分支嵌套时更容易出现长线和交叉。
357
359
  - `--layout passthrough`:沿用 spec 里的 `bounds`(用于回写平台导出的流程)。
358
360
  - `passthrough` 之外的模式下,`relative:false`、节点尺寸、连线 `sourceRef/targetRef` 与
359
- `formulaConfig.branchFormulas`(引用真实连线 ID)、分支线 `exitX=0/1` 锚点均自动生成。
361
+ `formulaConfig.branchFormulas`(引用真实连线 ID)均自动生成;分支出线按实际相对位置写
362
+ `exitX=0/1`,多出边/多入边写 `exitX/entryX=0.25/0.75`,减少线条挤在同一点。
363
+ - 布局依赖:`@dagrejs/dagre`(MIT)。CLI 其余逻辑仍不依赖任何前端包。
360
364
 
361
365
  **Schema 注入**:`--resolve-schema` 会为引用数据模型的节点调 `getSourceJsonSchema` /
362
366
  `getProcessDataOutJsonSchema` 补齐 `extraAttr.context_outputParam` / `context_bindFormId`。
@@ -373,6 +377,36 @@ llc flow delete --id <flowId> --yes
373
377
  - `AddDataNode` / `AddOrUpdateNode`:分类表单或未填 `commitType` 时豁免 `flow`
374
378
  - 校验失败时输出 `field` 与修复建议,`flow validate` / `flow compile` 退出码为 `1`,不会写入环境
375
379
 
380
+ **参数映射(`TransformNode` / `ExecuteNode` 等)**:
381
+
382
+ ```json
383
+ {
384
+ "id": "N4",
385
+ "type": "TransformNode",
386
+ "name": "转换",
387
+ "config": {
388
+ "outParam": {
389
+ "fd_title": { "type": "string", "description": "标题" },
390
+ "fd_amount": { "type": "number" }
391
+ },
392
+ "mapping": {
393
+ "fd_title": "$N2.fd_title",
394
+ "fd_amount": "$N2.fd_amount"
395
+ }
396
+ }
397
+ }
398
+ ```
399
+
400
+ - `outParam` 可写字段映射对象(自动转成 `{id:'responseBody',type:'object',properties:{...}}` 的 JSON Schema 字符串),
401
+ 也可直接写完整 Schema 或 Schema 字符串;编译器写入 `outParamType=1`
402
+ - `mapping` 的 `$<节点id>.<路径>` 自动转成
403
+ `{sourceType:'node',sourcePath:'node.N2.fd_title',targetPath:'fd_title'}`;`$context.<路径>` 转为上下文映射
404
+ - 编译器同时把出参 Schema 写入 `extraAttr.context_outputParam`,供下游映射与 IDE 显示
405
+ - 未写 `mapping` 时,若上游节点已提供 `context.context_outputParam`,按同名字段自动推断,并给出
406
+ `MAPPING_AUTO_INFERRED` 提示
407
+ - 常量或表达式请用 `{ "paramMappingWay": "formula", "valueFormula": "..." }`;
408
+ `ExecuteNode` / `InterfaceNode` / `SubActionNode` / `FlowBotsNode` 用同一 `mapping` 生成 `inputParamMapping`
409
+
376
410
  **边界与已知限制**:
377
411
 
378
412
  - `AddDataNode` / `AddOrUpdateNode` 的 `fields` 是平台真实结构
@@ -290,7 +290,7 @@ function registerFlowCommand(program) {
290
290
  .command('compile')
291
291
  .description('把 Flow Spec 编译为可直接提交的 fdDesign(含编排布局与离线校验)')
292
292
  .requiredOption('--spec <@file|json>', 'Flow Spec:@文件路径 或内联 JSON 字符串')
293
- .option('--layout <mode>', '布局模式:ide(默认,分支同层左右对称)| layered | passthrough(沿用已有坐标)')
293
+ .option('--layout <mode>', '布局模式:ide(默认,dagre 分层)| layered(旧版同层分支)| passthrough(沿用已有坐标)')
294
294
  .option('--center-x <n>', '主链中心 x,默认 679(对应 1920 宽屏)')
295
295
  .option('--rank-padding <n>', '层间距增量,默认 52(层距 = 该层最高节点 + 52)')
296
296
  .option('--lane-gap <n>', '泳道间距覆盖值,默认 2 × 层距')
@@ -319,7 +319,7 @@ function registerFlowCommand(program) {
319
319
  .command('validate')
320
320
  .description('只做离线校验(图结构 / 必填 / 编码 / 布局重叠),不输出设计')
321
321
  .requiredOption('--spec <@file|json>', 'Flow Spec:@文件路径 或内联 JSON 字符串')
322
- .option('--layout <mode>', '布局模式:ide | layered | passthrough')
322
+ .option('--layout <mode>', '布局模式:ide(默认,dagre 分层)| layered(旧版同层分支)| passthrough(沿用坐标)')
323
323
  .option('--center-x <n>', '主链中心 x')
324
324
  .option('--rank-padding <n>', '层间距增量')
325
325
  .option('--lane-gap <n>', '泳道间距')
@@ -579,7 +579,7 @@ function registerFlowCommand(program) {
579
579
  .option('--enable', '成功后调用 startup 启用')
580
580
  .option('--resolve-schema', '联网补齐 extraAttr.context_*(SchemaResolver)')
581
581
  .option('--strict-schema', 'Schema 拉取失败时升级为错误')
582
- .option('--layout <mode>', '布局模式:ide | layered | passthrough')
582
+ .option('--layout <mode>', '布局模式:ide(默认,dagre 分层)| layered(旧版同层分支)| passthrough(沿用坐标)')
583
583
  .option('--center-x <n>', '主链中心 x')
584
584
  .option('--lane-gap <n>', '泳道间距')
585
585
  .option('--min-gap <n>', '最小边缘间隙')
@@ -687,7 +687,7 @@ function registerFlowCommand(program) {
687
687
  .option('--no-ignore-bounds', '比较时也包含 drawAttr.bounds')
688
688
  .option('--resolve-schema', '对比前联网补齐 extraAttr.context_*(与发布时的选项保持一致)')
689
689
  .option('--strict-schema', 'Schema 拉取失败时升级为错误')
690
- .option('--layout <mode>', '布局模式:ide | layered | passthrough')
690
+ .option('--layout <mode>', '布局模式:ide(默认,dagre 分层)| layered(旧版同层分支)| passthrough(沿用坐标)')
691
691
  .option('--center-x <n>', '主链中心 x')
692
692
  .option('--json', '以 JSON 输出差异')).action(async (opts) => {
693
693
  const spec = readJsonInput(String(opts.spec), '--spec');
@@ -291,9 +291,11 @@ function buildCreatePayload(opts, flow) {
291
291
  fdModelName: FLOW_MODEL_NAME,
292
292
  fdCode: flow.fdCode,
293
293
  };
294
- const incParams = buildIncParams(opts.incParams);
295
- if (incParams !== undefined)
296
- config.incParams = incParams;
294
+ const builtInc = buildIncParams(opts.incParams);
295
+ if (builtInc) {
296
+ config.incParams = builtInc.incParams;
297
+ config.formIncParams = builtInc.formIncParams;
298
+ }
297
299
  const active = flow.fdProcessActive ?? {};
298
300
  if (active.fdFormId) {
299
301
  // 动作流绑定了表单:按钮按当前文档 id/ids 传参
@@ -343,7 +345,17 @@ function assertTriggerableFlow(flow) {
343
345
  throw new Error(`动作流 ${flow.fdName ?? flow.fdId} 未开启「操作触发」,请在 trigger.fdRunType 中包含 operate 后重新发布`);
344
346
  }
345
347
  }
346
- /** --inc-params 支持 @file 或内联 JSON;返回平台要求的 [{field:{name},value:{val}}] */
348
+ /**
349
+ * 编译动作流入参。
350
+ *
351
+ * 平台落库结构(addOperationModal/content.tsx:533-576):
352
+ * fdConfig.incParams = [{ batchNo: 'B001' }, { repeat: 2 }]
353
+ * fdConfig.formIncParams = [{ field: { name: 'batchNo' } }, ...]
354
+ * 入参支持三种写法:
355
+ * 对象 { "batchNo": "B001", "repeat": 2 }
356
+ * 数组 [{ "batchNo": "B001" }]
357
+ * 数组 [{ "field": { "name": "batchNo" }, "value": { "val": "B001" } }]
358
+ */
347
359
  function buildIncParams(input) {
348
360
  if (input === undefined || input === null || input === '')
349
361
  return undefined;
@@ -355,9 +367,33 @@ function buildIncParams(input) {
355
367
  catch (err) {
356
368
  throw new Error(`--inc-params 不是合法 JSON:${err.message}`);
357
369
  }
358
- if (!Array.isArray(parsed))
359
- throw new Error('--inc-params 需为数组:[{field:{name},value:{val}}]');
360
- return parsed;
370
+ const entries = [];
371
+ const pushEntry = (name, value) => {
372
+ entries.push({ [name]: value });
373
+ };
374
+ if (Array.isArray(parsed)) {
375
+ for (const item of parsed) {
376
+ if (!item || typeof item !== 'object')
377
+ continue;
378
+ const record = item;
379
+ const field = record.field;
380
+ if (field && typeof field.name === 'string') {
381
+ const value = record.value;
382
+ pushEntry(field.name, value && 'val' in value ? value.val : record.value);
383
+ continue;
384
+ }
385
+ Object.entries(record).forEach(([name, value]) => pushEntry(name, value));
386
+ }
387
+ }
388
+ else if (parsed && typeof parsed === 'object') {
389
+ Object.entries(parsed).forEach(([name, value]) => pushEntry(name, value));
390
+ }
391
+ if (entries.length === 0)
392
+ return undefined;
393
+ return {
394
+ incParams: entries,
395
+ formIncParams: entries.map((entry) => ({ field: { name: Object.keys(entry)[0] } })),
396
+ };
361
397
  }
362
398
  /**
363
399
  * 基于现有操作构造 update 请求体。
@@ -440,9 +476,11 @@ function buildUpdatePayload(current, opts, flow) {
440
476
  fdModelName: FLOW_MODEL_NAME,
441
477
  fdCode: flow.fdCode,
442
478
  };
443
- const incParams = buildIncParams(opts.incParams);
444
- if (incParams !== undefined)
445
- config.incParams = incParams;
479
+ const builtInc = buildIncParams(opts.incParams);
480
+ if (builtInc) {
481
+ config.incParams = builtInc.incParams;
482
+ config.formIncParams = builtInc.formIncParams;
483
+ }
446
484
  }
447
485
  else {
448
486
  const dialogId = opts.dialogId !== undefined
@@ -20,6 +20,7 @@ exports.normalizeGraph = normalizeGraph;
20
20
  exports.compileFlow = compileFlow;
21
21
  const layout_1 = require("./layout");
22
22
  Object.defineProperty(exports, "NODE_SIZE", { enumerable: true, get: function () { return layout_1.NODE_SIZE; } });
23
+ const mapping_1 = require("./mapping");
23
24
  const schema_1 = require("./schema");
24
25
  const validate_1 = require("./validate");
25
26
  Object.defineProperty(exports, "hasErrors", { enumerable: true, get: function () { return validate_1.hasErrors; } });
@@ -104,6 +105,10 @@ function normalizeGraph(spec, mode, hints = {}) {
104
105
  if (business.length === 0 && edges.length === 0)
105
106
  addEdge({ from: startId, to: endId });
106
107
  const byId = new Map(nodes.map((n) => [n.id, n]));
108
+ // 在必填校验与节点实例化之前统一补齐参数映射(TransformNode 等)
109
+ for (const node of nodes) {
110
+ (0, mapping_1.prepareNodeConfig)(node, diagnostics, { edges, byId });
111
+ }
107
112
  const ranks = { [startId]: 0 };
108
113
  const branchTargets = new Set();
109
114
  for (const edge of edges) {
@@ -317,15 +322,34 @@ function compileGraph(spec, ctx) {
317
322
  });
318
323
  }
319
324
  const nodes = normalized.graph.nodes.map((node) => buildNode(node, layout.bounds, ctx, branchDefaults, branchFormulas, diagnostics));
325
+ const incomingCount = new Map();
326
+ const outgoingCount = new Map();
327
+ for (const edge of normalized.graph.edges) {
328
+ incomingCount.set(edge.to, (incomingCount.get(edge.to) ?? 0) + 1);
329
+ outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
330
+ }
320
331
  const lines = normalized.graph.edges.map((edge) => {
321
- // 条件分支出边需要左右锚点,否则 IDE 里两条线会重叠(真实 style: exitX=0/1)。
332
+ // 多出边/多入边按实际相对位置设置锚点,避免多条线挤在节点同一点。
333
+ // 条件分支:左右侧出(exitX=0/1);汇聚:左/右 1/4 处入(entryX=0.25/0.75)。
322
334
  const source = nodeById.get(edge.from);
323
- const target = nodeById.get(edge.to);
324
335
  const isBranchOut = source?.type === 'BranchNode';
325
- const exitX = isBranchOut && target ? (target._lane < 0 ? 0 : target._lane > 0 ? 1 : 0.5) : 0.5;
326
- const style = isBranchOut
327
- ? `line;exitX=${exitX};exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;`
328
- : '';
336
+ const sourceBounds = layout.bounds[edge.from];
337
+ const targetBounds = layout.bounds[edge.to];
338
+ const sourceCenterX = sourceBounds ? sourceBounds.x + sourceBounds.width / 2 : undefined;
339
+ const targetCenterX = targetBounds ? targetBounds.x + targetBounds.width / 2 : undefined;
340
+ const leftOfTarget = sourceCenterX !== undefined && targetCenterX !== undefined ? sourceCenterX < targetCenterX : true;
341
+ let style = '';
342
+ if (isBranchOut) {
343
+ const exitX = sourceCenterX !== undefined && targetCenterX !== undefined
344
+ ? (targetCenterX < sourceCenterX ? 0 : targetCenterX > sourceCenterX ? 1 : 0.5)
345
+ : 0.5;
346
+ style = `line;exitX=${exitX};exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;`;
347
+ }
348
+ else if ((outgoingCount.get(edge.from) ?? 0) > 1 || (incomingCount.get(edge.to) ?? 0) > 1) {
349
+ const exitX = (outgoingCount.get(edge.from) ?? 0) > 1 ? (leftOfTarget ? 0.25 : 0.75) : 0.5;
350
+ const entryX = (incomingCount.get(edge.to) ?? 0) > 1 ? (leftOfTarget ? 0.25 : 0.75) : 0.5;
351
+ style = `line;exitX=${exitX};exitY=1;exitDx=0;exitDy=0;entryX=${entryX};entryY=0;entryDx=0;entryDy=0;`;
352
+ }
329
353
  return {
330
354
  id: lineIdOf.get(edge),
331
355
  name: edge.name ?? '',
@@ -337,7 +361,7 @@ function compileGraph(spec, ctx) {
337
361
  // 仅 --emit-waypoints 时额外写入 wayPoints(非 IDE 原生行为)。
338
362
  drawAttr: ctx.options.emitWayPoints
339
363
  ? { style, wayPoints: [] }
340
- : isBranchOut ? { style } : {},
364
+ : style ? { style } : {},
341
365
  };
342
366
  });
343
367
  const design = {
@@ -2,22 +2,26 @@
2
2
  /**
3
3
  * 动作流编排布局引擎
4
4
  *
5
- * 源码依据(均来自 action-flow-ide):
6
- * - 尺寸表 styleSheet.ts:339-347:BranchNode 56×56,其余 180×38;回显时宽高被强制覆盖
7
- * - 种子 mxgraph/utils.tsx:93-135:N1 在 (C-90, 80)、N2 在 (C-90, 166),L1 直连
8
- * - 向下插入 R4(mxgraph/utils.tsx:422-465,1408-1428):distance = h_prev + 52,
9
- * cy_new = cy_prev + distance,下游整体 y += distance
10
- * - 左右插入 R5(mxgraph/utils.tsx:442-458,568-584):与源节点同层中心线,
11
- * x = C - w/2 ± 2*distance
12
- * - 无整图自动布局、无后端布局服务;坐标是持久化契约,IDE 回显原样使用
5
+ * 坐标是持久化契约:IDE 回显 `drawAttr.bounds` 时原样使用,不会重算,
6
+ * 也没有整图自动布局或后端布局服务,因此坐标质量直接决定连线是否可读。
13
7
  *
14
- * 因此这里按「rank(逻辑层)+ lane(层内泳道)+ 中心线」复刻编排习惯:
15
- * 主链居中,分支的直接后继与分支节点同层、左右对称,汇聚节点下移一层。
8
+ * 两种模式:
9
+ * - `ide`(默认):dagre 分层布局(Sugiyama:分层 → 层内排序 → 坐标分配)。
10
+ * 分支后继位于下一层,父节点由 dagre 居中于子节点之上,汇聚节点居中于多个父节点下方,
11
+ * 能显著减少长边与交叉。dagre 是 mxGraph 生态常用的布局库(MIT)。
12
+ * - `layered`:旧版「rank + lane」算法,复刻 IDE 手工「左右插入」习惯——
13
+ * 分支后继与分支节点同层、左右对称。保留用于兼容历史流程,但分支多时线段较乱。
14
+ * - `passthrough`:原样沿用节点 bounds(UI 导出流程回写)。
15
+ *
16
+ * 尺寸与坐标依据:
17
+ * - styleSheet.ts:339-347:BranchNode 56×56,其余 180×38
18
+ * - mxgraph/utils.tsx:93-135:主画布起始 y=80
16
19
  */
17
20
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.DEFAULT_CENTER_X = exports.MIN_GAP = exports.RANK_PADDING = exports.START_Y = exports.NODE_SIZE = void 0;
21
+ exports.DEFAULT_NODE_GAP = exports.DEFAULT_CENTER_X = exports.MIN_GAP = exports.RANK_PADDING = exports.START_Y = exports.NODE_SIZE = void 0;
19
22
  exports.nodeSize = nodeSize;
20
23
  exports.layoutGraph = layoutGraph;
24
+ const dagre_1 = require("@dagrejs/dagre");
21
25
  /** 与 styleSheet.ts nodeBounds 一致 */
22
26
  exports.NODE_SIZE = {
23
27
  BranchNode: { width: 56, height: 56 },
@@ -25,76 +29,192 @@ exports.NODE_SIZE = {
25
29
  };
26
30
  /** 主画布开始节点的 y(mxgraph/utils.tsx:130) */
27
31
  exports.START_Y = 80;
28
- /** R4 的层间距增量:distance = h_prev + 52 */
32
+ /** 层间距增量:distance = h_prev + 52 */
29
33
  exports.RANK_PADDING = 52;
30
34
  /**
31
35
  * 最小边缘间隙,默认 40。
32
36
  *
33
37
  * IDE 常规编排的垂直间隙由 R4 推导:gap = 52 + (h_prev - h_new)/2;
34
38
  * 38 高节点后紧跟 56 高 BranchNode 时最小为 52 - (56-38)/2 = 43px。
35
- * 因此不能照搬 mxHierarchicalLayout 的 interRankCellSpacing=50(该值只在
36
- * 折叠/展开时生效),否则正常分支流程会被误判为布局重叠。
37
39
  */
38
40
  exports.MIN_GAP = 40;
39
41
  /** W=1920 时 (1920-562)/2;CLI 无浏览器宽度,可用 --center-x 覆盖 */
40
42
  exports.DEFAULT_CENTER_X = 679;
43
+ /** dagre 同层节点默认水平间距 */
44
+ exports.DEFAULT_NODE_GAP = 60;
41
45
  function nodeSize(nodeType) {
42
46
  return exports.NODE_SIZE[nodeType] ?? exports.NODE_SIZE.general;
43
47
  }
44
48
  const round = (v) => Math.round(v * 100) / 100;
45
49
  function layoutGraph(graph, options = {}) {
46
50
  const mode = options.mode ?? 'ide';
47
- const centerX = options.centerX ?? exports.DEFAULT_CENTER_X;
48
- const padding = options.rankPadding ?? exports.RANK_PADDING;
49
- const minGap = options.minGap ?? exports.MIN_GAP;
51
+ if (mode === 'passthrough')
52
+ return layoutPassthrough(graph);
53
+ if (mode === 'layered')
54
+ return layoutLayered(graph, options);
55
+ return layoutDagre(graph, options);
56
+ }
57
+ /* ------------------------------------------------------------------ 重叠 / 负坐标检查 */
58
+ function checkBounds(graph, bounds, minGap) {
50
59
  const diagnostics = [];
51
- const bounds = {};
52
- // passthrough:原样沿用已有坐标(P1 克隆 / UI 导出流程回写)
53
- if (mode === 'passthrough') {
54
- for (const node of graph.nodes) {
55
- const size = nodeSize(node.type);
56
- const given = node.bounds;
57
- if (!given || typeof given.x !== 'number' || typeof given.y !== 'number') {
60
+ const placed = graph.nodes.filter((node) => bounds[node.id]);
61
+ for (let i = 0; i < placed.length; i++) {
62
+ for (let j = i + 1; j < placed.length; j++) {
63
+ const a = bounds[placed[i].id];
64
+ const b = bounds[placed[j].id];
65
+ const horizontal = a.x < b.x + b.width + minGap && b.x < a.x + a.width + minGap;
66
+ const vertical = a.y < b.y + b.height + minGap && b.y < a.y + a.height + minGap;
67
+ if (horizontal && vertical) {
58
68
  diagnostics.push({
59
69
  level: 'error',
60
- code: 'LAYOUT_MISSING_BOUNDS',
61
- message: `passthrough 模式要求节点提供 bounds.{x,y}:${node.id}`,
62
- nodeId: node.id,
70
+ code: 'LAYOUT_OVERLAP',
71
+ message: `节点 ${placed[i].id} 与 ${placed[j].id} 的布局重叠或间距不足 ${minGap}px`,
72
+ nodeId: placed[j].id,
63
73
  });
64
- continue;
65
74
  }
66
- bounds[node.id] = {
67
- x: given.x,
68
- y: given.y,
69
- width: size.width,
70
- height: size.height,
71
- relative: given.relative ?? false,
72
- };
73
75
  }
74
- return { bounds, rankCenters: [], diagnostics };
75
76
  }
76
- const maxRank = graph.nodes.reduce((max, n) => Math.max(max, n._rank), 0);
77
+ for (const node of placed) {
78
+ const b = bounds[node.id];
79
+ if (b.x < 0 || b.y < 0) {
80
+ diagnostics.push({
81
+ level: 'warn',
82
+ code: 'LAYOUT_NEGATIVE',
83
+ message: `节点 ${node.id} 坐标为负 (${b.x}, ${b.y}),IDE 中可能显示不全`,
84
+ nodeId: node.id,
85
+ });
86
+ }
87
+ }
88
+ return diagnostics;
89
+ }
90
+ /* ------------------------------------------------------------------ passthrough */
91
+ function layoutPassthrough(graph) {
92
+ const diagnostics = [];
93
+ const bounds = {};
94
+ for (const node of graph.nodes) {
95
+ const size = nodeSize(node.type);
96
+ const given = node.bounds;
97
+ if (!given || typeof given.x !== 'number' || typeof given.y !== 'number') {
98
+ diagnostics.push({
99
+ level: 'error',
100
+ code: 'LAYOUT_MISSING_BOUNDS',
101
+ message: `passthrough 模式要求节点提供 bounds.{x,y}:${node.id}`,
102
+ nodeId: node.id,
103
+ });
104
+ continue;
105
+ }
106
+ bounds[node.id] = {
107
+ x: given.x,
108
+ y: given.y,
109
+ width: size.width,
110
+ height: size.height,
111
+ relative: given.relative ?? false,
112
+ };
113
+ }
114
+ return { bounds, rankCenters: [], diagnostics };
115
+ }
116
+ /* ------------------------------------------------------------------ dagre(默认) */
117
+ function layoutDagre(graph, options) {
118
+ const centerX = options.centerX ?? exports.DEFAULT_CENTER_X;
119
+ const minGap = options.minGap ?? exports.MIN_GAP;
120
+ const diagnostics = [];
121
+ const g = new dagre_1.graphlib.Graph({ multigraph: true });
122
+ g.setGraph({
123
+ rankdir: 'TB',
124
+ nodesep: options.nodeGap ?? exports.DEFAULT_NODE_GAP,
125
+ ranksep: options.rankPadding ?? exports.RANK_PADDING,
126
+ marginx: 0,
127
+ marginy: 0,
128
+ });
129
+ g.setDefaultEdgeLabel(() => ({}));
130
+ for (const node of graph.nodes) {
131
+ const size = nodeSize(node.type);
132
+ g.setNode(node.id, { width: size.width, height: size.height });
133
+ }
134
+ graph.edges.forEach((edge, index) => {
135
+ if (!g.hasNode(edge.from) || !g.hasNode(edge.to))
136
+ return;
137
+ g.setEdge(edge.from, edge.to, {}, `e${index}`);
138
+ });
139
+ try {
140
+ (0, dagre_1.layout)(g);
141
+ }
142
+ catch (err) {
143
+ diagnostics.push({
144
+ level: 'warn',
145
+ code: 'LAYOUT_DAGRE_FAILED',
146
+ message: `dagre 布局失败,已回退到 layered:${err.message.split('\n')[0]}`,
147
+ });
148
+ return layoutLayered(graph, options);
149
+ }
150
+ const start = graph.nodes.find((node) => node._isStart) ?? graph.nodes[0];
151
+ const startLabel = start ? g.node(start.id) : undefined;
152
+ const startSize = start ? nodeSize(start.type) : exports.NODE_SIZE.general;
153
+ const startX = startLabel && typeof startLabel.x === 'number' ? startLabel.x : undefined;
154
+ const startY = startLabel && typeof startLabel.y === 'number' ? startLabel.y : undefined;
155
+ const offsetX = startX !== undefined ? centerX - startX : 0;
156
+ const offsetY = startY !== undefined ? exports.START_Y - (startY - startSize.height / 2) : exports.START_Y;
157
+ const raw = new Map();
158
+ let minX = Number.POSITIVE_INFINITY;
159
+ for (const node of graph.nodes) {
160
+ const label = g.node(node.id);
161
+ if (!label || typeof label.x !== 'number' || typeof label.y !== 'number')
162
+ continue;
163
+ const size = nodeSize(node.type);
164
+ const x = label.x + offsetX - size.width / 2;
165
+ const y = label.y + offsetY - size.height / 2;
166
+ raw.set(node.id, { x, y });
167
+ minX = Math.min(minX, x);
168
+ }
169
+ // 负坐标会显示不全:整体右移
170
+ const shiftX = Number.isFinite(minX) && minX < 0 ? 20 - minX : 0;
171
+ const bounds = {};
172
+ const centers = new Set();
173
+ for (const node of graph.nodes) {
174
+ const point = raw.get(node.id);
175
+ if (!point)
176
+ continue;
177
+ const size = nodeSize(node.type);
178
+ bounds[node.id] = {
179
+ x: round(point.x + shiftX),
180
+ y: round(point.y),
181
+ width: size.width,
182
+ height: size.height,
183
+ relative: false,
184
+ };
185
+ centers.add(Math.round(point.y + size.height / 2));
186
+ }
187
+ diagnostics.push(...checkBounds(graph, bounds, minGap));
188
+ return { bounds, rankCenters: [...centers].sort((a, b) => a - b), diagnostics };
189
+ }
190
+ /* ------------------------------------------------------------------ 旧版 layered */
191
+ function layoutLayered(graph, options) {
192
+ const centerX = options.centerX ?? exports.DEFAULT_CENTER_X;
193
+ const padding = options.rankPadding ?? exports.RANK_PADDING;
194
+ const minGap = options.minGap ?? exports.MIN_GAP;
195
+ const bounds = {};
196
+ const maxRank = graph.nodes.reduce((max, node) => Math.max(max, node._rank), 0);
77
197
  // 每层取最高节点,决定「该层到下一层」的间距:38→90、56→108
78
198
  const rankHeight = [];
79
199
  for (let r = 0; r <= maxRank; r++) {
80
- const heights = graph.nodes.filter((n) => n._rank === r).map((n) => nodeSize(n.type).height);
200
+ const heights = graph.nodes.filter((node) => node._rank === r).map((node) => nodeSize(node.type).height);
81
201
  rankHeight[r] = heights.length > 0 ? Math.max(...heights) : exports.NODE_SIZE.general.height;
82
202
  }
83
203
  const gapOf = (r) => (rankHeight[r] ?? exports.NODE_SIZE.general.height) + padding;
84
- // 中心线:cy(0) = 80 + h_start/2 = 99;cy(r) = cy(r-1) + gap(r-1)
204
+ // 中心线:cy(0) = 80 + h_start/2;cy(r) = cy(r-1) + gap(r-1)
85
205
  const rankCenters = [];
86
- const startNode = graph.nodes.find((n) => n._isStart);
206
+ const startNode = graph.nodes.find((node) => node._isStart);
87
207
  rankCenters[0] = exports.START_Y + (startNode ? nodeSize(startNode.type).height : exports.NODE_SIZE.general.height) / 2;
88
208
  for (let r = 1; r <= maxRank; r++) {
89
209
  rankCenters[r] = rankCenters[r - 1] + gapOf(r - 1);
90
210
  }
91
211
  for (const node of graph.nodes) {
92
212
  const size = nodeSize(node.type);
93
- const r = node._rank;
213
+ const rank = node._rank;
94
214
  // R5 的 ±2*distance:distance 取当前层最高节点对应的层间距
95
- const laneGap = options.laneGap ?? 2 * gapOf(r);
215
+ const laneGap = options.laneGap ?? 2 * gapOf(rank);
96
216
  const x = centerX - size.width / 2 + node._lane * laneGap;
97
- const y = (rankCenters[r] ?? rankCenters[rankCenters.length - 1]) - size.height / 2;
217
+ const y = (rankCenters[rank] ?? rankCenters[rankCenters.length - 1]) - size.height / 2;
98
218
  bounds[node.id] = {
99
219
  x: round(x),
100
220
  y: round(y),
@@ -103,34 +223,5 @@ function layoutGraph(graph, options = {}) {
103
223
  relative: false,
104
224
  };
105
225
  }
106
- // 矩形重叠检查(含最小间隙)
107
- const placed = graph.nodes.filter((n) => bounds[n.id]);
108
- for (let i = 0; i < placed.length; i++) {
109
- for (let j = i + 1; j < placed.length; j++) {
110
- const a = bounds[placed[i].id];
111
- const b = bounds[placed[j].id];
112
- const horizontal = a.x < b.x + b.width + minGap && b.x < a.x + a.width + minGap;
113
- const vertical = a.y < b.y + b.height + minGap && b.y < a.y + a.height + minGap;
114
- if (horizontal && vertical) {
115
- diagnostics.push({
116
- level: 'error',
117
- code: 'LAYOUT_OVERLAP',
118
- message: `节点 ${placed[i].id} 与 ${placed[j].id} 的布局重叠或间距不足 ${minGap}px`,
119
- nodeId: placed[j].id,
120
- });
121
- }
122
- }
123
- }
124
- for (const node of placed) {
125
- const b = bounds[node.id];
126
- if (b.x < 0 || b.y < 0) {
127
- diagnostics.push({
128
- level: 'warn',
129
- code: 'LAYOUT_NEGATIVE',
130
- message: `节点 ${node.id} 坐标为负 (${b.x}, ${b.y}),IDE 中可能显示不全;可增大 --center-x / --lane-gap`,
131
- nodeId: node.id,
132
- });
133
- }
134
- }
135
- return { bounds, rankCenters, diagnostics };
226
+ return { bounds, rankCenters, diagnostics: checkBounds(graph, bounds, minGap) };
136
227
  }
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ /**
3
+ * 参数映射编译器
4
+ *
5
+ * 平台里映射的持久化结构只有两种(`configuration/controls/paramMapping/index.tsx:100-135`):
6
+ * point: { paramMappingWay:'point', pointMappings:[{ sourceType, sourcePath, targetPath }] }
7
+ * formula: { paramMappingWay:'formula', valueFormula:'<JSON 字符串>' }
8
+ *
9
+ * spec 里允许更友好的写法(编译后会被删除,不落库):
10
+ * "config": {
11
+ * "outParam": { "fd_title": { "type": "string", "description": "标题" } },
12
+ * "mapping": { "fd_title": "$N2.fd_title", "fd_amount": "$N2.fd_amount" }
13
+ * }
14
+ *
15
+ * - `outParam` 也接受完整 JSON Schema(含 type/properties)或 JSON 字符串;
16
+ * - `mapping` 的值支持 `$<节点id>.<路径>` 与 `$context.<路径>`;
17
+ * - 也接受 `{ paramMappingWay:'formula', valueFormula:'...' }` 或 pointMappings 数组直接透传。
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.prepareNodeConfig = prepareNodeConfig;
21
+ const JSON_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object']);
22
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
23
+ const NODE_REF = /^\$([0-9a-zA-Z_]{2,32})\.(.+)$/;
24
+ const CONTEXT_REF = /^\$context\.(.+)$/;
25
+ function targetPathOf(key) {
26
+ const index = key.indexOf('.');
27
+ return index >= 0 ? key.slice(index + 1) : key;
28
+ }
29
+ /** 字段映射 → JSON Schema(`{id:'responseBody', type:'object', properties:{...}}`) */
30
+ function buildSchemaFromFields(fields) {
31
+ const properties = {};
32
+ for (const [name, raw] of Object.entries(fields)) {
33
+ if (isRecord(raw)) {
34
+ properties[name] = { id: name, required: false, ...raw };
35
+ continue;
36
+ }
37
+ if (typeof raw === 'string' && JSON_TYPES.has(raw)) {
38
+ properties[name] = { id: name, type: raw, required: false, description: '' };
39
+ continue;
40
+ }
41
+ properties[name] = {
42
+ id: name,
43
+ type: 'string',
44
+ required: false,
45
+ ...(typeof raw === 'string' && raw ? { description: raw } : {}),
46
+ };
47
+ }
48
+ return { id: 'responseBody', type: 'object', description: '', properties };
49
+ }
50
+ /** 统一 outParam:字符串原样保留,对象转成 JSON Schema 字符串 */
51
+ function normalizeOutParam(value) {
52
+ if (typeof value === 'string') {
53
+ const trimmed = value.trim();
54
+ if (!trimmed)
55
+ return {};
56
+ try {
57
+ const parsed = JSON.parse(trimmed);
58
+ return isRecord(parsed) ? { text: trimmed, schema: parsed } : { text: value };
59
+ }
60
+ catch {
61
+ return { text: value };
62
+ }
63
+ }
64
+ if (isRecord(value)) {
65
+ const isSchema = 'type' in value || 'properties' in value || '$ref' in value;
66
+ const schema = isSchema
67
+ ? { id: 'responseBody', type: 'object', description: '', ...value }
68
+ : buildSchemaFromFields(value);
69
+ return { text: JSON.stringify(schema), schema };
70
+ }
71
+ return {};
72
+ }
73
+ function parseMappingSource(expr) {
74
+ if (typeof expr !== 'string')
75
+ return undefined;
76
+ const value = expr.trim();
77
+ const contextMatch = CONTEXT_REF.exec(value);
78
+ if (contextMatch) {
79
+ return { sourceType: 'context', sourcePath: `context.${contextMatch[1]}` };
80
+ }
81
+ const nodeMatch = NODE_REF.exec(value);
82
+ if (nodeMatch) {
83
+ return { sourceType: 'node', sourcePath: `node.${nodeMatch[1]}.${nodeMatch[2]}` };
84
+ }
85
+ return undefined;
86
+ }
87
+ /** `{ 目标字段: 表达式 }` → pointMappings */
88
+ function buildPointMapping(mapping, diagnostics, nodeId, field) {
89
+ const pointMappings = [];
90
+ for (const [target, expr] of Object.entries(mapping)) {
91
+ if (target === 'paramMappingWay' || target === 'pointMappings' || target === 'valueFormula')
92
+ continue;
93
+ const source = parseMappingSource(expr);
94
+ if (!source) {
95
+ diagnostics.push({
96
+ level: 'warn',
97
+ code: 'MAPPING_VALUE_UNSUPPORTED',
98
+ message: `节点 ${nodeId} 的映射 ${target} 不是 $节点.路径 / $context.路径 形式,已跳过`,
99
+ nodeId,
100
+ field,
101
+ hint: '常量或表达式请改用 { "paramMappingWay": "formula", "valueFormula": "..." }',
102
+ });
103
+ continue;
104
+ }
105
+ pointMappings.push({ sourceType: source.sourceType, sourcePath: source.sourcePath, targetPath: targetPathOf(target) });
106
+ }
107
+ if (pointMappings.length === 0)
108
+ return undefined;
109
+ return { paramMappingWay: 'point', pointMappings };
110
+ }
111
+ /** 把 spec 的 mapping 统一成平台结构 */
112
+ function buildMapping(input, diagnostics, nodeId, field) {
113
+ if (typeof input === 'string') {
114
+ const value = input.trim();
115
+ if (!value)
116
+ return undefined;
117
+ if (value.startsWith('{'))
118
+ return { paramMappingWay: 'formula', valueFormula: value };
119
+ diagnostics.push({
120
+ level: 'error', code: 'MAPPING_FORMULA_INVALID',
121
+ message: `节点 ${nodeId} 的 ${field} 字符串必须是 valueFormula 的 JSON`, nodeId, field,
122
+ });
123
+ return undefined;
124
+ }
125
+ if (Array.isArray(input)) {
126
+ return input.length > 0 ? { paramMappingWay: 'point', pointMappings: input } : undefined;
127
+ }
128
+ if (!isRecord(input))
129
+ return undefined;
130
+ if (input.paramMappingWay === 'formula') {
131
+ if (typeof input.valueFormula === 'string' && input.valueFormula.trim()) {
132
+ return { paramMappingWay: 'formula', valueFormula: input.valueFormula };
133
+ }
134
+ if (input.valueFormula !== undefined) {
135
+ return { paramMappingWay: 'formula', valueFormula: JSON.stringify(input.valueFormula) };
136
+ }
137
+ diagnostics.push({
138
+ level: 'error', code: 'MAPPING_FORMULA_REQUIRED',
139
+ message: `节点 ${nodeId} 的 ${field} 声明 paramMappingWay=formula 但缺少 valueFormula`, nodeId, field,
140
+ });
141
+ return undefined;
142
+ }
143
+ return buildPointMapping(input, diagnostics, nodeId, field);
144
+ }
145
+ /** 平台要求 valueFormula 是字符串;对象写法统一序列化 */
146
+ function normalizeMappingValue(value) {
147
+ if (!isRecord(value))
148
+ return value;
149
+ if (value.valueFormula !== undefined && typeof value.valueFormula !== 'string') {
150
+ return { ...value, valueFormula: JSON.stringify(value.valueFormula) };
151
+ }
152
+ return value;
153
+ }
154
+ /** 上游已有 `context_outputParam` 时,按同名字段自动补齐 point 映射 */
155
+ function inferFromUpstream(node, outputNames, graph) {
156
+ for (const edge of graph.edges.filter((item) => item.to === node.id)) {
157
+ const upstream = graph.byId.get(edge.from);
158
+ const upstreamContext = upstream?.context;
159
+ const schema = upstreamContext?.context_outputParam;
160
+ if (!isRecord(schema))
161
+ continue;
162
+ const properties = isRecord(schema.properties) ? schema.properties : undefined;
163
+ if (!properties)
164
+ continue;
165
+ const pointMappings = outputNames
166
+ .filter((name) => name in properties)
167
+ .map((name) => ({
168
+ sourceType: 'node',
169
+ sourcePath: `node.${edge.from}.${name}`,
170
+ targetPath: name,
171
+ }));
172
+ if (pointMappings.length > 0)
173
+ return { pointMappings, upstreamId: edge.from };
174
+ }
175
+ return undefined;
176
+ }
177
+ /**
178
+ * 在归一化阶段就地补齐节点配置(校验与实例化都会读取同一份 config)。
179
+ * 只处理参数映射相关的 nodeType,其余节点原样返回。
180
+ */
181
+ function prepareNodeConfig(node, diagnostics, graph) {
182
+ const config = { ...(node.config ?? {}) };
183
+ node.config = config;
184
+ if (node.type === 'TransformNode') {
185
+ const normalized = normalizeOutParam(config.outParam);
186
+ if (normalized.text) {
187
+ config.outParam = normalized.text;
188
+ if (config.outParamType === undefined)
189
+ config.outParamType = 1;
190
+ if (normalized.schema) {
191
+ // IDE 的 onParamChange 会把出参 Schema 写入 context_outputParam(transformNode.tsx:64-67)
192
+ node.context = { ...(node.context ?? {}), context_outputParam: normalized.schema };
193
+ }
194
+ }
195
+ else if (config.outParam !== undefined) {
196
+ diagnostics.push({
197
+ level: 'error', code: 'OUT_PARAM_INVALID',
198
+ message: `节点 ${node.id} 的 config.outParam 需为 JSON Schema 字符串或字段映射对象`,
199
+ nodeId: node.id, field: 'outParam',
200
+ });
201
+ }
202
+ if (config.outputParamMapping !== undefined) {
203
+ config.outputParamMapping = normalizeMappingValue(config.outputParamMapping);
204
+ }
205
+ else if (config.mapping !== undefined) {
206
+ const generated = buildMapping(config.mapping, diagnostics, node.id, 'mapping');
207
+ if (generated)
208
+ config.outputParamMapping = generated;
209
+ }
210
+ if (config.outputParamMapping === undefined && normalized.schema && isRecord(normalized.schema.properties) && graph) {
211
+ const names = Object.keys(normalized.schema.properties);
212
+ const inferred = inferFromUpstream(node, names, graph);
213
+ if (inferred) {
214
+ config.outputParamMapping = { paramMappingWay: 'point', pointMappings: inferred.pointMappings };
215
+ diagnostics.push({
216
+ level: 'warn', code: 'MAPPING_AUTO_INFERRED',
217
+ message: `节点 ${node.id} 未提供 mapping,已按上游 ${inferred.upstreamId} 的同名字段自动生成 ${inferred.pointMappings.length} 条映射`,
218
+ nodeId: node.id, field: 'outputParamMapping',
219
+ });
220
+ }
221
+ }
222
+ delete config.mapping;
223
+ return;
224
+ }
225
+ if (node.type === 'EndNode') {
226
+ const normalized = normalizeOutParam(config.outParam);
227
+ if (normalized.text) {
228
+ config.outParam = normalized.text;
229
+ // 主动触发的结束节点 outParamType=0(endNode.tsx:46)
230
+ if (config.outParamType === undefined)
231
+ config.outParamType = 0;
232
+ if (normalized.schema) {
233
+ node.context = { ...(node.context ?? {}), context_outputParam: normalized.schema };
234
+ }
235
+ }
236
+ if (config.outputParamMapping !== undefined) {
237
+ config.outputParamMapping = normalizeMappingValue(config.outputParamMapping);
238
+ }
239
+ else if (config.mapping !== undefined) {
240
+ const generated = buildMapping(config.mapping, diagnostics, node.id, 'mapping');
241
+ if (generated)
242
+ config.outputParamMapping = generated;
243
+ }
244
+ delete config.mapping;
245
+ return;
246
+ }
247
+ if (['ExecuteNode', 'InterfaceNode', 'SubActionNode', 'FlowBotsNode'].includes(node.type)) {
248
+ if (config.inputParamMapping !== undefined) {
249
+ config.inputParamMapping = normalizeMappingValue(config.inputParamMapping);
250
+ }
251
+ else if (config.mapping !== undefined) {
252
+ const generated = buildMapping(config.mapping, diagnostics, node.id, 'mapping');
253
+ if (generated)
254
+ config.inputParamMapping = generated;
255
+ }
256
+ delete config.mapping;
257
+ }
258
+ }
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ const locale_1 = require("./commands/locale");
12
12
  const operation_1 = require("./commands/operation");
13
13
  const template_1 = require("./commands/template");
14
14
  const logger_1 = require("./utils/logger");
15
- const VERSION = '2.8.1';
15
+ const VERSION = '2.9.1';
16
16
  const program = new commander_1.Command();
17
17
  exports.program = program;
18
18
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lan-low-code-cli",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
4
4
  "description": "lan-low-code cli:低代码表单引擎命令行工具(创建表单、获取可用字段、自动完善表单字段)",
5
5
  "keywords": [
6
6
  "low-code",
@@ -29,6 +29,7 @@
29
29
  "pack:skill": "node scripts/pack.js --target skill"
30
30
  },
31
31
  "dependencies": {
32
+ "@dagrejs/dagre": "1.1.5",
32
33
  "commander": "^11.1.0"
33
34
  },
34
35
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lan-low-code-cli
3
- version: 2.8.1
3
+ version: 2.9.1
4
4
  author: Pig George
5
5
  description: Operate the Landray low-code form engine through the `llc` CLI. Use when the user asks to create, inspect, modify, publish, or delete low-code apps and forms; generate field configs; configure layouts (grid/tabs), field permissions, options, i18n, or control actions (onChange/onLoad); or run pre-publish validation. Also use for troubleshooting form loading/rendering errors.
6
6
  tags:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lan-low-code-cli
3
- version: 2.8.1
3
+ version: 2.9.1
4
4
  author: Pig George
5
5
  description: 通过 `llc` CLI 操作蓝凌低代码表单引擎。适用于:创建、查看、修改、发布或删除低代码应用与表单;生成字段配置;配置布局(栅格/页签)、字段权限、选项、多语言、控件动作(onChange/onLoad);或执行发布前校验、排查表单加载/渲染报错。
6
6
  tags:
@@ -271,11 +271,14 @@ Application-scoped action flows. Every write command needs `--yes` and an explic
271
271
 
272
272
  - `--code` must match `^[a-zA-Z0-9_]{1,30}$`; node `id` `^[0-9a-zA-Z_]{2,32}$`. Start/End nodes
273
273
  and their edges are auto-added; disconnected roots/leaves are wired automatically.
274
- - **Layout**: `ide` (default) reproduces the platform's arrangement — main chain centred on
275
- `--center-x` (default `679`), `BranchNode` 56×56, branch successors on the same rank at lanes ±1,
276
- join one rank lower, rank spacing `maxHeight + 52`. `layered` pushes branch successors to the
277
- next rank; `passthrough` keeps `bounds` from the spec. `drawAttr.bounds` uses top-left `x/y` with
278
- `relative:false` and is persisted verbatim by the IDE (it never recomputes coordinates).
274
+ - **Layout**: `ide` (default) uses **dagre** (Sugiyama: layering → ordering → coordinates). Main chain
275
+ centred on `--center-x` (default `679`), `BranchNode` 56×56 and others 180×38, branch successors on
276
+ the next rank, parents centred over children, joins centred under parents, rank gap
277
+ `--rank-padding` (default 52), same-rank gap 60. `layered` keeps the legacy rank+lane algorithm
278
+ (branch successors on the same rank, left/right symmetric); `passthrough` keeps `bounds` from the
279
+ spec. `drawAttr.bounds` uses top-left `x/y` with `relative:false` and is persisted verbatim by the
280
+ IDE (it never recomputes coordinates). Branch edges get `exitX=0/1` from the actual relative x;
281
+ multi-in/multi-out edges get `exitX`/`entryX` 0.25/0.75 to avoid sharing one anchor point.
279
282
  - Branch handling: `defaultTargetRef` comes from the edge with `"default": true`; a
280
283
  `formulaConfig.branchFormulas` referencing the real line IDs is generated automatically, and branch
281
284
  edges get `exitX=0/1` anchor styles so they do not overlap in the designer.
@@ -296,6 +299,14 @@ Application-scoped action flows. Every write command needs `--yes` and an explic
296
299
  `loopDefine.type`; `MessageNode` validates `notifyType`/`subject`/`templateCode`/`linkPage`/`linkUrl`
297
300
  by `subjectType` and `messageLink.linkType`; `AddDataNode`/`AddOrUpdateNode` skip `flow` for
298
301
  category forms or when `commitType` is absent. Diagnostics carry `field` and `hint`; errors exit `1`.
302
+ - **Parameter mapping shorthand**: `TransformNode` accepts `config.outParam` as a field map
303
+ (`{"fd_title":{"type":"string","description":"标题"}}`) or a full JSON Schema / Schema string, plus
304
+ `config.mapping` as `{"targetPath":"$<nodeId>.<path>"}` (also `$context.<path>`). The compiler emits
305
+ `outParamType=1`, `outParam` (Schema string), `outputParamMapping` (`pointMappings`) and copies the
306
+ Schema into `extraAttr.context_outputParam`. When `mapping` is absent it is inferred from an upstream
307
+ `context.context_outputParam` by matching field names (`MAPPING_AUTO_INFERRED` warning). Constants or
308
+ expressions require `{"paramMappingWay":"formula","valueFormula":"..."}`. `ExecuteNode` /
309
+ `InterfaceNode` / `SubActionNode` / `FlowBotsNode` reuse `config.mapping` for `inputParamMapping`.
299
310
  - Publishing derives `fdProcessActive` on the backend from `startNodeDef`; `fdStatus=1` means enabled.
300
311
  `flow logs` endpoints vary by deployment (`ModelingProcessLog/findAll` vs
301
312
  `sysModelingLog/findAllWithAppId`); use `--endpoint` if the defaults fail.
@@ -260,10 +260,12 @@ llc operation detach --form-id <fdId> --operation-id <operationId> #
260
260
 
261
261
  - `--code` 需匹配 `^[a-zA-Z0-9_]{1,30}$`;节点 `id` 需匹配 `^[0-9a-zA-Z_]{2,32}$`。
262
262
  开始/结束节点及其连线自动补齐,无入边/无出边的业务节点自动接线。
263
- - **布局**:`ide`(默认)复刻平台编排——主链以 `--center-x`(默认 `679`)居中,`BranchNode`
264
- 为 56×56,分支后继与分支节点同层、泳道 ±1,汇聚下移一层,层距 = 该层最高节点 + 52。
265
- `layered` 把分支后继放到下一层;`passthrough` 沿用 spec 中的 `bounds`。
266
- `drawAttr.bounds` 是左上角 `x/y` 且带 `relative:false`,IDE **原样持久化、不会重算坐标**。
263
+ - **布局**:`ide`(默认)使用 **dagre**(Sugiyama:分层 → 层内排序 → 坐标分配)。主链以
264
+ `--center-x`(默认 `679`)居中,`BranchNode` 56×56、其余 180×38;分支后继位于下一层,父节点
265
+ 居中于子节点之上,汇聚节点居中于多个父节点下方;层间距 `--rank-padding`(默认 52),同层间距 60。
266
+ `layered` 保留旧版 rank+lane 算法(分支后继与分支节点同层、左右对称);`passthrough` 沿用 spec
267
+ 中的 `bounds`。`drawAttr.bounds` 是左上角 `x/y` 且带 `relative:false`,IDE **原样持久化、不会重算坐标**。
268
+ 分支出线按实际相对位置写 `exitX=0/1`;多出边/多入边写 `exitX`/`entryX` 0.25/0.75,避免共用一个锚点。
267
269
  - 分支处理:`defaultTargetRef` 取自 `"default": true` 的出边;自动生成引用真实连线 ID 的
268
270
  `formulaConfig.branchFormulas`;分支连线写入 `exitX=0/1` 锚点,避免 IDE 内两条线重叠。
269
271
  - `--resolve-schema` 通过 `getSourceJsonSchema` / `getProcessDataOutJsonSchema` 补齐
@@ -282,6 +284,14 @@ llc operation detach --form-id <fdId> --operation-id <operationId> #
282
284
  校验 `notifyType` / `subject` / `templateCode` / `linkPage` / `linkUrl`;
283
285
  `AddDataNode` / `AddOrUpdateNode` 在分类表单或未填 `commitType` 时豁免 `flow`。
284
286
  诊断会带 `field` 与 `hint`,有错误时退出码为 `1`。
287
+ - **参数映射简写**:`TransformNode` 的 `config.outParam` 可写字段映射对象
288
+ (`{"fd_title":{"type":"string","description":"标题"}}`),也可写完整 JSON Schema 或 Schema 字符串;
289
+ `config.mapping` 支持 `{"目标字段":"$<节点id>.<路径>"}`(也支持 `$context.<路径>`)。编译器会输出
290
+ `outParamType=1`、`outParam`(Schema 字符串)、`outputParamMapping`(`pointMappings`),并把 Schema
291
+ 写入 `extraAttr.context_outputParam`。未写 `mapping` 时,会按上游 `context.context_outputParam`
292
+ 的同名字段自动推断,并给出 `MAPPING_AUTO_INFERRED` 提示。常量或表达式需改用
293
+ `{"paramMappingWay":"formula","valueFormula":"..."}`。`ExecuteNode` / `InterfaceNode` /
294
+ `SubActionNode` / `FlowBotsNode` 复用 `config.mapping` 生成 `inputParamMapping`。
285
295
  - 发布时后端从 `startNodeDef` 派生 `fdProcessActive`;`fdStatus=1` 表示已启用。
286
296
  `flow logs` 端点因部署而异(`ModelingProcessLog/findAll` 与
287
297
  `sysModelingLog/findAllWithAppId`);默认都失败时用 `--endpoint` 指定。