weaver-e9-assistant-cli 1.1.4 → 1.1.11

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.
@@ -0,0 +1,761 @@
1
+ /**
2
+ * 流程表单代码块获取与本地执行模块。
3
+ *
4
+ * 背景:E9 代码块是浏览器端 JavaScript(WfForm API),CLI 直调 requestOperation API
5
+ * 会跳过代码块逻辑,导致 changeFieldValue 补值、registerCheckEvent 校验等丢失。
6
+ * 本模块在 Node.js 沙箱中模拟 WfForm 环境,执行代码块并收集字段变更。
7
+ *
8
+ * 使用:
9
+ * import { executeFormCodeBlocks } from './wfcodeblock.js';
10
+ * const { mutations, blocked } = await executeFormCodeBlocks(cfg, wfId, values, formData);
11
+ * Object.assign(values, mutations.values); // 合并代码块补值
12
+ */
13
+
14
+ import { createApiFromProfile } from './client.js';
15
+ import { getCachedFieldMap } from './memory.js';
16
+ import vm from 'node:vm';
17
+
18
+ // ── WfForm 常量(与 E9 浏览器端一致)────────────────────────────────────
19
+
20
+ const OPER_SAVE = '1';
21
+ const OPER_SUBMIT = '2';
22
+ const OPER_SUBMITCONFIRM = '3';
23
+ const OPER_REJECT = '4';
24
+ const OPER_REMARK = '5';
25
+ const OPER_INTERVENE = '6';
26
+ const OPER_FORWARD = '7';
27
+ const OPER_TAKEBACK = '8';
28
+ const OPER_DELETE = '9';
29
+ const OPER_ADDROW = '10';
30
+ const OPER_DELROW = '11';
31
+
32
+ const ACTION_ADDROW = '10';
33
+ const ACTION_DELROW = '11';
34
+
35
+ // ── 字段标记解析 ──────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * 解析 fieldMark("field123" / "field123_2" / "field110")。
39
+ * 返回 { fieldId, rowIndex, fullKey }。
40
+ * fullKey — 有明细行号时 = "fieldId_rowIndex",否则 = fieldId。
41
+ * 用于 mutations.values / getFieldValue 等场景区分主表和明细行。
42
+ */
43
+ function parseFieldMark(fieldMark) {
44
+ if (!fieldMark) return { fieldId: '', rowIndex: -1, fullKey: '' };
45
+ const raw = String(fieldMark).replace(/^field/i, '');
46
+ const idx = raw.indexOf('_');
47
+ if (idx >= 0) {
48
+ const fieldId = raw.slice(0, idx);
49
+ const rowIndex = Number(raw.slice(idx + 1)) || 0;
50
+ return { fieldId, rowIndex, fullKey: `${fieldId}_${rowIndex}` };
51
+ }
52
+ return { fieldId: raw, rowIndex: -1, fullKey: raw };
53
+ }
54
+
55
+ // ── 字段信息映射 ──────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * 从 formData(describeFormFields 返回值)构建字段信息映射。
59
+ * 返回 { byName: Map<"fieldname" → fieldId>, byId: Map<"fieldId" → fieldInfo> }
60
+ */
61
+ export function buildFieldInfoMap(formData) {
62
+ const byName = new Map();
63
+ const byId = new Map();
64
+ if (!formData?.tables) return { byName, byId };
65
+
66
+ for (const table of formData.tables) {
67
+ for (const fld of table.fields ?? []) {
68
+ const id = String(fld.id);
69
+ const name = fld.name ?? fld.label ?? '';
70
+ if (!id) continue;
71
+ byId.set(id, {
72
+ id,
73
+ name: fld.name,
74
+ label: fld.label,
75
+ htmltype: fld.htmltype,
76
+ viewattr: fld.viewattr,
77
+ table: table.key,
78
+ });
79
+ // 字段名映射:fieldname(数据库名)优先,label 兜底
80
+ if (name) byName.set(String(name).toLowerCase(), id);
81
+ if (fld.label && fld.label !== name) {
82
+ // label 做兜底映射(同名的以后面为准)
83
+ if (!byName.has(String(fld.label).toLowerCase())) {
84
+ byName.set(String(fld.label).toLowerCase(), id);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return { byName, byId };
90
+ }
91
+
92
+ /** 从 formData 提取 nodeId。 */
93
+ export function extractNodeId(formData) {
94
+ const sp = formData?.submitParams ?? {};
95
+ const p = formData?.params ?? {};
96
+ return String(sp.nodeid ?? p.nodeid ?? '');
97
+ }
98
+
99
+ // ── WfForm 模拟层 ──────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * 构建 WfForm 模拟对象,拦截所有 API 调用并记录变更。
103
+ *
104
+ * @param {object} opts
105
+ * @param {object} opts.values - 当前字段值 { fieldId: value }
106
+ * @param {Map} opts.fieldById - fieldId → fieldInfo 映射
107
+ * @param {Map} opts.fieldByName - fieldName → fieldId 映射
108
+ * @param {Function} [opts.resolveLinkageFn] - 联动解析函数,签名为 (fieldId, value) → { fieldId: {value, specialobj} }
109
+ */
110
+ export function buildWfFormMock(opts = {}) {
111
+ const values = { ...opts.values ?? {} };
112
+ const fieldById = opts.fieldById ?? new Map();
113
+ const fieldByName = opts.fieldByName ?? new Map();
114
+ const resolveLinkageFn = opts.resolveLinkageFn ?? (async () => ({}));
115
+ const userId = opts.userId ?? '';
116
+
117
+ // 收集的变更
118
+ const mutations = {
119
+ values: {}, // 主表 { fieldId: { value, specialobj } }
120
+ attrs: {}, // { fieldId: viewAttr }
121
+ details: [], // 明细操作:[{op:'add'|'set'|'del', table, rowIndex, ...}]
122
+ };
123
+
124
+ // 注册的事件处理器
125
+ const checkEvents = {}; // { '<type>': [callback] }
126
+ const changeEvents = []; // [{ fieldMarks: Set, handler: fn }]
127
+
128
+ // 是否阻断提交
129
+ let submitBlocked = false;
130
+
131
+ // ── 明细行状态(支持 addDetailRow / delDetailRow / getDetailAllRowIndexStr)──
132
+ const detailState = new Map(); // table -> { rows: Map<rowIndex, object>, nextIndex }
133
+ function getDetailState(table) {
134
+ const key = table || 'detail_1';
135
+ if (!detailState.has(key))
136
+ detailState.set(key, { rows: new Map(), nextIndex: 0 });
137
+ return detailState.get(key);
138
+ }
139
+ // 用调用方已填的明细行初始化(details[].index:detail_1→0)
140
+ for (const d of opts.details ?? []) {
141
+ const table = `detail_${Number(d.index) + 1}`;
142
+ const st = getDetailState(table);
143
+ (d.rows ?? []).forEach((row, i) => {
144
+ st.rows.set(i, { ...row });
145
+ if (i + 1 > st.nextIndex)
146
+ st.nextIndex = i + 1;
147
+ });
148
+ }
149
+
150
+ // ── WfForm 方法实现 ──
151
+
152
+ const wfForm = {
153
+ // 常量
154
+ OPER_SAVE, OPER_SUBMIT, OPER_SUBMITCONFIRM, OPER_REJECT, OPER_REMARK,
155
+ OPER_INTERVENE, OPER_FORWARD, OPER_TAKEBACK, OPER_DELETE,
156
+ OPER_ADDROW, OPER_DELROW,
157
+ ACTION_ADDROW, ACTION_DELROW,
158
+
159
+ // 3.3 修改单个字段值(不支持附件类型)
160
+ changeFieldValue(fieldMark, valueInfo) {
161
+ const { fieldId, rowIndex, fullKey } = parseFieldMark(fieldMark);
162
+ if (!fieldId || !valueInfo) return;
163
+ // 明细行字段用 fullKey(主表和明细行各自独立),主表字段用 fieldId。
164
+ // ⚠️ 不再因“已有值”跳过记录:用户手填值是否被覆盖,由调用方 prepareCreateSubmission
165
+ // 按“用户意图优先”判断并报告冲突;这里只忠实记录代码块的建议值(与浏览器行为一致)。
166
+ const guardKey = rowIndex >= 0 ? fullKey : fieldId;
167
+
168
+ const cv = { value: String(valueInfo.value ?? '') };
169
+ if (Array.isArray(valueInfo.specialobj) && valueInfo.specialobj.length > 0) {
170
+ cv.specialobj = valueInfo.specialobj;
171
+ }
172
+ if (rowIndex >= 0) {
173
+ // 明细行:记录 set 操作(由调用方合并进 details;用户已填值优先)
174
+ const table = fieldById.get(String(fieldId))?.table || 'detail_1';
175
+ mutations.details.push({ op: 'set', table, rowIndex, fieldId, value: cv.value, specialobj: cv.specialobj });
176
+ values[fullKey] = cv.value;
177
+ return;
178
+ }
179
+ mutations.values[fullKey] = cv;
180
+ values[guardKey] = cv.value;
181
+ },
182
+
183
+ // 3.4 改变单个字段显示属性
184
+ changeFieldAttr(fieldMark, viewAttr) {
185
+ const { fieldId, fullKey } = parseFieldMark(fieldMark);
186
+ if (!fieldId) return;
187
+ mutations.attrs[fullKey] = Number(viewAttr);
188
+ },
189
+
190
+ // 3.2 获取单个字段值
191
+ getFieldValue(fieldMark) {
192
+ const { fieldId, rowIndex, fullKey } = parseFieldMark(fieldMark);
193
+ if (!fieldId) return '';
194
+ // 明细行:用 fullKey 取最新值(changeFieldValue/addDetailRow 会写入 values[fullKey])
195
+ if (rowIndex >= 0)
196
+ return values[fullKey] !== undefined ? String(values[fullKey]) : '';
197
+ // 主表:优先 mutations 中的最新值,其次初始 values
198
+ if (mutations.values[fullKey] !== undefined) return mutations.values[fullKey].value;
199
+ return String(values[fieldId] ?? '');
200
+ },
201
+
202
+ // 3.1 将字段名称转换成字段 id
203
+ convertFieldNameToId(fieldname, symbol, prefix) {
204
+ if (!fieldname) return '';
205
+ // symbol: 'main'(默认) / 'detail_1' / ...
206
+ const key = String(fieldname).toLowerCase();
207
+ const id = fieldByName.get(key);
208
+ if (!id) return '';
209
+ return prefix !== false ? `field${id}` : id;
210
+ },
211
+
212
+ // 3.6 批量修改字段值或显示属性
213
+ changeMoreField(changeDatas, changeVariable) {
214
+ if (changeDatas && typeof changeDatas === 'object') {
215
+ for (const [mark, info] of Object.entries(changeDatas)) {
216
+ if (info && typeof info === 'object') {
217
+ wfForm.changeFieldValue(mark, info);
218
+ }
219
+ }
220
+ }
221
+ if (changeVariable && typeof changeVariable === 'object') {
222
+ for (const [mark, attr] of Object.entries(changeVariable)) {
223
+ if (attr && typeof attr === 'object' && attr.viewAttr != null) {
224
+ wfForm.changeFieldAttr(mark, attr.viewAttr);
225
+ }
226
+ }
227
+ }
228
+ },
229
+
230
+ // 3.5 同时修改字段的值及显示属性
231
+ changeSingleField(fieldMark, valueInfo, variableInfo) {
232
+ if (valueInfo) wfForm.changeFieldValue(fieldMark, valueInfo);
233
+ if (variableInfo?.viewAttr != null) wfForm.changeFieldAttr(fieldMark, variableInfo.viewAttr);
234
+ },
235
+
236
+ // 3.7 触发指定字段涉及的所有联动
237
+ triggerFieldAllLinkage(fieldMark) {
238
+ // 委托给外部 resolveLinkageFn(异步,但这里不 await——记录到待触发列表)
239
+ const { fieldId } = parseFieldMark(fieldMark);
240
+ if (fieldId) {
241
+ pendingLinkages.add(fieldId);
242
+ }
243
+ },
244
+
245
+ // 3.8 根据字段 ID 获取字段信息
246
+ getFieldInfo(fieldid) {
247
+ const info = fieldById.get(String(fieldid));
248
+ if (!info) return {};
249
+ return {
250
+ htmltype: info.htmltype,
251
+ fieldname: info.name,
252
+ fieldlabel: info.label,
253
+ viewattr: info.viewattr,
254
+ };
255
+ },
256
+
257
+ // 2.1 注册拦截事件(提交前执行)
258
+ registerCheckEvent(type, fun) {
259
+ if (typeof fun !== 'function') return;
260
+ const types = String(type).split(',');
261
+ for (const t of types) {
262
+ if (!checkEvents[t]) checkEvents[t] = [];
263
+ checkEvents[t].push(fun);
264
+ }
265
+ },
266
+
267
+ // 4.1 注册字段值改变事件
268
+ bindFieldChangeEvent(fieldMarkStr, funobj) {
269
+ if (typeof funobj !== 'function') return;
270
+ const marks = String(fieldMarkStr).split(',');
271
+ const fieldMarks = new Set(marks);
272
+ changeEvents.push({ fieldMarks, handler: funobj });
273
+ },
274
+
275
+ // 2.2 注册钩子事件(指定动作完成后触发)
276
+ registerAction(actionname, fn) {
277
+ // post-action hooks 在提交场景不需要模拟——跳过
278
+ },
279
+
280
+ // 工具方法
281
+ isMobile() { return false; },
282
+ // 3.9 实时获取字段属性——返回后台配置的 viewattr
283
+ getFieldRealViewAttr(fieldid) {
284
+ const info = fieldById.get(String(fieldid));
285
+ return info?.viewattr ?? 2; // 默认可编辑
286
+ },
287
+ // E9 官方文档方法名 alias(getFieldCurViewAttr = 同义方法,旧版代码块可能用 getFieldRealViewAttr)
288
+ getFieldCurViewAttr(fieldid) {
289
+ const info = fieldById.get(String(fieldid));
290
+ return info?.viewattr ?? 2;
291
+ },
292
+ // 全局存储(代码块常取 currentUserid 做权限/条件分支)
293
+ getGlobalStore() {
294
+ return { commonParam: { currentUserid: userId } };
295
+ },
296
+ // 明细表:取该明细表的行下标字符串(如 "0,1,2";无行返回 "")
297
+ getDetailAllRowIndexStr(detailTable) {
298
+ const st = getDetailState(detailTable);
299
+ return [...st.rows.keys()].sort((a, b) => a - b).join(',');
300
+ },
301
+ // 4.1 新增明细行:记录并返回新行下标
302
+ addDetailRow(detailTable, rowData) {
303
+ const table = detailTable || 'detail_1';
304
+ const st = getDetailState(table);
305
+ const rowIndex = st.nextIndex++;
306
+ const row = {};
307
+ for (const [mark, info] of Object.entries(rowData ?? {})) {
308
+ const { fieldId } = parseFieldMark(mark);
309
+ if (!fieldId)
310
+ continue;
311
+ const value = String(info?.value ?? '');
312
+ row[fieldId] = value;
313
+ values[`${fieldId}_${rowIndex}`] = value;
314
+ }
315
+ st.rows.set(rowIndex, row);
316
+ mutations.details.push({ op: 'add', table, rowIndex, row });
317
+ return rowIndex;
318
+ },
319
+ // 4.2 删除明细行(rowIndex="all" 表示清空该明细表)
320
+ delDetailRow(detailTable, rowIndex) {
321
+ const table = detailTable || 'detail_1';
322
+ const st = getDetailState(table);
323
+ if (String(rowIndex) === 'all') {
324
+ mutations.details.push({ op: 'del', table, all: true });
325
+ st.rows.clear();
326
+ st.nextIndex = 0;
327
+ return;
328
+ }
329
+ const idx = Number(rowIndex);
330
+ if (!Number.isFinite(idx))
331
+ return;
332
+ mutations.details.push({ op: 'del', table, rowIndex: idx });
333
+ st.rows.delete(idx);
334
+ },
335
+ // 4.2 明细字段值变化事件绑定(CLI 场景下标不可用,只 mock 注册不报错)
336
+ bindDetailFieldChangeEvent(fieldMarkStr, funobj) {
337
+ // no-op:不模拟明细行动态增删事件
338
+ },
339
+ };
340
+
341
+ // 待触发的联动字段集合
342
+ const pendingLinkages = new Set();
343
+
344
+ return { wfForm, mutations, changeEvents, checkEvents, pendingLinkages, submitBlocked: () => submitBlocked, setSubmitBlocked: (v) => { submitBlocked = v; }, values };
345
+ }
346
+
347
+ // ── 沙箱执行 ──────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * 在 Node.js vm 沙箱中执行代码块 JS。
351
+ * @returns {object} mockEnv(含 wfForm, mutations, changeEvents, checkEvents, pendingLinkages 等)
352
+ */
353
+ export function runCodeBlockInSandbox(jsCode, mockEnv) {
354
+ if (!jsCode || typeof jsCode !== 'string' || jsCode.trim() === '') return mockEnv;
355
+
356
+ const { wfForm } = mockEnv;
357
+
358
+ // jQuery mock:jQuery(document).ready(fn) / jQuery().ready(fn) — 立即执行回调
359
+ const readyFns = [];
360
+ const jQuery = function (sel) {
361
+ return {
362
+ ready(fn) { if (typeof fn === 'function') readyFns.push(fn); return this; },
363
+ on() { return this; },
364
+ val() { return ''; },
365
+ find() { return this; },
366
+ each(fn) { return this; },
367
+ attr() { return ''; },
368
+ html() { return ''; },
369
+ text() { return ''; },
370
+ css() { return this; },
371
+ show() { return this; },
372
+ hide() { return this; },
373
+ prop() { return ''; },
374
+ is() { return false; },
375
+ };
376
+ };
377
+ // jQuery 静态方法
378
+ jQuery.ajax = () => {};
379
+ jQuery.get = () => {};
380
+ jQuery.post = () => {};
381
+ jQuery.getJSON = () => {};
382
+
383
+ const sandbox = {
384
+ window: { WfForm: wfForm },
385
+ WfForm: wfForm,
386
+ wfForm, // 别名(部分代码块用小写)
387
+ jQuery,
388
+ $: jQuery,
389
+ document: {},
390
+ console,
391
+ alert: () => {},
392
+ setTimeout: (fn, ms) => { if (typeof fn === 'function') fn(); return 0; },
393
+ setInterval: () => 0,
394
+ clearTimeout: () => {},
395
+ clearInterval: () => {},
396
+ // 常用全局
397
+ parseInt,
398
+ parseFloat,
399
+ isNaN,
400
+ Math,
401
+ Date,
402
+ String,
403
+ Number,
404
+ Boolean,
405
+ Array,
406
+ Object,
407
+ JSON,
408
+ RegExp,
409
+ Error,
410
+ };
411
+
412
+ try {
413
+ const context = vm.createContext(sandbox);
414
+ vm.runInContext(jsCode, context, { timeout: 10000 });
415
+ } catch (e) {
416
+ // 代码块执行异常不阻断业务——可能是环境差异(DOM 操作等)
417
+ mockEnv.executionError = e.message;
418
+ console.error(` [wfcodeblock] 代码块执行异常(忽略):${e.message}`);
419
+ }
420
+
421
+ // 执行所有 jQuery.ready 回调(异常不阻断,但记录首个错误供状态行展示)
422
+ for (const fn of readyFns) {
423
+ try {
424
+ fn();
425
+ }
426
+ catch (e) {
427
+ if (!mockEnv.executionError) {
428
+ mockEnv.executionError = e.message;
429
+ console.error(` [wfcodeblock] 代码块回调异常(忽略):${e.message}`);
430
+ }
431
+ }
432
+ }
433
+
434
+ return mockEnv;
435
+ }
436
+
437
+ // ── 事件模拟 ──────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * 模拟字段值改变事件:对每个已填值的字段,调用 bindFieldChangeEvent 注册的回调。
441
+ * 执行过程中若代码块调了 changeFieldValue,新值也会被收集并继续触发相关事件。
442
+ */
443
+ export function simulateFieldChangeEvents(mockEnv, filledValues) {
444
+ const { wfForm, changeEvents, pendingLinkages } = mockEnv;
445
+ const processed = new Set();
446
+
447
+ // 初始触发:所有有值的字段
448
+ const queue = [];
449
+ for (const [fieldId, val] of Object.entries(filledValues ?? {})) {
450
+ if (val !== undefined && val !== '') {
451
+ queue.push(String(fieldId));
452
+ }
453
+ }
454
+
455
+ // 最多迭代 5 轮(防止循环依赖无限触发)
456
+ for (let round = 0; round < 5 && queue.length > 0; round++) {
457
+ const thisRound = [...new Set(queue)];
458
+ queue.length = 0;
459
+
460
+ for (const fieldId of thisRound) {
461
+ if (processed.has(fieldId)) continue;
462
+ processed.add(fieldId);
463
+
464
+ for (const ev of changeEvents) {
465
+ // 检查该事件是否监听此字段
466
+ const fieldMark = `field${fieldId}`;
467
+ let matched = false;
468
+ for (const m of ev.fieldMarks) {
469
+ if (m === fieldMark || m === fieldId || m.replace(/^field/, '') === fieldId) {
470
+ matched = true;
471
+ break;
472
+ }
473
+ }
474
+ if (!matched) continue;
475
+
476
+ // ★ 用 wfForm.getFieldValue 取最新值(而非初始 filledValues)
477
+ const currentVal = String(wfForm.getFieldValue(fieldMark) ?? '');
478
+
479
+ try {
480
+ // 调用 handler(obj, id, value)
481
+ ev.handler({}, fieldId, currentVal);
482
+ } catch { /* handler 异常忽略 */ }
483
+
484
+ // 触发联动(代码块内 changeFieldValue 后自动触发)
485
+ for (const pf of pendingLinkages) {
486
+ if (!processed.has(pf)) queue.push(pf);
487
+ }
488
+ pendingLinkages.clear();
489
+ }
490
+ }
491
+
492
+ // 新一轮触发:检查是否有新的 changeFieldValue 变更需要响应
493
+ if (queue.length === 0) break;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * 模拟提交前校验:执行 registerCheckEvent(OPER_SUBMIT) 注册的回调。
499
+ * 若回调未调用 callback(),则 blocked=true(阻断提交)。
500
+ *
501
+ * @returns {boolean} blocked — true 表示提交被代码块阻断
502
+ */
503
+ export function simulateSubmitCheck(mockEnv) {
504
+ const { checkEvents, setSubmitBlocked } = mockEnv;
505
+ const callbacks = checkEvents[OPER_SUBMIT];
506
+ if (!callbacks || callbacks.length === 0) return false;
507
+
508
+ let blocked = false;
509
+
510
+ for (const fn of callbacks) {
511
+ let callbackCalled = false;
512
+ const callback = () => { callbackCalled = true; };
513
+
514
+ try {
515
+ fn(callback);
516
+ } catch { /* 校验异常忽略 */ }
517
+
518
+ if (!callbackCalled) {
519
+ blocked = true;
520
+ }
521
+ }
522
+
523
+ setSubmitBlocked(blocked);
524
+ return blocked;
525
+ }
526
+
527
+ // ── API: 抓取代码块 ────────────────────────────────────────────────────
528
+
529
+ /**
530
+ * 从 getLayout 响应中提取 layoutid(优先 layouttype=0 显示模板)。
531
+ * 对齐 e9-cli pickLayoutId 逻辑。
532
+ */
533
+ function pickLayoutId(gl) {
534
+ if (!gl) return '';
535
+ const arr = Array.isArray(gl) ? gl : (gl?.datas || gl?.data || gl?.layouts || (gl?.layoutid ? [gl] : []));
536
+ const list = Array.isArray(arr) ? arr : [arr];
537
+ const pick = list.find(x => String(x?.layouttype ?? x?.layoutType ?? '') === '0' || x?.isdefault)
538
+ || list.find(x => { const id = x?.layoutid ?? x?.id; return id && String(id) !== '0'; })
539
+ || list[0];
540
+ const id = pick?.layoutid ?? pick?.id ?? pick?.layoutId ?? '';
541
+ return id && String(id) !== '0' ? String(id) : '';
542
+ }
543
+
544
+ /**
545
+ * 发现节点的真实 layoutid。
546
+ * 1. getLayout API → pickLayoutId()
547
+ * 2. 兜底:getNodeModeInfo API → 提取 modeid 值
548
+ */
549
+ async function discoverLayoutId(api, wfId, nodeId) {
550
+ // 1) getLayout
551
+ try {
552
+ const gl = await api.get('/api/workflow/wfexceldesign/getLayout', {
553
+ wfid: String(wfId), nodeid: String(nodeId), isbill: '1',
554
+ });
555
+ const j = gl?.json ?? {};
556
+ const lid = pickLayoutId(j);
557
+ if (lid) return lid;
558
+ } catch { /* getLayout 本环境常返回 500 */ }
559
+
560
+ // 2) getNodeModeInfo 兜底
561
+ try {
562
+ const mi = await api.get('/api/workflow/nodeformedit/getModeInfo', {
563
+ workflowid: String(wfId), nodeid: String(nodeId), __random__: String(Date.now()),
564
+ });
565
+ const s = JSON.stringify(mi?.json ?? mi);
566
+ const m = s.match(/"modeid":(-?\d+)/g) || [];
567
+ const vals = [...new Set(m.map(x => x.split(':')[1]))].filter(v => v !== '-1');
568
+ if (vals.length) return vals[0];
569
+ } catch { /* ignore */ }
570
+
571
+ return '';
572
+ }
573
+
574
+ /**
575
+ * 从 E9 服务端抓取并解码代码块 JS。
576
+ * 1. discoverLayoutId → 真实 layoutid
577
+ * 2. POST doLoadDesigner → 取 scripts hex
578
+ * 3. POST decode → 得明文 JS
579
+ *
580
+ * @returns {string|null} 明文 JS 或 null(无代码块/抓取失败)
581
+ */
582
+ export async function fetchAndDecodeCodeBlocks(cfg, workflowId, nodeId, formId) {
583
+ if (!workflowId || !nodeId) return null;
584
+
585
+ const api = await createApiFromProfile(cfg);
586
+ const wfId = String(workflowId);
587
+ const nid = String(nodeId);
588
+ const fid = String(formId ?? '');
589
+
590
+ // 1) 发现真实 layoutid
591
+ const layoutid = await discoverLayoutId(api, wfId, nid);
592
+ if (!layoutid) return null;
593
+
594
+ // 2) 读取布局
595
+ let scriptsHex = '';
596
+ try {
597
+ const r = await api.post('/api/workflow/wfexceldesign/doLoadDesigner', {
598
+ wfid: wfId,
599
+ nodeid: nid,
600
+ formid: fid,
601
+ isbill: '1',
602
+ layouttype: '0',
603
+ layoutid,
604
+ });
605
+ const j = r?.json ?? {};
606
+
607
+ // scripts 可能在顶层 loaded.scripts 或 layoutInfo.scripts 里
608
+ const layoutInfo = j.layoutInfo;
609
+ let parsed;
610
+ if (layoutInfo) {
611
+ try {
612
+ parsed = typeof layoutInfo === 'string' ? JSON.parse(layoutInfo) : layoutInfo;
613
+ } catch { parsed = null; }
614
+ }
615
+ scriptsHex = j.scripts || parsed?.scripts || '';
616
+ if (!scriptsHex) return null;
617
+ } catch {
618
+ return null;
619
+ }
620
+
621
+ // 3) 解码 hex → 明文 JS
622
+ try {
623
+ const d = await api.post('/api/workflow/wfexceldesign/decode', {
624
+ content: String(scriptsHex),
625
+ });
626
+ let text = d?.text ?? '';
627
+ if (!text) {
628
+ const json = d?.json ?? {};
629
+ text = json.result ?? json.content ?? json.scripts ?? json.text ?? '';
630
+ }
631
+ if (!text) return null;
632
+
633
+ // decode 返回形如 <script>JS</script> 或含 JSON {result:"<script>...</script>"}
634
+ // 先尝试 JSON 解析(decode 可能把结果包在 JSON 里)
635
+ if (text.trim().startsWith('{')) {
636
+ try {
637
+ const j = JSON.parse(text);
638
+ text = j.result ?? text;
639
+ } catch { /* not JSON */ }
640
+ }
641
+
642
+ // 提取 <script>...</script> 标签内的 JS(可能有多个)
643
+ const scripts = [];
644
+ const re = /<script[^>]*>([\s\S]*?)<\/script>/gi;
645
+ let m;
646
+ while ((m = re.exec(text)) !== null) {
647
+ if (m[1].trim()) scripts.push(m[1].trim());
648
+ }
649
+ return scripts.length > 0 ? scripts.join('\n') : text.replace(/<[^>]+>/g, '').trim() || null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+
655
+ // ── 主入口 ────────────────────────────────────────────────────────────
656
+
657
+ /**
658
+ * 执行流程表单的代码块,模拟浏览器端 WfForm 逻辑。
659
+ *
660
+ * 流程:
661
+ * 1. 从 formData 提取 nodeId / formId
662
+ * 2. 构建字段信息映射(name→id, id→info)
663
+ * 3. 抓取代码块 JS(doLoadDesigner + decode)
664
+ * 4. 构建 WfForm 模拟层
665
+ * 5. 在 vm 沙箱中执行代码块 JS(注册事件)
666
+ * 6. 模拟字段变更事件(bindFieldChangeEvent 回调)
667
+ * 7. 执行 triggerFieldAllLinkage 收集的字段联动
668
+ * 8. 模拟提交前校验(registerCheckEvent 回调)
669
+ * 9. 返回收集的变更值
670
+ *
671
+ * @param {object} cfg - 环境配置
672
+ * @param {string} workflowId
673
+ * @param {object} values - 当前已填字段值 { fieldId: value }
674
+ * @param {object} formData - describeFormFields 返回值
675
+ * @param {object} [opts] - 可选参数
676
+ * @param {Function} [opts.resolveLinkageFn] - 联动解析函数,签名为 async (fieldId, value) => { fieldId: {value, specialobj} }
677
+ * @param {string} [opts.userId] - 当前用户 ID,注入 getGlobalStore().commonParam.currentUserid
678
+ * @param {string} [opts.selfId] - 用户 selfId,用于读取缓存的字段映射(跳过 buildFieldInfoMap 重建)
679
+ * @returns {{ mutations: { values: object, attrs: object }, blocked: boolean }}
680
+ */
681
+ export async function executeFormCodeBlocks(cfg, workflowId, values, formData, opts = {}) {
682
+ const result = {
683
+ mutations: { values: {}, attrs: {}, details: [] },
684
+ blocked: false,
685
+ // 状态行:无代码块 / 执行完成(计数)/ 执行失败(原因)
686
+ codeblock: { fetched: false, jsBytes: 0, valueCount: 0, detailCount: 0, attrCount: 0, blocked: false, error: null },
687
+ };
688
+
689
+ const nodeId = extractNodeId(formData);
690
+ const formId = formData?.submitParams?.formid ?? formData?.params?.formid ?? '';
691
+
692
+ // 没有 nodeId 无法定位代码块
693
+ if (!nodeId) return result;
694
+
695
+ const cachedFieldMap = (opts.selfId && workflowId)
696
+ ? getCachedFieldMap(cfg, opts.selfId, workflowId)
697
+ : null;
698
+ const { byName, byId } = cachedFieldMap || buildFieldInfoMap(formData);
699
+
700
+ // 抓取代码块
701
+ const jsCode = await fetchAndDecodeCodeBlocks(cfg, workflowId, nodeId, formId);
702
+ if (!jsCode) return result; // 无代码块——正常,大多数流程没有
703
+
704
+ const resolveLinkageFn = opts.resolveLinkageFn ?? (async () => ({}));
705
+ const userId = opts.userId ?? '';
706
+
707
+ // 构建 WfForm 模拟层
708
+ const mockEnv = buildWfFormMock({
709
+ values,
710
+ fieldById: byId,
711
+ fieldByName: byName,
712
+ resolveLinkageFn,
713
+ userId,
714
+ details: opts.details, // 已填明细行(供 addDetailRow/delDetailRow 正确续行)
715
+ });
716
+
717
+ // 执行代码块 JS(注册事件处理器)
718
+ runCodeBlockInSandbox(jsCode, mockEnv);
719
+
720
+ // 模拟字段变更事件(bindFieldChangeEvent 回调)
721
+ simulateFieldChangeEvents(mockEnv, values);
722
+
723
+ // ★ 执行 triggerFieldAllLinkage 收集的联动字段
724
+ if (mockEnv.pendingLinkages && mockEnv.pendingLinkages.size > 0) {
725
+ for (const fieldId of mockEnv.pendingLinkages) {
726
+ const fieldVal = mockEnv.values[fieldId] ?? '';
727
+ try {
728
+ const linkageResult = await resolveLinkageFn(fieldId, fieldVal);
729
+ if (linkageResult && typeof linkageResult === 'object') {
730
+ for (const [key, cv] of Object.entries(linkageResult)) {
731
+ const targetId = key.replace(/^field/, '');
732
+ // 不覆盖用户已填值
733
+ if (values[targetId] !== undefined && values[targetId] !== '') continue;
734
+ if (cv && typeof cv === 'object' && cv.value !== undefined) {
735
+ result.mutations.values[targetId] = { value: String(cv.value), specialobj: cv.specialobj };
736
+ }
737
+ }
738
+ }
739
+ } catch { /* 联动失败不阻断 */ }
740
+ }
741
+ }
742
+
743
+ // 模拟提交前校验
744
+ const blocked = simulateSubmitCheck(mockEnv);
745
+
746
+ // 合并 mutations(代码块 changeFieldValue 产生 + 联动产生)
747
+ Object.assign(result.mutations.values, mockEnv.mutations.values);
748
+ Object.assign(result.mutations.attrs, mockEnv.mutations.attrs);
749
+ result.mutations.details = mockEnv.mutations.details ?? [];
750
+ result.blocked = blocked;
751
+ result.codeblock = {
752
+ fetched: true,
753
+ jsBytes: jsCode.length,
754
+ valueCount: Object.keys(result.mutations.values).length,
755
+ detailCount: result.mutations.details.length,
756
+ attrCount: Object.keys(result.mutations.attrs).length,
757
+ blocked,
758
+ error: mockEnv.executionError ?? null,
759
+ };
760
+ return result;
761
+ }