@qingflow-tech/qingflow-cli 1.1.50 → 1.1.53

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 (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +8 -2
  4. package/skills/qingflow-cli/SKILL.md +115 -3
  5. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +3 -9
  6. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
  7. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  8. package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
  9. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  10. package/skills/qingflow-cli/reference/builder/README.md +1 -1
  11. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
  12. package/skills/qingflow-cli/reference/data/README.md +1 -1
  13. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  14. package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
  15. package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
  16. package/src/qingflow_mcp/app_form_compat.py +3 -0
  17. package/src/qingflow_mcp/app_form_validator.py +69 -17
  18. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  19. package/src/qingflow_mcp/builder_facade/models.py +227 -17
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  45. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  46. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
  47. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
  49. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
  50. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
  51. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
  52. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
  53. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
  54. package/src/qingflow_mcp/builder_facade/service.py +1666 -509
  55. package/src/qingflow_mcp/builder_facade/views_extract.py +592 -0
  56. package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
  57. package/src/qingflow_mcp/cli/commands/builder.py +76 -55
  58. package/src/qingflow_mcp/config.py +1 -0
  59. package/src/qingflow_mcp/public_surface.py +3 -0
  60. package/src/qingflow_mcp/response_trim.py +11 -2
  61. package/src/qingflow_mcp/server_app_builder.py +34 -10
  62. package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
  63. package/src/qingflow_mcp/solution/executor.py +96 -8
  64. package/src/qingflow_mcp/solution/spec_models.py +1 -0
  65. package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
  66. package/src/qingflow_mcp/tools/base.py +42 -10
  67. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
  68. package/src/qingflow_mcp/tools/view_tools.py +0 -6
  69. package/src/qingflow_mcp/tools/view_writer.py +44 -0
  70. package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
  71. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +0 -42
@@ -38,7 +38,7 @@ Rules:
38
38
  - Portal chart sections use one `chart` field: `{"chart_id":"..."}` to reuse an existing QingBI chart, or `{"app_key":"...","name":"...","chart_type":"...",...}` to create/update the needed chart inline.
39
39
  - Buttons are part of the view design: declare them in the target view with `action_buttons`.
40
40
  - Update instructions live inside the resource document. Do not use `update-*` as a separate main route.
41
- - Batch read, batch write, and patch are resource-specific variants, not separate workflows.
41
+ - Batch read, batch write, and patch are resource-specific variants, not separate workflows. View writes are single-operation: `builder views apply` handles one view per call; consult [50-views.md](./50-views.md) before preparing multiple view changes.
42
42
 
43
43
  ## File responsibilities
44
44
 
@@ -41,7 +41,7 @@
41
41
  这些不是独立流程,而是读、写、改三个动作的多资源形态:
42
42
 
43
43
  - **批量读**:读多个应用时优先用 `--app-keys`,例如 `builder app get --app-keys a,b fields`、`builder app get --app-keys a,b views`、`builder button get --app-keys a,b`、`builder associated-resource get --app-keys a,b`、`builder publish verify --app-keys a,b`。读结果同时看成功项和 `errors[]`,不要因单个应用失败否定全批。
44
- - **批量写**:视图、按钮、关联资源和报表按各自资源契约使用对应的批量文件;应用表单不走 `apps-file`,而是每个应用独立提交完整 AppForm 声明。
44
+ - **批量写**:仅在资源契约明确支持时使用批量文件;视图例外,`builder views apply` 每次只提交一个视图的一种操作,不支持多视图批量创建或把 upsert/patch/remove 混在一次调用中。应用表单不走 `apps-file`,而是每个应用独立提交完整 AppForm 声明。
45
45
  - **局部改**:只改已存在资源的少量参数时用对应 patch 文件;`patch_*` 内只写目标定位字段和 `set/unset`,不要用不完整 `upsert_*` 伪装局部更新。
46
46
 
47
47
  ---
@@ -201,8 +201,8 @@ qingflow builder schema apply \
201
201
  - 如果返回 `MULTI_APP_STATIC_VALIDATION_FAILED`,读取 `details.issues[]` 的 `path/error_code/fix_hint` 修 payload;这是写入前失败,通常可以修正后重试,不需要 readback。
202
202
  - 多应用 schema apply 不做事务回滚;如果部分失败,只补失败的 `apps[].row_number`,不要整批重复提交。
203
203
  - 这个模式只负责应用壳与字段;视图、按钮、关联资源、报表仍拿返回的 `app_key` 继续走各自 apply 工具。
204
- - `target_app_ref`、`as_data_title`、`display_field` 等只属于旧载荷。迁移时将关系改写为 AppForm `config.targetAppKey`、`config.displayField`、`config.visibleFields`,并使用已确认的真实 app key。
205
- - 历史 schema adapter 写入后可读 `details.relation_readback_matrix[]`:逐项核对旧 `target_app_key`、`relation_mode`、`display_field`、`visible_fields`。迁移到当前 AppForm 后,改为读取 draft/published form 的 `config.targetAppKey`、`config.relationMode`、`config.displayField`、`config.visibleFields`,不要提交旧 `update_fields` patch。
204
+ - `target_app_ref`、`as_data_title`、`display_field` 等只属于旧载荷。迁移时将关系改写为 AppForm `config.targetAppKey`、`config.displayFields[]`(含 `auth`)、`config.twoWayBoundField`,并使用已确认的真实 app key。
205
+ - 历史 schema adapter 写入后可读 `details.relation_readback_matrix[]`:逐项核对旧 `target_app_key`、`relation_mode`、`display_field`、`visible_fields`。迁移到当前 AppForm 后,改为读取 draft/published form 的 `config.targetAppKey`、`config.relationMode`、`config.twoWayBoundField`、`config.displayFields[]`,不要提交旧 `update_fields` patch。
206
206
  - 一个应用可以包含多个 `relation` 字段;不要因为 relation 数量超过 1 就降级成文本。若真实写入失败或 relation readback mismatch,只按实际错误和 `details.relation_repair_plan[]` 做最小修复。
207
207
  - 完整系统遇到超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或 readback 不完整时,先按 §3.4 读回,不要直接拆成单应用创建。
208
208
 
@@ -349,7 +349,7 @@ qingflow builder flow apply \
349
349
  | **`address`** | 地址复合字段。 |
350
350
  | **`attachment`** | 附件。 |
351
351
  | **`boolean`** | 是否。 |
352
- | **`relation`** | 旧载荷使用 `target_app_key` / `target_app_ref`;当前 AppForm 使用 `config.targetAppKey`、`config.relationMode`、`config.displayField`、`config.visibleFields`。 |
352
+ | **`relation`** | 旧载荷使用 `target_app_key` / `target_app_ref`;当前 AppForm 使用 `config.targetAppKey`、`config.relationMode`、`config.twoWayBoundField`、`config.displayFields[]`。 |
353
353
  | **`subtable`** | 当前 AppForm 使用 `subfields[]` 及 Schema 返回的 camelCase 配置;旧 `subfield_updates` 只作迁移线索。 |
354
354
  | **`q_linker`** | 当前 AppForm 使用 `config.qLinkerBinding`,其 `outputs[].targetField` 必须按字段 Schema 配置。 |
355
355
  | **`code_block`** | 当前 AppForm 使用 `config.codeBlockBinding`,其 `outputs[].targetField` 必须按字段 Schema 配置。 |
@@ -374,14 +374,14 @@ qingflow builder flow apply \
374
374
 
375
375
  后端很多保存接口仍是整份配置保存,但新版 CLI/MCP 对外提供 **public partial patch**:
376
376
 
377
- - 已有视图:`builder views apply --views-file`,其中对应项写 `operation: "patch"`
377
+ - 已有视图:`builder views apply --app-key APP_KEY --patch-views-file PATH`,文件只放一项,写 `view_key`/`name` 与 `set`/`unset`
378
378
  - 已有按钮:`builder button apply --patch-buttons-file`
379
379
  - 已有关联资源:`builder associated-resource apply --patch-resources-file`
380
380
  - 已有报表:`builder charts apply --patch-file`
381
381
  - 已有流程节点:`builder flow apply --patch-nodes-file`
382
382
  - 已有门户组件:`builder portal apply --patch-sections-file`
383
383
 
384
- 局部更新的语义是:你只写要替换的参数,工具内部读取当前配置、补齐后端必填字段,再整份保存。每个 patch 项直接使用对象真实定位字段 + `set` / 可选 `unset`,不要写字面量 `selector` key。示例:`{"operation":"patch","app_key":"APP_KEY","view_key":"VIEW_KEY","set":{"query_conditions":{...}}}`、`{"button_id": 1001, "set": {"button_text": "新名称"}}`、`{"associated_item_id": 123, "set": {"match_mappings": [...]}}`、`{"chart_id": 456, "set": {"name": "新报表名"}}`、`{"id": "node_1", "set": {"name": "主管审批"}}`、`{"chart": {"chart_key": "xxx"}, "set": {"rows": 7}}`。
384
+ 局部更新的语义是:你只写要替换的参数,工具内部读取当前配置、补齐后端必填字段,再整份保存。每个 patch 项直接使用对象真实定位字段 + `set` / 可选 `unset`,不要写字面量 `selector` key。示例:`{"view_key":"VIEW_KEY","set":{"query_conditions":{...}}}`、`{"button_id": 1001, "set": {"button_text": "新名称"}}`、`{"associated_item_id": 123, "set": {"match_mappings": [...]}}`、`{"chart_id": 456, "set": {"name": "新报表名"}}`、`{"id": "node_1", "set": {"name": "主管审批"}}`、`{"chart": {"chart_key": "xxx"}, "set": {"rows": 7}}`。
385
385
 
386
386
  `upsert_*` 用于创建或提供完整目标配置;不要只给 `name/type/query_conditions` 这类局部片段然后期待后端自动合并。
387
387
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  ## 执行与恢复
21
21
 
22
- - 已知 `app_key` 时直接执行目标业务命令;只有业务命令明确报告会话或工作区错误后才恢复认证上下文。
22
+ - 已知 `app_key` 时直接执行目标业务命令;只有应用名称时先用 `app list --query` 查找并核对 `app_name/title`,目标明确且唯一时取其 `app_key`,否则请用户确认。不能把名称传给 `--app-key`。只有业务命令明确报告会话或工作区错误后才恢复认证上下文。
23
23
  - 定位目标记录时先 query,再按需 get。字段只使用当前 schema 与字段能力允许的 id、能力和形状。
24
24
  - insert、update、delete 会改变真实业务数据。删除、覆盖已有值或批量写入前,必须确认目标和影响范围。
25
25
  - 返回后先检查 `write_executed`、`retry_safe`、字段错误和 hints。已执行、部分成功或状态不明时不得重放原请求。
@@ -7,7 +7,7 @@ Use these files as copyable bases after reading the matching resource document.
7
7
  | Historical single-app schema probe | [schema/schema_single_app_form_standard.example.json](./schema/schema_single_app_form_standard.example.json) | Retired `--form-file` shape; use `builder app-form schema/get/validate/apply` for current declarations. |
8
8
  | Historical multi-app schema probe | [schema/schema_multi_app_form_relation_layout.example.json](./schema/schema_multi_app_form_relation_layout.example.json) | Retired `--apps-file` shape; current systems create independent AppForms and then bind confirmed app keys. |
9
9
  | Existing-app layout probe | [layout/layout_sections_full.example.json](./layout/layout_sections_full.example.json) | Historical layout-patch shape; current new and existing forms use the complete AppForm `spec.body`. |
10
- | Views | [views/views_upsert_table_minimal.example.json](./views/views_upsert_table_minimal.example.json), [views/views_batch_full.example.json](./views/views_batch_full.example.json) | `views[]` shape with filters, query panel, view-bound buttons, batch upsert/patch/remove. |
10
+ | Views | [views/views_upsert_table_minimal.example.json](./views/views_upsert_table_minimal.example.json), [views/views_apply_v1alpha1.example.json](./views/views_apply_v1alpha1.example.json) | 单视图 JSON 对象,包含筛选、查询面板和视图绑定按钮;通过 `--app-key` 指定应用。 |
11
11
  | Charts | [charts/charts_upsert_dashboard_starter.example.json](./charts/charts_upsert_dashboard_starter.example.json) | Standalone chart apply; portal usually creates missing charts inline. |
12
12
  | Portal | [portal/portal_sections_standard_workbench.example.json](./portal/portal_sections_standard_workbench.example.json) | Standard workbench with inline QingBI charts and data views. |
13
13
  | Associated resources | [associated/associated_resources_semantic_full.example.json](./associated/associated_resources_semantic_full.example.json) | Current-record matching through `match_mappings`. |
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "项目台账视图",
3
+ "type": "table",
4
+ "columns": [100, 101, 102, 103],
5
+ "data_permission": {"type": "custom", "scope": "dept"},
6
+ "sort": [{"que_id": 100, "ascending": false}],
7
+ "display": {"row_height": "compact", "pin_navigate": true},
8
+ "query_conditions": {
9
+ "enabled": true,
10
+ "exact": false,
11
+ "hide_before_query": false,
12
+ "rows": [["项目名称", "负责人"], ["创建时间"]]
13
+ }
14
+ }
@@ -1,42 +1,36 @@
1
1
  {
2
- "views": [
2
+ "name": "示例业务操作视图_CLI模板",
3
+ "type": "table",
4
+ "columns": [100, 101, 102, 103],
5
+ "filters": [
6
+ {"que_id": 100, "operator": "neq", "value": "已关闭"}
7
+ ],
8
+ "query_conditions": {
9
+ "enabled": true,
10
+ "exact": false,
11
+ "hide_before_query": false,
12
+ "rows": [["主记录编号", "主记录名称"], ["状态", "负责人"]]
13
+ },
14
+ "action_buttons": [
3
15
  {
4
- "operation": "upsert",
5
- "app_key": "APP_KEY",
6
- "name": "示例业务操作视图_CLI模板",
7
- "type": "table",
8
- "columns": ["主记录编号", "主记录名称", "状态", "负责人"],
9
- "filters": [
10
- {"field_name": "状态", "operator": "neq", "value": "已关闭"}
16
+ "text": "行内创建子记录",
17
+ "action": "add_data",
18
+ "target_app_key": "TARGET_APP_KEY",
19
+ "field_mappings": [
20
+ {"source_field": "数据ID", "target_field": "关联主记录"}
11
21
  ],
12
- "query_conditions": {
13
- "enabled": true,
14
- "exact": false,
15
- "hide_before_query": false,
16
- "rows": [["主记录编号", "主记录名称"], ["状态", "负责人"]]
17
- },
18
- "action_buttons": [
19
- {
20
- "text": "行内创建子记录",
21
- "action": "add_data",
22
- "target_app_key": "TARGET_APP_KEY",
23
- "field_mappings": [
24
- {"source_field": "数据ID", "target_field": "关联主记录"}
25
- ],
26
- "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
27
- "placement": "list"
28
- },
29
- {
30
- "text": "详情创建子记录",
31
- "action": "add_data",
32
- "target_app_key": "TARGET_APP_KEY",
33
- "field_mappings": [
34
- {"source_field": "数据ID", "target_field": "关联主记录"}
35
- ],
36
- "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
37
- "placement": "detail"
38
- }
39
- ]
22
+ "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
23
+ "placement": "list"
24
+ },
25
+ {
26
+ "text": "详情创建子记录",
27
+ "action": "add_data",
28
+ "target_app_key": "TARGET_APP_KEY",
29
+ "field_mappings": [
30
+ {"source_field": "数据ID", "target_field": "关联主记录"}
31
+ ],
32
+ "default_values": {"目标状态字段": "REPLACE_WITH_TARGET_OPTION_LABEL"},
33
+ "placement": "detail"
40
34
  }
41
35
  ]
42
36
  }
@@ -10,6 +10,9 @@ from .json_types import JSONObject
10
10
  TYPE_ALIASES = {
11
11
  "multiline": "long_text",
12
12
  "textarea": "long_text",
13
+ "richtext": "rich_text",
14
+ "rich-text": "rich_text",
15
+ "rich_text": "rich_text",
13
16
  "radio": "single_choice",
14
17
  "boolean": "single_choice",
15
18
  "select": "single_select",
@@ -11,9 +11,15 @@ from .app_form_compat import normalize_app_form_declaration
11
11
  from .builder_facade.app_form_schema import AppFormContractError, compact_schema_errors, schema_error_message
12
12
 
13
13
 
14
+ #: code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl
15
+ #: 原样落库),运行时 DataSvcImpl.getAnswerValueByString:选项/数字/日期有专门
16
+ #: 分支;attachment 按拆分 URL 写入(专门分支);subtable 整表组装(专门分支);
17
+ #: rich_text/data_relation 走 Question.canImport 兜底按字符串写入(不报错)。
18
+ #: 四者为存量合法配置(提取读得出、apply 必须写回,两闸门同口径)。
14
19
  _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
15
20
  "text",
16
21
  "long_text",
22
+ "rich_text",
17
23
  "number",
18
24
  "amount",
19
25
  "date",
@@ -21,6 +27,9 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
21
27
  "single_choice",
22
28
  "single_select",
23
29
  "multi_select",
30
+ "attachment",
31
+ "data_relation",
32
+ "subtable",
24
33
  }
25
34
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
26
35
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -218,19 +227,32 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
218
227
  widths = ["width" in field for field in fields if isinstance(field, dict)]
219
228
  if widths and any(widths) and not all(widths):
220
229
  errors.append((row_path, "width must be present on every field in the row or omitted from all"))
221
- if widths and all(widths) and sum(field.get("width", 0) for field in fields if isinstance(field, dict)) != 100:
222
- errors.append((row_path, "field widths must total 100"))
230
+ # 行宽契约(与后端语义对齐):任意 1-100 整数 + 全有或全无。
231
+ # 后端 html_width 无取值/总和校验,存量存在 33/34 三等分与字段删除后
232
+ # 留白(如 3×25=75)的行;get 读出的原布局必须能原样过校验
233
+ # (apply(get(S)) ≡ S 命题的必要条件)。
234
+ if widths and all(widths):
235
+ bad = [f.get("width") for f in fields if isinstance(f, dict) and not (isinstance(f.get("width"), int) and 1 <= f["width"] <= 100)]
236
+ if bad:
237
+ errors.append((row_path, "field widths must be an integer between 1 and 100"))
223
238
  for field_index, field in enumerate(fields):
224
239
  if isinstance(field, dict):
225
240
  top_fields.append((f"{row_path}/{field_index}", field))
226
241
  def resolve_local_selector(selector: Any) -> tuple[str, tuple[str, dict[str, Any]] | None]:
227
- """Resolve against the declared body, preserving backend-dependent ID-only cases."""
242
+ """Resolve against the declared body, preserving backend-dependent ID-only cases.
243
+
244
+ 返回态:resolved(唯一定位)/ invalid(定位失败)/ ambiguous(方案 A:name-only
245
+ 命中多个同名字段,调用方须报 AMBIGUOUS_FIELD_SELECTOR)/ unknown(存在未命名
246
+ 字段,后端侧再判)/ schema(结构不完整,交给 Schema 校验)。
247
+ """
228
248
  if not isinstance(selector, dict):
229
249
  return "schema", None
230
250
  raw_que_id = selector.get("queId")
231
251
  que_id = raw_que_id if isinstance(raw_que_id, int) and not isinstance(raw_que_id, bool) else None
232
252
  raw_name = selector.get("name")
233
- name = raw_name if isinstance(raw_name, str) and raw_name else ""
253
+ # name 两侧 strip:存量字段名带前导/尾随空格,queId 已同时给出不构成歧义
254
+ # (与 compiler 侧三个 resolve 函数同口径)。
255
+ name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else ""
234
256
  if raw_que_id is not None and que_id is None:
235
257
  return "schema", None
236
258
  if raw_name is not None and not name:
@@ -239,26 +261,52 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
239
261
  matches = [(path, field) for path, field in top_fields if field.get("queId") == que_id]
240
262
  if len(matches) != 1:
241
263
  return "invalid", None
242
- declared_name = str(matches[0][1].get("name") or "")
264
+ declared_name = str(matches[0][1].get("name") or "").strip()
243
265
  if name and declared_name and name != declared_name:
244
266
  return "invalid", None
245
267
  return "resolved", matches[0]
246
268
  if name:
247
- matches = [(path, field) for path, field in top_fields if str(field.get("name") or "") == name]
269
+ matches = [(path, field) for path, field in top_fields if str(field.get("name") or "").strip() == name]
248
270
  if len(matches) == 1:
249
271
  return "resolved", matches[0]
250
272
  if len(matches) > 1:
251
- return "invalid", None
273
+ return "ambiguous", None
252
274
  if any(not str(field.get("name") or "") for _, field in top_fields):
253
275
  return "unknown", None
254
276
  return "invalid", None
255
277
  return "schema", None
256
278
 
279
+ def ambiguity_message(selector: Any, subject: str) -> str:
280
+ name = str((selector or {}).get("name") or "").strip()
281
+ candidate_ids = sorted(
282
+ {
283
+ field.get("queId")
284
+ for _, field in top_fields
285
+ if str(field.get("name") or "").strip() == name
286
+ and isinstance(field.get("queId"), int)
287
+ and not isinstance(field.get("queId"), bool)
288
+ }
289
+ )
290
+ return (
291
+ f"AMBIGUOUS_FIELD_SELECTOR: {subject} name {name!r} matches multiple fields "
292
+ f"(candidate queIds: {candidate_ids}); duplicate names are allowed but "
293
+ "selectors must use queId to disambiguate"
294
+ )
295
+
257
296
  title_selector = spec.get("dataTitleField")
258
297
  if top_fields:
259
298
  if "dataTitleField" not in spec and "appKey" in payload:
260
299
  pass
261
- elif title_selector is None or resolve_local_selector(title_selector)[0] == "invalid":
300
+ elif title_selector is None:
301
+ # dataTitleField=null 仅在带 appKey 的更新/round-trip 场景放行:后端写链
302
+ # FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个可用
303
+ # 字段,get 读面在后端未设置时原样输出 null。创建场景(无 appKey)仍需
304
+ # 显式 selector。
305
+ if "appKey" not in payload:
306
+ errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
307
+ elif resolve_local_selector(title_selector)[0] == "ambiguous":
308
+ errors.append(("/spec/dataTitleField", ambiguity_message(title_selector, "dataTitleField")))
309
+ elif resolve_local_selector(title_selector)[0] == "invalid":
262
310
  errors.append(("/spec/dataTitleField", "dataTitleField must resolve to exactly one top-level field"))
263
311
  elif title_selector is not None:
264
312
  errors.append(("/spec/dataTitleField", "dataTitleField must be null when body is empty"))
@@ -266,11 +314,15 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
266
314
  cover_selector = spec.get("dataCoverField")
267
315
  if cover_selector is not None:
268
316
  cover_resolution, cover = resolve_local_selector(cover_selector)
269
- if cover_resolution == "invalid":
317
+ if cover_resolution == "ambiguous":
318
+ errors.append(("/spec/dataCoverField", ambiguity_message(cover_selector, "dataCoverField")))
319
+ elif cover_resolution == "invalid":
270
320
  errors.append(("/spec/dataCoverField", "dataCoverField must resolve to exactly one top-level field"))
271
321
  elif cover_resolution == "resolved" and cover is not None and _field_type(cover[1]) != "attachment":
272
322
  errors.append(("/spec/dataCoverField", "dataCoverField must select a top-level attachment field"))
273
- names: dict[str, str] = {}
323
+ # 方案 A(duplicate-field-name):同父级允许同名(后端以 queId 为身份,40013
324
+ # 实测 1624 组),不再报 duplicate name;歧义在 selector 解析处以
325
+ # AMBIGUOUS_FIELD_SELECTOR 显式拒绝。
274
326
  fields_by_id = {
275
327
  field.get("queId"): field
276
328
  for _, field in top_fields
@@ -278,12 +330,6 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
278
330
  }
279
331
  output_targets: dict[str, tuple[str, str]] = {}
280
332
  for path, field in top_fields:
281
- name = str(field.get("name") or "")
282
- if name:
283
- if name in names:
284
- errors.append((f"{path}/name", f"duplicate top-level field name; first declared at {names[name]}"))
285
- else:
286
- names[name] = f"{path}/name"
287
333
  field_type = _field_type(field)
288
334
  if field_type == "data_relation":
289
335
  config = field.get("config") if isinstance(field.get("config"), dict) else {}
@@ -329,6 +375,9 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
329
375
  selector = item.get("field") if isinstance(item.get("field"), dict) else {}
330
376
  input_path = f"{path}/config/{binding_name}/inputs/{index}"
331
377
  resolution, selected = resolve_local_selector(selector)
378
+ if resolution == "ambiguous":
379
+ errors.append((f"{input_path}/field", ambiguity_message(selector, f"{binding_name}.inputs[{index}].field")))
380
+ continue
332
381
  if resolution == "invalid":
333
382
  errors.append((f"{input_path}/field", "input field must resolve to exactly one top-level field"))
334
383
  continue
@@ -380,13 +429,16 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
380
429
  )
381
430
  )
382
431
 
383
- producer = name or str(field.get("queId") or field_type)
432
+ producer = str(field.get("name") or "") or str(field.get("queId") or field_type)
384
433
  for index, item in enumerate(binding.get("outputs") or []):
385
434
  if not isinstance(item, dict):
386
435
  continue
387
436
  selector = item.get("targetField")
388
437
  target_path = f"{path}/config/{binding_name}/outputs/{index}/targetField"
389
438
  resolution, selected = resolve_local_selector(selector)
439
+ if resolution == "ambiguous":
440
+ errors.append((target_path, ambiguity_message(selector, f"{binding_name}.outputs[{index}].targetField")))
441
+ continue
390
442
  if resolution == "invalid":
391
443
  errors.append((target_path, "targetField must resolve to exactly one top-level field"))
392
444
  continue