@qingflow-tech/qingflow-cli 1.1.61-beta.4 → 1.1.61-beta.6

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 (61) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +2 -1
  4. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +1 -1
  5. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +6 -258
  6. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
  7. package/skills/qingflow-cli/reference/builder/70-portal.md +38 -17
  8. package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +100 -0
  9. package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +6 -7
  10. package/src/qingflow_mcp/app_form_validator.py +20 -3
  11. package/src/qingflow_mcp/builder_facade/app_form.py +137 -17
  12. package/src/qingflow_mcp/builder_facade/models.py +177 -15
  13. package/src/qingflow_mcp/builder_facade/portals_schema.py +397 -0
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +14 -1
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +49 -9
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +27 -0
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +146 -0
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +146 -0
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +146 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +146 -0
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +146 -0
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +146 -0
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +146 -0
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +146 -0
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +2 -2
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +27 -0
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +146 -0
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +146 -0
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +146 -0
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +146 -0
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +2 -2
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +146 -0
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +2 -2
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +27 -0
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -2
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +20 -7
  37. package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +59 -0
  38. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +1397 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +193 -0
  40. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +643 -0
  41. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +203 -0
  42. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +268 -0
  43. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +265 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +131 -0
  45. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +199 -0
  46. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +123 -0
  47. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +196 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +800 -0
  49. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1 -1
  50. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +1 -1
  51. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +1 -1
  52. package/src/qingflow_mcp/builder_facade/service.py +1251 -88
  53. package/src/qingflow_mcp/cli/commands/builder.py +132 -5
  54. package/src/qingflow_mcp/public_surface.py +2 -0
  55. package/src/qingflow_mcp/server_app_builder.py +34 -3
  56. package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
  57. package/src/qingflow_mcp/solution/executor.py +5 -2
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +118 -12
  59. package/src/qingflow_mcp/tools/portal_tools.py +4 -54
  60. package/src/qingflow_mcp/tools/portal_writer.py +59 -0
  61. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
@@ -28,10 +28,16 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
28
28
  "single_select",
29
29
  "multi_select",
30
30
  "attachment",
31
+ # r10 存量实证放行:link(92nl6blfk402)/member(ab9fjvj33001)——后端写链
32
+ # 无类型白名单,运行时同为字符串写入路径
33
+ "link",
34
+ "member",
31
35
  "data_relation",
32
36
  "subtable",
33
37
  }
34
- _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
38
+ # key 允许含空白(r10 实测存量 key 如「应用包 ID」带空格,\s 排除导致占位符
39
+ # 扫描漏检 → validate 误报「key must appear as {{key}}」)
40
+ _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}]+)\}\}")
35
41
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
36
42
  #: 公式字段引用正则(#queId_<id>$,支持表格嵌套 <id>.<id>)
37
43
  _FORMULA_FIELD_REF_RE = re.compile(r"#queId_([^$]+)\$")
@@ -353,7 +359,15 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
353
359
  "is_empty and is_not_empty require a literal operand with values: []",
354
360
  )
355
361
  )
356
- elif operand.get("kind") == "literal" and not values:
362
+ elif (
363
+ operand.get("kind") == "literal"
364
+ and not values
365
+ and operator not in {"subordinate_to", "not_subordinate_to"}
366
+ ):
367
+ # subordinate 两算子放行空 literal:存量「设置后清空」形态
368
+ # (r13 实证 41ce0ad8 六处)——后端写链 checkRelationMatchRules
369
+ # 仅校验 dynamic_range(FormSvcImpl:10448),formula 渲染对空
370
+ # judgeValueDetails 输出 [](Formula:397),读回解析对称,往返保真
357
371
  errors.append(
358
372
  (
359
373
  f"{operand_path}/values",
@@ -386,7 +400,10 @@ def _static_errors(payload: dict[str, Any]) -> list[tuple[str, str]]:
386
400
  selected_name = selected[1].get("name") if selected is not None else selector.get("name")
387
401
  if not selected_name and selector.get("queId") in fields_by_id:
388
402
  selected_name = fields_by_id[selector["queId"]].get("name")
389
- if selected_name and item.get("key") != selected_name:
403
+ # 两侧 strip:模板引用名提取时已 strip(字段名存量带尾随空格,如
404
+ # am4oh08v2401「答:Answer 」),严格相等会把「引用名≠当前字段名」的
405
+ # 存量形态误报为 key 不一致
406
+ if selected_name and str(item.get("key") or "").strip() != str(selected_name).strip():
390
407
  errors.append((f"{input_path}/key", "json_path key must equal the selected field name"))
391
408
 
392
409
  if field_type == "q_linker":
@@ -54,6 +54,24 @@ QUE_TYPE_TO_CANONICAL = {
54
54
  22: "department",
55
55
  25: "relation",
56
56
  26: "code_block",
57
+ # 透传型字段(r10 读面 65 app 缺口):声明层 EmptyConfig,round-trip 经后端
58
+ # 模板保真(选项/技能配置列随模板透传);语义 = QuestionTypeEnum 14/15/17/23
59
+ # 与 Constants QUE_TYPE_EMBED_FIELD(27)、AI 系 30-37(AI_CONTENT_GENERATION
60
+ # /EXTRACTION/CLASSIFICATION、AI_MACHINE_TRANSLATION、AI_IMAGE_RECOGNITION
61
+ # /GENERATION、AI_DOCUMENT_PARSING、AI_TEXT_SUMMARY)
62
+ 14: "date_duration",
63
+ 15: "image_select",
64
+ 17: "location",
65
+ 23: "ocr",
66
+ 27: "embed_field",
67
+ 30: "ai_content_generation",
68
+ 31: "ai_content_extraction",
69
+ 32: "ai_content_classification",
70
+ 33: "ai_machine_translation",
71
+ 34: "ai_image_recognition",
72
+ 35: "ai_image_generation",
73
+ 36: "ai_document_parsing",
74
+ 37: "ai_text_summary",
57
75
  }
58
76
  CANONICAL_TO_QUE_TYPE = {
59
77
  "text": 2,
@@ -78,6 +96,20 @@ CANONICAL_TO_QUE_TYPE = {
78
96
  "data_relation": 19,
79
97
  "relation": 25,
80
98
  "subtable": 18,
99
+
100
+ "date_duration": 14,
101
+ "image_select": 15,
102
+ "location": 17,
103
+ "ocr": 23,
104
+ "embed_field": 27,
105
+ "ai_content_generation": 30,
106
+ "ai_content_extraction": 31,
107
+ "ai_content_classification": 32,
108
+ "ai_machine_translation": 33,
109
+ "ai_image_recognition": 34,
110
+ "ai_image_generation": 35,
111
+ "ai_document_parsing": 36,
112
+ "ai_text_summary": 37,
81
113
  }
82
114
  SUBFIELD_TYPES = {
83
115
  "text",
@@ -1227,11 +1259,20 @@ class AppFormNormalizer:
1227
1259
  name = match.group("name").strip()
1228
1260
  if name:
1229
1261
  token = match.group("token").strip()
1262
+ # 名字两侧 strip 匹配:存量字段名带尾随空格(如 am4oh08v2401
1263
+ # 「答:Answer 」),模板引用名无空格——严格相等会丢 queId 锚定
1230
1264
  matching_ids = [
1231
1265
  que_id
1232
1266
  for que_id, field_name in field_names.items()
1233
- if field_name == name and _encrypt_question_id(que_id) == token
1267
+ if str(field_name).strip() == name and _encrypt_question_id(que_id) == token
1234
1268
  ]
1269
+ if not matching_ids:
1270
+ # 悬空引用(字段已删/改名,模板仍留旧引用):原文透传——占位符
1271
+ # 化会丢 token 无法复原(加密不可逆),后端写链对模板原样落库;
1272
+ # 不注册 input(声明无此输入,写回与 B0 模板逐字一致,
1273
+ # r10 实测 9gtommb3ng01 等 6 app「inputs[N].field could not
1274
+ # be resolved」即此形态)
1275
+ return match.group(0)
1235
1276
  json_path_inputs.setdefault(
1236
1277
  name,
1237
1278
  {
@@ -1244,11 +1285,21 @@ class AppFormNormalizer:
1244
1285
 
1245
1286
  request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
1246
1287
  inputs.extend(json_path_inputs.values())
1247
- request_url = _required_text(
1248
- request_values["url"],
1249
- message="Q-Linker request URL is empty",
1250
- details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
1251
- )
1288
+ # URL 空容忍边界(#208③):后端写链仅 configMode=1(自定义)校验 url/method/
1289
+ # resultType 非空(FormQuestionSvcImpl.createQuestionRemoteLookUp:444 抛
1290
+ # REMOTE_LOOK_UP_SETTING_WRONG);configMode=2 插件模板 / 3 连接中心 /
1291
+ # 4 轻翼智能体的 URL 由模板侧提供,本表 url 空 是合法存量形态——非自定义
1292
+ # 模式放行空 URL 透传(写回走模板透传,round-trip 保真;r12 归因 27 app;
1293
+ # 自定义模式 url 空的 2 app 脓数据仍拒绝,入排除账本)。
1294
+ config_mode = _positive_int(config.get("configMode")) or 1
1295
+ if config_mode == 1:
1296
+ request_url = _required_text(
1297
+ request_values["url"],
1298
+ message="Q-Linker request URL is empty",
1299
+ details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
1300
+ )
1301
+ else:
1302
+ request_url = str(request_values["url"] or "")
1252
1303
  request = _without_none(
1253
1304
  {
1254
1305
  "url": request_url,
@@ -1771,7 +1822,9 @@ class AppFormCompiler:
1771
1822
  if not isinstance(item, dict) or item.get("source") != "json_path":
1772
1823
  continue
1773
1824
  selected = _resolve_internal_selector(item.get("field") or {}, internal_fields, f"qLinkerBinding.inputs[{index}].field")
1774
- if str(item.get("key") or "") != str(selected.get("name") or ""):
1825
+ # 两侧 strip:模板引用名提取时已 strip(存量字段名带尾随空格,
1826
+ # 如 r10 am4oh08v2401「答:Answer 」),严格相等误报 key 不一致
1827
+ if str(item.get("key") or "").strip() != str(selected.get("name") or "").strip():
1775
1828
  raise AppFormFailure(
1776
1829
  "UNSUPPORTED_FIELD_CONFIG",
1777
1830
  "json_path input key must equal the selected field name for lossless readback",
@@ -2449,7 +2502,18 @@ class AppFormCompiler:
2449
2502
  def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
2450
2503
  field_type = _field_type(field)
2451
2504
  que_type = CANONICAL_TO_QUE_TYPE[field_type]
2452
- build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation", "rich_text"} else field_type
2505
+ build_type = (
2506
+ "boolean" if field_type == "single_choice"
2507
+ else "text" if field_type in {
2508
+ "link", "data_relation", "rich_text",
2509
+ # 透传型新类型(r10 读面覆盖):build 走 text 兜底,类型/配置经模板保真
2510
+ "date_duration", "image_select", "location", "ocr", "embed_field",
2511
+ "ai_content_generation", "ai_content_extraction", "ai_content_classification",
2512
+ "ai_machine_translation", "ai_image_recognition", "ai_image_generation",
2513
+ "ai_document_parsing", "ai_text_summary",
2514
+ }
2515
+ else field_type
2516
+ )
2453
2517
  built_internal = deepcopy(internal)
2454
2518
  built_internal["type"] = build_type
2455
2519
  if build_type == "subtable":
@@ -2821,7 +2885,21 @@ class AppFormCompiler:
2821
2885
  if source.get("kind") == "app":
2822
2886
  source_fields, _target_form = self._target_form_candidates(profile, str(source.get("appKey") or ""))
2823
2887
  source_fields_are_backend = True
2824
- target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
2888
+ # 展示字段同样按后端语义透传:写链对 displayedQueId 无存在性校验
2889
+ # 原样落库(fillMatchRulesList 读链按全局 question∪draft_question
2890
+ # 解析)。解析不到时 queType=None → 写侧 origin_type 回退 current
2891
+ # 模板值(见 data_relation relatedQueId/queOriginType 组装)。
2892
+ try:
2893
+ target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
2894
+ except AppFormFailure as error:
2895
+ if error.error_code != "INVALID_FIELD_SELECTOR" or _positive_int((source.get("field") or {}).get("queId")) is None:
2896
+ raise
2897
+ target = {
2898
+ "queId": _positive_int(source["field"].get("queId")),
2899
+ "queTitle": str(source["field"].get("name") or "").strip() or None,
2900
+ "queType": None,
2901
+ "_app_form_unresolved": True,
2902
+ }
2825
2903
  relation.update(
2826
2904
  {
2827
2905
  "displayedQueId": target["queId"],
@@ -3070,7 +3148,23 @@ class AppFormCompiler:
3070
3148
  location: str,
3071
3149
  ) -> JSONObject:
3072
3150
  if source_fields_are_backend:
3073
- return _resolve_backend_selector(selector, source_fields, location)
3151
+ try:
3152
+ return _resolve_backend_selector(selector, source_fields, location)
3153
+ except AppFormFailure as error:
3154
+ # 目标表单候选集(草稿∪发布 formQues)解析不到、但 selector 带正
3155
+ # queId:按后端语义透传——读链 fillMatchRulesList 按 queId 查
3156
+ # question∪draft_question 全局表,查不到容忍置空(new Question());
3157
+ # 写链 updateFormInfo 对 matchRules/sortConfig 源 queId 无存在性
3158
+ # 校验、原样落库。存量里该形态 = 引用指向目标表单 GET 响应之外
3159
+ # 的字段(跨表/已删,如 r8 实测 00e0e0d8 筛选源),严格拒绝即
3160
+ # round-trip 断裂。queType 置 None 与后端「查不到置空」同构。
3161
+ if error.error_code == "INVALID_FIELD_SELECTOR" and _positive_int(selector.get("queId")) is not None:
3162
+ return {
3163
+ "queId": _positive_int(selector.get("queId")),
3164
+ "queTitle": str(selector.get("name") or "").strip() or None,
3165
+ "_app_form_unresolved": True,
3166
+ }
3167
+ raise
3074
3168
  return _resolve_internal_selector(selector, source_fields, location)
3075
3169
 
3076
3170
  @staticmethod
@@ -3088,7 +3182,11 @@ class AppFormCompiler:
3088
3182
  return title
3089
3183
 
3090
3184
  @staticmethod
3091
- def _data_relation_que_type(field: JSONObject) -> int:
3185
+ def _data_relation_que_type(field: JSONObject) -> int | None:
3186
+ if field.get("_app_form_unresolved"):
3187
+ # 透传形态(目标表单候选集解析不到):后端 fillMatchRulesList 对查
3188
+ # 不到的 queId 置空 queType 继续出参,写链不校验——同构置 None。
3189
+ return None
3092
3190
  backend_type = _positive_int(field.get("queType"))
3093
3191
  if backend_type is not None:
3094
3192
  return backend_type
@@ -3142,7 +3240,12 @@ class AppFormCompiler:
3142
3240
  ordered_ops = common | {"greater_than", "greater_or_equal", "less_than", "less_or_equal", "dynamic_range", "decimal_range"}
3143
3241
  organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
3144
3242
  field_type = _canonical_backend_field_type(source)
3145
- if field_type in {"number", "amount", "date", "datetime"}:
3243
+ if source.get("_app_form_unresolved"):
3244
+ # 透传形态:源字段类型不可知(后端读链查不到置空),后端写链对
3245
+ # operator×源类型组合无校验——跳过类型白名单,仅保留 operand 形状
3246
+ # 校验(下方),与「读面保留、写面透传」的存量形态处理同口径。
3247
+ allowed = common | text_ops | ordered_ops | organization_ops
3248
+ elif field_type in {"number", "amount", "date", "datetime"}:
3146
3249
  # date/datetime 补 belongs_to/not_belongs_to:后端 judgeType=13(JUDGE_BELONG_TO)
3147
3250
  # 对时间字段的存量语义是「属于时间范围」(judgeValues 形如
3148
3251
  # "2025-01-30 14:20:59~2026-01-30 14:20:59"),与组织字段的属于语义
@@ -3150,6 +3253,12 @@ class AppFormCompiler:
3150
3253
  allowed = ordered_ops | ({"belongs_to", "not_belongs_to"} if field_type in {"date", "datetime"} else set())
3151
3254
  elif field_type in {"member", "department"}:
3152
3255
  allowed = organization_ops
3256
+ elif field_type in {"text", "multi_select"}:
3257
+ # text/multi_select 补 belongs_to/not_belongs_to:judgeType=13 同为多态
3258
+ # 语义(选项「属于」集合),后端写链对 operator×源类型无校验——存量
3259
+ # 实证 r8:bjqj2bge2g02(multi_select 资产类型 belongs_to 当前字段)、
3260
+ # e4f644fe(text 客户等级 belongs_to literal ["BC"])。
3261
+ allowed = text_ops | {"belongs_to", "not_belongs_to"}
3153
3262
  else:
3154
3263
  allowed = text_ops
3155
3264
  if operator not in allowed:
@@ -3163,7 +3272,10 @@ class AppFormCompiler:
3163
3272
  if operator in EMPTY_OPERATORS:
3164
3273
  if operand.get("kind") != "literal" or values not in ([], None):
3165
3274
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "empty-value operators require an empty literal operand", "CONTRACT_PRECHECK")
3166
- elif operand.get("kind") == "literal" and not values:
3275
+ elif operand.get("kind") == "literal" and not values and operator not in {"subordinate_to", "not_subordinate_to"}:
3276
+ # subordinate 两算子放行空 literal:存量「设置后清空」形态(r13 实证
3277
+ # 41ce0ad8)——后端写链仅校验 dynamic_range(checkRelationMatchRules),
3278
+ # formula 对空 judgeValueDetails 渲染 [],读回解析对称,往返保真
3167
3279
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "literal data_relation operands must contain at least one value", "CONTRACT_PRECHECK")
3168
3280
 
3169
3281
  @staticmethod
@@ -3279,11 +3391,19 @@ class AppFormCompiler:
3279
3391
  merged: list[JSONObject] = []
3280
3392
  seen: set[int] = set()
3281
3393
  for item in [*_backend_question_candidates(draft_form), *_backend_question_candidates(published_form)]:
3282
- que_id = _positive_int(item.get("queId"))
3283
- if que_id is not None:
3284
- if que_id in seen:
3394
+ raw_id = item.get("queId")
3395
+ if isinstance(raw_id, int) and not isinstance(raw_id, bool):
3396
+ # queId 是字段身份,身份语义覆盖 0 与负数:0 是系统「编号」虚拟行
3397
+ # (que_type=8,GET 草稿/发布两态各带一行)、负数是系统行(-1 数据来源
3398
+ # 等,补导后物理在表)。只按正整数去重会让 queId=0 的「编号」在
3399
+ # 并集里出现两次,by_name 引用恒定命中 2 个候选 → AMBIGUOUS_
3400
+ # FIELD_SELECTOR(r13 103 app data_relation/relation 深层配置写回
3401
+ # 全量失败根因)。同 queId 即同字段,草稿优先(与正整数身份同
3402
+ # 语义);同名不同 queId 的真实用户字段仍双候选保留,by_name 歧义
3403
+ # 照常显式拒绝(存量脏形态不消歧,禁止静默取第一个)。
3404
+ if raw_id in seen:
3285
3405
  continue
3286
- seen.add(que_id)
3406
+ seen.add(raw_id)
3287
3407
  merged.append(item)
3288
3408
  return merged, (draft_form or published_form)
3289
3409
 
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  from enum import Enum
5
- from typing import Any, Union
5
+ from typing import Any, Literal, Union
6
6
 
7
7
  from pydantic import AliasChoices, Field, model_validator
8
8
 
@@ -2508,14 +2508,20 @@ class PortalInlineChartPatch(ChartUpsertPatch):
2508
2508
 
2509
2509
 
2510
2510
  class PortalComponentPositionPatch(StrictModel):
2511
- pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
2512
- pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
2513
- pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
2514
- pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
2515
- mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
2516
- mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
2517
- mobile_w: int = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
2518
- mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
2511
+ # 后端语义(DashSvcImpl 读/写链):position 各坐标可为 null——生产数据中筛选组件
2512
+ # (type=6)的 x/y/cols/rows 原生为 NULL,读面透传 null、写链 setPosition 直存 null。
2513
+ # 因此显式 null 必须原样透传(round-trip 保真);仅键缺省时才回落布局默认值。
2514
+ pc_x: int | None = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
2515
+ pc_y: int | None = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
2516
+ # 宽高别名同时接受 w/h 与嵌套形态词汇 cols/rows:真实使用反馈显示 agent 常把
2517
+ # 嵌套词汇(cols/rows)与平铺词汇(x/y)混写(position: {"x":0,"y":0,"cols":24,"rows":5}),
2518
+ # 词汇表分裂是位写出错的高频形态(schema 描述同步说明)。
2519
+ pc_w: int | None = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w", "cols"))
2520
+ pc_h: int | None = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h", "rows"))
2521
+ mobile_x: int | None = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
2522
+ mobile_y: int | None = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
2523
+ mobile_w: int | None = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
2524
+ mobile_h: int | None = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
2519
2525
  mobile_provided: bool = Field(default=False, exclude=True)
2520
2526
 
2521
2527
  @model_validator(mode="before")
@@ -2554,6 +2560,10 @@ class PortalChartRefPatch(StrictModel):
2554
2560
  app_key: str | None = None
2555
2561
  chart_id: str | None = None
2556
2562
  chart_name: str | None = None
2563
+ # 引用面判别:后端有两类图表组件——轻流报表(DASH_CONTENT_TYPE_CHART=1,chart 表唯一键
2564
+ # chartKey,写链 addChartInfos 按 chartKey 解析)与轻析 BI 报表(DASH_CONTENT_TYPE_BICHART=9,
2565
+ # bi_chart 表整型 chart_id,写链 addBiChartInfo 直存)。缺省 bi_chart(向后兼容既有声明)。
2566
+ ref_kind: Literal["chart", "bi_chart"] | None = None
2557
2567
 
2558
2568
  @model_validator(mode="before")
2559
2569
  @classmethod
@@ -2573,12 +2583,18 @@ class PortalChartRefPatch(StrictModel):
2573
2583
  payload["chart_name"] = payload.pop("name")
2574
2584
  if "appKey" in payload and "app_key" not in payload:
2575
2585
  payload["app_key"] = payload.pop("appKey")
2586
+ if "refKind" in payload and "ref_kind" not in payload:
2587
+ payload["ref_kind"] = payload.pop("refKind")
2588
+ if "kind" in payload and "ref_kind" not in payload:
2589
+ payload["ref_kind"] = payload.pop("kind")
2576
2590
  return payload
2577
2591
 
2578
2592
  @model_validator(mode="after")
2579
2593
  def validate_target(self) -> "PortalChartRefPatch":
2580
2594
  if not (self.chart_id or self.chart_name):
2581
2595
  raise ValueError("chart_ref requires chart_id or chart_name")
2596
+ if self.ref_kind == "chart" and not self.chart_id:
2597
+ raise ValueError("chart_ref with ref_kind=chart requires chart_id (chart 表唯一键 chartKey)")
2582
2598
  if self.chart_name and not self.chart_id and not self.app_key:
2583
2599
  raise ValueError("chart_ref.app_key is required when resolving by chart_name")
2584
2600
  return self
@@ -2604,17 +2620,49 @@ class PortalViewRefPatch(StrictModel):
2604
2620
 
2605
2621
 
2606
2622
  class PortalSectionPatch(StrictModel):
2607
- title: str
2623
+ # title 语义=组件显示标题(draft_dash_chart.component_title / grid_config.gridTitle 等),
2624
+ # 生产存量存在空标题形态(显示标题开但标题空,读链回退视图名/图表名)——None=不设标题,
2625
+ # 编译对 None 不落库(后端透传 NULL),round-trip 保真;手写声明缺省 title=None(不设)。
2626
+ title: str | None = None
2608
2627
  source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
2609
2628
  role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
2629
+ # 显示标题开关(draft_dash_chart.being_show_title):提取侧读面必有值(True/False);
2630
+ # 声明缺省 None 时编译回落产品默认 True(与既有创建语义兼容)。
2631
+ show_title: bool | None = Field(
2632
+ default=None,
2633
+ validation_alias=AliasChoices("show_title", "showTitle", "being_show_title", "beingShowTitle"),
2634
+ )
2610
2635
  position: PortalComponentPositionPatch | None = None
2611
- dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
2636
+ dash_style_config: dict[str, Any] | None = Field(
2637
+ default=None,
2638
+ validation_alias=AliasChoices("dash_style_config", "style_config", "dashStyleConfigBO", "dashStyleConfig"),
2639
+ )
2612
2640
  config: dict[str, Any] = Field(default_factory=dict)
2613
2641
  chart_ref: PortalChartRefPatch | None = None
2614
2642
  chart: PortalInlineChartPatch | None = Field(default=None, validation_alias=AliasChoices("chart", "inline_chart", "inlineChart", "create_chart", "createChart"))
2615
2643
  view_ref: PortalViewRefPatch | None = None
2644
+ view_display: dict[str, Any] | None = Field(
2645
+ default=None,
2646
+ validation_alias=AliasChoices("view_display", "viewDisplay"),
2647
+ )
2616
2648
  text: str | None = None
2617
2649
  url: str | None = None
2650
+ login_auth: bool | None = Field(
2651
+ default=None,
2652
+ validation_alias=AliasChoices("login_auth", "loginAuth", "being_login_auth", "beingLoginAuth"),
2653
+ )
2654
+ carousel: dict[str, Any] | None = Field(
2655
+ default=None,
2656
+ validation_alias=AliasChoices("carousel", "carousel_config", "carouselConfig"),
2657
+ )
2658
+ task: dict[str, Any] | None = Field(
2659
+ default=None,
2660
+ validation_alias=AliasChoices("task", "task_config", "taskConfig"),
2661
+ )
2662
+ favourite: dict[str, Any] | None = Field(
2663
+ default=None,
2664
+ validation_alias=AliasChoices("favourite", "favourite_config", "favouriteConfig"),
2665
+ )
2618
2666
 
2619
2667
  @model_validator(mode="before")
2620
2668
  @classmethod
@@ -2659,7 +2707,7 @@ class PortalSectionPatch(StrictModel):
2659
2707
 
2660
2708
  @model_validator(mode="after")
2661
2709
  def validate_shape(self) -> "PortalSectionPatch":
2662
- supported = {"chart", "view", "grid", "filter", "text", "link"}
2710
+ supported = {"chart", "view", "grid", "filter", "text", "link", "carousel", "task", "favourite"}
2663
2711
  if self.source_type not in supported:
2664
2712
  raise ValueError(f"unsupported portal source_type '{self.source_type}'")
2665
2713
  if self.source_type == "chart" and self.chart_ref is None and self.chart is None:
@@ -2670,6 +2718,12 @@ class PortalSectionPatch(StrictModel):
2670
2718
  raise ValueError("text section requires text")
2671
2719
  if self.source_type == "link" and self.url is None:
2672
2720
  raise ValueError("link section requires url")
2721
+ if self.source_type == "carousel" and self.carousel is None:
2722
+ raise ValueError("carousel section requires carousel")
2723
+ if self.source_type == "task" and self.task is None:
2724
+ raise ValueError("task section requires task")
2725
+ if self.source_type == "favourite" and self.favourite is None:
2726
+ raise ValueError("favourite section requires favourite")
2673
2727
  return self
2674
2728
 
2675
2729
 
@@ -2685,8 +2739,20 @@ class PortalApplyRequest(StrictModel):
2685
2739
  icon: str | None = None
2686
2740
  color: str | None = None
2687
2741
  hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
2688
- dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
2742
+ global_config: dict[str, Any] | None = Field(
2743
+ default=None,
2744
+ validation_alias=AliasChoices("global_config", "globalConfig", "dash_global_config", "dashGlobalConfig"),
2745
+ )
2746
+ style_template_id: int | None = Field(
2747
+ default=None,
2748
+ validation_alias=AliasChoices("style_template_id", "styleTemplateId"),
2749
+ )
2750
+ overall_style: dict[str, Any] | None = Field(
2751
+ default=None,
2752
+ validation_alias=AliasChoices("overall_style", "overallStyle", "overallConfigBO"),
2753
+ )
2689
2754
  config: dict[str, Any] = Field(default_factory=dict)
2755
+ schema_version: str | None = Field(default=None, validation_alias=AliasChoices("schema_version", "schemaVersion"))
2690
2756
 
2691
2757
  @model_validator(mode="before")
2692
2758
  @classmethod
@@ -2694,8 +2760,22 @@ class PortalApplyRequest(StrictModel):
2694
2760
  if not isinstance(value, dict):
2695
2761
  return value
2696
2762
  payload = dict(value)
2697
- if "dash_name" not in payload and "dashName" not in payload and "name" in payload:
2698
- payload["dash_name"] = payload.pop("name")
2763
+ payload.pop("schema_version", None)
2764
+ payload.pop("schemaVersion", None)
2765
+ # schema 文档声明的别名补全(dashKey/dashName):此前条件判断里出现了 dashName
2766
+ # 却从未消费,别名声明撞上 StrictModel 被拒,形成 validate 提示与 apply 行为
2767
+ # 不一致的观感(实测反馈失败二)。canonical 键优先;别名始终被消费(与 canonical
2768
+ # 同传时静默让位,不留残键触发 extra_forbidden)。
2769
+ if "dashKey" in payload:
2770
+ payload.setdefault("dash_key", payload.pop("dashKey"))
2771
+ if "name" in payload:
2772
+ payload.setdefault("dash_name", payload.pop("name"))
2773
+ if "dashName" in payload:
2774
+ payload.setdefault("dash_name", payload.pop("dashName"))
2775
+ if "dash_global_config" in payload and "global_config" not in payload:
2776
+ payload["global_config"] = payload.pop("dash_global_config")
2777
+ elif "dashGlobalConfig" in payload and "global_config" not in payload:
2778
+ payload["global_config"] = payload.pop("dashGlobalConfig")
2699
2779
  if "sections" not in payload and "pages" in payload:
2700
2780
  pages = payload.pop("pages")
2701
2781
  if not isinstance(pages, list):
@@ -2730,6 +2810,88 @@ class PortalApplyRequest(StrictModel):
2730
2810
  return self
2731
2811
 
2732
2812
 
2813
+ def _public_portal_section_payload(section: PortalSectionPatch) -> dict[str, Any]:
2814
+ """Dump one portal section with canonical naming for schema validation."""
2815
+ payload: dict[str, Any] = {
2816
+ "title": section.title,
2817
+ "source_type": section.source_type,
2818
+ }
2819
+ if section.role is not None:
2820
+ payload["role"] = section.role
2821
+ if section.position is not None:
2822
+ payload["position"] = {
2823
+ "pc": {
2824
+ "x": section.position.pc_x,
2825
+ "y": section.position.pc_y,
2826
+ "cols": section.position.pc_w,
2827
+ "rows": section.position.pc_h,
2828
+ },
2829
+ }
2830
+ if section.position.mobile_provided:
2831
+ payload["position"]["mobile"] = {
2832
+ "x": section.position.mobile_x,
2833
+ "y": section.position.mobile_y,
2834
+ "cols": section.position.mobile_w,
2835
+ "rows": section.position.mobile_h,
2836
+ }
2837
+ if section.dash_style_config is not None:
2838
+ payload["style_config"] = section.dash_style_config
2839
+ if section.chart_ref is not None:
2840
+ payload["chart_ref"] = section.chart_ref.model_dump(mode="json", exclude_none=True)
2841
+ if section.chart is not None:
2842
+ payload["chart"] = section.chart.model_dump(mode="json", exclude_none=True)
2843
+ if section.view_ref is not None:
2844
+ payload["view_ref"] = section.view_ref.model_dump(mode="json", exclude_none=True)
2845
+ if section.view_display is not None:
2846
+ payload["view_display"] = section.view_display
2847
+ if section.text is not None:
2848
+ payload["text"] = section.text
2849
+ if section.url is not None:
2850
+ payload["url"] = section.url
2851
+ if section.login_auth is not None:
2852
+ payload["login_auth"] = section.login_auth
2853
+ if section.carousel is not None:
2854
+ payload["carousel"] = section.carousel
2855
+ if section.task is not None:
2856
+ payload["task"] = section.task
2857
+ if section.favourite is not None:
2858
+ payload["favourite"] = section.favourite
2859
+ if section.config:
2860
+ payload["config"] = section.config
2861
+ return payload
2862
+
2863
+
2864
+ def public_portal_apply_payload(request: PortalApplyRequest) -> dict[str, Any]:
2865
+ """Canonical-named portal declaration used for schema validation."""
2866
+ payload: dict[str, Any] = {}
2867
+ if request.dash_key:
2868
+ payload["dash_key"] = request.dash_key
2869
+ if request.dash_name is not None:
2870
+ payload["name"] = request.dash_name
2871
+ if request.package_tag_id is not None:
2872
+ payload["package_tag_id"] = request.package_tag_id
2873
+ payload["publish"] = request.publish
2874
+ if request.sections is not None:
2875
+ payload["sections"] = [_public_portal_section_payload(section) for section in request.sections]
2876
+ if request.layout_preset is not None:
2877
+ payload["layout_preset"] = request.layout_preset
2878
+ if request.visibility is not None:
2879
+ payload["visibility"] = request.visibility.model_dump(mode="json", exclude_none=True)
2880
+ if request.icon is not None:
2881
+ payload["icon"] = request.icon
2882
+ if request.color is not None:
2883
+ payload["color"] = request.color
2884
+ if request.hide_copyright is not None:
2885
+ payload["hide_copyright"] = request.hide_copyright
2886
+ if request.global_config is not None:
2887
+ payload["global_config"] = request.global_config
2888
+ if request.style_template_id is not None:
2889
+ payload["style_template_id"] = request.style_template_id
2890
+ if request.overall_style is not None:
2891
+ payload["overall_style"] = request.overall_style
2892
+ return payload
2893
+
2894
+
2733
2895
  FieldPatch.model_rebuild()
2734
2896
  FieldMutation.model_rebuild()
2735
2897
  FieldUpdatePatch.model_rebuild()