@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
@@ -26,7 +26,7 @@ from ..resource_uri import validate_positive_resource_id, validate_resource_iden
26
26
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
27
27
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
28
28
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
29
- from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
29
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, update_workspace_icon_from_existing, workspace_icon_config
30
30
  from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
31
31
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, _flatten_viewgraph_questions, extract_field_map
32
32
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
@@ -35,6 +35,7 @@ from ..tools.custom_button_tools import CustomButtonTools
35
35
  from ..tools.directory_tools import DirectoryTools
36
36
  from ..tools.package_tools import PackageTools
37
37
  from ..tools.portal_tools import PortalTools
38
+ from ..tools.portal_writer import PortalWriter
38
39
  from ..tools.qingbi_report_tools import QingbiReportTools
39
40
  from ..tools.role_tools import RoleTools
40
41
  from ..tools.solution_tools import SolutionTools
@@ -45,6 +46,7 @@ from . import workflow_spec as workflow_spec_api
45
46
  from .button_style_catalog import button_style_catalog_payload
46
47
  from ..tools.view_writer import ViewWriter
47
48
  from .views_schema import ViewsContractError, compact_none_values, views_schema_registry
49
+ from .portals_schema import PortalsContractError, PortalsSchemaContext, portals_schema_registry
48
50
  from .models import (
49
51
  AppChartsReadResponse,
50
52
  AppFieldsReadResponse,
@@ -81,6 +83,7 @@ from .models import (
81
83
  LayoutSectionPatch,
82
84
  LayoutPreset,
83
85
  PortalApplyRequest,
86
+ public_portal_apply_payload,
84
87
  PortalGetResponse,
85
88
  PortalListResponse,
86
89
  PortalReadSummaryResponse,
@@ -167,6 +170,8 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
167
170
  # 落库),运行时 DataSvcImpl.getAnswerValueByString:attachment 按拆分 URL 写入、
168
171
  # subtable 整表组装(均有专门分支),rich_text/data_relation 走 Question.canImport
169
172
  # 兜底按字符串写入(不报错)——四者为存量合法配置,与 validator 侧白名单同口径。
173
+ # r10 再放行 link/member(存量实证:92nl6blfk402 link×2、ab9fjvj33001 member;
174
+ # 运行时同为字符串写入路径)。
170
175
  INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
171
176
  FieldType.text.value,
172
177
  FieldType.long_text.value,
@@ -181,6 +186,8 @@ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
181
186
  FieldType.attachment.value,
182
187
  "data_relation",
183
188
  FieldType.subtable.value,
189
+ "link",
190
+ "member",
184
191
  )
185
192
 
186
193
  MATCH_TYPE_ACCURACY = 1
@@ -191,6 +198,9 @@ JUDGE_INCLUDE = 2
191
198
  JUDGE_GREATER_OR_EQUAL = 5
192
199
  JUDGE_LESS_OR_EQUAL = 7
193
200
  JUDGE_EQUAL_ANY = 9
201
+ # 成员/部门归属判定(Constants.JUDGE_BELONG_TO):轮播筛选存量数据出现,后端公式引擎
202
+ # JUDGE_FUNCTION[13]="JUDGE_BELONG_TO" 全支持,声明侧以 belongs_to 表达保真回写。
203
+ JUDGE_BELONG_TO = 13
194
204
  JUDGE_FUZZY_MATCH = 19
195
205
  JUDGE_INCLUDE_ANY = 20
196
206
  DEFAULT_TYPE_RELATION = 2
@@ -305,6 +315,7 @@ class AiBuilderFacade:
305
315
  self._view_writer = ViewWriter(views.sessions, views.backend)
306
316
  self.workflows = workflows
307
317
  self.portals = portals
318
+ self._portal_writer = PortalWriter(portals.sessions, portals.backend)
308
319
  self.charts = charts
309
320
  self.roles = roles
310
321
  self.directory = directory
@@ -3090,6 +3101,11 @@ class AiBuilderFacade:
3090
3101
  # 这类失败降级为非阻断——只有 uid/dept_id 直通也覆盖不了的引用才真正阻断。
3091
3102
  _SOFT_RESOLUTION_KINDS = {"member_email", "member_name", "department_name", "role", "external_member_email"}
3092
3103
  _NOT_FOUND_RESOLUTION_CODES = {"MEMBER_NOT_FOUND", "AMBIGUOUS_MEMBER", "ROLE_NOT_FOUND", "AMBIGUOUS_ROLE", "DEPARTMENT_NOT_FOUND", "AMBIGUOUS_DEPARTMENT"}
3104
+ # 目录查询失败类(隔离 eval 环境外部登录资源不可达 → 后端成员/角色搜索 49500 超时,
3105
+ # 实测确定性环境限制非瞬态):在 uid/dept_id 直通已解析出至少一个主体、即主体集合
3106
+ # 不依赖搜索通道时,搜索失败同样降级为非阻断(主体集合由直通锚完整保留);
3107
+ # 纯手写声明(仅 email/name 无 uid)仍阻断,避免静默丢成员。
3108
+ uid_direct_count = len(member_resolution.get("member_entries") or []) + len(department_resolution.get("department_entries") or [])
3093
3109
  blocking_issues = [
3094
3110
  issue
3095
3111
  for issue in [
@@ -3099,10 +3115,13 @@ class AiBuilderFacade:
3099
3115
  *external_member_resolution["issues"],
3100
3116
  ]
3101
3117
  # 冗余 email/name 引用「找不到」降级为非阻断(uid/id 直通已保主体不丢);
3102
- # 权限拒绝/查询失败类(LOOKUP_FAILED/PERMISSION_DENIED)仍阻断
3118
+ # 权限拒绝/查询失败类(LOOKUP_FAILED/PERMISSION_DENIED):主体直通已非空时降级,否则仍阻断
3103
3119
  if not (
3104
3120
  str(issue.get("kind") or "") in _SOFT_RESOLUTION_KINDS
3105
- and str(issue.get("error_code") or "") in _NOT_FOUND_RESOLUTION_CODES
3121
+ and (
3122
+ str(issue.get("error_code") or "") in _NOT_FOUND_RESOLUTION_CODES
3123
+ or (str(issue.get("error_code") or "").endswith("_SEARCH_FAILED") and uid_direct_count > 0)
3124
+ )
3106
3125
  )
3107
3126
  ]
3108
3127
  self._raise_visibility_resolution_issues(blocking_issues)
@@ -8761,7 +8780,25 @@ class AiBuilderFacade:
8761
8780
  "warnings": warnings,
8762
8781
  }
8763
8782
 
8764
- def portal_get(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
8783
+ def portal_get(
8784
+ self,
8785
+ *,
8786
+ profile: str,
8787
+ dash_key: str,
8788
+ being_draft: bool = True,
8789
+ schema_kind: str = "raw",
8790
+ schema_version: str | None = None,
8791
+ ) -> JSONObject:
8792
+ """读取门户详情;schema_kind=summary|complete 时返回 canonical 声明投影,raw 为原始归一响应。"""
8793
+ normalized_kind = str(schema_kind or "raw").strip().lower() or "raw"
8794
+ if normalized_kind not in {"raw", "summary", "complete"}:
8795
+ return _failed(
8796
+ "UNSUPPORTED_SCHEMA_KIND",
8797
+ f"unsupported portal get schema kind: {normalized_kind}",
8798
+ normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind},
8799
+ details={"supportedKinds": ["raw", "summary", "complete"]},
8800
+ suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "schema_kind": "raw"}},
8801
+ )
8765
8802
  try:
8766
8803
  result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
8767
8804
  except (QingflowApiError, RuntimeError) as error:
@@ -8769,10 +8806,52 @@ class AiBuilderFacade:
8769
8806
  return _failed_from_api_error(
8770
8807
  "PORTAL_GET_FAILED",
8771
8808
  api_error,
8772
- normalized_args={"dash_key": dash_key, "being_draft": being_draft},
8809
+ normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind},
8773
8810
  details={"dash_key": dash_key, "being_draft": being_draft},
8774
8811
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
8775
8812
  )
8813
+ if normalized_kind in {"summary", "complete"}:
8814
+ schema_context = None
8815
+ if schema_version is not None:
8816
+ try:
8817
+ schema_context = portals_schema_registry().resolve_schema_version(schema_version)
8818
+ except PortalsContractError as error:
8819
+ return _failed(
8820
+ error.error_code,
8821
+ str(error.message),
8822
+ normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind, "schema_version": schema_version},
8823
+ details={"reason_path": error.path or "", **(error.details or {})},
8824
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "summary"}},
8825
+ )
8826
+ declaration, read_gaps = _portal_declaration_from_dash_detail(result, schema_kind=normalized_kind)
8827
+ response = {
8828
+ "status": "success",
8829
+ "error_code": None,
8830
+ "recoverable": False,
8831
+ "message": f"read portal declaration ({normalized_kind})",
8832
+ "normalized_args": {
8833
+ "dash_key": dash_key,
8834
+ "being_draft": being_draft,
8835
+ "schema_kind": normalized_kind,
8836
+ "schema_version": schema_version,
8837
+ },
8838
+ "missing_fields": [],
8839
+ "allowed_values": {"schema_kind": ["raw", "summary", "complete"]},
8840
+ "details": {"read_gaps": read_gaps} if read_gaps else {},
8841
+ "request_id": None,
8842
+ "suggested_next_call": None,
8843
+ "noop": False,
8844
+ "warnings": [],
8845
+ "verification": {"portal_exists": True, "being_draft": being_draft},
8846
+ "verified": True,
8847
+ "schemaVersion": schema_context.schema_version if schema_context is not None else portals_schema_registry().latest_version,
8848
+ "latestVersion": portals_schema_registry().latest_version,
8849
+ "apiVersion": schema_context.api_version if schema_context is not None else "builder.qingflow.com/v1alpha1",
8850
+ "schemaKind": normalized_kind,
8851
+ "declaration": declaration,
8852
+ "read_gap_count": len(read_gaps),
8853
+ }
8854
+ return response
8776
8855
  dash_icon = str(result.get("dashIcon") or "").strip() or None
8777
8856
  response = PortalGetResponse(
8778
8857
  dash_key=dash_key,
@@ -8867,6 +8946,104 @@ class AiBuilderFacade:
8867
8946
  **response.model_dump(mode="json"),
8868
8947
  }
8869
8948
 
8949
+ def portal_schema(
8950
+ self,
8951
+ *,
8952
+ schema_version: str | None = None,
8953
+ schema_kind: str | None = None,
8954
+ ) -> JSONObject:
8955
+ """读取版本化门户声明 schema;组件专项 kind 用于获取自包含专项配置定义。"""
8956
+ normalized_args = {"schema_version": schema_version, "schema_kind": schema_kind}
8957
+ try:
8958
+ payload = portals_schema_registry().schema(schema_version=schema_version, schema_kind=schema_kind)
8959
+ except PortalsContractError as error:
8960
+ return _failed(
8961
+ error.error_code,
8962
+ str(error.message),
8963
+ normalized_args=normalized_args,
8964
+ details={"reason_path": error.path or "", **(error.details or {})},
8965
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "summary"}},
8966
+ )
8967
+ return {
8968
+ "status": "success",
8969
+ "error_code": None,
8970
+ "recoverable": False,
8971
+ "message": "read portals declaration schema",
8972
+ "normalized_args": normalized_args,
8973
+ "missing_fields": [],
8974
+ "allowed_values": {"schema_kind": payload["supportedSchemaKinds"]},
8975
+ "details": {},
8976
+ "request_id": None,
8977
+ "suggested_next_call": None,
8978
+ "warnings": [],
8979
+ "verification": {"schema_loaded": True},
8980
+ **payload,
8981
+ }
8982
+
8983
+ def portal_validate(
8984
+ self,
8985
+ *,
8986
+ declaration: JSONObject,
8987
+ schema_version: str | None = None,
8988
+ ) -> JSONObject:
8989
+ """纯离线校验一条门户声明;不访问后端。"""
8990
+ normalized_args = {"schema_version": schema_version}
8991
+ resolved_version = (
8992
+ str(schema_version or (declaration.get("schema_version") if isinstance(declaration, dict) else None) or "").strip() or None
8993
+ )
8994
+ try:
8995
+ context = portals_schema_registry().resolve_schema_version(resolved_version)
8996
+ except PortalsContractError as error:
8997
+ return _failed(
8998
+ error.error_code,
8999
+ str(error.message),
9000
+ normalized_args=normalized_args,
9001
+ details={"reason_path": error.path or "", **(error.details or {})},
9002
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "complete"}},
9003
+ )
9004
+ issues: list[dict[str, Any]] = []
9005
+ if not isinstance(declaration, dict):
9006
+ issues.append({"path": "/", "code": "INVALID_SPEC", "message": "portals declaration must be a JSON object"})
9007
+ else:
9008
+ declaration_item = {
9009
+ key: value
9010
+ for key, value in declaration.items()
9011
+ if key not in {"schema_version", "schemaVersion", "apiVersion"}
9012
+ }
9013
+ try:
9014
+ parsed = PortalApplyRequest.model_validate(declaration_item)
9015
+ context.validate_upsert(compact_none_values(public_portal_apply_payload(parsed)))
9016
+ except ValidationError as exc:
9017
+ first_error = exc.errors()[0]
9018
+ issues.append(
9019
+ {
9020
+ "path": f"/{'/'.join(str(part) for part in first_error.get('loc', ()))}".rstrip("/"),
9021
+ "code": "INVALID_SPEC",
9022
+ "message": str(first_error.get("msg") or exc),
9023
+ }
9024
+ )
9025
+ except PortalsContractError as error:
9026
+ issues.append({"path": error.path or "/", "code": error.error_code, "message": str(error.message)})
9027
+ ok = not issues
9028
+ return {
9029
+ "status": "success" if ok else "failed",
9030
+ "error_code": None if ok else issues[0]["code"],
9031
+ "recoverable": True,
9032
+ "message": "portals declaration is valid" if ok else "portals declaration failed validation",
9033
+ "normalized_args": normalized_args,
9034
+ "missing_fields": [],
9035
+ "allowed_values": {},
9036
+ "details": {"issues": issues},
9037
+ "request_id": None,
9038
+ "suggested_next_call": None,
9039
+ "warnings": [],
9040
+ "verification": {"validated_entries": 1 if ok else 0},
9041
+ "ok": ok,
9042
+ "schemaVersion": context.schema_version,
9043
+ "apiVersion": context.api_version,
9044
+ "issues": issues,
9045
+ }
9046
+
8870
9047
  def view_get(self, *, profile: str, view_key: str) -> JSONObject:
8871
9048
  try:
8872
9049
  config = self.views.view_get_config(profile=profile, viewgraph_key=view_key).get("result") or {}
@@ -15086,7 +15263,13 @@ class AiBuilderFacade:
15086
15263
  "safe_to_retry": not write_executed,
15087
15264
  })
15088
15265
 
15089
- def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
15266
+ def portal_apply(
15267
+ self,
15268
+ *,
15269
+ profile: str,
15270
+ request: PortalApplyRequest,
15271
+ schema_version: str | None = None,
15272
+ ) -> JSONObject:
15090
15273
  apply_started_at = time.perf_counter()
15091
15274
  portal_initial_read_ms: int | None = None
15092
15275
  portal_create_ms: int | None = None
@@ -15096,16 +15279,39 @@ class AiBuilderFacade:
15096
15279
  portal_draft_readback_ms: int | None = None
15097
15280
  portal_publish_ms: int | None = None
15098
15281
  portal_live_readback_ms: int | None = None
15282
+ effective_schema_version = str(schema_version or request.schema_version or "").strip() or None
15099
15283
  normalized_args = request.model_dump(mode="json")
15284
+ normalized_args["schema_version"] = effective_schema_version
15285
+ schema_context: PortalsSchemaContext | None = None
15286
+ if effective_schema_version is not None:
15287
+ try:
15288
+ schema_context = portals_schema_registry().resolve_schema_version(effective_schema_version)
15289
+ schema_context.validate_upsert(
15290
+ compact_none_values(public_portal_apply_payload(request))
15291
+ )
15292
+ except PortalsContractError as error:
15293
+ return _failed(
15294
+ error.error_code,
15295
+ str(error.message),
15296
+ normalized_args=normalized_args,
15297
+ details={"reason_path": error.path or "", **(error.details or {})},
15298
+ suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_version": effective_schema_version, "schema_kind": "complete"}},
15299
+ )
15100
15300
  permission_outcomes: list[PermissionCheckOutcome] = []
15101
15301
  dash_key = str(request.dash_key or "").strip()
15102
15302
  creating = not dash_key
15103
- sections_requested = creating or bool(request.sections)
15303
+ # sections_requested 语义:显式携带 sections 键(含空列表)即走组件重建链;
15304
+ # 未传(不在 model_fields_set)才是纯 base-info 更新。模型缺省 sections=[] 与
15305
+ # 显式空列表同值,只能用 fields_set 区分。空 sections=[] 是合法存量形态
15306
+ # (无组件门户):后端 updateDash 对空组件列表=删全部后不重建(getComponents
15307
+ # 非空才重建),createDash 不建组件——把空列表当「未传」会把携带
15308
+ # global_config/hide_copyright 的空门户拒之门外。
15309
+ sections_requested = creating or ("sections" in request.model_fields_set)
15104
15310
  verify_dash_name = creating or request.dash_name is not None
15105
15311
  verify_dash_icon = bool(request.icon or request.color)
15106
15312
  verify_auth = request.visibility is not None or request.auth is not None
15107
15313
  verify_hide_copyright = request.hide_copyright is not None and sections_requested
15108
- verify_dash_global_config = request.dash_global_config is not None and sections_requested
15314
+ verify_dash_global_config = request.global_config is not None and sections_requested
15109
15315
  verify_tags = creating or request.package_tag_id is not None
15110
15316
  requested_visibility = request.visibility
15111
15317
  if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
@@ -15155,6 +15361,8 @@ class AiBuilderFacade:
15155
15361
  permission_outcomes.append(portal_permission_outcome)
15156
15362
 
15157
15363
  def finalize(response: JSONObject) -> JSONObject:
15364
+ if schema_context is not None:
15365
+ response["schemaVersion"] = schema_context.schema_version
15158
15366
  _merge_duration_breakdown(
15159
15367
  response,
15160
15368
  portal_initial_read_ms=portal_initial_read_ms,
@@ -15192,8 +15400,8 @@ class AiBuilderFacade:
15192
15400
  unsupported_base_only_keys: list[str] = []
15193
15401
  if request.hide_copyright is not None:
15194
15402
  unsupported_base_only_keys.append("hide_copyright")
15195
- if request.dash_global_config is not None:
15196
- unsupported_base_only_keys.append("dash_global_config")
15403
+ if request.global_config is not None:
15404
+ unsupported_base_only_keys.append("global_config")
15197
15405
  if request.config:
15198
15406
  unsupported_base_only_keys.append("config")
15199
15407
  if unsupported_base_only_keys:
@@ -15216,18 +15424,20 @@ class AiBuilderFacade:
15216
15424
  layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
15217
15425
  if creating:
15218
15426
  create_payload = _build_public_portal_base_payload(
15219
- dash_name=request.dash_name or "未命名门户",
15427
+ dash_name=request.dash_name if request.dash_name is not None else "未命名门户",
15220
15428
  package_tag_id=target_package_tag_id,
15221
15429
  icon=request.icon,
15222
15430
  color=request.color,
15223
15431
  auth=desired_auth,
15224
15432
  hide_copyright=request.hide_copyright,
15225
- dash_global_config=request.dash_global_config,
15433
+ dash_global_config=request.global_config,
15434
+ style_template_id=request.style_template_id,
15435
+ overall_style=request.overall_style,
15226
15436
  config=request.config,
15227
15437
  base_payload=None,
15228
15438
  )
15229
15439
  portal_create_started_at = time.perf_counter()
15230
- create_result = self.portals.portal_create(profile=profile, payload=create_payload)
15440
+ create_result = self._portal_writer.create_portal(profile=profile, payload=create_payload)
15231
15441
  portal_create_ms = _duration_ms(portal_create_started_at)
15232
15442
  write_executed = True
15233
15443
  created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
@@ -15250,13 +15460,15 @@ class AiBuilderFacade:
15250
15460
  }
15251
15461
  base_payload = {}
15252
15462
  update_payload = _build_public_portal_base_payload(
15253
- dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
15463
+ dash_name=request.dash_name if request.dash_name is not None else (str(base_payload.get("dashName") or "").strip() or "未命名门户"),
15254
15464
  package_tag_id=target_package_tag_id,
15255
15465
  icon=request.icon,
15256
15466
  color=request.color,
15257
15467
  auth=desired_auth,
15258
15468
  hide_copyright=request.hide_copyright,
15259
- dash_global_config=request.dash_global_config,
15469
+ dash_global_config=request.global_config,
15470
+ style_template_id=request.style_template_id,
15471
+ overall_style=request.overall_style,
15260
15472
  config=request.config,
15261
15473
  base_payload=base_payload,
15262
15474
  )
@@ -15272,7 +15484,7 @@ class AiBuilderFacade:
15272
15484
  layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload, layout_metadata=layout_metadata)
15273
15485
  update_payload["components"] = component_payload
15274
15486
  portal_update_started_at = time.perf_counter()
15275
- self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
15487
+ self._portal_writer.update_portal(profile=profile, dash_key=dash_key, payload=update_payload)
15276
15488
  portal_update_ms = _duration_ms(portal_update_started_at)
15277
15489
  write_executed = True
15278
15490
  else:
@@ -15289,15 +15501,22 @@ class AiBuilderFacade:
15289
15501
  )
15290
15502
  if base_info_update_requested:
15291
15503
  portal_base_info_update_started_at = time.perf_counter()
15292
- self.portals.portal_update_base_info(
15504
+ base_info_payload = {
15505
+ "dashName": update_payload.get("dashName"),
15506
+ "dashIcon": update_payload.get("dashIcon"),
15507
+ "auth": deepcopy(update_payload.get("auth")),
15508
+ }
15509
+ # tags 空列表省略键:后端 updateDashBaseInfo(DashSvcImpl)对非 null 的空 tags
15510
+ # 按 ws 过滤后判空抛 PARAMS_ERROR → 整个 baseInfo 写失败(事务回滚,name/icon/auth
15511
+ # 全部静默失效,r8 实测 16 门)。省略键 → getTags()==null → 后端跳过 tags 校验与更新,
15512
+ # 其余字段正常写。声明显式给 package_tag_id 时 tags 非空,不受影响。
15513
+ base_info_tags = deepcopy(update_payload.get("tags") or [])
15514
+ if base_info_tags:
15515
+ base_info_payload["tags"] = base_info_tags
15516
+ self._portal_writer.update_portal_base_info(
15293
15517
  profile=profile,
15294
15518
  dash_key=dash_key,
15295
- payload={
15296
- "dashName": update_payload.get("dashName"),
15297
- "dashIcon": update_payload.get("dashIcon"),
15298
- "auth": deepcopy(update_payload.get("auth")),
15299
- "tags": deepcopy(update_payload.get("tags") or []),
15300
- },
15519
+ payload=base_info_payload,
15301
15520
  )
15302
15521
  portal_base_info_update_ms = _duration_ms(portal_base_info_update_started_at)
15303
15522
  write_executed = True
@@ -16612,25 +16831,58 @@ class AiBuilderFacade:
16612
16831
  resolved_chart = inline_chart_resolutions.get(section_index)
16613
16832
  if not isinstance(resolved_chart, dict):
16614
16833
  raise ValueError(f"inline chart '{section.chart.name}' could not be created or updated for portal section '{section.title}'")
16834
+ chart_type = str(
16835
+ resolved_chart.get("chart_type")
16836
+ or _public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type"))
16837
+ or ""
16838
+ ).strip()
16839
+ chart_config = {
16840
+ "biChartId": resolved_chart["chart_id"],
16841
+ # title=None(存量空标题)不落库——_compact_dict 剔除,后端透传 NULL 保真;
16842
+ # beingShowTitle 以声明 show_title 为准(读面提取必有值),缺省回落产品默认 True。
16843
+ "chartComponentTitle": section.title,
16844
+ "beingShowTitle": section.show_title if section.show_title is not None else True,
16845
+ **deepcopy(section.config),
16846
+ }
16847
+ component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
16848
+ layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
16849
+ elif section.chart_ref is not None and str(getattr(section.chart_ref, "ref_kind", "") or "") == "chart":
16850
+ # 轻流报表引用面(后端 DASH_CONTENT_TYPE_CHART=1):chart_id 携带的是
16851
+ # chart 表唯一键 chartKey,写链 addChartInfos 按 chartKey 解析,不走
16852
+ # BI 报表列表解析;不携带运行态派生键(chartId/chartName/formTitle 等读面回填)。
16853
+ chart_key = str(getattr(section.chart_ref, "chart_id", "") or "").strip()
16854
+ if not chart_key:
16855
+ raise ValueError(f"chart_ref with ref_kind=chart requires chart_id for portal section '{section.title}'")
16856
+ chart_config = {
16857
+ "chartKey": chart_key,
16858
+ # 同上:title=None 不落库、beingShowTitle 声明优先、缺省 True。
16859
+ "chartComponentTitle": section.title,
16860
+ "beingShowTitle": section.show_title if section.show_title is not None else True,
16861
+ **deepcopy(section.config),
16862
+ }
16863
+ component = {"type": 1, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
16864
+ layout_metadata.append({"source_type": section.source_type, "chart_type": str(_public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type")) or "").strip(), "role": section.role})
16615
16865
  else:
16616
16866
  resolved_chart = _resolve_chart_reference(
16617
16867
  charts=self.charts,
16618
16868
  profile=profile,
16619
16869
  ref=section.chart_ref,
16620
16870
  )
16621
- chart_type = str(
16622
- resolved_chart.get("chart_type")
16623
- or _public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type"))
16624
- or ""
16625
- ).strip()
16626
- chart_config = {
16627
- "biChartId": resolved_chart["chart_id"],
16628
- "chartComponentTitle": section.title,
16629
- "beingShowTitle": True,
16630
- **deepcopy(section.config),
16631
- }
16632
- component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
16633
- layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
16871
+ chart_type = str(
16872
+ resolved_chart.get("chart_type")
16873
+ or _public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type"))
16874
+ or ""
16875
+ ).strip()
16876
+ chart_config = {
16877
+ "biChartId": resolved_chart["chart_id"],
16878
+ # title=None(存量空标题)不落库——_compact_dict 剔除,后端透传 NULL 保真;
16879
+ # beingShowTitle 以声明 show_title 为准(读面提取必有值),缺省回落产品默认 True。
16880
+ "chartComponentTitle": section.title,
16881
+ "beingShowTitle": section.show_title if section.show_title is not None else True,
16882
+ **deepcopy(section.config),
16883
+ }
16884
+ component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
16885
+ layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
16634
16886
  elif section.source_type == "view":
16635
16887
  resolved_view = _resolve_view_reference(
16636
16888
  facade=self,
@@ -16644,34 +16896,58 @@ class AiBuilderFacade:
16644
16896
  view_config = {
16645
16897
  "appKey": resolved_view["app_key"],
16646
16898
  "viewgraphKey": resolved_view["view_key"],
16647
- "viewgraphName": section.title,
16899
+ # viewgraphName=视图名(引用面锚,view_ref.view_name 优先);componentTitle=组件
16900
+ # 显示标题(title=None 的存量空标题形态经 _compact_dict 剔除,落库 NULL 保真,
16901
+ # 旧逻辑把视图名兑底进 componentTitle 会覆盖读链 chartName 派生分支)。
16902
+ "viewgraphName": getattr(section.view_ref, "view_name", None) or section.title,
16648
16903
  "formTitle": app_title_cache[resolved_view["app_key"]],
16649
16904
  "componentTitle": section.title,
16650
16905
  "viewgraphType": resolved_view["view_type"],
16651
- "beingShowTitle": True,
16906
+ # beingShowTitle 声明 show_title 优先(view_display.show_title 提取必有值;
16907
+ # 存量 false 被硬 True 覆写会漂移库表);缺省保持产品默认 True。
16908
+ "beingShowTitle": section.show_title if section.show_title is not None else True,
16652
16909
  "dataManageStatus": True,
16653
16910
  **deepcopy(section.config),
16654
16911
  }
16912
+ if section.view_display:
16913
+ display = _compile_portal_view_display(section.view_display)
16914
+ for key, value in display.items():
16915
+ view_config[key] = value
16655
16916
  component = {"type": 10, "position": position_payload, "viewgraphConfig": _compact_dict(view_config)}
16656
16917
  layout_metadata.append({"source_type": section.source_type, "role": section.role})
16657
16918
  elif section.source_type == "grid":
16658
16919
  component = {
16659
16920
  "type": 2,
16660
16921
  "position": position_payload,
16922
+ # gridTitle=None(存量空标题)剔除不落库;beingShowTitle 缺省 True、声明 config 内
16923
+ # beingShowTitle 键后置覆盖(提取侧 grid config 保留该键,存量 false 保真)。
16661
16924
  "gridConfig": _compact_dict({"gridTitle": section.title, "beingShowTitle": True, **deepcopy(section.config)}),
16662
16925
  }
16663
16926
  layout_metadata.append({"source_type": section.source_type, "role": section.role})
16664
16927
  elif section.source_type == "filter":
16665
- component = {"type": 6, "position": position_payload, "filterConfig": deepcopy(section.config)}
16928
+ component = {"type": 6, "position": position_payload, "filterConfig": _compile_portal_filter_config(section.config)}
16666
16929
  layout_metadata.append({"source_type": section.source_type, "role": section.role})
16667
16930
  elif section.source_type == "text":
16668
16931
  component = {"type": 5, "position": position_payload, "textConfig": {"text": section.text or "", **deepcopy(section.config)}}
16669
16932
  layout_metadata.append({"source_type": section.source_type, "role": section.role})
16933
+ elif section.source_type == "carousel":
16934
+ carousel_config = self._compile_portal_carousel_config(profile=profile, section=section)
16935
+ component = {"type": 3, "position": position_payload, "carouselConfig": _compact_dict(carousel_config)}
16936
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
16937
+ elif section.source_type == "task":
16938
+ task_config = _compile_portal_task_config(section.title, section.task or {})
16939
+ component = {"type": 8, "position": position_payload, "taskConfig": _compact_dict(task_config)}
16940
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
16941
+ elif section.source_type == "favourite":
16942
+ favourite_config = _compile_portal_favourite_config(section.title, section.favourite or {})
16943
+ component = {"type": 7, "position": position_payload, "favouriteConfig": _compact_dict(favourite_config)}
16944
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
16670
16945
  else:
16946
+ link_config = {"url": section.url or "", "beingLoginAuth": bool(section.login_auth) if section.login_auth is not None else False, **deepcopy(section.config)}
16671
16947
  component = {
16672
16948
  "type": 4,
16673
16949
  "position": position_payload,
16674
- "linkConfig": {"url": section.url or "", "beingLoginAuth": False, **deepcopy(section.config)},
16950
+ "linkConfig": _compact_dict(link_config),
16675
16951
  }
16676
16952
  layout_metadata.append({"source_type": section.source_type, "role": section.role})
16677
16953
  if dash_style is not None:
@@ -16679,6 +16955,113 @@ class AiBuilderFacade:
16679
16955
  resolved_components.append(component)
16680
16956
  return resolved_components, layout_metadata, inline_chart_results
16681
16957
 
16958
+ def _compile_portal_carousel_config(self, *, profile: str, section: PortalSectionPatch) -> dict[str, Any]:
16959
+ """Compile a carousel section declaration into the backend carouselConfig shape."""
16960
+ raw = section.carousel or {}
16961
+ if not isinstance(raw, dict):
16962
+ raise ValueError("carousel section config must be an object")
16963
+ app_key = str(raw.get("source_app_key") or raw.get("sourceAppKey") or "").strip()
16964
+ carousel_config: dict[str, Any] = {}
16965
+ fields_by_que_id: dict[int, dict[str, Any]] = {}
16966
+ field_name_to_field: dict[str, dict[str, Any]] = {}
16967
+ if app_key:
16968
+ carousel_config["sourceApp"] = {"appKey": app_key}
16969
+ if raw.get("source_form_title") or raw.get("sourceFormTitle"):
16970
+ carousel_config["sourceApp"]["formTitle"] = str(raw.get("source_form_title") or raw.get("sourceFormTitle"))
16971
+ # 字段索引从原始 form schema 面构建(_read_schema_with_fallback + schema 分支,
16972
+ # 与 views apply 同源):app_read_fields 解析面的字段不带 option_details,
16973
+ # _resolve_view_filter_values 的选项文本→ID 解析分支不生效,选项文本被
16974
+ # 原样透传进 formula,后端写后自读 Integer.parseInt("文本") 崩溃回滚
16975
+ # (r6 实测 9b91e277;七条约束之四:两读面锚定同一数据源)。
16976
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
16977
+ fields_by_que_id = _collect_fields_by_que_id([], schema=schema)
16978
+ field_name_to_field = {
16979
+ str(field.get("name") or ""): field
16980
+ for field in fields_by_que_id.values()
16981
+ if str(field.get("name") or "")
16982
+ }
16983
+ # 悬空轮播(存量形态:源表已删,related_form_id NULL,读面 sourceApp 空):后端写链
16984
+ # DraftComponentCarousel 构造对 sourceApp 空/无 appKey 不写 relatedFormId,读面回空
16985
+ # sourceApp——round-trip 保真;无源表则无从解析字段引用,仅保留运行参数(jump_mode 等)。
16986
+
16987
+ def _resolve_que_ref(field_name: Any, *, config_key: str) -> dict[str, Any] | None:
16988
+ raw_name = str(field_name or "").strip()
16989
+ if not raw_name:
16990
+ return None
16991
+ field: dict[str, Any] | None = None
16992
+ if raw_name.isdigit():
16993
+ que_id = _coerce_positive_int(raw_name)
16994
+ field = fields_by_que_id.get(que_id) if que_id is not None else None
16995
+ else:
16996
+ field = field_name_to_field.get(raw_name)
16997
+ if field is None:
16998
+ raise ValueError(
16999
+ f"carousel section '{section.title}' references unknown {config_key} '{raw_name}' in app '{app_key}'"
17000
+ )
17001
+ que_id = _coerce_positive_int(field.get("que_id"))
17002
+ if que_id is None:
17003
+ raise ValueError(
17004
+ f"carousel section '{section.title}' {config_key} '{raw_name}' has no que_id in app '{app_key}'"
17005
+ )
17006
+ return {"queId": que_id}
17007
+
17008
+ title_que = _resolve_que_ref(raw.get("title_field") or raw.get("titleField"), config_key="title_field")
17009
+ image_que = _resolve_que_ref(raw.get("image_field") or raw.get("imageField"), config_key="image_field")
17010
+ link_que = _resolve_que_ref(raw.get("link_field") or raw.get("linkField"), config_key="link_field")
17011
+ if title_que is not None:
17012
+ carousel_config["titleQue"] = title_que
17013
+ if image_que is not None:
17014
+ carousel_config["imageQue"] = image_que
17015
+ if link_que is not None:
17016
+ carousel_config["linkQue"] = link_que
17017
+ jump_mode = _coerce_positive_int(raw.get("jump_mode") or raw.get("jumpMode"))
17018
+ if jump_mode is not None:
17019
+ carousel_config["jumpMode"] = jump_mode
17020
+ if "scaling" in raw or "beingScaling" in raw:
17021
+ carousel_config["beingScaling"] = bool(raw.get("scaling", raw.get("beingScaling")))
17022
+ show_latest_limit = _coerce_positive_int(raw.get("show_latest_limit") or raw.get("showLatestLimit"))
17023
+ if show_latest_limit is not None:
17024
+ carousel_config["showLatestLimit"] = show_latest_limit
17025
+ scroll_interval = _coerce_positive_int(raw.get("scroll_interval") or raw.get("scrollInterval"))
17026
+ if scroll_interval is not None:
17027
+ carousel_config["scrollInterval"] = scroll_interval
17028
+ raw_filters = raw.get("filters")
17029
+ if isinstance(raw_filters, list) and raw_filters:
17030
+ translated_rules: list[dict[str, Any]] = []
17031
+ for raw_rule in raw_filters:
17032
+ if hasattr(raw_rule, "model_dump"):
17033
+ raw_rule = raw_rule.model_dump(mode="json")
17034
+ if not isinstance(raw_rule, dict):
17035
+ continue
17036
+ field_name = str(raw_rule.get("field_name") or raw_rule.get("fieldName") or "").strip()
17037
+ field: dict[str, Any] | None = None
17038
+ if field_name.isdigit():
17039
+ # 与 _resolve_que_ref 同型:queId 数字串优先按稳定锚解析(提取器 canonical 形态)
17040
+ que_id = _coerce_positive_int(field_name)
17041
+ field = fields_by_que_id.get(que_id) if que_id is not None else None
17042
+ if field is None:
17043
+ field = field_name_to_field.get(field_name)
17044
+ if field is None:
17045
+ raise ValueError(
17046
+ f"carousel section '{section.title}' filters reference unknown field '{field_name}' in app '{app_key}'"
17047
+ )
17048
+ rule_payload = {
17049
+ "que_id": field.get("que_id"),
17050
+ "operator": raw_rule.get("operator") or raw_rule.get("op") or "eq",
17051
+ "values": list(raw_rule.get("values") or []),
17052
+ }
17053
+ groups, filter_issues = _build_view_filter_groups(fields_by_que_id=fields_by_que_id, filters=[rule_payload])
17054
+ blocking = [issue for issue in filter_issues if issue.get("error_code")]
17055
+ if blocking:
17056
+ raise ValueError(
17057
+ f"carousel section '{section.title}' filters failed to compile: {blocking[0]}"
17058
+ )
17059
+ if groups:
17060
+ translated_rules.extend(groups[0])
17061
+ if translated_rules:
17062
+ carousel_config["filterConfig"] = [translated_rules]
17063
+ return carousel_config
17064
+
16682
17065
  def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
16683
17066
  session_profile = self.apps.sessions.get_profile(profile)
16684
17067
  backend_session = self.apps.sessions.get_backend_session(profile)
@@ -20252,6 +20635,142 @@ def _chart_item_dataset_source_type(item: dict[str, Any]) -> str:
20252
20635
  return ""
20253
20636
 
20254
20637
 
20638
+ def _compile_portal_global_config(raw: dict[str, Any] | None) -> dict[str, Any]:
20639
+ """Compile declaration-named global_config keys to backend dashGlobalConfig keys."""
20640
+ config: dict[str, Any] = dict(raw or {})
20641
+ if "auto_refresh" in config:
20642
+ config["beingAutoRefresh"] = config.pop("auto_refresh")
20643
+ if "export" in config:
20644
+ config["beingExport"] = config.pop("export")
20645
+ return config
20646
+
20647
+
20648
+ def _compile_portal_view_display(raw: dict[str, Any] | None) -> dict[str, Any]:
20649
+ """Compile declaration view_display keys to backend viewgraphConfig keys."""
20650
+ display: dict[str, Any] = {}
20651
+ payload = raw or {}
20652
+ if "show_title" in payload:
20653
+ display["beingShowTitle"] = bool(payload["show_title"])
20654
+ elif "beingShowTitle" in payload:
20655
+ display["beingShowTitle"] = bool(payload["beingShowTitle"])
20656
+ if payload.get("component_title") or payload.get("componentTitle"):
20657
+ display["componentTitle"] = str(payload.get("component_title") or payload.get("componentTitle"))
20658
+ return display
20659
+
20660
+
20661
+ def _compile_portal_task_config(section_title: str, raw: dict[str, Any]) -> dict[str, Any]:
20662
+ """Compile a task section declaration into the backend taskConfig shape."""
20663
+ payload = raw or {}
20664
+ task_config: dict[str, Any] = {
20665
+ "componentTaskTitle": section_title,
20666
+ }
20667
+ if "show_title" in payload:
20668
+ task_config["beingShowTitle"] = bool(payload["show_title"])
20669
+ elif "beingShowTitle" in payload:
20670
+ task_config["beingShowTitle"] = bool(payload["beingShowTitle"])
20671
+ if "hint" in payload or "componentTaskHint" in payload:
20672
+ task_config["componentTaskHint"] = str(payload.get("hint") or payload.get("componentTaskHint") or "")
20673
+ if "show_hint" in payload:
20674
+ task_config["beingShowHint"] = bool(payload["show_hint"])
20675
+ elif "beingShowHint" in payload:
20676
+ task_config["beingShowHint"] = bool(payload["beingShowHint"])
20677
+ if "enabled" in payload:
20678
+ task_config["beingUsing"] = bool(payload["enabled"])
20679
+ elif "beingUsing" in payload:
20680
+ task_config["beingUsing"] = bool(payload["beingUsing"])
20681
+ raw_tasks = payload.get("tasks")
20682
+ if isinstance(raw_tasks, list) and raw_tasks:
20683
+ task_items: list[dict[str, Any]] = []
20684
+ for index, raw_item in enumerate(raw_tasks):
20685
+ if not isinstance(raw_item, dict):
20686
+ continue
20687
+ item: dict[str, Any] = {
20688
+ "title": str(raw_item.get("title") or ""),
20689
+ "type": _coerce_positive_int(raw_item.get("task_type") or raw_item.get("type")) or 1,
20690
+ "ordinal": _coerce_positive_int(raw_item.get("ordinal")) or index,
20691
+ "beingCheck": bool(raw_item.get("checked", raw_item.get("beingCheck", False))),
20692
+ }
20693
+ task_items.append(item)
20694
+ if task_items:
20695
+ task_config["dashTaskConfigList"] = task_items
20696
+ return task_config
20697
+
20698
+
20699
+ def _compile_portal_filter_config(raw: dict[str, Any] | None) -> dict[str, Any]:
20700
+ """Compile a filter section config into the backend ComponentFilter shape.
20701
+
20702
+ Declaration form: ``{graphs: [{chart_key, chart_name}], rules: [...]}``.
20703
+ Legacy raw form (``{graphList: [...], filterConfig: [...]}`` 后端键) 透传兼容。
20704
+ """
20705
+ payload = raw or {}
20706
+ compiled: dict[str, Any] = {}
20707
+ raw_graphs = payload.get("graphs")
20708
+ if isinstance(raw_graphs, list):
20709
+ graph_list: list[dict[str, Any]] = []
20710
+ for item in raw_graphs:
20711
+ if not isinstance(item, dict):
20712
+ continue
20713
+ graph: dict[str, Any] = {
20714
+ "graphKey": str(item.get("chart_key") or item.get("chartKey") or item.get("graphKey") or "").strip(),
20715
+ "graphName": str(item.get("chart_name") or item.get("chartName") or item.get("graphName") or "").strip(),
20716
+ "graphType": item.get("graph_type") or item.get("graphType") or "CHART",
20717
+ }
20718
+ if graph["graphKey"] or graph["graphName"]:
20719
+ graph_list.append(graph)
20720
+ compiled["graphList"] = graph_list
20721
+ elif isinstance(payload.get("graphList"), list):
20722
+ compiled["graphList"] = deepcopy(payload.get("graphList"))
20723
+ raw_rules = payload.get("rules")
20724
+ if isinstance(raw_rules, list):
20725
+ compiled["filterConfig"] = deepcopy(raw_rules)
20726
+ elif isinstance(payload.get("filterConfig"), list):
20727
+ compiled["filterConfig"] = deepcopy(payload.get("filterConfig"))
20728
+ return compiled
20729
+
20730
+
20731
+ def _compile_portal_favourite_config(section_title: str, raw: dict[str, Any]) -> dict[str, Any]:
20732
+ """Compile a favourite section declaration into the backend favouriteConfig shape."""
20733
+ payload = raw or {}
20734
+ favourite_config: dict[str, Any] = {
20735
+ "favouriteTitle": section_title,
20736
+ }
20737
+ if "show_title" in payload:
20738
+ favourite_config["beingShowTitle"] = bool(payload["show_title"])
20739
+ elif "beingShowTitle" in payload:
20740
+ favourite_config["beingShowTitle"] = bool(payload["beingShowTitle"])
20741
+ if "enabled" in payload:
20742
+ favourite_config["beingUsing"] = bool(payload["enabled"])
20743
+ elif "beingUsing" in payload:
20744
+ favourite_config["beingUsing"] = bool(payload["beingUsing"])
20745
+ raw_items = payload.get("items")
20746
+ if isinstance(raw_items, list) and raw_items:
20747
+ favourite_items: list[dict[str, Any]] = []
20748
+ for index, raw_item in enumerate(raw_items):
20749
+ if not isinstance(raw_item, dict):
20750
+ continue
20751
+ item: dict[str, Any] = {
20752
+ "title": str(raw_item.get("title") or ""),
20753
+ "type": _coerce_positive_int(raw_item.get("item_type") or raw_item.get("type")) or 1,
20754
+ "ordinal": _coerce_positive_int(raw_item.get("ordinal")) or index,
20755
+ }
20756
+ link_key = str(raw_item.get("link_key") or raw_item.get("linkKey") or "").strip()
20757
+ if link_key:
20758
+ item["linkKey"] = link_key
20759
+ link_id = _coerce_positive_int(raw_item.get("link_id") or raw_item.get("linkId"))
20760
+ if link_id is not None:
20761
+ item["linkId"] = link_id
20762
+ icon_url = str(raw_item.get("icon_url") or raw_item.get("iconUrl") or "").strip()
20763
+ if icon_url:
20764
+ item["iconUrl"] = icon_url
20765
+ jump_mode = _coerce_positive_int(raw_item.get("jump_mode") or raw_item.get("jumpMode"))
20766
+ if jump_mode is not None:
20767
+ item["jumpMode"] = jump_mode
20768
+ favourite_items.append(item)
20769
+ if favourite_items:
20770
+ favourite_config["items"] = favourite_items
20771
+ return favourite_config
20772
+
20773
+
20255
20774
  def _build_public_portal_base_payload(
20256
20775
  *,
20257
20776
  dash_name: str,
@@ -20261,19 +20780,49 @@ def _build_public_portal_base_payload(
20261
20780
  auth: dict[str, Any] | None,
20262
20781
  hide_copyright: bool | None,
20263
20782
  dash_global_config: dict[str, Any] | None,
20783
+ style_template_id: int | None = None,
20784
+ overall_style: dict[str, Any] | None = None,
20264
20785
  config: dict[str, Any] | None,
20265
20786
  base_payload: dict[str, Any] | None,
20266
20787
  ) -> dict[str, Any]:
20267
20788
  data = deepcopy(base_payload) if isinstance(base_payload, dict) else {}
20268
- effective_name = str(dash_name or data.get("dashName") or "").strip() or "未命名门户"
20789
+ # dash_name 语义:None=未指定(沿用 base_payload 读面名,缺省再回落未命名门户);
20790
+ # 空串=显式空名(后端 dash_name 允许空串,生产存量存在;round-trip 保真必须透传)。
20791
+ if dash_name is not None:
20792
+ effective_name = str(dash_name).strip()
20793
+ else:
20794
+ effective_name = str(data.get("dashName") or "").strip() or "未命名门户"
20269
20795
  effective_hide_copyright = hide_copyright if hide_copyright is not None else bool(data.get("hideCopyright", False))
20270
- if icon or color or not data.get("dashIcon"):
20796
+ # dashIcon round-trip 保真(r4 全量实测「默认值补写」修复):
20797
+ # - 声明显式给 icon/color → 重编码(存量形态走最小 patch);
20798
+ # - 声明未指定 + 创建语义(base_payload 无) → 合成默认(创建行为不变);
20799
+ # - 声明未指定 + 更新语义 → 不碰 dashIcon:存量有则保留原值,存量空则**不合成**。
20800
+ # 后端 updateDashBaseInfo 对 dashIcon 无校验直接透传;合成默认会让「B0 无图标」
20801
+ # 门户在 apply 后长出默认图标/默认色(r4 实测 50 处 color diff 根因)。
20802
+ if icon or color:
20803
+ if base_payload is not None and isinstance(base_payload.get("dashIcon"), str):
20804
+ data["dashIcon"] = update_workspace_icon_from_existing(
20805
+ icon=icon,
20806
+ color=color,
20807
+ title=effective_name,
20808
+ existing_icon=str(data.get("dashIcon") or "").strip() or None,
20809
+ fallback_icon_name="view-grid",
20810
+ )
20811
+ else:
20812
+ data["dashIcon"] = encode_workspace_icon_with_defaults(
20813
+ icon=icon,
20814
+ color=color,
20815
+ title=effective_name,
20816
+ fallback_icon_name="view-grid",
20817
+ existing_icon=str(data.get("dashIcon") or "").strip() or None,
20818
+ )
20819
+ elif base_payload is None:
20271
20820
  data["dashIcon"] = encode_workspace_icon_with_defaults(
20272
- icon=icon,
20273
- color=color,
20821
+ icon=None,
20822
+ color=None,
20274
20823
  title=effective_name,
20275
20824
  fallback_icon_name="view-grid",
20276
- existing_icon=str(data.get("dashIcon") or "").strip() or None,
20825
+ existing_icon=None,
20277
20826
  )
20278
20827
  data["dashName"] = effective_name
20279
20828
  data["auth"] = deepcopy(auth if auth is not None else data.get("auth") or default_member_auth())
@@ -20283,10 +20832,15 @@ def _build_public_portal_base_payload(
20283
20832
  elif "tags" not in data:
20284
20833
  data["tags"] = []
20285
20834
  global_config = deepcopy(dash_global_config if isinstance(dash_global_config, dict) else data.get("dashGlobalConfig") or {})
20835
+ global_config = _compile_portal_global_config(global_config)
20286
20836
  global_config.pop("layout", None)
20287
20837
  global_config["interval"] = global_config.get("interval") or 60
20288
20838
  global_config["beingAutoRefresh"] = bool(global_config.get("beingAutoRefresh", False))
20289
20839
  data["dashGlobalConfig"] = global_config
20840
+ if style_template_id is not None:
20841
+ data["styleTemplateId"] = style_template_id
20842
+ if overall_style is not None:
20843
+ data["overallConfigBO"] = deepcopy(overall_style)
20290
20844
  if isinstance(config, dict):
20291
20845
  data.update(deepcopy(config))
20292
20846
  return data
@@ -20392,18 +20946,37 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
20392
20946
 
20393
20947
  def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
20394
20948
  mobile_provided = bool(getattr(position, "mobile_provided", False))
20395
- mobile_rows = int(getattr(position, "mobile_h", 8)) if mobile_provided else int(getattr(position, "pc_h", 8))
20949
+
20950
+ def _coord(raw: Any, *, explicit: bool, default: int) -> int | None:
20951
+ # explicit=False(mobile 未提供):忽略模型默认值,沿用调用方 default(布局推断),
20952
+ # 与旧实现 `int(v) if provided else default` 行为一致;
20953
+ # explicit=True:显式 null 原样透传(后端 DashPosition null 语义,筛选组件等原生
20954
+ # NULL 位置读写链双向透传),仅键缺省时才回落布局默认值。
20955
+ if not explicit:
20956
+ return default
20957
+ if raw is None:
20958
+ return None
20959
+ return int(raw)
20960
+
20961
+ mobile_h_raw = getattr(position, "mobile_h", None) if mobile_provided else None
20962
+ pc_h_raw = getattr(position, "pc_h", None)
20963
+ if mobile_provided:
20964
+ # mobile 显式提供:rows 以 mobile_h 为准,null 透传(后端语义)
20965
+ mobile_rows = None if mobile_h_raw is None else int(mobile_h_raw)
20966
+ else:
20967
+ # mobile 未提供:rows 跟随 pc 高度(与旧实现一致);pc_h 显式 null 时回落布局默认 8
20968
+ mobile_rows = int(pc_h_raw) if pc_h_raw is not None else 8
20396
20969
  return {
20397
20970
  "pc": {
20398
- "x": int(getattr(position, "pc_x", 0)),
20399
- "y": int(getattr(position, "pc_y", 0)),
20400
- "cols": int(getattr(position, "pc_w", 12)),
20401
- "rows": int(getattr(position, "pc_h", 8)),
20971
+ "x": _coord(getattr(position, "pc_x", None), explicit=True, default=0),
20972
+ "y": _coord(getattr(position, "pc_y", None), explicit=True, default=0),
20973
+ "cols": _coord(getattr(position, "pc_w", None), explicit=True, default=12),
20974
+ "rows": _coord(getattr(position, "pc_h", None), explicit=True, default=8),
20402
20975
  },
20403
20976
  "mobile": {
20404
- "x": int(getattr(position, "mobile_x", 0)) if mobile_provided else 0,
20405
- "y": int(getattr(position, "mobile_y", 0)) if mobile_provided else int(inferred_mobile_y),
20406
- "cols": int(getattr(position, "mobile_w", 6)) if mobile_provided else 6,
20977
+ "x": _coord(getattr(position, "mobile_x", None), explicit=mobile_provided, default=0),
20978
+ "y": _coord(getattr(position, "mobile_y", None), explicit=mobile_provided, default=inferred_mobile_y),
20979
+ "cols": _coord(getattr(position, "mobile_w", None), explicit=mobile_provided, default=6),
20407
20980
  "rows": mobile_rows,
20408
20981
  },
20409
20982
  }
@@ -20434,6 +21007,15 @@ def _portal_component_position_public(
20434
21007
  elif source_name == "link":
20435
21008
  cols = 12
20436
21009
  rows = 2
21010
+ elif source_name == "carousel":
21011
+ cols = 24
21012
+ rows = 7
21013
+ elif source_name == "task":
21014
+ cols = 12
21015
+ rows = 8
21016
+ elif source_name == "favourite":
21017
+ cols = 12
21018
+ rows = 6
20437
21019
  else:
20438
21020
  if layout_preset == "dashboard_2col":
20439
21021
  cols = 12
@@ -23584,6 +24166,24 @@ def _ensure_field_temp_ids(fields: list[dict[str, Any]]) -> None:
23584
24166
  temp_id -= 100
23585
24167
 
23586
24168
 
24169
+ def _field_index_by_ref(fields: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
24170
+ """按稳定引用(que_id,新字段退化 que_temp_id)建索引——方案 A 同名场景
24171
+ 下 by_name 会把不同 queId 的同名字段折叠成一个,relation 目标错位(实测
24172
+ a9lfp40imk02:两个同名 code_block 各自输出到同名不同 queId 的 text 字段,
24173
+ by_name 把两条 relation 撞到同一目标,触发 multiple default-value relations)。
24174
+ """
24175
+ index: dict[int, dict[str, Any]] = {}
24176
+ for field in fields:
24177
+ if not isinstance(field, dict):
24178
+ continue
24179
+ ref = _coerce_positive_int(field.get("que_id"))
24180
+ if ref is None:
24181
+ ref = _coerce_any_int(field.get("que_temp_id"))
24182
+ if ref is not None and ref not in index:
24183
+ index[ref] = field
24184
+ return index
24185
+
24186
+
23587
24187
  def _compile_code_block_binding_fields(
23588
24188
  *,
23589
24189
  fields: list[dict[str, Any]],
@@ -23596,6 +24196,7 @@ def _compile_code_block_binding_fields(
23596
24196
  for field in next_fields
23597
24197
  if isinstance(field, dict) and str(field.get("name") or "")
23598
24198
  }
24199
+ by_ref = _field_index_by_ref(next_fields)
23599
24200
  existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
23600
24201
  relation_specs: list[dict[str, Any]] = []
23601
24202
  affected_source_refs: set[int] = set()
@@ -23711,7 +24312,12 @@ def _compile_code_block_binding_fields(
23711
24312
  target_payload = output_item.get("target_field")
23712
24313
  if not isinstance(target_payload, dict):
23713
24314
  continue
23714
- target_field = by_name.get(str(target_payload.get("name") or ""))
24315
+ # 编译产物 target_field 已带按 queId 唯一解析出的 que_id(同名字段
24316
+ # 不折叠);以 ref 定位目标,que_id 缺失(新字段 temp 形态)才退化
24317
+ # by_name——by_name 对同名目标会错位到任意一个同名字段。
24318
+ target_field = by_ref.get(_coerce_any_int(target_payload.get("que_id")) or -1)
24319
+ if not isinstance(target_field, dict):
24320
+ target_field = by_name.get(str(target_payload.get("name") or ""))
23715
24321
  if not isinstance(target_field, dict):
23716
24322
  continue
23717
24323
  target_ref = _coerce_positive_int(target_field.get("que_id"))
@@ -23779,6 +24385,7 @@ def _materialize_code_block_question_relations(
23779
24385
  relation_specs: list[dict[str, Any]],
23780
24386
  ) -> list[dict[str, Any]]:
23781
24387
  by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
24388
+ by_ref = _field_index_by_ref(fields)
23782
24389
  materialized: list[dict[str, Any]] = []
23783
24390
  for relation in relation_specs:
23784
24391
  if not isinstance(relation, dict):
@@ -23786,8 +24393,15 @@ def _materialize_code_block_question_relations(
23786
24393
  if _coerce_positive_int(relation.get("relationType")) == RELATION_TYPE_CODE_BLOCK:
23787
24394
  materialized.append(deepcopy(relation))
23788
24395
  continue
23789
- source_field = by_name.get(str(relation.get("source_field_name") or ""))
23790
- target_field = by_name.get(str(relation.get("target_field_name") or ""))
24396
+ # relation_spec 的 *_field_ref 来自编译期按 queId 唯一解析的结果,优先
24397
+ # 以 ref 定位源/目标字段;ref 缺失(新字段 temp 形态)退化 by_name——
24398
+ # 同名字段场景 by_name 会折叠错位(与 _compile_* 侧同口径)。
24399
+ source_field = by_ref.get(_coerce_any_int(relation.get("source_field_ref")) or -1)
24400
+ if not isinstance(source_field, dict):
24401
+ source_field = by_name.get(str(relation.get("source_field_name") or ""))
24402
+ target_field = by_ref.get(_coerce_any_int(relation.get("target_field_ref")) or -1)
24403
+ if not isinstance(target_field, dict):
24404
+ target_field = by_name.get(str(relation.get("target_field_name") or ""))
23791
24405
  if not isinstance(source_field, dict) or not isinstance(target_field, dict):
23792
24406
  continue
23793
24407
  source_ref = _coerce_positive_int(source_field.get("que_id"))
@@ -23911,6 +24525,7 @@ def _compile_q_linker_binding_fields(
23911
24525
  for field in next_fields
23912
24526
  if isinstance(field, dict) and str(field.get("name") or "")
23913
24527
  }
24528
+ by_ref = _field_index_by_ref(next_fields)
23914
24529
  existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
23915
24530
  relation_specs: list[dict[str, Any]] = []
23916
24531
  affected_source_refs: set[int] = set()
@@ -24007,21 +24622,39 @@ def _compile_q_linker_binding_fields(
24007
24622
  compiled_json_value = None if request.get("json_value") is None else str(request.get("json_value") or "")
24008
24623
  compiled_xml_value = None if request.get("xml_value") is None else str(request.get("xml_value") or "")
24009
24624
  for index, input_item in enumerate(cast(list[dict[str, Any]], binding.get("inputs") or [])):
24010
- source_field = _resolve_field_selector_with_uniqueness(
24011
- fields=next_fields,
24012
- selector_payload=cast(dict[str, Any], input_item.get("field") or {}),
24013
- location=f"q_linker_binding.inputs[{index}].field",
24014
- )
24015
- source_que_id = _coerce_positive_int(source_field.get("que_id"))
24016
- if source_que_id is None:
24017
- source_que_id = _coerce_any_int(source_field.get("que_temp_id"))
24625
+ selector_payload = cast(dict[str, Any], input_item.get("field") or {})
24626
+ declared_que_id = _coerce_positive_int(selector_payload.get("que_id"))
24627
+ declared_name = str(selector_payload.get("name") or "").strip()
24628
+ try:
24629
+ source_field = _resolve_field_selector_with_uniqueness(
24630
+ fields=next_fields,
24631
+ selector_payload=selector_payload,
24632
+ location=f"q_linker_binding.inputs[{index}].field",
24633
+ )
24634
+ except AmbiguousFieldSelector:
24635
+ raise
24636
+ except ValueError:
24637
+ if declared_que_id is None:
24638
+ raise
24639
+ # 透传:queId 已锚定但字段不在当前表单(存量悬空/改名引用)——后端
24640
+ # 写链对 params_que_id 无存在性校验,按声明 queId 直写,名称用声明名
24641
+ source_field = None
24642
+ if source_field is None:
24643
+ source_que_id = declared_que_id
24644
+ else:
24645
+ source_que_id = _coerce_positive_int(source_field.get("que_id"))
24646
+ if source_que_id is None:
24647
+ source_que_id = _coerce_any_int(source_field.get("que_temp_id"))
24018
24648
  if source_que_id is None:
24019
- raise ValueError(f"q_linker input field '{source_field.get('name')}' does not have a stable que_id")
24649
+ raise ValueError(f"q_linker input field '{declared_name or (source_field or {}).get('name')}' does not have a stable que_id")
24650
+ # 引用重组优先用声明名:B0 模板引用名与当前字段名可能不同(存量字段名
24651
+ # 带尾随空格等,如 am4oh08v2401),按解析名重组会改写模板破坏 round-trip
24652
+ compose_name = declared_name or str((source_field or {}).get("name") or "")
24020
24653
  key = str(input_item.get("key") or "").strip()
24021
24654
  source_kind = str(input_item.get("source") or "").strip().lower()
24022
24655
  compiled_inputs.append(
24023
24656
  {
24024
- "field": {"name": source_field.get("name"), "que_id": source_que_id},
24657
+ "field": {"name": compose_name, "que_id": source_que_id},
24025
24658
  "key": key,
24026
24659
  "source": source_kind,
24027
24660
  }
@@ -24035,7 +24668,7 @@ def _compile_q_linker_binding_fields(
24035
24668
  compiled_url_encoded.append(kv_item)
24036
24669
  elif source_kind == "json_path":
24037
24670
  token = "{{" + key + "}}"
24038
- reference = _compose_qlinker_value_reference(field_name=str(source_field.get("name") or ""), que_id=source_que_id)
24671
+ reference = _compose_qlinker_value_reference(field_name=compose_name, que_id=source_que_id)
24039
24672
  compiled_url = compiled_url.replace(token, reference)
24040
24673
  if compiled_json_value is not None:
24041
24674
  compiled_json_value = compiled_json_value.replace(token, reference)
@@ -24121,7 +24754,12 @@ def _compile_q_linker_binding_fields(
24121
24754
  target_payload = output_item.get("target_field")
24122
24755
  if not isinstance(target_payload, dict):
24123
24756
  continue
24124
- target_field = by_name.get(str(target_payload.get("name") or ""))
24757
+ # 编译产物 target_field 已带按 queId 唯一解析出的 que_id(同名字段
24758
+ # 不折叠);以 ref 定位目标,que_id 缺失(新字段 temp 形态)才退化
24759
+ # by_name——by_name 对同名目标会错位到任意一个同名字段。
24760
+ target_field = by_ref.get(_coerce_any_int(target_payload.get("que_id")) or -1)
24761
+ if not isinstance(target_field, dict):
24762
+ target_field = by_name.get(str(target_payload.get("name") or ""))
24125
24763
  if not isinstance(target_field, dict):
24126
24764
  continue
24127
24765
  target_ref = _coerce_positive_int(target_field.get("que_id"))
@@ -24179,12 +24817,20 @@ def _materialize_q_linker_question_relations(
24179
24817
  relation_specs: list[dict[str, Any]],
24180
24818
  ) -> list[dict[str, Any]]:
24181
24819
  by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
24820
+ by_ref = _field_index_by_ref(fields)
24182
24821
  materialized: list[dict[str, Any]] = []
24183
24822
  for relation in relation_specs:
24184
24823
  if not isinstance(relation, dict):
24185
24824
  continue
24186
- source_field = by_name.get(str(relation.get("source_field_name") or ""))
24187
- target_field = by_name.get(str(relation.get("target_field_name") or ""))
24825
+ # relation_spec 的 *_field_ref 来自编译期按 queId 唯一解析的结果,优先
24826
+ # 以 ref 定位源/目标字段;ref 缺失(新字段 temp 形态)退化 by_name——
24827
+ # 同名字段场景 by_name 会折叠错位(与 _compile_* 侧同口径)。
24828
+ source_field = by_ref.get(_coerce_any_int(relation.get("source_field_ref")) or -1)
24829
+ if not isinstance(source_field, dict):
24830
+ source_field = by_name.get(str(relation.get("source_field_name") or ""))
24831
+ target_field = by_ref.get(_coerce_any_int(relation.get("target_field_ref")) or -1)
24832
+ if not isinstance(target_field, dict):
24833
+ target_field = by_name.get(str(relation.get("target_field_name") or ""))
24188
24834
  if not isinstance(source_field, dict) or not isinstance(target_field, dict):
24189
24835
  continue
24190
24836
  source_ref = _coerce_positive_int(source_field.get("que_id"))
@@ -27814,6 +28460,385 @@ def _normalize_portal_components(components: Any) -> list[dict[str, Any]]:
27814
28460
  return items
27815
28461
 
27816
28462
 
28463
+ def _portal_component_source_type_by_backend(value: Any) -> str:
28464
+ raw = value if isinstance(value, (int, str)) else str(value or "").strip()
28465
+ mapping = {
28466
+ 1: "chart",
28467
+ 2: "grid",
28468
+ 3: "carousel",
28469
+ 4: "link",
28470
+ 5: "text",
28471
+ 6: "filter",
28472
+ 7: "favourite",
28473
+ 8: "task",
28474
+ 9: "chart",
28475
+ 10: "view",
28476
+ }
28477
+ if isinstance(raw, int):
28478
+ return mapping.get(raw, "unknown")
28479
+ raw_str = str(raw or "").strip()
28480
+ if raw_str.isdigit():
28481
+ return mapping.get(int(raw_str), "unknown")
28482
+ return _normalize_portal_component_source_type(raw)
28483
+
28484
+
28485
+ def _portal_section_from_component(
28486
+ component: dict[str, Any],
28487
+ *,
28488
+ index: int,
28489
+ complete: bool,
28490
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
28491
+ """Reverse-compile one DashComponentInfo into a canonical section."""
28492
+ source_type = _portal_component_source_type_by_backend(component.get("type"))
28493
+ config_key_map = {
28494
+ "grid": "gridConfig",
28495
+ "link": "linkConfig",
28496
+ "text": "textConfig",
28497
+ "filter": "filterConfig",
28498
+ "chart": "chartConfig",
28499
+ "view": "viewgraphConfig",
28500
+ "carousel": "carouselConfig",
28501
+ "task": "taskConfig",
28502
+ "favourite": "favouriteConfig",
28503
+ }
28504
+ if source_type == "unknown":
28505
+ return None, {
28506
+ "index": index,
28507
+ "code": "UNKNOWN_COMPONENT_TYPE",
28508
+ "message": f"component type {component.get('type')} is not part of the portals declaration contract",
28509
+ "blocking_field": "source_type",
28510
+ }
28511
+ config = component.get(config_key_map.get(source_type, "")) if isinstance(component.get(config_key_map.get(source_type, "")), dict) else {}
28512
+ section: dict[str, Any] = {"source_type": source_type}
28513
+ title = _extract_portal_component_title(component, source_type=source_type)
28514
+ gaps: list[dict[str, Any]] = []
28515
+ if source_type == "carousel":
28516
+ title = str(config.get("sourceApp", {}).get("formTitle") or "").strip() or f"轮播 {index + 1}"
28517
+ elif source_type == "task":
28518
+ title = str(config.get("componentTaskTitle") or f"待办 {index + 1}")
28519
+ elif source_type == "favourite":
28520
+ title = str(config.get("favouriteTitle") or f"收藏 {index + 1}")
28521
+ # chart/grid/view 的 title 语义=组件显示标题(component_title/grid_title 落库列),
28522
+ # 生产存量大量存在「显示标题开但标题空」形态(读链渲染时回退图表名/视图名):
28523
+ # 合成「组件 N」会把不存在的标题落库(读链 chartName 派生被覆盖 + 库表漂移),
28524
+ # 如实保留 None(编译不落库,round-trip 保真)。carousel/task/favourite 的
28525
+ # 读面自有真名回填(formTitle/componentTaskTitle/favouriteTitle),其合成兑底保留。
28526
+ # filter 同样不合成:后端 addComponentInfos 对筛选组件不落任何标题(无标题存储),
28527
+ # 读面按代理键 HashMap 迭代序返回——合成「组件 {index+1}」的标签是列表序号的
28528
+ # 函数,apply 删重建致代理键位移后标题跟着置换(r6 幂等差 3 的唯一根因)。
28529
+ if source_type not in {"chart", "grid", "view", "filter"}:
28530
+ title = title or f"组件 {index + 1}"
28531
+ section["title"] = title
28532
+ if source_type in {"chart", "view"} and complete:
28533
+ # beingShowTitle 落库列(draft_dash_chart.being_show_title):读面必有值;
28534
+ # 提取必须保真(存量 false 被「硬编码 True」覆写会漂移库表)。grid 的开关
28535
+ # 在 grid_config.beingShowTitle 键内随 config 保留,无需单独建模。
28536
+ if "beingShowTitle" in config:
28537
+ section["show_title"] = bool(config.get("beingShowTitle"))
28538
+ position = component.get("position")
28539
+ if isinstance(position, dict) and position:
28540
+ section["position"] = deepcopy(position)
28541
+ if complete:
28542
+ style = component.get("dashStyleConfigBO")
28543
+ if isinstance(style, dict) and style:
28544
+ section["style_config"] = deepcopy(style)
28545
+ if source_type == "chart":
28546
+ # 后端两类图表组件引用面不同(DashSvcImpl 写链):type=1 轻流报表锚 chart 表唯一键
28547
+ # chartKey(addChartInfos);type=9 轻析 BI 报表锚 bi_chart 整型 chart_id(addBiChartInfo)。
28548
+ # 提取时以读面 type 判别 ref_kind,保证 apply 回写同一引用面。
28549
+ backend_type = component.get("type")
28550
+ is_qingflow_chart = backend_type == 1 or (
28551
+ backend_type != 9 and str(config.get("chartKey") or "").strip() and not config.get("biChartId")
28552
+ )
28553
+ if is_qingflow_chart:
28554
+ chart_ref: dict[str, Any] = {
28555
+ "ref_kind": "chart",
28556
+ "chart_id": str(config.get("chartKey") or "").strip() or None,
28557
+ "chart_name": str(config.get("chartName") or title or "").strip() or None,
28558
+ }
28559
+ else:
28560
+ chart_ref = {
28561
+ "chart_id": str(config.get("biChartId") or "").strip() or None,
28562
+ "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
28563
+ }
28564
+ chart_ref = {key: value for key, value in chart_ref.items() if value is not None}
28565
+ if not chart_ref.get("chart_id"):
28566
+ gaps.append({
28567
+ "index": index,
28568
+ "code": "CHART_REF_MISSING",
28569
+ "message": "chart component has no chartKey/biChartId in the read face",
28570
+ "blocking_field": "chart_ref.chart_id",
28571
+ })
28572
+ return None, gaps[0]
28573
+ section["chart_ref"] = chart_ref
28574
+ elif source_type == "view":
28575
+ view_ref: dict[str, Any] = {
28576
+ "app_key": str(config.get("appKey") or "").strip() or None,
28577
+ "view_key": str(config.get("viewgraphKey") or "").strip() or None,
28578
+ "view_name": str(config.get("viewgraphName") or title or "").strip() or None,
28579
+ }
28580
+ view_ref = {key: value for key, value in view_ref.items() if value is not None}
28581
+ if not view_ref.get("view_key"):
28582
+ gaps.append({
28583
+ "index": index,
28584
+ "code": "VIEW_REF_MISSING",
28585
+ "message": "view component has no viewgraphKey in the read face",
28586
+ "blocking_field": "view_ref.view_key",
28587
+ })
28588
+ return None, gaps[0]
28589
+ section["view_ref"] = view_ref
28590
+ if complete and ("beingShowTitle" in config or config.get("componentTitle")):
28591
+ display: dict[str, Any] = {}
28592
+ if "beingShowTitle" in config:
28593
+ display["show_title"] = bool(config.get("beingShowTitle"))
28594
+ if config.get("componentTitle"):
28595
+ display["component_title"] = str(config.get("componentTitle"))
28596
+ if display:
28597
+ section["view_display"] = display
28598
+ elif source_type == "grid":
28599
+ grid_config: dict[str, Any] = {}
28600
+ for key in ("gridTitle", "beingShowTitle", "items"):
28601
+ if key in config:
28602
+ grid_config[key] = deepcopy(config.get(key))
28603
+ if isinstance(grid_config.get("items"), list):
28604
+ # 归一到声明规范键集(DraftComponentGridItem 落库列 + 读面派生 title):
28605
+ # 丢弃运行态/代理键(id/gridId/beingShowTitle/beingTrial/trialExpireDate——
28606
+ # 前者是自增代理键(比对器另剔),后两者读面运行态不入库,title/iconUrl 由
28607
+ # setTitleAndIconByType 按引用派生);空 title 条目仍过滤(后端读面同型)。
28608
+ kept_item_keys = {
28609
+ "type", "jumpMode", "linkAppKey", "linkFormType", "linkDashKey",
28610
+ "linkTagId", "linkUrl", "title", "customTitle", "beingCustomTitle",
28611
+ "iconUrl", "ordinal",
28612
+ }
28613
+ normalized_items: list[dict[str, Any]] = []
28614
+ for item in grid_config["items"]:
28615
+ if not isinstance(item, dict) or not str(item.get("title") or "").strip():
28616
+ continue
28617
+ normalized = {
28618
+ key: deepcopy(value)
28619
+ for key, value in item.items()
28620
+ if key in kept_item_keys and value is not None
28621
+ }
28622
+ # iconUrl 语义按条目类型分裂(Java ComponentGridItemInfo#setTitleAndIconByType):
28623
+ # type 1/2/3(应用/门户/应用包)的 iconUrl 与 title 是读面按引用目标当前
28624
+ # 图标实时派生的投影——门户声明不拥有也无法保真(写侧不回写生效值),
28625
+ # 提取侧剔除;自定义条目 iconUrl 落库原样读回,保留 round-trip。
28626
+ # 空串 iconUrl 统一剔除(与 favourite 提取对称,消 ""↔缺键不对称)。
28627
+ if _coerce_positive_int(normalized.get("type")) in {1, 2, 3} or not str(normalized.get("iconUrl") or "").strip():
28628
+ normalized.pop("iconUrl", None)
28629
+ normalized_items.append(normalized)
28630
+ grid_config["items"] = normalized_items
28631
+ section["config"] = grid_config
28632
+ elif source_type == "text":
28633
+ section["text"] = str(config.get("text") or "")
28634
+ if complete and config:
28635
+ extra_config = {key: deepcopy(value) for key, value in config.items() if key not in {"text"}}
28636
+ if extra_config:
28637
+ section["config"] = extra_config
28638
+ elif source_type == "link":
28639
+ section["url"] = str(config.get("url") or "")
28640
+ if complete and "beingLoginAuth" in config:
28641
+ section["login_auth"] = bool(config.get("beingLoginAuth"))
28642
+ elif source_type == "filter":
28643
+ graph_list = config.get("graphList") if isinstance(config.get("graphList"), list) else []
28644
+ graphs = [
28645
+ {
28646
+ "chart_key": str(item.get("graphKey") or "").strip() or None,
28647
+ "chart_name": str(item.get("graphName") or "").strip() or None,
28648
+ }
28649
+ for item in graph_list
28650
+ if isinstance(item, dict)
28651
+ ]
28652
+ graphs = [
28653
+ {key: value for key, value in item.items() if value is not None}
28654
+ for item in graphs
28655
+ ]
28656
+ rules: list[dict[str, Any]] = []
28657
+ for group in config.get("filterConfig") or []:
28658
+ if not isinstance(group, dict):
28659
+ continue
28660
+ # 剔除后端删除重建的代理键(filterGroupId/filterItemId,自增 id 每次位移,七条约束之六):
28661
+ # 声明级比以此归一,规则语义真源是 filterGroupName/filterItemName/judge 系内容列。
28662
+ rules.append({
28663
+ key: deepcopy(value)
28664
+ for key, value in group.items()
28665
+ if key not in {"filterGroupId"}
28666
+ })
28667
+ for group in rules:
28668
+ for item in group.get("filterGroupConfig") or []:
28669
+ if isinstance(item, dict):
28670
+ item.pop("filterItemId", None)
28671
+ section["config"] = {"graphs": graphs, "rules": rules}
28672
+ elif source_type == "carousel":
28673
+ carousel: dict[str, Any] = {}
28674
+ source_app = config.get("sourceApp") if isinstance(config.get("sourceApp"), dict) else {}
28675
+ if source_app.get("appKey"):
28676
+ carousel["source_app_key"] = str(source_app.get("appKey"))
28677
+ if source_app.get("formTitle"):
28678
+ carousel["source_form_title"] = str(source_app.get("formTitle"))
28679
+ for raw_key, target_key in (("titleQue", "title_field"), ("imageQue", "image_field"), ("linkQue", "link_field")):
28680
+ que = config.get(raw_key) if isinstance(config.get(raw_key), dict) else {}
28681
+ if que.get("queId") is not None:
28682
+ carousel[target_key] = str(que.get("queId"))
28683
+ if "jumpMode" in config:
28684
+ carousel["jump_mode"] = config.get("jumpMode")
28685
+ if "beingScaling" in config:
28686
+ carousel["scaling"] = bool(config.get("beingScaling"))
28687
+ if "showLatestLimit" in config:
28688
+ carousel["show_latest_limit"] = config.get("showLatestLimit")
28689
+ if "scrollInterval" in config:
28690
+ carousel["scroll_interval"] = config.get("scrollInterval")
28691
+ filter_config = config.get("filterConfig")
28692
+ if isinstance(filter_config, list) and filter_config:
28693
+ # ComponentCarouselConfig.filterConfig 是单组 AND 的 MatchRule 列表。
28694
+ # DashSvcImpl#getDashDetail 会补 judgeValueDetails/judgeType;复用门户既有
28695
+ # match-rule 映射,优先以 details 作为选项值真源,避免只回传派生 judgeValues。
28696
+ raw_rules = filter_config[0] if isinstance(filter_config[0], list) else filter_config
28697
+ filters: list[dict[str, Any]] = []
28698
+ for rule in raw_rules:
28699
+ if not isinstance(rule, dict):
28700
+ continue
28701
+ que_id = _coerce_positive_int(rule.get("queId"))
28702
+ if que_id is None:
28703
+ gaps.append({
28704
+ "index": index,
28705
+ "code": "CAROUSEL_FILTER_FIELD_UNRESOLVED",
28706
+ "message": "carousel filter rule has no resolvable queId",
28707
+ "blocking_field": "carousel.filters",
28708
+ })
28709
+ continue
28710
+ field = {"que_id": que_id, "name": str(rule.get("queTitle") or que_id)}
28711
+ values = _public_view_filter_rule_values(rule, field=field)
28712
+ operator = _public_view_filter_operator_from_judge_type(rule.get("judgeType"), values=values)
28713
+ if operator.startswith("judge_type:"):
28714
+ gaps.append({
28715
+ "index": index,
28716
+ "code": "CAROUSEL_FILTER_OPERATOR_UNSUPPORTED",
28717
+ "message": "carousel filter rule judgeType is not expressible in v1alpha1",
28718
+ "blocking_field": "carousel.filters",
28719
+ "judge_type": rule.get("judgeType"),
28720
+ })
28721
+ continue
28722
+ item: dict[str, Any] = {"field_name": str(que_id), "operator": operator}
28723
+ if operator not in {"is_empty", "not_empty"}:
28724
+ item["values"] = values
28725
+ filters.append(item)
28726
+ if filters:
28727
+ carousel["filters"] = filters
28728
+ section["carousel"] = carousel
28729
+ elif source_type == "task":
28730
+ task: dict[str, Any] = {}
28731
+ if "beingShowTitle" in config:
28732
+ task["show_title"] = bool(config.get("beingShowTitle"))
28733
+ if config.get("componentTaskHint") is not None:
28734
+ task["hint"] = str(config.get("componentTaskHint") or "")
28735
+ if "beingShowHint" in config:
28736
+ task["show_hint"] = bool(config.get("beingShowHint"))
28737
+ if "beingUsing" in config:
28738
+ task["enabled"] = bool(config.get("beingUsing"))
28739
+ raw_tasks = config.get("dashTaskConfigList")
28740
+ if isinstance(raw_tasks, list) and raw_tasks:
28741
+ task["tasks"] = [
28742
+ {
28743
+ "title": str(item.get("title") or ""),
28744
+ "task_type": item.get("type"),
28745
+ "ordinal": item.get("ordinal"),
28746
+ "checked": bool(item.get("beingCheck")),
28747
+ }
28748
+ for item in raw_tasks
28749
+ if isinstance(item, dict)
28750
+ ]
28751
+ section["task"] = task
28752
+ elif source_type == "favourite":
28753
+ favourite: dict[str, Any] = {}
28754
+ if "beingShowTitle" in config:
28755
+ favourite["show_title"] = bool(config.get("beingShowTitle"))
28756
+ if "beingUsing" in config:
28757
+ favourite["enabled"] = bool(config.get("beingUsing"))
28758
+ raw_items = config.get("items")
28759
+ if isinstance(raw_items, list) and raw_items:
28760
+ favourite["items"] = [
28761
+ {
28762
+ "title": str(item.get("title") or ""),
28763
+ "item_type": item.get("type"),
28764
+ "link_key": str(item.get("linkKey") or "") or None,
28765
+ "link_id": item.get("linkId"),
28766
+ "icon_url": str(item.get("iconUrl") or "") or None,
28767
+ "jump_mode": item.get("jumpMode"),
28768
+ "ordinal": item.get("ordinal"),
28769
+ }
28770
+ for item in raw_items
28771
+ if isinstance(item, dict)
28772
+ ]
28773
+ favourite["items"] = [
28774
+ {key: value for key, value in item.items() if value is not None}
28775
+ for item in favourite["items"]
28776
+ ]
28777
+ section["favourite"] = favourite
28778
+ return section, (gaps[0] if gaps else None)
28779
+
28780
+
28781
+ def _portal_declaration_from_dash_detail(
28782
+ detail: dict[str, Any],
28783
+ *,
28784
+ schema_kind: str,
28785
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
28786
+ """Reverse-compile a DashDetailInfo read into a canonical portal declaration."""
28787
+ complete = schema_kind == "complete"
28788
+ gaps: list[dict[str, Any]] = []
28789
+ sections: list[dict[str, Any]] = []
28790
+ raw_components = detail.get("components") if isinstance(detail.get("components"), list) else []
28791
+ for index, component in enumerate(raw_components):
28792
+ if not isinstance(component, dict):
28793
+ continue
28794
+ section, gap = _portal_section_from_component(component, index=index, complete=complete)
28795
+ if gap is not None:
28796
+ gaps.append(gap)
28797
+ if section is not None:
28798
+ sections.append(section)
28799
+ declaration: dict[str, Any] = {"sections": sections}
28800
+ # 后端 dash_name 允许空串(生产存量存在,updateDashBaseInfo 无非空校验):
28801
+ # 提取侧始终写出 name 键,空名透传(round-trip 保真),不再把空名当成缺省。
28802
+ declaration["name"] = str(detail.get("dashName") or "").strip()
28803
+ tags = detail.get("tags") if isinstance(detail.get("tags"), list) else []
28804
+ first_tag = tags[0] if tags and isinstance(tags[0], dict) else {}
28805
+ tag_id = _coerce_positive_int(first_tag.get("tagId"))
28806
+ if tag_id is not None:
28807
+ declaration["package_tag_id"] = tag_id
28808
+ if not complete:
28809
+ return declaration, gaps
28810
+ dash_icon = str(detail.get("dashIcon") or "").strip() or None
28811
+ if dash_icon:
28812
+ icon_config = workspace_icon_config(dash_icon)
28813
+ icon = str(icon_config.get("icon_name") or "").strip() or None
28814
+ color = str(icon_config.get("icon_color") or "").strip() or None
28815
+ if icon:
28816
+ declaration["icon"] = icon
28817
+ if color:
28818
+ declaration["color"] = color
28819
+ if "hideCopyright" in detail:
28820
+ declaration["hide_copyright"] = bool(detail.get("hideCopyright"))
28821
+ global_config = detail.get("dashGlobalConfig") if isinstance(detail.get("dashGlobalConfig"), dict) else {}
28822
+ if global_config:
28823
+ declaration["global_config"] = {}
28824
+ if "interval" in global_config:
28825
+ declaration["global_config"]["interval"] = global_config.get("interval")
28826
+ if "beingAutoRefresh" in global_config:
28827
+ declaration["global_config"]["auto_refresh"] = bool(global_config.get("beingAutoRefresh"))
28828
+ if "beingExport" in global_config:
28829
+ declaration["global_config"]["export"] = bool(global_config.get("beingExport"))
28830
+ visibility = _public_visibility_from_member_auth(detail.get("auth"))
28831
+ if isinstance(visibility, dict) and visibility:
28832
+ declaration["visibility"] = visibility
28833
+ style_template_id = _coerce_positive_int(detail.get("styleTemplateId"))
28834
+ if style_template_id is not None:
28835
+ declaration["style_template_id"] = style_template_id
28836
+ overall = detail.get("overallConfigBO")
28837
+ if isinstance(overall, dict) and overall:
28838
+ declaration["overall_style"] = deepcopy(overall)
28839
+ return declaration, gaps
28840
+
28841
+
27817
28842
  def _custom_button_view_configs_from_view_summaries(views: list[dict[str, Any]]) -> list[dict[str, Any]]:
27818
28843
  view_configs: list[dict[str, Any]] = []
27819
28844
  for view in views:
@@ -27967,7 +28992,10 @@ def _extract_portal_component_title(component: dict[str, Any], *, source_type: s
27967
28992
  "text": ["title", "textTitle"],
27968
28993
  "filter": ["title", "filterTitle"],
27969
28994
  "chart": ["chartComponentTitle", "componentTitle", "title"],
27970
- "view": ["componentTitle", "viewgraphName", "title"],
28995
+ # view 的 title 语义=组件显示标题(component_title 落库列,可空);视图名
28996
+ # (viewgraphName)是引用面锚进 view_ref.view_name,不再混入 title 候选
28997
+ # (旧候选把它兑底进 title,编译时覆写 componentTitle 造成库表漂移)。
28998
+ "view": ["componentTitle", "title"],
27971
28999
  }
27972
29000
  for key in title_candidates.get(source_type, []):
27973
29001
  title = str(config.get(key) or "").strip()
@@ -30529,6 +31557,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
30529
31557
  elif operator == "in":
30530
31558
  payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
30531
31559
  payload["judgeValues"] = judge_values
31560
+ elif operator == "belongs_to":
31561
+ payload["judgeType"] = JUDGE_BELONG_TO
31562
+ payload["judgeValues"] = judge_values
30532
31563
  elif operator == "contains":
30533
31564
  payload["judgeType"] = JUDGE_INCLUDE
30534
31565
  payload["judgeValues"] = judge_values[:1] if judge_values else []
@@ -30850,6 +31881,8 @@ def _public_view_filter_operator_from_judge_type(judge_type: Any, *, values: lis
30850
31881
  # ANY 判型+空值 = "该字段未选任何值"的合法存量形态(judgeType 保真回写,
30851
31882
  # 转 is_empty 会丢判型——后端 9 与 0 是不同判定域,实测回读不同)
30852
31883
  return "in"
31884
+ if normalized == JUDGE_BELONG_TO:
31885
+ return "belongs_to"
30853
31886
  if normalized == JUDGE_GREATER_OR_EQUAL:
30854
31887
  return "gte"
30855
31888
  if normalized == JUDGE_LESS_OR_EQUAL: