@qingflow-tech/qingflow-cli 1.1.61-beta.3 → 1.1.61-beta.5

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 (58) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-cli/SKILL.md +2 -0
  5. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
  6. package/skills/qingflow-cli/reference/builder/70-portal.md +37 -16
  7. package/skills/qingflow-cli/reference/data/README.md +38 -0
  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 +164 -13
  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/service.py +1104 -71
  50. package/src/qingflow_mcp/cli/commands/builder.py +114 -5
  51. package/src/qingflow_mcp/public_surface.py +2 -0
  52. package/src/qingflow_mcp/server_app_builder.py +32 -1
  53. package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
  54. package/src/qingflow_mcp/solution/executor.py +5 -2
  55. package/src/qingflow_mcp/tools/ai_builder_tools.py +110 -12
  56. package/src/qingflow_mcp/tools/portal_tools.py +4 -54
  57. package/src/qingflow_mcp/tools/portal_writer.py +59 -0
  58. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
@@ -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,17 @@ 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
+ pc_w: int | None = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
2517
+ pc_h: int | None = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
2518
+ mobile_x: int | None = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
2519
+ mobile_y: int | None = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
2520
+ mobile_w: int | None = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
2521
+ mobile_h: int | None = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
2519
2522
  mobile_provided: bool = Field(default=False, exclude=True)
2520
2523
 
2521
2524
  @model_validator(mode="before")
@@ -2554,6 +2557,10 @@ class PortalChartRefPatch(StrictModel):
2554
2557
  app_key: str | None = None
2555
2558
  chart_id: str | None = None
2556
2559
  chart_name: str | None = None
2560
+ # 引用面判别:后端有两类图表组件——轻流报表(DASH_CONTENT_TYPE_CHART=1,chart 表唯一键
2561
+ # chartKey,写链 addChartInfos 按 chartKey 解析)与轻析 BI 报表(DASH_CONTENT_TYPE_BICHART=9,
2562
+ # bi_chart 表整型 chart_id,写链 addBiChartInfo 直存)。缺省 bi_chart(向后兼容既有声明)。
2563
+ ref_kind: Literal["chart", "bi_chart"] | None = None
2557
2564
 
2558
2565
  @model_validator(mode="before")
2559
2566
  @classmethod
@@ -2573,12 +2580,18 @@ class PortalChartRefPatch(StrictModel):
2573
2580
  payload["chart_name"] = payload.pop("name")
2574
2581
  if "appKey" in payload and "app_key" not in payload:
2575
2582
  payload["app_key"] = payload.pop("appKey")
2583
+ if "refKind" in payload and "ref_kind" not in payload:
2584
+ payload["ref_kind"] = payload.pop("refKind")
2585
+ if "kind" in payload and "ref_kind" not in payload:
2586
+ payload["ref_kind"] = payload.pop("kind")
2576
2587
  return payload
2577
2588
 
2578
2589
  @model_validator(mode="after")
2579
2590
  def validate_target(self) -> "PortalChartRefPatch":
2580
2591
  if not (self.chart_id or self.chart_name):
2581
2592
  raise ValueError("chart_ref requires chart_id or chart_name")
2593
+ if self.ref_kind == "chart" and not self.chart_id:
2594
+ raise ValueError("chart_ref with ref_kind=chart requires chart_id (chart 表唯一键 chartKey)")
2582
2595
  if self.chart_name and not self.chart_id and not self.app_key:
2583
2596
  raise ValueError("chart_ref.app_key is required when resolving by chart_name")
2584
2597
  return self
@@ -2604,17 +2617,49 @@ class PortalViewRefPatch(StrictModel):
2604
2617
 
2605
2618
 
2606
2619
  class PortalSectionPatch(StrictModel):
2607
- title: str
2620
+ # title 语义=组件显示标题(draft_dash_chart.component_title / grid_config.gridTitle 等),
2621
+ # 生产存量存在空标题形态(显示标题开但标题空,读链回退视图名/图表名)——None=不设标题,
2622
+ # 编译对 None 不落库(后端透传 NULL),round-trip 保真;手写声明缺省 title=None(不设)。
2623
+ title: str | None = None
2608
2624
  source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
2609
2625
  role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
2626
+ # 显示标题开关(draft_dash_chart.being_show_title):提取侧读面必有值(True/False);
2627
+ # 声明缺省 None 时编译回落产品默认 True(与既有创建语义兼容)。
2628
+ show_title: bool | None = Field(
2629
+ default=None,
2630
+ validation_alias=AliasChoices("show_title", "showTitle", "being_show_title", "beingShowTitle"),
2631
+ )
2610
2632
  position: PortalComponentPositionPatch | None = None
2611
- dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
2633
+ dash_style_config: dict[str, Any] | None = Field(
2634
+ default=None,
2635
+ validation_alias=AliasChoices("dash_style_config", "style_config", "dashStyleConfigBO", "dashStyleConfig"),
2636
+ )
2612
2637
  config: dict[str, Any] = Field(default_factory=dict)
2613
2638
  chart_ref: PortalChartRefPatch | None = None
2614
2639
  chart: PortalInlineChartPatch | None = Field(default=None, validation_alias=AliasChoices("chart", "inline_chart", "inlineChart", "create_chart", "createChart"))
2615
2640
  view_ref: PortalViewRefPatch | None = None
2641
+ view_display: dict[str, Any] | None = Field(
2642
+ default=None,
2643
+ validation_alias=AliasChoices("view_display", "viewDisplay"),
2644
+ )
2616
2645
  text: str | None = None
2617
2646
  url: str | None = None
2647
+ login_auth: bool | None = Field(
2648
+ default=None,
2649
+ validation_alias=AliasChoices("login_auth", "loginAuth", "being_login_auth", "beingLoginAuth"),
2650
+ )
2651
+ carousel: dict[str, Any] | None = Field(
2652
+ default=None,
2653
+ validation_alias=AliasChoices("carousel", "carousel_config", "carouselConfig"),
2654
+ )
2655
+ task: dict[str, Any] | None = Field(
2656
+ default=None,
2657
+ validation_alias=AliasChoices("task", "task_config", "taskConfig"),
2658
+ )
2659
+ favourite: dict[str, Any] | None = Field(
2660
+ default=None,
2661
+ validation_alias=AliasChoices("favourite", "favourite_config", "favouriteConfig"),
2662
+ )
2618
2663
 
2619
2664
  @model_validator(mode="before")
2620
2665
  @classmethod
@@ -2659,7 +2704,7 @@ class PortalSectionPatch(StrictModel):
2659
2704
 
2660
2705
  @model_validator(mode="after")
2661
2706
  def validate_shape(self) -> "PortalSectionPatch":
2662
- supported = {"chart", "view", "grid", "filter", "text", "link"}
2707
+ supported = {"chart", "view", "grid", "filter", "text", "link", "carousel", "task", "favourite"}
2663
2708
  if self.source_type not in supported:
2664
2709
  raise ValueError(f"unsupported portal source_type '{self.source_type}'")
2665
2710
  if self.source_type == "chart" and self.chart_ref is None and self.chart is None:
@@ -2670,6 +2715,12 @@ class PortalSectionPatch(StrictModel):
2670
2715
  raise ValueError("text section requires text")
2671
2716
  if self.source_type == "link" and self.url is None:
2672
2717
  raise ValueError("link section requires url")
2718
+ if self.source_type == "carousel" and self.carousel is None:
2719
+ raise ValueError("carousel section requires carousel")
2720
+ if self.source_type == "task" and self.task is None:
2721
+ raise ValueError("task section requires task")
2722
+ if self.source_type == "favourite" and self.favourite is None:
2723
+ raise ValueError("favourite section requires favourite")
2673
2724
  return self
2674
2725
 
2675
2726
 
@@ -2685,8 +2736,20 @@ class PortalApplyRequest(StrictModel):
2685
2736
  icon: str | None = None
2686
2737
  color: str | None = None
2687
2738
  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"))
2739
+ global_config: dict[str, Any] | None = Field(
2740
+ default=None,
2741
+ validation_alias=AliasChoices("global_config", "globalConfig", "dash_global_config", "dashGlobalConfig"),
2742
+ )
2743
+ style_template_id: int | None = Field(
2744
+ default=None,
2745
+ validation_alias=AliasChoices("style_template_id", "styleTemplateId"),
2746
+ )
2747
+ overall_style: dict[str, Any] | None = Field(
2748
+ default=None,
2749
+ validation_alias=AliasChoices("overall_style", "overallStyle", "overallConfigBO"),
2750
+ )
2689
2751
  config: dict[str, Any] = Field(default_factory=dict)
2752
+ schema_version: str | None = Field(default=None, validation_alias=AliasChoices("schema_version", "schemaVersion"))
2690
2753
 
2691
2754
  @model_validator(mode="before")
2692
2755
  @classmethod
@@ -2694,8 +2757,14 @@ class PortalApplyRequest(StrictModel):
2694
2757
  if not isinstance(value, dict):
2695
2758
  return value
2696
2759
  payload = dict(value)
2760
+ payload.pop("schema_version", None)
2761
+ payload.pop("schemaVersion", None)
2697
2762
  if "dash_name" not in payload and "dashName" not in payload and "name" in payload:
2698
2763
  payload["dash_name"] = payload.pop("name")
2764
+ if "dash_global_config" in payload and "global_config" not in payload:
2765
+ payload["global_config"] = payload.pop("dash_global_config")
2766
+ elif "dashGlobalConfig" in payload and "global_config" not in payload:
2767
+ payload["global_config"] = payload.pop("dashGlobalConfig")
2699
2768
  if "sections" not in payload and "pages" in payload:
2700
2769
  pages = payload.pop("pages")
2701
2770
  if not isinstance(pages, list):
@@ -2730,6 +2799,88 @@ class PortalApplyRequest(StrictModel):
2730
2799
  return self
2731
2800
 
2732
2801
 
2802
+ def _public_portal_section_payload(section: PortalSectionPatch) -> dict[str, Any]:
2803
+ """Dump one portal section with canonical naming for schema validation."""
2804
+ payload: dict[str, Any] = {
2805
+ "title": section.title,
2806
+ "source_type": section.source_type,
2807
+ }
2808
+ if section.role is not None:
2809
+ payload["role"] = section.role
2810
+ if section.position is not None:
2811
+ payload["position"] = {
2812
+ "pc": {
2813
+ "x": section.position.pc_x,
2814
+ "y": section.position.pc_y,
2815
+ "cols": section.position.pc_w,
2816
+ "rows": section.position.pc_h,
2817
+ },
2818
+ }
2819
+ if section.position.mobile_provided:
2820
+ payload["position"]["mobile"] = {
2821
+ "x": section.position.mobile_x,
2822
+ "y": section.position.mobile_y,
2823
+ "cols": section.position.mobile_w,
2824
+ "rows": section.position.mobile_h,
2825
+ }
2826
+ if section.dash_style_config is not None:
2827
+ payload["style_config"] = section.dash_style_config
2828
+ if section.chart_ref is not None:
2829
+ payload["chart_ref"] = section.chart_ref.model_dump(mode="json", exclude_none=True)
2830
+ if section.chart is not None:
2831
+ payload["chart"] = section.chart.model_dump(mode="json", exclude_none=True)
2832
+ if section.view_ref is not None:
2833
+ payload["view_ref"] = section.view_ref.model_dump(mode="json", exclude_none=True)
2834
+ if section.view_display is not None:
2835
+ payload["view_display"] = section.view_display
2836
+ if section.text is not None:
2837
+ payload["text"] = section.text
2838
+ if section.url is not None:
2839
+ payload["url"] = section.url
2840
+ if section.login_auth is not None:
2841
+ payload["login_auth"] = section.login_auth
2842
+ if section.carousel is not None:
2843
+ payload["carousel"] = section.carousel
2844
+ if section.task is not None:
2845
+ payload["task"] = section.task
2846
+ if section.favourite is not None:
2847
+ payload["favourite"] = section.favourite
2848
+ if section.config:
2849
+ payload["config"] = section.config
2850
+ return payload
2851
+
2852
+
2853
+ def public_portal_apply_payload(request: PortalApplyRequest) -> dict[str, Any]:
2854
+ """Canonical-named portal declaration used for schema validation."""
2855
+ payload: dict[str, Any] = {}
2856
+ if request.dash_key:
2857
+ payload["dash_key"] = request.dash_key
2858
+ if request.dash_name is not None:
2859
+ payload["name"] = request.dash_name
2860
+ if request.package_tag_id is not None:
2861
+ payload["package_tag_id"] = request.package_tag_id
2862
+ payload["publish"] = request.publish
2863
+ if request.sections is not None:
2864
+ payload["sections"] = [_public_portal_section_payload(section) for section in request.sections]
2865
+ if request.layout_preset is not None:
2866
+ payload["layout_preset"] = request.layout_preset
2867
+ if request.visibility is not None:
2868
+ payload["visibility"] = request.visibility.model_dump(mode="json", exclude_none=True)
2869
+ if request.icon is not None:
2870
+ payload["icon"] = request.icon
2871
+ if request.color is not None:
2872
+ payload["color"] = request.color
2873
+ if request.hide_copyright is not None:
2874
+ payload["hide_copyright"] = request.hide_copyright
2875
+ if request.global_config is not None:
2876
+ payload["global_config"] = request.global_config
2877
+ if request.style_template_id is not None:
2878
+ payload["style_template_id"] = request.style_template_id
2879
+ if request.overall_style is not None:
2880
+ payload["overall_style"] = request.overall_style
2881
+ return payload
2882
+
2883
+
2733
2884
  FieldPatch.model_rebuild()
2734
2885
  FieldMutation.model_rebuild()
2735
2886
  FieldUpdatePatch.model_rebuild()