@steedos-labs/plugin-workflow 3.0.44 → 3.0.46

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.
Files changed (37) hide show
  1. package/designer/dist/amis-renderer/amis-renderer.css +1 -1
  2. package/designer/dist/amis-renderer/amis-renderer.js +1 -1
  3. package/designer/dist/assets/index-BNKlgpz1.js +943 -0
  4. package/designer/dist/assets/index-D7JdeS9f.css +1 -0
  5. package/designer/dist/index.html +3 -3
  6. package/main/default/manager/handlers_manager.js +4 -4
  7. package/main/default/manager/uuflow_manager.js +1 -1
  8. package/main/default/objects/instances/buttons/instance_terminate.button.yml +1 -1
  9. package/main/default/objects/workflow_designer_backups.object.yml +5 -0
  10. package/main/default/pages/flowdetail.page.amis.json +0 -31
  11. package/main/default/routes/am.router.js +37 -3
  12. package/main/default/routes/amis_form_design.router.js +1 -1
  13. package/main/default/routes/api_workflow_ai_form_design.router.js +38 -22
  14. package/main/default/routes/api_workflow_ai_form_design_stream.router.js +46 -23
  15. package/main/default/routes/api_workflow_instance_forward.router.js +2 -2
  16. package/main/default/routes/api_workflow_next_step.router.js +8 -1
  17. package/main/default/routes/api_workflow_next_step_users.router.js +22 -2
  18. package/main/default/routes/api_workflow_submit.router.js +31 -2
  19. package/main/default/routes/flow_form_design.ejs +7 -2
  20. package/main/default/routes/flow_form_design.router.js +3 -2
  21. package/main/default/routes/object_workflows.router.js +3 -3
  22. package/main/default/test/test_formula_compat.js +14 -2
  23. package/main/default/test/test_migrateApprovalCommentsField.js +220 -0
  24. package/main/default/test/test_rollbackApprovalCommentsField.js +235 -0
  25. package/main/default/triggers/amis_form_design.trigger.js +2 -1
  26. package/main/default/utils/designerManager.js +3 -6
  27. package/main/default/utils/formula-compat.js +7 -1
  28. package/package.json +1 -1
  29. package/public/amis-renderer/amis-renderer.css +1 -1
  30. package/public/amis-renderer/amis-renderer.js +1 -1
  31. package/public/workflow/index.css +24 -1
  32. package/src/rests/approvalCommentsConsole.js +9 -0
  33. package/src/rests/migrateApprovalCommentsField.js +31 -12
  34. package/src/rests/rollbackApprovalCommentsField.js +32 -21
  35. package/src/schema/steedos_form_schema.amis.js +3 -1
  36. package/designer/dist/assets/index-C3K_lyOl.css +0 -1
  37. package/designer/dist/assets/index-XZWBSnmF.js +0 -943
@@ -62,7 +62,7 @@ router.post('/am/ai/form-design-stream', async function auth(req, res, next) {
62
62
  const baseURL = process.env.WORKFLOW_AI_BASE_URL || 'https://api.openai.com/v1';
63
63
  const model = process.env.WORKFLOW_AI_MODEL || 'gpt-4o';
64
64
 
65
- const { fields, events, userRequest, images, instance_template, form_script, flow_events } = req.body;
65
+ const { fields, events, userRequest, images, instance_template, form_script, flow_events, name_forumla } = req.body;
66
66
 
67
67
  if (!userRequest) {
68
68
  sendEvent('error', { error: '请输入需求描述' });
@@ -89,6 +89,9 @@ router.post('/am/ai/form-design-stream', async function auth(req, res, next) {
89
89
  if (flow_events) {
90
90
  extraSections += `\n\n## 流程事件 (flow_events)\n\`\`\`javascript\n${flow_events}\n\`\`\``;
91
91
  }
92
+ if (name_forumla) {
93
+ extraSections += `\n\n## 旧版标题公式 (name_forumla)\n${name_forumla}\n\n说明:标题公式用 {字段名} 引用字段值,升级时需将其中的 {旧字段名} 替换为 {新版字段name},输出到 nameFormula 字段。`;
94
+ }
92
95
 
93
96
  const userContent = `## 当前表单字段\n${fieldsJson}\n\n## 当前事件脚本\n${eventsJson}${extraSections}\n\n## 用户需求\n${userRequest}\n\n请返回修改后的完整 JSON 对象(包含 tableColumns、fields 和 events)。`;
94
97
 
@@ -312,8 +315,6 @@ router.post('/am/ai/form-design-stream', async function auth(req, res, next) {
312
315
  return s || 'field_' + Math.random().toString(36).slice(2, 6);
313
316
  }
314
317
 
315
- const tblCols = (typeof result.tableColumns === 'number' && result.tableColumns >= 1) ? Math.min(result.tableColumns, 3) : 2;
316
-
317
318
  if (result.fields) {
318
319
  for (const field of result.fields) {
319
320
  // Backward compat: map legacy userPicker/groupPicker to new types
@@ -329,15 +330,9 @@ router.post('/am/ai/form-design-stream', async function auth(req, res, next) {
329
330
  if (field.name && !jsIdentifierRe.test(field.name)) {
330
331
  field.name = sanitizeName(field.name);
331
332
  }
332
- if (field.type === 'section' || field.type === 'table' || field.type === 'grid') {
333
- field.tableColspan = tblCols;
334
- field.colspan = 12;
335
- } else {
336
- // AI outputs colspan as 1..tableColumns; convert to tableColspan + 12-grid colspan
337
- const aiColspan = Math.max(1, Math.min(Number(field.colspan) || 1, tblCols));
338
- field.tableColspan = aiColspan;
339
- field.colspan = (aiColspan >= tblCols) ? 12 : Math.round(12 / tblCols * aiColspan);
340
- }
333
+ // Remove colspan/tableColspan from AI output layout is determined by field type defaults
334
+ delete field.colspan;
335
+ delete field.tableColspan;
341
336
  if (field.type === 'table' && Array.isArray(field.children)) {
342
337
  for (const child of field.children) {
343
338
  // Backward compat: map legacy types in children
@@ -438,8 +433,8 @@ function buildSystemPrompt() {
438
433
  - "orgMulti" — 组织(多选,选择多个部门)
439
434
  - "reference" — 对象选择(选择指定对象的记录,支持多选,可展示记录的多个字段)
440
435
  - "richtext" — 富文本(支持格式化文本、颜色、链接等)
436
+ - "autoNumber" — 自动编号(只读字段,自动生成带前缀、日期、序号的唯一编号)
441
437
  - required: 布尔值,是否必填
442
- - colspan: 数字,字段占几列,取值范围 1~tableColumns。默认 1(占一列),设为 tableColumns 则独占一行。section 和 table 始终等于 tableColumns
443
438
  - description: 字符串,字段说明/提示文字,支持 HTML
444
439
  - placeholder: 字符串,输入占位提示
445
440
  - defaultValue: 字符串,默认值
@@ -551,6 +546,16 @@ function buildSystemPrompt() {
551
546
  - displayFields: 数组,选择记录后额外展示的字段,格式 [{ "field": "字段API名", "label": "显示标签" }]
552
547
  - pickerMultiple: 布尔值,是否允许多选,默认 true
553
548
 
549
+ - autoNumber 类型(自动编号):
550
+ - 自动编号字段始终为只读(readonly: true),运行时由系统自动生成唯一编号
551
+ - autoNumberPrefix: 字符串,编号前缀,如 "SN-"、"HT-",默认 ""
552
+ - autoNumberSuffix: 字符串,编号后缀,默认 ""
553
+ - autoNumberPadding: 数字,序号位数(补零),默认 4,如 4 → 0001
554
+ - autoNumberDateFormat: 字符串,日期片段格式,可选值:"none"(无日期) | "YYYY"(年) | "YYYYMM"(年月,默认) | "YYYYMMDD"(年月日)
555
+ - autoNumberResetCycle: 字符串,序号重置周期,可选值:"none"(不重置) | "yearly"(每年) | "monthly"(每月,默认) | "daily"(每天)
556
+ - 生成的编号格式示例:前缀 + 日期 + 序号 + 后缀,如 "SN-202603-0001"
557
+ - 示例:{ "type": "autoNumber", "label": "合同编号", "name": "contract_no", "readonly": true, "autoNumberPrefix": "HT-", "autoNumberDateFormat": "YYYYMM", "autoNumberPadding": 4, "autoNumberResetCycle": "monthly" }
558
+
554
559
  - 可见性规则(任何字段都可设置):
555
560
  - visibilityRules: 数组,控制字段显示/隐藏的规则,当所有/任一规则满足时字段可见,否则隐藏。每条规则格式:
556
561
  { "field": "控制字段的name", "operator": "操作符", "value": "比较值" }
@@ -597,7 +602,7 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
597
602
  ## 规则
598
603
  1. 修改时尽可能保留用户已有字段的 name,只改需要改的部分
599
604
  2. 新增字段的 name 必须是合法的 JavaScript 变量名(字母/下划线开头,仅含字母、数字、下划线),要语义清晰
600
- 3. section 分组、table 子表、grid 网格的 colspan 必须等于 tableColumns(即独占整行)
605
+ 3. section 分组、table 子表、grid 网格始终独占整行,不需要设置 colspan
601
606
  4. formula 字段的公式表达式使用 \${} 语法,内部直接引用字段名,如 "\${unit_price * quantity}";公式内部不能再嵌套 \${},错误示例:"\${RMB(\${amount})}",正确示例:"\${RMB(amount)}"
602
607
  5. formula 字段只能使用上方"支持的函数(完整列表)"中列出的 36 个函数,禁止使用任何 Excel 专有函数(NETWORKDAYS、WORKDAY、EDATE、SUMIF、VLOOKUP、IFERROR、EOMONTH 等)或 JavaScript 方法(Math.floor、parseFloat、toString 等),违反此规则会导致运行时报错
603
608
  6. 如果用户只要求修改脚本/事件,则 fields 保持原样不变
@@ -628,7 +633,7 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
628
633
  - 分析模板时,首先统计 HTML 表格展开所有 colspan 后的**实际最大总列数**(包含标签列和数据列),然后选择布局方案:
629
634
  - **⭐ 优先使用方案二(grid 网格字段)**。只有当模板表格极其简单(总列数 ≤ 6 且无 rowspan/colspan)时才考虑方案一。如果你不确定该用哪个,请使用方案二。
630
635
 
631
- #### 方案一:简单布局(tableColumns + tableColspan
636
+ #### 方案一:简单布局(tableColumns)
632
637
  适用条件:模板 HTML 表格展开后总列数 ≤ 6(含标签列),每行最多 3 个输入字段,且无 rowspan/colspan 合并单元格。**如果模板中出现任何 rowspan 或 colspan,则不得使用方案一,必须使用方案二。**
633
638
 
634
639
  1. **确定 tableColumns**(每行字段数):
@@ -636,19 +641,16 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
636
641
  - 模板中每行有 2 个输入字段 → tableColumns=2
637
642
  - 模板中每行有 3 个输入字段 → tableColumns=3(最大值)
638
643
 
639
- 2. **字段 tableColspan**:基于 tableColumns 栅格设置,默认每个字段占 1
640
- - tableColumns=2 时:每个字段默认 tableColspan=1,独占一行的字段 tableColspan=2
641
- - tableColumns=3 时:每个字段默认 tableColspan=1,跨两列的 tableColspan=2,独占一行的 tableColspan=3
642
- - 注意:tableColspan 的最大值等于 tableColumns
644
+ 2. **字段排布**:每个字段默认占 1 列,按顺序自动流入行中
643
645
 
644
646
  3. **分组布局**:模板中的分类标题应转为 type="section" 字段
645
647
 
646
648
  4. **字段 label**:优先使用模板中该字段旁边的显示文本
647
649
 
648
650
  #### 方案二:复杂表格布局(grid 网格字段)⭐ 默认首选方案
649
- 适用条件:模板 HTML 表格展开后总列数 > 6,或者存在**任何 rowspan 或 colspan**,或者有多级表头(主标题下有子标题),或者表格结构无法用 tableColumns(1-3) + colspan 简单表达。**绝大多数审批单模板都应使用此方案。**
651
+ 适用条件:模板 HTML 表格展开后总列数 > 6,或者存在**任何 rowspan 或 colspan**,或者有多级表头(主标题下有子标题),或者表格结构无法用 tableColumns(1-3) 简单表达。**绝大多数审批单模板都应使用此方案。**
650
652
 
651
- **必须使用 grid 网格字段来精确还原表格布局。禁止将复杂模板用 tableColumns + colspan 简化处理。如果模板有任何 rowspan/colspan 合并单元格,就必须使用 grid。**
653
+ **必须使用 grid 网格字段来精确还原表格布局。禁止将复杂模板用 tableColumns 简化处理。如果模板有任何 rowspan/colspan 合并单元格,就必须使用 grid。**
652
654
 
653
655
  转换步骤:
654
656
  1. **分析 HTML 表格结构**:统计表格的总行数和总列数(考虑 colspan 展开后的最大列数),确定 gridRows 和 gridCols
@@ -658,7 +660,7 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
658
660
  - HTML 的 \`rowspan\`/\`colspan\` 属性直接映射到 gridData 单元格的 \`rowspan\`/\`colspan\`
659
661
  3. **构建 children 数组**:为每个 input 类型的单元格创建对应的子字段,通过 fieldId 关联
660
662
  4. **设置 gridColumnWidths**:根据 HTML 表格各列的实际宽度比例设置百分比数组
661
- 5. **grid 字段的 colspan 设为 tableColumns**(独占整行),tableColumns 可设为 1
663
+ 5. **grid 字段始终独占整行**,tableColumns 可设为 1
662
664
 
663
665
  转换示例 — 假设模板有一个 5 列表格(标题+4个数据列):
664
666
  HTML: \`<tr><td rowspan="3">工程名称</td><td>材料费</td><td>{{values.clf_bq}}</td><td>{{values.clf_lj}}</td><td>{{values.clf_bz}}</td></tr>\`
@@ -666,7 +668,7 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
666
668
  \`\`\`json
667
669
  {
668
670
  "type": "grid", "label": "费用明细", "name": "cost_grid",
669
- "gridRows": 10, "gridCols": 5, "colspan": 1,
671
+ "gridRows": 10, "gridCols": 5,
670
672
  "gridColumnWidths": [15, 15, 25, 25, 20],
671
673
  "gridData": [
672
674
  { "row": 0, "col": 0, "cellType": "label", "value": "工程名称", "rowspan": 3, "align": "center" },
@@ -718,6 +720,19 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
718
720
  \`\`\`json
719
721
  { "name": "apply_increase_amount", "type": "number", "max": 4999999.99, "precision": 2 }
720
722
  \`\`\`
723
+ ### 旧版自动编号字段迁移
724
+ - 旧版表单中的自动编号字段通过 default_value 属性实现,格式为 \`default_value = "auto_number('规则名称')"\`
725
+ - 新版必须将此类字段转换为 \`type: "autoNumber"\` 类型
726
+ - 迁移规则:
727
+ - 遇到字段的 default_value 包含 \`auto_number(...)\` 时,将该字段的 type 改为 "autoNumber",设置 readonly: true
728
+ - 删除原有的 default_value 属性
729
+ - 如果旧版编号规则名称中能推断出前缀、日期格式等信息,相应设置 autoNumberPrefix、autoNumberDateFormat 等属性;否则使用默认值
730
+ - 默认配置:autoNumberPrefix: ""、autoNumberPadding: 4、autoNumberDateFormat: "YYYYMM"、autoNumberResetCycle: "monthly"
731
+ - 迁移示例:
732
+ 旧版字段:{ "name": "合同编号", "type": "text", "default_value": "auto_number('contract_no')" }
733
+ 新版字段:{ "name": "contract_no", "label": "合同编号", "type": "autoNumber", "readonly": true, "autoNumberPrefix": "", "autoNumberPadding": 4, "autoNumberDateFormat": "YYYYMM", "autoNumberResetCycle": "monthly" }
734
+ - migrationLog 记录格式:"字段 <name>: default_value auto_number(...) 转换为 autoNumber 类型"
735
+
721
736
  ### 旧版 HTML 字段类型迁移
722
737
  - 旧版表单中存在 type 为 \"html\" 的字段,这是旧版的 HTML 编辑器字段,允许用户输入和编辑 HTML 格式的富文本内容
723
738
  - 新版表单中 **不再支持 \"html\" 类型**,已被 **\"richtext\"(富文本)** 类型完全替代
@@ -754,9 +769,17 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
754
769
  "onValueChange": "脚本代码或空字符串",
755
770
  "onSubmit": "脚本代码或空字符串"
756
771
  },
772
+ "nameFormula": "标题公式(仅升级场景,将旧版标题公式中的字段引用更新为新版字段 name)",
757
773
  "migrationLog": ["迁移记录,仅升级场景需要"]
758
774
  }
759
775
 
776
+ ### nameFormula(标题公式,升级场景)
777
+ 当用户提供了旧版标题公式(name_forumla)时,需要将其中的字段引用更新为新版字段 name。
778
+ - 旧版标题公式格式:用 {字段名} 引用字段值,如 "{申请人} 的 {类型} 申请"
779
+ - 升级时将 {旧字段名} 替换为 {新字段name},如 "{applicant_name} 的 {request_type} 申请"
780
+ - 公式中的纯文本部分保持不变
781
+ - 如果用户未提供旧版标题公式,则 nameFormula 字段可省略或返回空字符串
782
+
760
783
  ### migrationLog(迁移记录)
761
784
  当用户提供了 flow_events、form_script、instance_template 中包含业务逻辑(如校验、赋值、显隐控制等脚本代码)时,必须在 migrationLog 数组中为每条被迁移的逻辑记录一条说明,格式:
762
785
  "<来源>: <原逻辑简述> → <迁移到的具体位置>"
@@ -205,7 +205,7 @@ router.post("/api/workflow/v2/instance/forward", requireAuthentication, async fu
205
205
  const key = f.code;
206
206
  let old_v = old_values[key];
207
207
  if (old_v) {
208
- const fieldOptions = f.options?.split("\n").map(n => {
208
+ const fieldOptions = _.isArray(f.options) ? f.options : f.options?.split("\n").map(n => {
209
209
  const itemSplits = n.split(":");
210
210
  return {
211
211
  label: itemSplits[0],
@@ -288,7 +288,7 @@ router.post("/api/workflow/v2/instance/forward", requireAuthentication, async fu
288
288
  const key = field.code;
289
289
  let old_v = old_values[key];
290
290
  if (old_v) {
291
- const fieldOptions = field.options?.split("\n").map(n => {
291
+ const fieldOptions = _.isArray(field.options) ? field.options : field.options?.split("\n").map(n => {
292
292
  const itemSplits = n.split(":");
293
293
  return {
294
294
  label: itemSplits[0],
@@ -65,7 +65,9 @@ const getNextSteps = async (flow, flowVersionId, instance, currentStep, judge, a
65
65
 
66
66
  let nextSteps = [];
67
67
  const lines = currentStep.lines;
68
-
68
+ if(!currentStep.id){
69
+ currentStep.id = currentStep._id;
70
+ }
69
71
  switch (currentStep.step_type) {
70
72
  case 'condition': //条件
71
73
  const stepIds = await UUFlowManager.getNextSteps(instance, flow, currentStep, judge, autoFormDoc);
@@ -336,6 +338,11 @@ router.post('/api/workflow/v2/nextSteps', requireAuthentication, async function
336
338
  const form = await objectql.getObject('forms').findOne(instance.form);
337
339
  const { fields } = await UUFlowManager.getFormVersion(form, instance.form_version);
338
340
  const resNextSteps = await calcSteps(instance, flow, flowVersionId, fields, values);
341
+
342
+ _.map(resNextSteps, (step) => {
343
+ step.stepHandler = instance.step_approve ? instance.step_approve[step._id] : null;
344
+ });
345
+
339
346
  res.status(200).send({
340
347
  'nextSteps': resNextSteps
341
348
  });
@@ -29,7 +29,24 @@ const getFieldValue = (values, code)=>{
29
29
 
30
30
  const getFormFieldValue = (fields, values, fieldId)=>{
31
31
  const code = getFieldName(fields, fieldId);
32
- return getFieldValue(values, code);
32
+ const _values = getFieldValue(values, code);
33
+
34
+ if(_.isArray(_values)){
35
+ return _.map(_values, (value)=>{
36
+ if(_.isObject(value) && value.id){
37
+ return value.id;
38
+ }else{
39
+ return value;
40
+ }
41
+ })
42
+ }else{
43
+ if(_.isObject(_values) && _values.id){
44
+ return _values.id;
45
+ }else{
46
+ return _values;
47
+ }
48
+ }
49
+
33
50
  }
34
51
 
35
52
  /**
@@ -269,7 +286,10 @@ router.post('/api/workflow/v2/nextStepUsersValue', requireAuthentication, async
269
286
  for (let i = traces.length - 1; i >= 0; i--) {
270
287
  const trace = traces[i];
271
288
  if (trace.step === nextStepId && trace.approves && trace.approves.length > 0) {
272
- newNextUsers = trace.approves.map(approve => approve.handler);
289
+ // 需要排除以下 approve.jude ['returned', 'terminated', 'retrieved'].includes(judge)
290
+ newNextUsers = trace.approves
291
+ .filter(approve => !['returned', 'terminated', 'retrieved'].includes(approve.judge))
292
+ .map(approve => approve.handler);
273
293
  break;
274
294
  }
275
295
  }
@@ -55,11 +55,40 @@ router.post('/api/workflow/submit', requireAuthentication, async function (req,
55
55
  var hashData = req.body;
56
56
  var result = [];
57
57
  const instance_from_client = hashData['Instances'][0];
58
- // beforeDraftSubmit
59
58
  const insId = instance_from_client._id;
60
- await excuteTriggers({ when: 'beforeDraftSubmit', userId: userId, flowId: instance_from_client['flow'], insId: insId });
61
59
 
62
60
  try {
61
+ // 校验流程启用状态、流程版本和表单版本是否为最新
62
+ const flowsCollection = await getCollection('flows');
63
+ const formsCollection = await getCollection('forms');
64
+ const ins = await db.instances.findOne({ _id: insId });
65
+ if (!ins) {
66
+ throw new Error('申请单不存在');
67
+ }
68
+ const flow = await flowsCollection.findOne({ _id: ins.flow }, { projection: { state: 1, current: 1 } });
69
+ if (!flow) {
70
+ throw new Error('流程不存在');
71
+ }
72
+ // 校验流程是否已启用
73
+ if (flow.state !== 'enabled') {
74
+ throw new Error('流程未启用,操作失败');
75
+ }
76
+ // 校验流程版本是否为最新版
77
+ if (ins.flow_version !== flow.current._id) {
78
+ throw new Error('流程已升级,请刷新后重新填写提交');
79
+ }
80
+ const form = await formsCollection.findOne({ _id: ins.form }, { projection: { current: 1 } });
81
+ if (!form) {
82
+ throw new Error('表单不存在');
83
+ }
84
+ // 校验表单版本是否为最新版
85
+ if (ins.form_version !== form.current._id) {
86
+ throw new Error('表单已升级,请刷新后重新填写提交');
87
+ }
88
+
89
+ // beforeDraftSubmit
90
+ await excuteTriggers({ when: 'beforeDraftSubmit', userId: userId, flowId: instance_from_client['flow'], insId: insId });
91
+
63
92
  var r = await UUFlowManager.submit_instance(instance_from_client, userSession);
64
93
  if (r.alerts) {
65
94
  result.push(r);
@@ -70,6 +70,7 @@
70
70
  title: "<%=locale%>" === 'zh-CN' ? '流程表单设计器': 'Flow Form Designer',
71
71
  saveText: "<%=locale%>" === 'zh-CN' ? '保存': 'Save',
72
72
  deployText: "<%=locale%>" === 'zh-CN' ? '发布': 'Deploy',
73
+ unpkgUrl: "<%=unpkgUrl%>"
73
74
  };
74
75
 
75
76
  const getArgumentsList = (func)=>{
@@ -279,7 +280,7 @@
279
280
  * 保持同步。
280
281
  */
281
282
  const _getSafeCode = (code) => {
282
- return code.replace(/(/g, '_').replace(/)/g, '').replace(/\(/g, '_').replace(/\)/g, '').replace(/、/g, '_').replace(/,/g, '_').replace(/%/g, '_').replace(/=/g, '_');
283
+ return code.replace(/(/g, '_').replace(/)/g, '').replace(/\(/g, '_').replace(/\)/g, '').replace(/、/g, '_').replace(/,/g, '_').replace(/%/g, '_').replace(/=/g, '_').replace(/:/g, '_').replace(/\//g, '_').replace(/-/g, '_');
283
284
  };
284
285
 
285
286
  /**
@@ -304,6 +305,9 @@
304
305
  const _isContextVariable = (code) => code === 'applicant' || code === 'approver';
305
306
 
306
307
  if (isFunction || isOperator || isObjectField || isDotField) {
308
+ // 将数学分组方括号 [] 转为圆括号 ()
309
+ newFormula = newFormula.replace(/\[/g, '(').replace(/\]/g, ')');
310
+
307
311
  if (isFunction) {
308
312
  newFormula = newFormula.replace(/sum\s*\(/ig, 'SUM(');
309
313
  newFormula = newFormula.replace(/average\s*\(/ig, 'AVG(');
@@ -621,7 +625,8 @@
621
625
  break;
622
626
  case "section":
623
627
  tpl.type = "steedos-field-group";
624
- tpl.title = field.name;
628
+ // 历史表单 section 字段可能只有 code 没有 name,需要 fallback 到 code,避免 title undefined 导致保存时分组字段丢失
629
+ tpl.title = field.name || field.code;
625
630
  tpl.description = field.description;
626
631
  tpl.body = []
627
632
  if (field.fields) {
@@ -31,7 +31,7 @@ router.get('/api/workflow/form_design', auth.requireAuthentication, async functi
31
31
  }
32
32
  const retUrl = req.query.retUrl || process.env.ROOT_URL + '/app/admin/flows/view/' + req.query.fid
33
33
  const steedosBuilderUrl = process.env.STEEDOS_BUILDER_URL || 'https://builder.steedos.cn';
34
- const builderHost = `${steedosBuilderUrl}/object?${assetUrl}retUrl=${retUrl}&locale=${locale}&isObjectDesign=1&pType=objectDesign`;
34
+ const builderHost = `${steedosBuilderUrl}/object?${assetUrl}retUrl=${retUrl}&locale=${locale}&isObjectDesign=1&pType=objectDesign&unpkgUrl=${process.env.STEEDOS_UNPKG_URL || 'https://unpkg.steedos.cn'}`;
35
35
 
36
36
  const filename = __dirname+'/flow_form_design.ejs';
37
37
 
@@ -46,7 +46,8 @@ router.get('/api/workflow/form_design', auth.requireAuthentication, async functi
46
46
  userSession: userSession,
47
47
  id: flow.form,
48
48
  useOpenAPI: process.env.STEEDOS_PUBLIC_USE_OPEN_API,
49
- locale
49
+ locale,
50
+ unpkgUrl: process.env.STEEDOS_UNPKG_URL || 'https://unpkg.steedos.cn',
50
51
  }
51
52
  const options = {}
52
53
  ejs.renderFile(filename, data, options, function(err, str){
@@ -80,7 +80,7 @@ router.get('/api/object_workflows/workflow_field/options', requireAuthentication
80
80
  if (f.fields) {
81
81
  f.fields.forEach(function (ff) {
82
82
  form_fields.push({
83
- 'label': ff.name || ff.code,
83
+ 'label': ff.label || ff.name || ff.code,
84
84
  'value': ff.code
85
85
  });
86
86
  });
@@ -89,14 +89,14 @@ router.get('/api/object_workflows/workflow_field/options', requireAuthentication
89
89
  if (f.fields) {
90
90
  f.fields.forEach(function (ff) {
91
91
  form_fields.push({
92
- 'label': (f.name || f.code) + "=>" + (ff.name || ff.code),
92
+ 'label': (f.label || f.name || f.code) + "=>" + (ff.label || ff.name || ff.code),
93
93
  'value': f.code + '.' + ff.code
94
94
  });
95
95
  });
96
96
  }
97
97
  } else {
98
98
  form_fields.push({
99
- 'label': f.name || f.code,
99
+ 'label': f.label || f.name || f.code,
100
100
  'value': f.code
101
101
  });
102
102
  }
@@ -95,7 +95,19 @@ const tableFieldMapCases = [
95
95
  const run = () => {
96
96
  console.log('[formula-compat] running tests...');
97
97
 
98
- assert.strictEqual(getSafeCode('工程形象进度%(2)=(3)/(1)'), '工程形象进度__2__3/_1');
98
+ assert.strictEqual(getSafeCode('工程形象进度%(2)=(3)/(1)'), '工程形象进度__2__3__1');
99
+
100
+ // 方括号转换测试
101
+ assert.strictEqual(
102
+ mapFormula('[{油量(本地)} + {油量(异地)}]/{本月里程数}*100'),
103
+ '${(油量_本地 + 油量_异地)/本月里程数*100}',
104
+ '方括号分组应转换为圆括号'
105
+ );
106
+ assert.strictEqual(
107
+ mapFormula('{月末里程数} - {月初里程数}'),
108
+ '${月末里程数 - 月初里程数}',
109
+ '简单减法公式应正确转换'
110
+ );
99
111
 
100
112
  for (const item of cases) {
101
113
  const actual = mapFormula(item.input);
@@ -134,7 +146,7 @@ const run = () => {
134
146
  assert.strictEqual(map['数量'], '明细表', 'getTableFieldMap 应递归处理 section 中的 table');
135
147
  assert.strictEqual(map['标题'], undefined, 'getTableFieldMap 非子表字段不应在映射中');
136
148
 
137
- const totalAssertions = 1 + cases.length + tableFieldMapCases.length + 4;
149
+ const totalAssertions = 1 + 2 + cases.length + tableFieldMapCases.length + 4;
138
150
  console.log(`[formula-compat] ${totalAssertions} assertions passed.`);
139
151
  };
140
152
 
@@ -0,0 +1,220 @@
1
+ const assert = require('assert');
2
+ const mod = require('../../../src/rests/migrateApprovalCommentsField');
3
+
4
+ const { matchesApprovalPattern, transformField, processFieldsArray } = mod._helpers;
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // matchesApprovalPattern tests
8
+ // ---------------------------------------------------------------------------
9
+ const patternCases = [
10
+ { input: '{traces.步骤名}', expected: true },
11
+ { input: '${traces.步骤名}', expected: true },
12
+ { input: '{signature.traces.步骤名}', expected: true },
13
+ { input: '${signature.traces.步骤名}', expected: true },
14
+ { input: '{yijianlan:{step:"步骤名"}}', expected: true },
15
+ { input: '{普通字段}', expected: false },
16
+ { input: null, expected: false },
17
+ { input: undefined, expected: false }
18
+ ];
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // processFieldsArray: flat (first-level) fields
22
+ // ---------------------------------------------------------------------------
23
+ const flatFields = [
24
+ {
25
+ type: 'input',
26
+ code: '主管经理意见',
27
+ formula: '{signature.traces.主管经理审核}',
28
+ _id: 'id-1'
29
+ },
30
+ {
31
+ type: 'input',
32
+ code: '普通字段',
33
+ _id: 'id-2'
34
+ }
35
+ ];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // processFieldsArray: nested section fields (the bug scenario)
39
+ // ---------------------------------------------------------------------------
40
+ const nestedFields = [
41
+ {
42
+ type: 'section',
43
+ code: '申请单位意见',
44
+ _id: 'sec-1',
45
+ fields: [
46
+ {
47
+ type: 'input',
48
+ code: '部门负责人意见',
49
+ formula: '{signature.traces.申请单位部门负责人意见}',
50
+ _id: 'id-3'
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ type: 'section',
56
+ code: '财务处意见',
57
+ _id: 'sec-2',
58
+ fields: [
59
+ {
60
+ type: 'input',
61
+ code: '部门负责人意见1',
62
+ name: '部门负责人意见',
63
+ formula: '{signature.traces.财务处部门负责人意见}',
64
+ _id: 'id-4'
65
+ }
66
+ ]
67
+ }
68
+ ];
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // processFieldsArray: deeply nested (section > section)
72
+ // ---------------------------------------------------------------------------
73
+ const deeplyNestedFields = [
74
+ {
75
+ type: 'section',
76
+ code: '外层',
77
+ _id: 'sec-outer',
78
+ fields: [
79
+ {
80
+ type: 'panel',
81
+ code: '内层',
82
+ _id: 'panel-inner',
83
+ fields: [
84
+ {
85
+ type: 'input',
86
+ code: '深层签字字段',
87
+ formula: '{traces.深层步骤}',
88
+ _id: 'id-deep'
89
+ }
90
+ ]
91
+ }
92
+ ]
93
+ }
94
+ ];
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // processFieldsArray: mixed top-level and nested
98
+ // ---------------------------------------------------------------------------
99
+ const mixedFields = [
100
+ {
101
+ type: 'input',
102
+ code: '顶层签字字段',
103
+ formula: '{signature.traces.顶层步骤}',
104
+ _id: 'id-top'
105
+ },
106
+ {
107
+ type: 'section',
108
+ code: '分组',
109
+ _id: 'sec-mixed',
110
+ fields: [
111
+ {
112
+ type: 'input',
113
+ code: '嵌套签字字段',
114
+ formula: '{signature.traces.嵌套步骤}',
115
+ _id: 'id-nested'
116
+ },
117
+ {
118
+ type: 'input',
119
+ code: '普通嵌套字段',
120
+ _id: 'id-plain-nested'
121
+ }
122
+ ]
123
+ }
124
+ ];
125
+
126
+ const run = () => {
127
+ console.log('[migrateApprovalCommentsField] running tests...');
128
+
129
+ // --- matchesApprovalPattern ---
130
+ for (const item of patternCases) {
131
+ assert.strictEqual(
132
+ matchesApprovalPattern(item.input),
133
+ item.expected,
134
+ `matchesApprovalPattern("${item.input}") should be ${item.expected}`
135
+ );
136
+ }
137
+ console.log(` matchesApprovalPattern: ${patternCases.length} assertions passed.`);
138
+
139
+ // --- transformField: should transform a matching field ---
140
+ const transformed = transformField(flatFields[0]);
141
+ assert.ok(transformed, 'transformField should return a non-null result for matching field');
142
+ assert.strictEqual(transformed.type, 'steedos-field');
143
+ assert.strictEqual(transformed.__approval_comments_transformed, true);
144
+ assert.strictEqual(transformed.config.steps[0].name, '主管经理审核');
145
+ assert.strictEqual(transformed.config.steps[0].show_image_sign, true);
146
+
147
+ // --- transformField: should skip non-matching field ---
148
+ const skipped = transformField(flatFields[1]);
149
+ assert.strictEqual(skipped, null, 'transformField should return null for non-matching field');
150
+
151
+ // --- transformField: should skip already-transformed field ---
152
+ const alreadyTransformed = { ...flatFields[0], __approval_comments_transformed: true };
153
+ assert.strictEqual(transformField(alreadyTransformed), null, 'transformField should skip already-transformed fields');
154
+ console.log(' transformField: 4 assertions passed.');
155
+
156
+ // --- processFieldsArray: flat fields ---
157
+ const flatResult = processFieldsArray(flatFields, 'current.fields');
158
+ assert.strictEqual(flatResult.updates.length, 1, 'Flat: should transform 1 matching field');
159
+ assert.strictEqual(flatResult.updates[0].path, 'current.fields.0', 'Flat: path should be current.fields.0');
160
+ assert.ok(flatResult.unsets.includes('current.fields.0.formula'), 'Flat: formula should be in unsets');
161
+ console.log(' processFieldsArray (flat): 3 assertions passed.');
162
+
163
+ // --- processFieldsArray: nested section fields (the bug regression test) ---
164
+ const nestedResult = processFieldsArray(nestedFields, 'current.fields');
165
+ assert.strictEqual(nestedResult.updates.length, 2,
166
+ 'Nested: should transform 2 matching fields inside sections');
167
+ const nestedPaths = nestedResult.updates.map(u => u.path);
168
+ assert.ok(nestedPaths.includes('current.fields.0.fields.0'),
169
+ 'Nested: first nested field path should be current.fields.0.fields.0');
170
+ assert.ok(nestedPaths.includes('current.fields.1.fields.0'),
171
+ 'Nested: second nested field path should be current.fields.1.fields.0');
172
+ assert.ok(nestedResult.unsets.includes('current.fields.0.fields.0.formula'),
173
+ 'Nested: formula unset path should be current.fields.0.fields.0.formula');
174
+ assert.ok(nestedResult.unsets.includes('current.fields.1.fields.0.formula'),
175
+ 'Nested: formula unset path should be current.fields.1.fields.0.formula');
176
+
177
+ // Verify step names are correct
178
+ const step0 = nestedResult.updates.find(u => u.path === 'current.fields.0.fields.0');
179
+ assert.strictEqual(step0.update.config.steps[0].name, '申请单位部门负责人意见',
180
+ 'Nested: step name should match formula');
181
+ const step1 = nestedResult.updates.find(u => u.path === 'current.fields.1.fields.0');
182
+ assert.strictEqual(step1.update.config.steps[0].name, '财务处部门负责人意见',
183
+ 'Nested: step name should match formula');
184
+ console.log(' processFieldsArray (nested sections): 7 assertions passed.');
185
+
186
+ // --- processFieldsArray: deeply nested (section > panel) ---
187
+ const deepResult = processFieldsArray(deeplyNestedFields, 'current.fields');
188
+ assert.strictEqual(deepResult.updates.length, 1, 'Deep: should transform 1 deeply nested field');
189
+ assert.strictEqual(deepResult.updates[0].path, 'current.fields.0.fields.0.fields.0',
190
+ 'Deep: path should reflect full nesting depth');
191
+ assert.ok(deepResult.unsets.includes('current.fields.0.fields.0.fields.0.formula'),
192
+ 'Deep: formula unset should use full nested path');
193
+ console.log(' processFieldsArray (deeply nested): 3 assertions passed.');
194
+
195
+ // --- processFieldsArray: mixed top-level and nested ---
196
+ const mixedResult = processFieldsArray(mixedFields, 'current.fields');
197
+ assert.strictEqual(mixedResult.updates.length, 2, 'Mixed: should transform 2 fields (1 top + 1 nested)');
198
+ const mixedPaths = mixedResult.updates.map(u => u.path);
199
+ assert.ok(mixedPaths.includes('current.fields.0'), 'Mixed: top-level field path should be current.fields.0');
200
+ assert.ok(mixedPaths.includes('current.fields.1.fields.0'), 'Mixed: nested field path should be current.fields.1.fields.0');
201
+ console.log(' processFieldsArray (mixed): 3 assertions passed.');
202
+
203
+ // --- processFieldsArray: historys path prefix ---
204
+ const historyResult = processFieldsArray(nestedFields, 'historys.0.fields');
205
+ assert.strictEqual(historyResult.updates.length, 2, 'History: should transform 2 nested fields');
206
+ const historyPaths = historyResult.updates.map(u => u.path);
207
+ assert.ok(historyPaths.includes('historys.0.fields.0.fields.0'),
208
+ 'History: nested field path should use historys prefix');
209
+ assert.ok(historyPaths.includes('historys.0.fields.1.fields.0'),
210
+ 'History: nested field path should use historys prefix');
211
+ console.log(' processFieldsArray (historys prefix): 3 assertions passed.');
212
+
213
+ console.log('[migrateApprovalCommentsField] All assertions passed.');
214
+ };
215
+
216
+ if (require.main === module) {
217
+ run();
218
+ }
219
+
220
+ module.exports = { run };