@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +8 -2
- package/skills/qingflow-cli/SKILL.md +115 -3
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +3 -9
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
- package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
- package/skills/qingflow-cli/reference/builder/README.md +1 -1
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
- package/skills/qingflow-cli/reference/data/README.md +1 -1
- package/skills/qingflow-cli/reference/examples/README.md +1 -1
- package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
- package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +69 -17
- package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
- package/src/qingflow_mcp/builder_facade/models.py +227 -17
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
- package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
- package/src/qingflow_mcp/builder_facade/service.py +1666 -509
- package/src/qingflow_mcp/builder_facade/views_extract.py +592 -0
- package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
- package/src/qingflow_mcp/cli/commands/builder.py +76 -55
- package/src/qingflow_mcp/config.py +1 -0
- package/src/qingflow_mcp/public_surface.py +3 -0
- package/src/qingflow_mcp/response_trim.py +11 -2
- package/src/qingflow_mcp/server_app_builder.py +34 -10
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
- package/src/qingflow_mcp/solution/executor.py +96 -8
- package/src/qingflow_mcp/solution/spec_models.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
- package/src/qingflow_mcp/tools/base.py +42 -10
- package/src/qingflow_mcp/tools/solution_tools.py +2 -0
- package/src/qingflow_mcp/tools/view_tools.py +0 -6
- package/src/qingflow_mcp/tools/view_writer.py +44 -0
- package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
- 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
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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。示例:`{"
|
|
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/
|
|
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
|
+
}
|
package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json
CHANGED
|
@@ -1,42 +1,36 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
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
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
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
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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 "
|
|
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
|
|
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 == "
|
|
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
|
-
|
|
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
|