@steedos-labs/plugin-workflow 3.0.32 → 3.0.35

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 (27) hide show
  1. package/designer/dist/amis-renderer/amis-renderer.css +1 -0
  2. package/designer/dist/amis-renderer/amis-renderer.js +1 -0
  3. package/designer/dist/amis-renderer/vite.svg +1 -0
  4. package/designer/dist/assets/index-CzP9-MzW.css +1 -0
  5. package/designer/dist/assets/index-r2EtJdFh.js +757 -0
  6. package/designer/dist/index.html +2 -2
  7. package/main/default/applications/approve_workflow.app.yml +2 -2
  8. package/main/default/client/flow2_render.client.js +4 -0
  9. package/main/default/objects/instances/buttons/instance_submit.button.yml +6 -0
  10. package/main/default/routes/am.router.js +24 -1
  11. package/main/default/routes/api_workflow_ai_code_generate.router.js +104 -0
  12. package/main/default/routes/api_workflow_ai_design.router.js +1 -1
  13. package/main/default/routes/api_workflow_ai_design_stream.router.js +363 -0
  14. package/main/default/routes/api_workflow_ai_form_design.router.js +534 -0
  15. package/main/default/routes/api_workflow_ai_form_design_stream.router.js +686 -0
  16. package/main/default/routes/api_workflow_nav.router.js +12 -12
  17. package/main/default/routes/api_workflow_next_step.router.js +2 -1
  18. package/main/default/routes/designer-v2.router.js +38 -2
  19. package/main/default/routes/flow_form_design.ejs +78 -14
  20. package/main/default/test/README.md +42 -0
  21. package/main/default/test/test_formula_compat.js +60 -0
  22. package/main/default/triggers/amis_form_design.trigger.js +6 -0
  23. package/main/default/utils/designerManager.js +4 -0
  24. package/main/default/utils/formula-compat.js +85 -0
  25. package/package.json +5 -3
  26. package/designer/dist/assets/index-CZdVfW7Z.css +0 -1
  27. package/designer/dist/assets/index-CiNm9Jhj.js +0 -22
@@ -0,0 +1,534 @@
1
+ const express = require("express");
2
+ const router = express.Router();
3
+ const steedosAuth = require('@steedos/auth');
4
+ const axios = require('axios');
5
+ const bodyParser = require('body-parser');
6
+
7
+ router.use(bodyParser.json({ limit: '20mb' }));
8
+
9
+ /**
10
+ * AI Form Designer endpoint
11
+ *
12
+ * Accepts natural language instructions and current form fields/events,
13
+ * calls an OpenAI-compatible LLM API to generate modified form fields and event scripts.
14
+ *
15
+ * Reuses the same environment variables as /am/ai/design:
16
+ * WORKFLOW_AI_API_KEY - API key for the LLM provider
17
+ * WORKFLOW_AI_BASE_URL - Base URL (default: https://api.openai.com/v1)
18
+ * WORKFLOW_AI_MODEL - Model name (default: gpt-4o)
19
+ */
20
+ router.post('/am/ai/form-design', async function auth(req, res, next) {
21
+ try {
22
+ const result = await steedosAuth.auth(req, res);
23
+ if (result && result.userId) {
24
+ req.user = result;
25
+ next();
26
+ } else {
27
+ res.status(401).json({ error: '请先登录' });
28
+ }
29
+ } catch (e) {
30
+ res.status(401).json({ error: '认证失败' });
31
+ }
32
+ }, async function (req, res) {
33
+ try {
34
+ const apiKey = process.env.WORKFLOW_AI_API_KEY;
35
+ if (!apiKey) {
36
+ return res.status(500).json({ error: '请先配置环境变量 WORKFLOW_AI_API_KEY' });
37
+ }
38
+
39
+ const baseURL = process.env.WORKFLOW_AI_BASE_URL || 'https://api.openai.com/v1';
40
+ const model = process.env.WORKFLOW_AI_MODEL || 'gpt-4o';
41
+
42
+ const { fields, events, userRequest, images, instance_template, form_script, flow_events } = req.body;
43
+
44
+ if (!userRequest) {
45
+ return res.status(400).json({ error: '请输入需求描述' });
46
+ }
47
+
48
+ const systemPrompt = `你是一个专业的表单设计师。你的任务是根据用户的自然语言描述,修改审批表单的字段定义和事件脚本(JSON 格式)。
49
+
50
+ ## 字段数据结构
51
+
52
+ 每个字段(field)是一个 JSON 对象,包含以下字段:
53
+ - name: 字符串,字段编码,必须是合法的 JavaScript 变量名(字母/下划线开头,仅含字母、数字、下划线),如 "leave_days"、"totalAmount"
54
+ - label: 字符串,字段显示名称,中文,如 "请假天数"、"总金额"
55
+ - type: 字符串,字段类型,只能是以下之一:
56
+ - "text" — 单行文本
57
+ - "textarea" — 多行文本
58
+ - "number" — 数字
59
+ - "date" — 日期
60
+ - "datetime" — 日期时间
61
+ - "time" — 时间(仅时分,如 "09:30")
62
+ - "select" — 下拉框(单选)
63
+ - "multiSelect" — 下拉多选
64
+ - "checkbox" — 复选框
65
+ - "radio" — 单选按钮
66
+ - "file" — 文件上传
67
+ - "image" — 图片上传
68
+ - "lookup" — 关联数据
69
+ - "section" — 分组标题(无值,仅用于布局分组)
70
+ - "table" — 子表(明细表,包含 children 子字段数组)
71
+ - "grid" — 网格(自由布局表格,用于复杂表格排版,支持合并单元格、静态文本标签和嵌入字段)
72
+ - "formula" — 公式计算字段
73
+ - "member" — 成员(选择单个用户)
74
+ - "memberMulti" — 成员(多选,选择多个用户)
75
+ - "org" — 组织(选择单个部门)
76
+ - "orgMulti" — 组织(多选,选择多个部门)
77
+ - "reference" — 对象选择(选择指定对象的记录,支持多选,可展示记录的多个字段)
78
+ - required: 布尔值,是否必填
79
+ - colspan: 数字,字段占几列,取值范围 1~tableColumns。默认 1(占一列),设为 tableColumns 则独占一行。section 和 table 始终等于 tableColumns
80
+ - description: 字符串,字段说明/提示文字,支持 HTML
81
+ - placeholder: 字符串,输入占位提示
82
+ - defaultValue: 字符串,默认值
83
+
84
+ ### 特定类型的额外字段
85
+
86
+ - select/multiSelect/radio 类型:
87
+ - options: 数组,选项列表,格式 [{ "label": "显示文本", "value": "值" }]
88
+
89
+ - number 类型:
90
+ - min: 数字,最小值
91
+ - max: 数字,最大值
92
+ - precision: 数字,小数位数
93
+ - thousandSeparator: 布尔值,是否显示千位分隔符(如 1,234,567.89),金额类字段建议开启
94
+
95
+ - text/textarea 类型:
96
+ - maxLength: 数字,最大字符数
97
+ - minLength: 数字,最小字符数
98
+
99
+ - formula 类型:
100
+ - formula: 字符串,公式表达式。语法:用 \${} 包裹表达式,内部直接使用字段名(不需要额外括号)。没有 \${} 的部分作为纯文本。
101
+ - ⚠️ 严格限制:只能使用下方列出的函数,禁止使用任何 Excel 函数、JavaScript 函数或其他未列出的函数(如 NETWORKDAYS、WORKDAY、EDATE、SUMIF、VLOOKUP、parseFloat、Math.floor 等一律不可用)。如果某个功能无法用已有函数实现,用 IF/AND/OR 的组合或算术运算代替,不要臆造函数名。
102
+ - 支持的函数(完整列表,共 36 个,仅此列表内的函数可用):
103
+ - 聚合:SUM(...) 求和,AVG/AVERAGE(...) 平均,MAX(...) 最大,MIN(...) 最小,COUNT(...) 计数(空/0不计)
104
+ - 数学:ABS(n) 绝对值,ROUND(n,d) 四舍五入,FLOOR(n) 向下取整,CEIL(n) 向上取整,INT(n) 截断取整,MOD(a,b) 取余,POWER(a,b) 幂,SQRT(n) 平方根
105
+ - 逻辑:IF(cond, trueVal, falseVal),AND(...) 全部为真,OR(...) 任一为真,NOT(v) 取反,ISNULL(v) 是否为空,ISBLANK(v) 是否为空(同 ISNULL)
106
+ - 日期:TODAY() 今日日期,NOW() 当前时间,DATE(y,m,d) 构造日期,YEAR(d) 取年,MONTH(d) 取月,DAY(d) 取日,DATEDIF(start,end,unit) 日期差(unit: "d"=天/"m"=月/"y"=年),DAYS(end,start) 两日期相差天数
107
+ - 文本:CONCAT(...) 拼接,LEFT(s,n)/RIGHT(s,n) 从左/右截取,MID(s,start,len) 子串,LEN(s) 长度,UPPER/LOWER/TRIM(s) 大小写/去空格,REPLACE(s,old,new) 替换,FIND(search,s) 查找位置(1-based,找不到返回0)
108
+ - 格式化:TEXT(v, fmt) —— fmt 为 "0.00" 按小数位格式化;fmt 为 "rmb" 转人民币大写(禁止使用 Excel 的 "[$-804]"、"[>=0]" 等区域码格式,一律用 "rmb" 代替)
109
+ - 人民币大写:RMB(n) 或 CNY(n) —— 数字转人民币大写,如 RMB(1234.56) → "壹仟贰佰叁拾肆元伍角陆分"
110
+ - 示例: "\${unit_price * quantity}" —— 算术
111
+ - 示例: "\${SUM(qty1, qty2) * price}" —— 函数+运算
112
+ - 示例: "总价: \${total_amount} 元" —— 混合文本+公式
113
+ - 示例: "\${IF(score >= 60, \"及格\", \"不及格\")}" —— 条件
114
+ - 示例: "\${IF(AND(start_date, end_date), DATEDIF(start_date, end_date, \"d\") + 1, 0)}" —— 日期天数差
115
+ - 示例: "\${ROUND(amount * 0.1, 2)}" —— 四舍五入
116
+ - 示例: "\${RMB(assessed_value)}" —— 人民币大写(推荐)
117
+ - 示例: "\${TEXT(amount, \"rmb\")}" —— 等同于 RMB(),两者都可用,不要用 TEXT(v, "[DBNum2]...") 的 Excel 格式
118
+ - 注意:公式内字段名直接写,不加 \${} 嵌套,正确:"\${RMB(amount)}",错误:"\${RMB(\${amount})}"
119
+
120
+ - section 类型(分组标题)的额外字段:
121
+ - colorScheme: 字符串,预设配色方案名称。可选值:"indigo"(靛蓝), "emerald"(翠绿), "amber"(琥珀), "rose"(玫红), "sky"(天蓝), "violet"(紫罗兰), "slate"(石板灰), "orange"(橙色), "teal"(青色)。设置后分组标题会显示对应的主题色
122
+ - sectionColor: 字符串,自定义主题色,十六进制颜色值如 "#6366f1"。设置后系统会根据此颜色自动生成完整配色方案(标题色、背景色、边框色等)。优先级高于 colorScheme
123
+ - titleColor: 字符串,自定义标题文字颜色,十六进制颜色值如 "#4338ca"。仅覆盖标题颜色,不影响其他配色
124
+ - 三个属性可以组合使用:colorScheme 提供基础配色,sectionColor 覆盖主题色,titleColor 覆盖标题色
125
+ - 示例:基本预设配色:{ "type": "section", "label": "基本信息", "colorScheme": "indigo" }
126
+ - 示例:自定义主题色:{ "type": "section", "label": "费用明细", "sectionColor": "#f59e0b" }
127
+ - 示例:组合使用:{ "type": "section", "label": "审批意见", "colorScheme": "rose", "titleColor": "#be123c" }
128
+
129
+ - table 类型(子表):
130
+ - children: 数组,子字段列表,每个子字段结构与普通字段相同(但不能嵌套 table/section/grid)
131
+ - 子字段可用类型:text, textarea, number, date, datetime, time, select, multiSelect, checkbox, radio, file, image, member, memberMulti, org, orgMulti, formula
132
+
133
+ - grid 类型(网格):
134
+ - 用于复杂的自由表格布局,支持任意行列数、合并/拆分单元格、嵌入字段或静态文本
135
+ - gridRows: 数字,行数,默认 3
136
+ - gridCols: 数字,列数,默认 4
137
+ - gridData: 数组,单元格数据列表,每个元素描述一个单元格:
138
+ { "row": 行号(0起), "col": 列号(0起), "cellType": "label"|"input", "value": "静态文本(label时)", "align": "left"|"center"|"right", "rowspan": 数字, "colspan": 数字, "fieldType": "字段类型(input时)", "fieldLabel": "字段标签", "fieldId": "关联的子字段_id" }
139
+ - children: 数组,网格中嵌入的子字段列表,每个子字段是普通 FormField(可包含 table 子表),通过 gridData 中的 fieldId 关联
140
+ - 网格中可嵌入的字段类型:text, textarea, number, date, datetime, time, select, multiSelect, checkbox, radio, file, image, member, memberMulti, org, orgMulti, formula, lookup, table(不能嵌入 section/grid)
141
+ - gridColumnWidths: 数组,每列的百分比宽度,如 [25, 25, 25, 25]
142
+ - colspan 始终等于 tableColumns(即网格独占整行)
143
+ - 示例:一个 2×3 网格,第一行是标签,第二行嵌入字段:
144
+ {
145
+ "type": "grid", "label": "费用明细", "gridRows": 2, "gridCols": 3,
146
+ "gridData": [
147
+ { "row": 0, "col": 0, "cellType": "label", "value": "项目", "align": "center" },
148
+ { "row": 0, "col": 1, "cellType": "label", "value": "金额", "align": "center" },
149
+ { "row": 0, "col": 2, "cellType": "label", "value": "备注", "align": "center" },
150
+ { "row": 1, "col": 0, "cellType": "input", "fieldType": "text", "fieldLabel": "项目名", "fieldId": "child1_id" },
151
+ { "row": 1, "col": 1, "cellType": "input", "fieldType": "number", "fieldLabel": "金额", "fieldId": "child2_id" },
152
+ { "row": 1, "col": 2, "cellType": "input", "fieldType": "textarea", "fieldLabel": "备注", "fieldId": "child3_id" }
153
+ ],
154
+ "children": [
155
+ { "_id": "child1_id", "name": "item_name", "label": "项目名", "type": "text", ... },
156
+ { "_id": "child2_id", "name": "item_amount", "label": "金额", "type": "number", ... },
157
+ { "_id": "child3_id", "name": "item_remark", "label": "备注", "type": "textarea", ... }
158
+ ]
159
+ }
160
+ - 注意:网格子字段的值存储在表单顶层 formValues 中(非嵌套),字段名直接作为 key
161
+
162
+ - image 类型:
163
+ - pickerMultiple: 布尔值,是否允许多选
164
+
165
+ - lookup 类型(关联数据):
166
+ - reference_to: 字符串,关联对象的 API 名称,如 "contracts"、"accounts"
167
+ - lookupLabelField: 字符串,用于搜索和显示的名称字段,默认 "name"
168
+ - pickerMultiple: 布尔值,是否允许多选,默认 false
169
+ - lookupFilters: 字符串,OData 过滤表达式,如 "status eq 'active'"
170
+ - lookupDisplayFields: 数组,关联数据后额外展示的字段,格式 [{ "field": "字段API名", "label": "显示标签" }]
171
+
172
+ - reference 类型(对象选择):
173
+ - reference_to: 字符串,关联对象的 API 名称,如 "contracts"、"accounts"
174
+ - reference_to_field: 字符串,用于搜索和显示的名称字段,默认 "name"
175
+ - displayFields: 数组,选择记录后额外展示的字段,格式 [{ "field": "字段API名", "label": "显示标签" }]
176
+ - pickerMultiple: 布尔值,是否允许多选,默认 true
177
+
178
+ - 可见性规则(任何字段都可设置):
179
+ - visibilityRules: 数组,控制字段显示/隐藏的规则,当所有/任一规则满足时字段可见,否则隐藏。每条规则格式:
180
+ { "field": "控制字段的name", "operator": "操作符", "value": "比较值" }
181
+ 操作符可选: "eq"(等于), "neq"(不等于), "contains"(包含), "notContains"(不包含), "empty"(为空), "notEmpty"(不为空)
182
+ - visibilityLogic: "and" 或 "or",多条规则的组合逻辑,默认 "and"
183
+ - 示例:当 "type" 字段等于 "B" 时才显示备注字段:
184
+ visibilityRules: [{ "field": "type", "operator": "eq", "value": "B" }]
185
+
186
+ - 动态必填规则(任何非 section 字段都可设置):
187
+ - requiredRules: 数组,当规则满足时字段变为必填(与静态 required:true 取 OR 关系)。规则格式同 visibilityRules。
188
+ - requiredLogic: "and" 或 "or",多条规则的组合逻辑,默认 "and"
189
+ - 示例:当 "has_attachment" 等于 "yes" 时,附件说明字段变为必填:
190
+ requiredRules: [{ "field": "has_attachment", "operator": "eq", "value": "yes" }]
191
+ - 注意:requiredRules 和 required 是独立的,required:false 且 requiredRules 满足 → 必填;required:true 无论 requiredRules → 始终必填
192
+
193
+ ## 事件脚本
194
+
195
+ events 对象包含三个可选的 JavaScript 脚本:
196
+ - onInit: 表单初始化时执行。可用参数:form (表单实例), fields (字段数据)
197
+ - form.setFieldValue(fieldName, value) — 设置字段值
198
+ - form.getFieldValue(fieldName) — 获取字段值
199
+ - form.setFieldOptions(fieldName, options) — 设置下拉选项
200
+ - form.setFieldHidden(fieldName, hidden) — 显隐字段
201
+ - form.setFieldRequired(fieldName, required) — 设为必填
202
+ - onValueChange: 字段值变化时执行。可用参数:field (变化的字段), value (新值), oldValue (旧值), form (表单实例), values (所有字段值)
203
+ - onSubmit: 表单提交前执行。可用参数:form (表单实例), values (提交数据)。返回 false 可阻止提交
204
+
205
+ 所有脚本支持 async/await,示例:
206
+ \`\`\`javascript
207
+ // onValueChange 示例
208
+ if (field.name === 'quantity' || field.name === 'unit_price') {
209
+ var qty = Number(form.getFieldValue('quantity')) || 0;
210
+ var price = Number(form.getFieldValue('unit_price')) || 0;
211
+ form.setFieldValue('total_amount', qty * price);
212
+ }
213
+ \`\`\`
214
+
215
+ ## 规则
216
+ 1. 修改时尽可能保留用户已有字段的 name,只改需要改的部分
217
+ 2. 新增字段的 name 必须是合法的 JavaScript 变量名(字母/下划线开头,仅含字母、数字、下划线),要语义清晰
218
+ 3. section 分组、table 子表、grid 网格的 colspan 必须等于 tableColumns(即独占整行)
219
+ 4. formula 字段的公式表达式使用 \${} 语法,内部直接引用字段名,如 "\${unit_price * quantity}";公式内部不能再嵌套 \${},错误示例:"\${RMB(\${amount})}",正确示例:"\${RMB(amount)}"
220
+ 5. formula 字段只能使用上方"支持的函数(完整列表)"中列出的 36 个函数,禁止使用任何 Excel 专有函数(NETWORKDAYS、WORKDAY、EDATE、SUMIF、VLOOKUP、IFERROR、EOMONTH 等)或 JavaScript 方法(Math.floor、parseFloat、toString 等),违反此规则会导致运行时报错
221
+ 6. 如果用户只要求修改脚本/事件,则 fields 保持原样不变
222
+ 7. 如果用户只要求修改字段,则 events 保持原样不变
223
+ 8. 返回完整的 fields 数组和 events 对象
224
+
225
+ ## 额外待转换的数据(升级场景)
226
+
227
+ 新版表单已**不再支持** instance_template(审批单模板)、form_script(表单脚本)、flow_events(流程事件),这些是旧版的遗留功能。当用户提供这些数据时,你需要:
228
+
229
+ 1. **分析其中的业务逻辑**,理解它们实现了什么功能
230
+ 2. **将业务逻辑迁移到新版机制中**:
231
+ - 字段显隐控制 → 转为字段的 visibilityRules
232
+ - 动态必填控制 → 转为字段的 requiredRules
233
+ - 字段值自动计算 → 转为 formula 类型字段,或写入 events.onValueChange
234
+ - 初始化赋值/选项加载 → 写入 events.onInit
235
+ - 值联动/级联逻辑 → 写入 events.onValueChange
236
+ - 提交前校验 → 写入 events.onSubmit
237
+ - 字段选项设置 → 转为字段的 options 属性,或通过 events.onInit / events.onValueChange 中 form.setFieldOptions() 设置
238
+ 3. **instance_template 仍可用于推断字段 label、colspan(列宽)和 rowspan(行高)**
239
+ 4. **不要在输出中返回** instance_template、form_script、flow_events 字段
240
+
241
+ ### 审批单模板 (instance_template)
242
+ - HTML 模板,字段引用格式通常为 {{values.字段名}}
243
+ - **布局还原是升级的核心目标**,instance_template 的 HTML 表格是布局的权威来源,新版表单的显示效果必须尽可能精确还原旧版模板的视觉布局
244
+ - 分析模板时,首先统计 HTML 表格展开所有 colspan 后的**实际最大总列数**(包含标签列和数据列),然后选择布局方案:
245
+ - **⭐ 优先使用方案二(grid 网格字段)**。只有当模板表格极其简单(总列数 ≤ 6 且无 rowspan/colspan)时才考虑方案一。如果你不确定该用哪个,请使用方案二。
246
+
247
+ #### 方案一:简单布局(tableColumns + tableColspan)
248
+ 适用条件:模板 HTML 表格展开后总列数 ≤ 6(含标签列),每行最多 3 个输入字段,且无 rowspan/colspan 合并单元格。**如果模板中出现任何 rowspan 或 colspan,则不得使用方案一,必须使用方案二。**
249
+
250
+ 1. **确定 tableColumns**(每行字段数):
251
+ - 模板中每行有 1 个输入字段 → tableColumns=1
252
+ - 模板中每行有 2 个输入字段 → tableColumns=2
253
+ - 模板中每行有 3 个输入字段 → tableColumns=3(最大值)
254
+
255
+ 2. **字段 tableColspan**:基于 tableColumns 栅格设置,默认每个字段占 1 列
256
+ - tableColumns=2 时:每个字段默认 tableColspan=1,独占一行的字段 tableColspan=2
257
+ - tableColumns=3 时:每个字段默认 tableColspan=1,跨两列的 tableColspan=2,独占一行的 tableColspan=3
258
+ - 注意:tableColspan 的最大值等于 tableColumns
259
+
260
+ 3. **分组布局**:模板中的分类标题应转为 type="section" 字段
261
+
262
+ 4. **字段 label**:优先使用模板中该字段旁边的显示文本
263
+
264
+ #### 方案二:复杂表格布局(grid 网格字段)⭐ 默认首选方案
265
+ 适用条件:模板 HTML 表格展开后总列数 > 6,或者存在**任何 rowspan 或 colspan**,或者有多级表头(主标题下有子标题),或者表格结构无法用 tableColumns(1-3) + colspan 简单表达。**绝大多数审批单模板都应使用此方案。**
266
+
267
+ **必须使用 grid 网格字段来精确还原表格布局。禁止将复杂模板用 tableColumns + colspan 简化处理。如果模板有任何 rowspan/colspan 合并单元格,就必须使用 grid。**
268
+
269
+ 转换步骤:
270
+ 1. **分析 HTML 表格结构**:统计表格的总行数和总列数(考虑 colspan 展开后的最大列数),确定 gridRows 和 gridCols
271
+ 2. **映射每个 \`<td>\` 到 gridData 单元格**:
272
+ - 纯文本/标题单元格(如 \`<td class="td-title">\`、表头)→ \`{ cellType: "label", value: "显示文本", align: "center" }\`
273
+ - 包含 \`{{values.字段名}}\` 的字段单元格 → \`{ cellType: "input", fieldType: "对应类型", fieldLabel: "标签", fieldId: "唯一ID" }\`
274
+ - HTML 的 \`rowspan\`/\`colspan\` 属性直接映射到 gridData 单元格的 \`rowspan\`/\`colspan\`
275
+ 3. **构建 children 数组**:为每个 input 类型的单元格创建对应的子字段,通过 fieldId 关联
276
+ 4. **设置 gridColumnWidths**:根据 HTML 表格各列的实际宽度比例设置百分比数组
277
+ 5. **grid 字段的 colspan 设为 tableColumns**(独占整行),tableColumns 可设为 1
278
+
279
+ 转换示例 — 假设模板有一个 5 列表格(标题+4个数据列):
280
+ HTML: \`<tr><td rowspan="3">工程名称</td><td>材料费</td><td>{{values.clf_bq}}</td><td>{{values.clf_lj}}</td><td>{{values.clf_bz}}</td></tr>\`
281
+ 转换为 grid:
282
+ \`\`\`json
283
+ {
284
+ "type": "grid", "label": "费用明细", "name": "cost_grid",
285
+ "gridRows": 10, "gridCols": 5, "colspan": 1,
286
+ "gridColumnWidths": [15, 15, 25, 25, 20],
287
+ "gridData": [
288
+ { "row": 0, "col": 0, "cellType": "label", "value": "工程名称", "rowspan": 3, "align": "center" },
289
+ { "row": 0, "col": 1, "cellType": "label", "value": "材料费", "align": "center" },
290
+ { "row": 0, "col": 2, "cellType": "input", "fieldType": "number", "fieldLabel": "本期材料费", "fieldId": "f_clf_bq" },
291
+ { "row": 0, "col": 3, "cellType": "input", "fieldType": "number", "fieldLabel": "累计材料费", "fieldId": "f_clf_lj" },
292
+ { "row": 0, "col": 4, "cellType": "input", "fieldType": "text", "fieldLabel": "材料费备注", "fieldId": "f_clf_bz" }
293
+ ],
294
+ "children": [
295
+ { "_id": "f_clf_bq", "name": "clf_bq", "label": "本期材料费", "type": "number" },
296
+ { "_id": "f_clf_lj", "name": "clf_lj", "label": "累计材料费", "type": "number" },
297
+ { "_id": "f_clf_bz", "name": "clf_bz", "label": "材料费备注", "type": "text" }
298
+ ]
299
+ }
300
+ \`\`\`
301
+
302
+ **重要**:grid 网格中的 label 单元格用于还原 HTML 表格中的标题/分类文字(如"工程费用""其他费用""小计""合计"等),这些文字不需要创建独立字段,直接作为 cellType:"label" 的静态文本即可。只有包含 {{values.xxx}} 的单元格才需要创建为 cellType:"input" 并在 children 中添加对应字段。
303
+
304
+ **⚠️ 列数精确匹配**:gridCols 必须等于 HTML 表格展开所有 colspan 后的实际最大列数。禁止合并、删减或精简列。如果模板表头"预算"下方有"合同预算"和"调整预算"两个子列,则必须保留为 2 个独立的 grid 列,不能合并成 1 列。同理,"决算"下方有子列也必须全部保留。遇到多级表头(主标题跨多列、下方有子标题),需正确设置主标题的 colspan 来覆盖子列。
305
+
306
+ **⚠️ 单元格完整覆盖**:gridData 中每一行的每个列位置(row=0..gridRows-1, col=0..gridCols-1)必须被一个单元格"覆盖"——要么该位置有一个 gridData 条目,要么它被另一个单元格的 rowspan/colspan 所覆盖。不允许出现"空洞"(某个位置既没有 gridData 条目也没有被合并覆盖)。
307
+ - 如果某行的某个单元格位于一个多列表头下方但只需要 1 列内容,应设置 colspan 使其跨满父表头的列范围。例如:表头"项目分类"占 col 2-3(colspan=2),则数据行中如果只有一个名称"设备购置"在 col 2,应给它 colspan=2 让它跨到 col 3,而不是留 col 3 为空。
308
+ - 公式:每行所有单元格的 (colspan值之和) + (被其他行的 rowspan 覆盖的列数) = gridCols
309
+ - 模板中的业务逻辑(如条件显隐的 JS 代码)需迁移到 visibilityRules 或 events 中
310
+ ### 表单脚本 (form_script)
311
+ - 旧版表单使用的 JavaScript 脚本,引用字段名的方式包括:doc.字段名、values.字段名 等
312
+ - 分析脚本中的逻辑,将其迁移到新版的 events(onInit / onValueChange / onSubmit)中
313
+ - 新版事件脚本使用 form.getFieldValue() / form.setFieldValue() / form.setFieldOptions() / form.setFieldHidden() / form.setFieldRequired() 等 API
314
+
315
+ ### 流程事件 (flow_events)
316
+ - 旧版流程级别的事件脚本
317
+ - 分析其中的业务逻辑,合并迁移到新版的 events 中
318
+
319
+ ## 输出格式
320
+ 返回一个 JSON 对象,格式如下(不要有任何其他文字、解释、markdown 标记):
321
+ {
322
+ "tableColumns": 数字,每行显示的字段数,默认 2,最大值为 3。根据表单布局需要调整,常见值为 1、2、3,
323
+ "fields": [ ... ],
324
+ "events": {
325
+ "onInit": "脚本代码或空字符串",
326
+ "onValueChange": "脚本代码或空字符串",
327
+ "onSubmit": "脚本代码或空字符串"
328
+ }
329
+ }`;
330
+
331
+ // Build current fields description
332
+ const fieldsJson = JSON.stringify(fields || [], null, 2);
333
+ const eventsJson = JSON.stringify(events || {}, null, 2);
334
+
335
+ // Build extra data sections for upgrade scenario
336
+ let extraSections = '';
337
+ if (instance_template) {
338
+ extraSections += `\n\n## 审批单模板 (instance_template)\n\`\`\`html\n${instance_template}\n\`\`\``;
339
+ }
340
+ if (form_script) {
341
+ extraSections += `\n\n## 表单脚本 (form_script)\n\`\`\`javascript\n${form_script}\n\`\`\``;
342
+ }
343
+ if (flow_events) {
344
+ extraSections += `\n\n## 流程事件 (flow_events)\n\`\`\`javascript\n${flow_events}\n\`\`\``;
345
+ }
346
+
347
+ const userContent = `## 当前表单字段
348
+ ${fieldsJson}
349
+
350
+ ## 当前事件脚本
351
+ ${eventsJson}${extraSections}
352
+
353
+ ## 用户需求
354
+ ${userRequest}
355
+
356
+ 请返回修改后的完整 JSON 对象(包含 tableColumns、fields 和 events)。`;
357
+
358
+ // Build user message content: support multi-modal (text + images)
359
+ let userMessageContent;
360
+ if (Array.isArray(images) && images.length > 0) {
361
+ // Multi-modal content array
362
+ userMessageContent = [
363
+ { type: 'text', text: userContent },
364
+ ...images.slice(0, 5).map(img => ({
365
+ type: 'image_url',
366
+ image_url: { url: img, detail: 'auto' },
367
+ })),
368
+ ];
369
+ } else {
370
+ userMessageContent = userContent;
371
+ }
372
+ console.log('User message content:', JSON.stringify([
373
+ { role: 'system', content: systemPrompt },
374
+ { role: 'user', content: userMessageContent },
375
+ ], null, 2));
376
+ const response = await axios.post(`${baseURL}/chat/completions`, {
377
+ model,
378
+ messages: [
379
+ { role: 'system', content: systemPrompt },
380
+ { role: 'user', content: userMessageContent },
381
+ ],
382
+ temperature: 0.3,
383
+ max_tokens: 16000,
384
+ }, {
385
+ headers: {
386
+ 'Authorization': `Bearer ${apiKey}`,
387
+ 'Content-Type': 'application/json',
388
+ },
389
+ timeout: 300000,
390
+ });
391
+
392
+ const content = response.data?.choices?.[0]?.message?.content;
393
+ if (!content) {
394
+ return res.status(500).json({ error: 'AI 未返回有效内容' });
395
+ }
396
+
397
+ // Try to parse the JSON from the response
398
+ let result;
399
+ try {
400
+ let text = content.trim();
401
+ // Strip markdown code block if present
402
+ if (text.startsWith('```json')) text = text.slice(7);
403
+ else if (text.startsWith('```')) text = text.slice(3);
404
+ if (text.endsWith('```')) text = text.slice(0, -3);
405
+ text = text.trim();
406
+
407
+ // Find JSON object
408
+ const objStart = text.indexOf('{');
409
+ const objEnd = text.lastIndexOf('}');
410
+ if (objStart !== -1 && objEnd > objStart) {
411
+ result = JSON.parse(text.substring(objStart, objEnd + 1));
412
+ } else {
413
+ result = JSON.parse(text);
414
+ }
415
+ } catch (parseErr) {
416
+ return res.status(500).json({
417
+ error: 'AI 返回内容无法解析为 JSON',
418
+ raw: content,
419
+ });
420
+ }
421
+
422
+ // Validate result structure
423
+ if (!result || typeof result !== 'object') {
424
+ return res.status(500).json({ error: 'AI 返回的不是有效的 JSON 对象' });
425
+ }
426
+
427
+ // Normalise: accept either { fields, events } or just an array (treat as fields)
428
+ if (Array.isArray(result)) {
429
+ result = { fields: result, events: events || {} };
430
+ }
431
+
432
+ // Ensure fields is an array
433
+ if (result.fields && !Array.isArray(result.fields)) {
434
+ return res.status(500).json({ error: 'AI 返回的 fields 不是数组' });
435
+ }
436
+
437
+ // Validate field types
438
+ const validTypes = [
439
+ 'text', 'textarea', 'number', 'date', 'datetime', 'time',
440
+ 'select', 'multiSelect', 'checkbox', 'radio', 'file', 'image',
441
+ 'lookup', 'section', 'table', 'grid', 'formula',
442
+ 'member', 'memberMulti', 'org', 'orgMulti', 'reference'
443
+ ];
444
+ const jsIdentifierRe = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
445
+ function sanitizeName(name) {
446
+ if (!name) return 'field_' + Math.random().toString(36).slice(2, 6);
447
+ let s = String(name).replace(/[^a-zA-Z0-9_$]/g, '_');
448
+ if (/^[0-9]/.test(s)) s = '_' + s;
449
+ return s || 'field_' + Math.random().toString(36).slice(2, 6);
450
+ }
451
+
452
+ const tblCols = (typeof result.tableColumns === 'number' && result.tableColumns >= 1) ? Math.min(result.tableColumns, 3) : 2;
453
+
454
+ if (result.fields) {
455
+ for (const field of result.fields) {
456
+ // Backward compat: map legacy userPicker/groupPicker to new types
457
+ if (field.type === 'userPicker') field.type = field.pickerMultiple ? 'memberMulti' : 'member';
458
+ if (field.type === 'groupPicker') field.type = field.pickerMultiple ? 'orgMulti' : 'org';
459
+ // Backward compat: map legacy select + pickerMultiple to multiSelect
460
+ if (field.type === 'select' && field.pickerMultiple) field.type = 'multiSelect';
461
+ if (field.type && !validTypes.includes(field.type)) {
462
+ field.type = 'text'; // fallback to text for unknown types
463
+ }
464
+ // Validate/sanitize field name to be a valid JS identifier
465
+ if (field.name && !jsIdentifierRe.test(field.name)) {
466
+ field.name = sanitizeName(field.name);
467
+ }
468
+ // Convert AI colspan (1..tableColumns) to tableColspan + 12-grid colspan
469
+ if (field.type === 'section' || field.type === 'table' || field.type === 'grid') {
470
+ field.tableColspan = tblCols;
471
+ field.colspan = 12;
472
+ } else {
473
+ const aiColspan = Math.max(1, Math.min(Number(field.colspan) || 1, tblCols));
474
+ field.tableColspan = aiColspan;
475
+ field.colspan = (aiColspan >= tblCols) ? 12 : Math.round(12 / tblCols * aiColspan);
476
+ }
477
+ // Validate children types for table
478
+ if (field.type === 'table' && Array.isArray(field.children)) {
479
+ for (const child of field.children) {
480
+ // Backward compat: map legacy types in children
481
+ if (child.type === 'userPicker') child.type = child.pickerMultiple ? 'memberMulti' : 'member';
482
+ if (child.type === 'groupPicker') child.type = child.pickerMultiple ? 'orgMulti' : 'org';
483
+ if (child.type === 'select' && child.pickerMultiple) child.type = 'multiSelect';
484
+ if (child.type && !validTypes.includes(child.type)) {
485
+ child.type = 'text';
486
+ }
487
+ // Validate/sanitize child field name
488
+ if (child.name && !jsIdentifierRe.test(child.name)) {
489
+ child.name = sanitizeName(child.name);
490
+ }
491
+ // Children cannot be section or table
492
+ if (child.type === 'section' || child.type === 'table') {
493
+ child.type = 'text';
494
+ }
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // Ensure events is an object
501
+ if (!result.events || typeof result.events !== 'object') {
502
+ result.events = events || {};
503
+ }
504
+
505
+ // Build response
506
+ const responseData = {
507
+ fields: result.fields || fields || [],
508
+ tableColumns: typeof result.tableColumns === 'number' ? result.tableColumns : undefined,
509
+ events: {
510
+ onInit: result.events.onInit || '',
511
+ onValueChange: result.events.onValueChange || '',
512
+ onSubmit: result.events.onSubmit || '',
513
+ },
514
+ };
515
+ return res.json(responseData);
516
+
517
+ } catch (error) {
518
+ console.error('AI form design error:', error?.response?.data || error?.message || error);
519
+ if (error.response) {
520
+ const status = error.response.status;
521
+ const data = error.response.data;
522
+ if (status === 401 || status === 403) {
523
+ return res.status(500).json({ error: 'AI API 密钥无效或权限不足,请检查 WORKFLOW_AI_API_KEY' });
524
+ }
525
+ if (status === 429) {
526
+ return res.status(500).json({ error: 'AI API 请求频率超限,请稍后重试' });
527
+ }
528
+ return res.status(500).json({ error: data?.error?.message || 'AI API 调用失败' });
529
+ }
530
+ return res.status(500).json({ error: error.message || 'AI 服务调用失败' });
531
+ }
532
+ });
533
+
534
+ exports.default = router;