@qingflow-tech/qingflow-cli 1.1.61-beta.4 → 1.1.61-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +6 -258
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
- package/skills/qingflow-cli/reference/builder/70-portal.md +38 -17
- package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +100 -0
- package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +6 -7
- package/src/qingflow_mcp/app_form_validator.py +20 -3
- package/src/qingflow_mcp/builder_facade/app_form.py +137 -17
- package/src/qingflow_mcp/builder_facade/models.py +177 -15
- package/src/qingflow_mcp/builder_facade/portals_schema.py +397 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +14 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +49 -9
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +20 -7
- package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +59 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +1397 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +193 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +643 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +203 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +268 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +265 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +131 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +199 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +123 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +196 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +800 -0
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +1 -1
- package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +1251 -88
- package/src/qingflow_mcp/cli/commands/builder.py +132 -5
- package/src/qingflow_mcp/public_surface.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +34 -3
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
- package/src/qingflow_mcp/solution/executor.py +5 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +118 -12
- package/src/qingflow_mcp/tools/portal_tools.py +4 -54
- package/src/qingflow_mcp/tools/portal_writer.py +59 -0
- package/src/qingflow_mcp/tools/solution_tools.py +2 -0
|
@@ -26,7 +26,12 @@ 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
|
|
29
|
+
from ..solution.compiler.icon_utils import (
|
|
30
|
+
encode_workspace_icon_with_defaults,
|
|
31
|
+
update_workspace_icon_from_existing,
|
|
32
|
+
validate_workspace_icon_choice,
|
|
33
|
+
workspace_icon_config,
|
|
34
|
+
)
|
|
30
35
|
from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
|
|
31
36
|
from ..solution.executor import _build_viewgraph_questions, _compact_dict, _flatten_viewgraph_questions, extract_field_map
|
|
32
37
|
from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
|
|
@@ -35,6 +40,7 @@ from ..tools.custom_button_tools import CustomButtonTools
|
|
|
35
40
|
from ..tools.directory_tools import DirectoryTools
|
|
36
41
|
from ..tools.package_tools import PackageTools
|
|
37
42
|
from ..tools.portal_tools import PortalTools
|
|
43
|
+
from ..tools.portal_writer import PortalWriter
|
|
38
44
|
from ..tools.qingbi_report_tools import QingbiReportTools
|
|
39
45
|
from ..tools.role_tools import RoleTools
|
|
40
46
|
from ..tools.solution_tools import SolutionTools
|
|
@@ -45,6 +51,7 @@ from . import workflow_spec as workflow_spec_api
|
|
|
45
51
|
from .button_style_catalog import button_style_catalog_payload
|
|
46
52
|
from ..tools.view_writer import ViewWriter
|
|
47
53
|
from .views_schema import ViewsContractError, compact_none_values, views_schema_registry
|
|
54
|
+
from .portals_schema import PortalsContractError, PortalsSchemaContext, portals_schema_registry
|
|
48
55
|
from .models import (
|
|
49
56
|
AppChartsReadResponse,
|
|
50
57
|
AppFieldsReadResponse,
|
|
@@ -81,6 +88,7 @@ from .models import (
|
|
|
81
88
|
LayoutSectionPatch,
|
|
82
89
|
LayoutPreset,
|
|
83
90
|
PortalApplyRequest,
|
|
91
|
+
public_portal_apply_payload,
|
|
84
92
|
PortalGetResponse,
|
|
85
93
|
PortalListResponse,
|
|
86
94
|
PortalReadSummaryResponse,
|
|
@@ -167,6 +175,8 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
|
|
|
167
175
|
# 落库),运行时 DataSvcImpl.getAnswerValueByString:attachment 按拆分 URL 写入、
|
|
168
176
|
# subtable 整表组装(均有专门分支),rich_text/data_relation 走 Question.canImport
|
|
169
177
|
# 兜底按字符串写入(不报错)——四者为存量合法配置,与 validator 侧白名单同口径。
|
|
178
|
+
# r10 再放行 link/member(存量实证:92nl6blfk402 link×2、ab9fjvj33001 member;
|
|
179
|
+
# 运行时同为字符串写入路径)。
|
|
170
180
|
INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
|
|
171
181
|
FieldType.text.value,
|
|
172
182
|
FieldType.long_text.value,
|
|
@@ -181,6 +191,8 @@ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
|
|
|
181
191
|
FieldType.attachment.value,
|
|
182
192
|
"data_relation",
|
|
183
193
|
FieldType.subtable.value,
|
|
194
|
+
"link",
|
|
195
|
+
"member",
|
|
184
196
|
)
|
|
185
197
|
|
|
186
198
|
MATCH_TYPE_ACCURACY = 1
|
|
@@ -191,6 +203,9 @@ JUDGE_INCLUDE = 2
|
|
|
191
203
|
JUDGE_GREATER_OR_EQUAL = 5
|
|
192
204
|
JUDGE_LESS_OR_EQUAL = 7
|
|
193
205
|
JUDGE_EQUAL_ANY = 9
|
|
206
|
+
# 成员/部门归属判定(Constants.JUDGE_BELONG_TO):轮播筛选存量数据出现,后端公式引擎
|
|
207
|
+
# JUDGE_FUNCTION[13]="JUDGE_BELONG_TO" 全支持,声明侧以 belongs_to 表达保真回写。
|
|
208
|
+
JUDGE_BELONG_TO = 13
|
|
194
209
|
JUDGE_FUZZY_MATCH = 19
|
|
195
210
|
JUDGE_INCLUDE_ANY = 20
|
|
196
211
|
DEFAULT_TYPE_RELATION = 2
|
|
@@ -305,6 +320,7 @@ class AiBuilderFacade:
|
|
|
305
320
|
self._view_writer = ViewWriter(views.sessions, views.backend)
|
|
306
321
|
self.workflows = workflows
|
|
307
322
|
self.portals = portals
|
|
323
|
+
self._portal_writer = PortalWriter(portals.sessions, portals.backend)
|
|
308
324
|
self.charts = charts
|
|
309
325
|
self.roles = roles
|
|
310
326
|
self.directory = directory
|
|
@@ -3052,6 +3068,7 @@ class AiBuilderFacade:
|
|
|
3052
3068
|
*,
|
|
3053
3069
|
profile: str,
|
|
3054
3070
|
visibility: VisibilityPatch | None,
|
|
3071
|
+
degradation_warnings: list[dict[str, Any]] | None = None,
|
|
3055
3072
|
) -> dict[str, Any]:
|
|
3056
3073
|
if visibility is None:
|
|
3057
3074
|
return default_member_auth()
|
|
@@ -3085,27 +3102,116 @@ class AiBuilderFacade:
|
|
|
3085
3102
|
member_ids=visibility.external_selectors.member_ids,
|
|
3086
3103
|
member_emails=visibility.external_selectors.member_emails,
|
|
3087
3104
|
)
|
|
3088
|
-
#
|
|
3089
|
-
#
|
|
3090
|
-
#
|
|
3105
|
+
# 冗余引用降级(review §2-1 逐主体锚定校验):提取器对同一成员同时产出
|
|
3106
|
+
# uid/email/name 三通道(uid 必有且直通解析,保证成员不丢);email/name 走
|
|
3107
|
+
# 后端通讯录搜索,受数据/权限边界影响解析可能失败。降级只在「该主体已被
|
|
3108
|
+
# 直通锚覆盖」时成立,否则升级为阻断——关闭手写声明混用 uid 锚定 + 仅
|
|
3109
|
+
# email/name 引用时,主体被静默丢弃的窗口。覆盖判定按失败类别的可验证面:
|
|
3110
|
+
# - AMBIGUOUS_*:目录命中多候选,候选(matches)与已解析直通锚 uid/deptId/
|
|
3111
|
+
# roleId 求交——交非空说明歧义主体之一已被锚定,主体集合不丢;
|
|
3112
|
+
# - *_NOT_FOUND:目录答「不存在」,失败值与已解析条目的真实属性(email/
|
|
3113
|
+
# name)比对(直通 stub 条目属性是 id 字符串,不会误匹配);
|
|
3114
|
+
# - *_SEARCH_FAILED:目录服务本身不可达,主体身份无从验证——保留全局
|
|
3115
|
+
# 「直通锚非空」guard(eval 隔离环境外部登录资源不可达 → 搜索 49500
|
|
3116
|
+
# 超时的确定性约束;纯手写声明仍阻断),降级明细经 degradation_warnings
|
|
3117
|
+
# 回响应信封,不再静默。
|
|
3091
3118
|
_SOFT_RESOLUTION_KINDS = {"member_email", "member_name", "department_name", "role", "external_member_email"}
|
|
3092
3119
|
_NOT_FOUND_RESOLUTION_CODES = {"MEMBER_NOT_FOUND", "AMBIGUOUS_MEMBER", "ROLE_NOT_FOUND", "AMBIGUOUS_ROLE", "DEPARTMENT_NOT_FOUND", "AMBIGUOUS_DEPARTMENT"}
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3120
|
+
member_entries = member_resolution.get("member_entries") or []
|
|
3121
|
+
department_entries = department_resolution.get("department_entries") or []
|
|
3122
|
+
role_entries = role_resolution.get("role_entries") or []
|
|
3123
|
+
external_entries = external_member_resolution.get("member_entries") or []
|
|
3124
|
+
uid_direct_count = len(member_entries) + len(department_entries)
|
|
3125
|
+
resolved_anchor_ids = {
|
|
3126
|
+
value
|
|
3127
|
+
for value in (
|
|
3128
|
+
_coerce_positive_int(entry.get("uid"))
|
|
3129
|
+
for entry in [*member_entries, *external_entries]
|
|
3130
|
+
)
|
|
3131
|
+
if value is not None
|
|
3132
|
+
} | {
|
|
3133
|
+
value
|
|
3134
|
+
for value in (
|
|
3135
|
+
_coerce_positive_int(entry.get("deptId"))
|
|
3136
|
+
for entry in department_entries
|
|
3137
|
+
)
|
|
3138
|
+
if value is not None
|
|
3139
|
+
} | {
|
|
3140
|
+
value
|
|
3141
|
+
for value in (_coerce_positive_int(entry.get("roleId")) for entry in role_entries)
|
|
3142
|
+
if value is not None
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
def _match_anchor_ids(issue: dict[str, Any]) -> set[int]:
|
|
3146
|
+
matched: set[int] = set()
|
|
3147
|
+
for candidate in issue.get("matches") or []:
|
|
3148
|
+
if not isinstance(candidate, dict):
|
|
3149
|
+
continue
|
|
3150
|
+
for key in ("uid", "id", "memberId", "deptId", "roleId"):
|
|
3151
|
+
value = _coerce_positive_int(candidate.get(key))
|
|
3152
|
+
if value is not None:
|
|
3153
|
+
matched.add(value)
|
|
3154
|
+
return matched
|
|
3155
|
+
|
|
3156
|
+
def _matches_resolved_attribute(issue: dict[str, Any]) -> bool:
|
|
3157
|
+
kind = str(issue.get("kind") or "")
|
|
3158
|
+
raw = str(issue.get("value") or "").strip()
|
|
3159
|
+
if not raw:
|
|
3160
|
+
return False
|
|
3161
|
+
if kind in {"member_email", "external_member_email"}:
|
|
3162
|
+
entries = external_entries if kind.startswith("external") else member_entries
|
|
3163
|
+
return any(raw.lower() == str(entry.get("email") or "").strip().lower() for entry in entries)
|
|
3164
|
+
if kind == "member_name":
|
|
3165
|
+
return any(raw == str(entry.get("name") or "").strip() for entry in member_entries)
|
|
3166
|
+
if kind == "department_name":
|
|
3167
|
+
return any(raw == str(entry.get("deptName") or "").strip() for entry in department_entries)
|
|
3168
|
+
if kind == "role":
|
|
3169
|
+
return any(raw == str(entry.get("roleName") or "").strip() for entry in role_entries)
|
|
3170
|
+
return False
|
|
3171
|
+
|
|
3172
|
+
blocking_issues: list[dict[str, Any]] = []
|
|
3173
|
+
degraded_issues: list[dict[str, Any]] = []
|
|
3174
|
+
for issue in [
|
|
3175
|
+
*member_resolution["issues"],
|
|
3176
|
+
*role_resolution["issues"],
|
|
3177
|
+
*department_resolution["issues"],
|
|
3178
|
+
*external_member_resolution["issues"],
|
|
3179
|
+
]:
|
|
3180
|
+
if str(issue.get("kind") or "") not in _SOFT_RESOLUTION_KINDS:
|
|
3181
|
+
blocking_issues.append(issue)
|
|
3182
|
+
continue
|
|
3183
|
+
error_code = str(issue.get("error_code") or "")
|
|
3184
|
+
if error_code.endswith("_SEARCH_FAILED"):
|
|
3185
|
+
covered = uid_direct_count > 0
|
|
3186
|
+
elif error_code.startswith("AMBIGUOUS_"):
|
|
3187
|
+
covered = bool(_match_anchor_ids(issue) & resolved_anchor_ids)
|
|
3188
|
+
elif error_code in _NOT_FOUND_RESOLUTION_CODES:
|
|
3189
|
+
covered = _matches_resolved_attribute(issue)
|
|
3190
|
+
else:
|
|
3191
|
+
# PERMISSION_DENIED/未知失败类:不可归因为「主体已被锚定的冗余通道」,
|
|
3192
|
+
# 维持阻断(master 语义)。
|
|
3193
|
+
blocking_issues.append(issue)
|
|
3194
|
+
continue
|
|
3195
|
+
if covered:
|
|
3196
|
+
degraded_issues.append(issue)
|
|
3197
|
+
else:
|
|
3198
|
+
blocking_issues.append(issue)
|
|
3108
3199
|
self._raise_visibility_resolution_issues(blocking_issues)
|
|
3200
|
+
if degraded_issues and degradation_warnings is not None:
|
|
3201
|
+
for issue in degraded_issues:
|
|
3202
|
+
degradation_warnings.append(
|
|
3203
|
+
_warning(
|
|
3204
|
+
"VISIBILITY_SOFT_REFERENCE_DEGRADED",
|
|
3205
|
+
(
|
|
3206
|
+
f"visibility {issue.get('kind')} '{issue.get('value')}' could not be resolved "
|
|
3207
|
+
f"({issue.get('error_code')}); the subject is dropped from visibility selectors "
|
|
3208
|
+
"unless it was also anchored by a direct id reference"
|
|
3209
|
+
),
|
|
3210
|
+
kind=issue.get("kind"),
|
|
3211
|
+
value=issue.get("value"),
|
|
3212
|
+
error_code=issue.get("error_code"),
|
|
3213
|
+
)
|
|
3214
|
+
)
|
|
3109
3215
|
|
|
3110
3216
|
external_depart_entries: list[dict[str, Any]] = []
|
|
3111
3217
|
for dept_id in visibility.external_selectors.dept_ids:
|
|
@@ -8761,7 +8867,25 @@ class AiBuilderFacade:
|
|
|
8761
8867
|
"warnings": warnings,
|
|
8762
8868
|
}
|
|
8763
8869
|
|
|
8764
|
-
def portal_get(
|
|
8870
|
+
def portal_get(
|
|
8871
|
+
self,
|
|
8872
|
+
*,
|
|
8873
|
+
profile: str,
|
|
8874
|
+
dash_key: str,
|
|
8875
|
+
being_draft: bool = True,
|
|
8876
|
+
schema_kind: str = "raw",
|
|
8877
|
+
schema_version: str | None = None,
|
|
8878
|
+
) -> JSONObject:
|
|
8879
|
+
"""读取门户详情;schema_kind=summary|complete 时返回 canonical 声明投影,raw 为原始归一响应。"""
|
|
8880
|
+
normalized_kind = str(schema_kind or "raw").strip().lower() or "raw"
|
|
8881
|
+
if normalized_kind not in {"raw", "summary", "complete"}:
|
|
8882
|
+
return _failed(
|
|
8883
|
+
"UNSUPPORTED_SCHEMA_KIND",
|
|
8884
|
+
f"unsupported portal get schema kind: {normalized_kind}",
|
|
8885
|
+
normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind},
|
|
8886
|
+
details={"supportedKinds": ["raw", "summary", "complete"]},
|
|
8887
|
+
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "schema_kind": "raw"}},
|
|
8888
|
+
)
|
|
8765
8889
|
try:
|
|
8766
8890
|
result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
|
|
8767
8891
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -8769,10 +8893,52 @@ class AiBuilderFacade:
|
|
|
8769
8893
|
return _failed_from_api_error(
|
|
8770
8894
|
"PORTAL_GET_FAILED",
|
|
8771
8895
|
api_error,
|
|
8772
|
-
normalized_args={"dash_key": dash_key, "being_draft": being_draft},
|
|
8896
|
+
normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind},
|
|
8773
8897
|
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
8774
8898
|
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
8775
8899
|
)
|
|
8900
|
+
if normalized_kind in {"summary", "complete"}:
|
|
8901
|
+
schema_context = None
|
|
8902
|
+
if schema_version is not None:
|
|
8903
|
+
try:
|
|
8904
|
+
schema_context = portals_schema_registry().resolve_schema_version(schema_version)
|
|
8905
|
+
except PortalsContractError as error:
|
|
8906
|
+
return _failed(
|
|
8907
|
+
error.error_code,
|
|
8908
|
+
str(error.message),
|
|
8909
|
+
normalized_args={"dash_key": dash_key, "being_draft": being_draft, "schema_kind": normalized_kind, "schema_version": schema_version},
|
|
8910
|
+
details={"reason_path": error.path or "", **(error.details or {})},
|
|
8911
|
+
suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "summary"}},
|
|
8912
|
+
)
|
|
8913
|
+
declaration, read_gaps = _portal_declaration_from_dash_detail(result, schema_kind=normalized_kind)
|
|
8914
|
+
response = {
|
|
8915
|
+
"status": "success",
|
|
8916
|
+
"error_code": None,
|
|
8917
|
+
"recoverable": False,
|
|
8918
|
+
"message": f"read portal declaration ({normalized_kind})",
|
|
8919
|
+
"normalized_args": {
|
|
8920
|
+
"dash_key": dash_key,
|
|
8921
|
+
"being_draft": being_draft,
|
|
8922
|
+
"schema_kind": normalized_kind,
|
|
8923
|
+
"schema_version": schema_version,
|
|
8924
|
+
},
|
|
8925
|
+
"missing_fields": [],
|
|
8926
|
+
"allowed_values": {"schema_kind": ["raw", "summary", "complete"]},
|
|
8927
|
+
"details": {"read_gaps": read_gaps} if read_gaps else {},
|
|
8928
|
+
"request_id": None,
|
|
8929
|
+
"suggested_next_call": None,
|
|
8930
|
+
"noop": False,
|
|
8931
|
+
"warnings": [],
|
|
8932
|
+
"verification": {"portal_exists": True, "being_draft": being_draft},
|
|
8933
|
+
"verified": True,
|
|
8934
|
+
"schemaVersion": schema_context.schema_version if schema_context is not None else portals_schema_registry().latest_version,
|
|
8935
|
+
"latestVersion": portals_schema_registry().latest_version,
|
|
8936
|
+
"apiVersion": schema_context.api_version if schema_context is not None else "builder.qingflow.com/v1alpha1",
|
|
8937
|
+
"schemaKind": normalized_kind,
|
|
8938
|
+
"declaration": declaration,
|
|
8939
|
+
"read_gap_count": len(read_gaps),
|
|
8940
|
+
}
|
|
8941
|
+
return response
|
|
8776
8942
|
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
8777
8943
|
response = PortalGetResponse(
|
|
8778
8944
|
dash_key=dash_key,
|
|
@@ -8867,6 +9033,124 @@ class AiBuilderFacade:
|
|
|
8867
9033
|
**response.model_dump(mode="json"),
|
|
8868
9034
|
}
|
|
8869
9035
|
|
|
9036
|
+
def portal_schema(
|
|
9037
|
+
self,
|
|
9038
|
+
*,
|
|
9039
|
+
schema_version: str | None = None,
|
|
9040
|
+
schema_kind: str | None = None,
|
|
9041
|
+
) -> JSONObject:
|
|
9042
|
+
"""读取版本化门户声明 schema;组件专项 kind 用于获取自包含专项配置定义。"""
|
|
9043
|
+
normalized_args = {"schema_version": schema_version, "schema_kind": schema_kind}
|
|
9044
|
+
try:
|
|
9045
|
+
payload = portals_schema_registry().schema(schema_version=schema_version, schema_kind=schema_kind)
|
|
9046
|
+
except PortalsContractError as error:
|
|
9047
|
+
return _failed(
|
|
9048
|
+
error.error_code,
|
|
9049
|
+
str(error.message),
|
|
9050
|
+
normalized_args=normalized_args,
|
|
9051
|
+
details={"reason_path": error.path or "", **(error.details or {})},
|
|
9052
|
+
suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "summary"}},
|
|
9053
|
+
)
|
|
9054
|
+
return {
|
|
9055
|
+
"status": "success",
|
|
9056
|
+
"error_code": None,
|
|
9057
|
+
"recoverable": False,
|
|
9058
|
+
"message": "read portals declaration schema",
|
|
9059
|
+
"normalized_args": normalized_args,
|
|
9060
|
+
"missing_fields": [],
|
|
9061
|
+
"allowed_values": {"schema_kind": payload["supportedSchemaKinds"]},
|
|
9062
|
+
"details": {},
|
|
9063
|
+
"request_id": None,
|
|
9064
|
+
"suggested_next_call": None,
|
|
9065
|
+
"warnings": [],
|
|
9066
|
+
"verification": {"schema_loaded": True},
|
|
9067
|
+
**payload,
|
|
9068
|
+
}
|
|
9069
|
+
|
|
9070
|
+
def portal_validate(
|
|
9071
|
+
self,
|
|
9072
|
+
*,
|
|
9073
|
+
declaration: JSONObject,
|
|
9074
|
+
schema_version: str | None = None,
|
|
9075
|
+
) -> JSONObject:
|
|
9076
|
+
"""纯离线校验一条门户声明;不访问后端。"""
|
|
9077
|
+
normalized_args = {"schema_version": schema_version}
|
|
9078
|
+
resolved_version = (
|
|
9079
|
+
str(schema_version or (declaration.get("schema_version") if isinstance(declaration, dict) else None) or "").strip() or None
|
|
9080
|
+
)
|
|
9081
|
+
try:
|
|
9082
|
+
context = portals_schema_registry().resolve_schema_version(resolved_version)
|
|
9083
|
+
except PortalsContractError as error:
|
|
9084
|
+
return _failed(
|
|
9085
|
+
error.error_code,
|
|
9086
|
+
str(error.message),
|
|
9087
|
+
normalized_args=normalized_args,
|
|
9088
|
+
details={"reason_path": error.path or "", **(error.details or {})},
|
|
9089
|
+
suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_kind": "complete"}},
|
|
9090
|
+
)
|
|
9091
|
+
issues: list[dict[str, Any]] = []
|
|
9092
|
+
if not isinstance(declaration, dict):
|
|
9093
|
+
issues.append({"path": "/", "code": "INVALID_SPEC", "message": "portals declaration must be a JSON object"})
|
|
9094
|
+
else:
|
|
9095
|
+
declaration_item = {
|
|
9096
|
+
key: value
|
|
9097
|
+
for key, value in declaration.items()
|
|
9098
|
+
if key not in {"schema_version", "schemaVersion", "apiVersion"}
|
|
9099
|
+
}
|
|
9100
|
+
try:
|
|
9101
|
+
parsed = PortalApplyRequest.model_validate(declaration_item)
|
|
9102
|
+
context.validate_upsert(compact_none_values(public_portal_apply_payload(parsed)))
|
|
9103
|
+
except ValidationError as exc:
|
|
9104
|
+
first_error = exc.errors()[0]
|
|
9105
|
+
issues.append(
|
|
9106
|
+
{
|
|
9107
|
+
"path": f"/{'/'.join(str(part) for part in first_error.get('loc', ()))}".rstrip("/"),
|
|
9108
|
+
"code": "INVALID_SPEC",
|
|
9109
|
+
"message": str(first_error.get("msg") or exc),
|
|
9110
|
+
}
|
|
9111
|
+
)
|
|
9112
|
+
except PortalsContractError as error:
|
|
9113
|
+
issues.append({"path": error.path or "/", "code": error.error_code, "message": str(error.message)})
|
|
9114
|
+
else:
|
|
9115
|
+
# 创建语义(无 dash_key)的 icon/color 目录校验与 apply 同口径:离线
|
|
9116
|
+
# jsonschema 不含目录枚举(更新语义须放行存量 hex 透传),不补这层
|
|
9117
|
+
# 会出现「validate 通过、apply 拒绝」的校验信任断裂(实测反馈失败二
|
|
9118
|
+
# 同型);更新语义不做目录校验(与 apply 更新分支一致)。
|
|
9119
|
+
if not str(parsed.dash_key or "").strip():
|
|
9120
|
+
icon_ok, icon_error_code, icon_message, _icon_details = validate_workspace_icon_choice(
|
|
9121
|
+
icon=parsed.icon,
|
|
9122
|
+
color=parsed.color,
|
|
9123
|
+
require_explicit=True,
|
|
9124
|
+
disallow_generic=True,
|
|
9125
|
+
)
|
|
9126
|
+
if not icon_ok:
|
|
9127
|
+
issues.append(
|
|
9128
|
+
{
|
|
9129
|
+
"path": "/color" if str(icon_error_code or "").endswith("COLOR_NOT_FOUND") or icon_error_code == "WORKSPACE_ICON_COLOR_REQUIRED" else "/icon",
|
|
9130
|
+
"code": icon_error_code or "WORKSPACE_ICON_INVALID",
|
|
9131
|
+
"message": icon_message or "invalid workspace icon configuration",
|
|
9132
|
+
}
|
|
9133
|
+
)
|
|
9134
|
+
ok = not issues
|
|
9135
|
+
return {
|
|
9136
|
+
"status": "success" if ok else "failed",
|
|
9137
|
+
"error_code": None if ok else issues[0]["code"],
|
|
9138
|
+
"recoverable": True,
|
|
9139
|
+
"message": "portals declaration is valid" if ok else "portals declaration failed validation",
|
|
9140
|
+
"normalized_args": normalized_args,
|
|
9141
|
+
"missing_fields": [],
|
|
9142
|
+
"allowed_values": {},
|
|
9143
|
+
"details": {"issues": issues},
|
|
9144
|
+
"request_id": None,
|
|
9145
|
+
"suggested_next_call": None,
|
|
9146
|
+
"warnings": [],
|
|
9147
|
+
"verification": {"validated_entries": 1 if ok else 0},
|
|
9148
|
+
"ok": ok,
|
|
9149
|
+
"schemaVersion": context.schema_version,
|
|
9150
|
+
"apiVersion": context.api_version,
|
|
9151
|
+
"issues": issues,
|
|
9152
|
+
}
|
|
9153
|
+
|
|
8870
9154
|
def view_get(self, *, profile: str, view_key: str) -> JSONObject:
|
|
8871
9155
|
try:
|
|
8872
9156
|
config = self.views.view_get_config(profile=profile, viewgraph_key=view_key).get("result") or {}
|
|
@@ -15086,7 +15370,13 @@ class AiBuilderFacade:
|
|
|
15086
15370
|
"safe_to_retry": not write_executed,
|
|
15087
15371
|
})
|
|
15088
15372
|
|
|
15089
|
-
def portal_apply(
|
|
15373
|
+
def portal_apply(
|
|
15374
|
+
self,
|
|
15375
|
+
*,
|
|
15376
|
+
profile: str,
|
|
15377
|
+
request: PortalApplyRequest,
|
|
15378
|
+
schema_version: str | None = None,
|
|
15379
|
+
) -> JSONObject:
|
|
15090
15380
|
apply_started_at = time.perf_counter()
|
|
15091
15381
|
portal_initial_read_ms: int | None = None
|
|
15092
15382
|
portal_create_ms: int | None = None
|
|
@@ -15096,23 +15386,51 @@ class AiBuilderFacade:
|
|
|
15096
15386
|
portal_draft_readback_ms: int | None = None
|
|
15097
15387
|
portal_publish_ms: int | None = None
|
|
15098
15388
|
portal_live_readback_ms: int | None = None
|
|
15389
|
+
effective_schema_version = str(schema_version or request.schema_version or "").strip() or None
|
|
15099
15390
|
normalized_args = request.model_dump(mode="json")
|
|
15391
|
+
normalized_args["schema_version"] = effective_schema_version
|
|
15392
|
+
schema_context: PortalsSchemaContext | None = None
|
|
15393
|
+
if effective_schema_version is not None:
|
|
15394
|
+
try:
|
|
15395
|
+
schema_context = portals_schema_registry().resolve_schema_version(effective_schema_version)
|
|
15396
|
+
schema_context.validate_upsert(
|
|
15397
|
+
compact_none_values(public_portal_apply_payload(request))
|
|
15398
|
+
)
|
|
15399
|
+
except PortalsContractError as error:
|
|
15400
|
+
return _failed(
|
|
15401
|
+
error.error_code,
|
|
15402
|
+
str(error.message),
|
|
15403
|
+
normalized_args=normalized_args,
|
|
15404
|
+
details={"reason_path": error.path or "", **(error.details or {})},
|
|
15405
|
+
suggested_next_call={"tool_name": "portal_schema", "arguments": {"schema_version": effective_schema_version, "schema_kind": "complete"}},
|
|
15406
|
+
)
|
|
15100
15407
|
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
15101
15408
|
dash_key = str(request.dash_key or "").strip()
|
|
15102
15409
|
creating = not dash_key
|
|
15103
|
-
sections_requested
|
|
15410
|
+
# sections_requested 语义:显式携带 sections 键(含空列表)即走组件重建链;
|
|
15411
|
+
# 未传(不在 model_fields_set)才是纯 base-info 更新。模型缺省 sections=[] 与
|
|
15412
|
+
# 显式空列表同值,只能用 fields_set 区分。空 sections=[] 是合法存量形态
|
|
15413
|
+
# (无组件门户):后端 updateDash 对空组件列表=删全部后不重建(getComponents
|
|
15414
|
+
# 非空才重建),createDash 不建组件——把空列表当「未传」会把携带
|
|
15415
|
+
# global_config/hide_copyright 的空门户拒之门外。
|
|
15416
|
+
sections_requested = creating or ("sections" in request.model_fields_set)
|
|
15104
15417
|
verify_dash_name = creating or request.dash_name is not None
|
|
15105
15418
|
verify_dash_icon = bool(request.icon or request.color)
|
|
15106
15419
|
verify_auth = request.visibility is not None or request.auth is not None
|
|
15107
15420
|
verify_hide_copyright = request.hide_copyright is not None and sections_requested
|
|
15108
|
-
verify_dash_global_config = request.
|
|
15421
|
+
verify_dash_global_config = request.global_config is not None and sections_requested
|
|
15109
15422
|
verify_tags = creating or request.package_tag_id is not None
|
|
15110
15423
|
requested_visibility = request.visibility
|
|
15111
15424
|
if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
|
|
15112
15425
|
requested_visibility = VisibilityPatch.model_validate(_public_visibility_from_member_auth(request.auth))
|
|
15426
|
+
visibility_degradation_warnings: list[dict[str, Any]] = []
|
|
15113
15427
|
try:
|
|
15114
15428
|
desired_auth = (
|
|
15115
|
-
self._compile_visibility_to_member_auth(
|
|
15429
|
+
self._compile_visibility_to_member_auth(
|
|
15430
|
+
profile=profile,
|
|
15431
|
+
visibility=requested_visibility,
|
|
15432
|
+
degradation_warnings=visibility_degradation_warnings,
|
|
15433
|
+
)
|
|
15116
15434
|
if requested_visibility is not None
|
|
15117
15435
|
else None
|
|
15118
15436
|
)
|
|
@@ -15155,6 +15473,14 @@ class AiBuilderFacade:
|
|
|
15155
15473
|
permission_outcomes.append(portal_permission_outcome)
|
|
15156
15474
|
|
|
15157
15475
|
def finalize(response: JSONObject) -> JSONObject:
|
|
15476
|
+
if schema_context is not None:
|
|
15477
|
+
response["schemaVersion"] = schema_context.schema_version
|
|
15478
|
+
# 可见性冗余引用降级明细回信封(review §2-1:降级不再静默)
|
|
15479
|
+
if visibility_degradation_warnings:
|
|
15480
|
+
existing_warnings = response.get("warnings")
|
|
15481
|
+
warnings = list(existing_warnings) if isinstance(existing_warnings, list) else []
|
|
15482
|
+
warnings.extend(item for item in visibility_degradation_warnings if item not in warnings)
|
|
15483
|
+
response["warnings"] = warnings
|
|
15158
15484
|
_merge_duration_breakdown(
|
|
15159
15485
|
response,
|
|
15160
15486
|
portal_initial_read_ms=portal_initial_read_ms,
|
|
@@ -15192,8 +15518,8 @@ class AiBuilderFacade:
|
|
|
15192
15518
|
unsupported_base_only_keys: list[str] = []
|
|
15193
15519
|
if request.hide_copyright is not None:
|
|
15194
15520
|
unsupported_base_only_keys.append("hide_copyright")
|
|
15195
|
-
if request.
|
|
15196
|
-
unsupported_base_only_keys.append("
|
|
15521
|
+
if request.global_config is not None:
|
|
15522
|
+
unsupported_base_only_keys.append("global_config")
|
|
15197
15523
|
if request.config:
|
|
15198
15524
|
unsupported_base_only_keys.append("config")
|
|
15199
15525
|
if unsupported_base_only_keys:
|
|
@@ -15216,18 +15542,20 @@ class AiBuilderFacade:
|
|
|
15216
15542
|
layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
|
|
15217
15543
|
if creating:
|
|
15218
15544
|
create_payload = _build_public_portal_base_payload(
|
|
15219
|
-
dash_name=request.dash_name
|
|
15545
|
+
dash_name=request.dash_name if request.dash_name is not None else "未命名门户",
|
|
15220
15546
|
package_tag_id=target_package_tag_id,
|
|
15221
15547
|
icon=request.icon,
|
|
15222
15548
|
color=request.color,
|
|
15223
15549
|
auth=desired_auth,
|
|
15224
15550
|
hide_copyright=request.hide_copyright,
|
|
15225
|
-
dash_global_config=request.
|
|
15551
|
+
dash_global_config=request.global_config,
|
|
15552
|
+
style_template_id=request.style_template_id,
|
|
15553
|
+
overall_style=request.overall_style,
|
|
15226
15554
|
config=request.config,
|
|
15227
15555
|
base_payload=None,
|
|
15228
15556
|
)
|
|
15229
15557
|
portal_create_started_at = time.perf_counter()
|
|
15230
|
-
create_result = self.
|
|
15558
|
+
create_result = self._portal_writer.create_portal(profile=profile, payload=create_payload)
|
|
15231
15559
|
portal_create_ms = _duration_ms(portal_create_started_at)
|
|
15232
15560
|
write_executed = True
|
|
15233
15561
|
created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
|
|
@@ -15250,13 +15578,15 @@ class AiBuilderFacade:
|
|
|
15250
15578
|
}
|
|
15251
15579
|
base_payload = {}
|
|
15252
15580
|
update_payload = _build_public_portal_base_payload(
|
|
15253
|
-
dash_name=request.dash_name
|
|
15581
|
+
dash_name=request.dash_name if request.dash_name is not None else (str(base_payload.get("dashName") or "").strip() or "未命名门户"),
|
|
15254
15582
|
package_tag_id=target_package_tag_id,
|
|
15255
15583
|
icon=request.icon,
|
|
15256
15584
|
color=request.color,
|
|
15257
15585
|
auth=desired_auth,
|
|
15258
15586
|
hide_copyright=request.hide_copyright,
|
|
15259
|
-
dash_global_config=request.
|
|
15587
|
+
dash_global_config=request.global_config,
|
|
15588
|
+
style_template_id=request.style_template_id,
|
|
15589
|
+
overall_style=request.overall_style,
|
|
15260
15590
|
config=request.config,
|
|
15261
15591
|
base_payload=base_payload,
|
|
15262
15592
|
)
|
|
@@ -15272,7 +15602,7 @@ class AiBuilderFacade:
|
|
|
15272
15602
|
layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload, layout_metadata=layout_metadata)
|
|
15273
15603
|
update_payload["components"] = component_payload
|
|
15274
15604
|
portal_update_started_at = time.perf_counter()
|
|
15275
|
-
self.
|
|
15605
|
+
self._portal_writer.update_portal(profile=profile, dash_key=dash_key, payload=update_payload)
|
|
15276
15606
|
portal_update_ms = _duration_ms(portal_update_started_at)
|
|
15277
15607
|
write_executed = True
|
|
15278
15608
|
else:
|
|
@@ -15289,15 +15619,22 @@ class AiBuilderFacade:
|
|
|
15289
15619
|
)
|
|
15290
15620
|
if base_info_update_requested:
|
|
15291
15621
|
portal_base_info_update_started_at = time.perf_counter()
|
|
15292
|
-
|
|
15622
|
+
base_info_payload = {
|
|
15623
|
+
"dashName": update_payload.get("dashName"),
|
|
15624
|
+
"dashIcon": update_payload.get("dashIcon"),
|
|
15625
|
+
"auth": deepcopy(update_payload.get("auth")),
|
|
15626
|
+
}
|
|
15627
|
+
# tags 空列表省略键:后端 updateDashBaseInfo(DashSvcImpl)对非 null 的空 tags
|
|
15628
|
+
# 按 ws 过滤后判空抛 PARAMS_ERROR → 整个 baseInfo 写失败(事务回滚,name/icon/auth
|
|
15629
|
+
# 全部静默失效,r8 实测 16 门)。省略键 → getTags()==null → 后端跳过 tags 校验与更新,
|
|
15630
|
+
# 其余字段正常写。声明显式给 package_tag_id 时 tags 非空,不受影响。
|
|
15631
|
+
base_info_tags = deepcopy(update_payload.get("tags") or [])
|
|
15632
|
+
if base_info_tags:
|
|
15633
|
+
base_info_payload["tags"] = base_info_tags
|
|
15634
|
+
self._portal_writer.update_portal_base_info(
|
|
15293
15635
|
profile=profile,
|
|
15294
15636
|
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
|
-
},
|
|
15637
|
+
payload=base_info_payload,
|
|
15301
15638
|
)
|
|
15302
15639
|
portal_base_info_update_ms = _duration_ms(portal_base_info_update_started_at)
|
|
15303
15640
|
write_executed = True
|
|
@@ -16612,25 +16949,58 @@ class AiBuilderFacade:
|
|
|
16612
16949
|
resolved_chart = inline_chart_resolutions.get(section_index)
|
|
16613
16950
|
if not isinstance(resolved_chart, dict):
|
|
16614
16951
|
raise ValueError(f"inline chart '{section.chart.name}' could not be created or updated for portal section '{section.title}'")
|
|
16952
|
+
chart_type = str(
|
|
16953
|
+
resolved_chart.get("chart_type")
|
|
16954
|
+
or _public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type"))
|
|
16955
|
+
or ""
|
|
16956
|
+
).strip()
|
|
16957
|
+
chart_config = {
|
|
16958
|
+
"biChartId": resolved_chart["chart_id"],
|
|
16959
|
+
# title=None(存量空标题)不落库——_compact_dict 剔除,后端透传 NULL 保真;
|
|
16960
|
+
# beingShowTitle 以声明 show_title 为准(读面提取必有值),缺省回落产品默认 True。
|
|
16961
|
+
"chartComponentTitle": section.title,
|
|
16962
|
+
"beingShowTitle": section.show_title if section.show_title is not None else True,
|
|
16963
|
+
**deepcopy(section.config),
|
|
16964
|
+
}
|
|
16965
|
+
component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
|
|
16966
|
+
layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
|
|
16967
|
+
elif section.chart_ref is not None and str(getattr(section.chart_ref, "ref_kind", "") or "") == "chart":
|
|
16968
|
+
# 轻流报表引用面(后端 DASH_CONTENT_TYPE_CHART=1):chart_id 携带的是
|
|
16969
|
+
# chart 表唯一键 chartKey,写链 addChartInfos 按 chartKey 解析,不走
|
|
16970
|
+
# BI 报表列表解析;不携带运行态派生键(chartId/chartName/formTitle 等读面回填)。
|
|
16971
|
+
chart_key = str(getattr(section.chart_ref, "chart_id", "") or "").strip()
|
|
16972
|
+
if not chart_key:
|
|
16973
|
+
raise ValueError(f"chart_ref with ref_kind=chart requires chart_id for portal section '{section.title}'")
|
|
16974
|
+
chart_config = {
|
|
16975
|
+
"chartKey": chart_key,
|
|
16976
|
+
# 同上:title=None 不落库、beingShowTitle 声明优先、缺省 True。
|
|
16977
|
+
"chartComponentTitle": section.title,
|
|
16978
|
+
"beingShowTitle": section.show_title if section.show_title is not None else True,
|
|
16979
|
+
**deepcopy(section.config),
|
|
16980
|
+
}
|
|
16981
|
+
component = {"type": 1, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
|
|
16982
|
+
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
16983
|
else:
|
|
16616
16984
|
resolved_chart = _resolve_chart_reference(
|
|
16617
16985
|
charts=self.charts,
|
|
16618
16986
|
profile=profile,
|
|
16619
16987
|
ref=section.chart_ref,
|
|
16620
16988
|
)
|
|
16621
|
-
|
|
16622
|
-
|
|
16623
|
-
|
|
16624
|
-
|
|
16625
|
-
|
|
16626
|
-
|
|
16627
|
-
|
|
16628
|
-
|
|
16629
|
-
|
|
16630
|
-
|
|
16631
|
-
|
|
16632
|
-
|
|
16633
|
-
|
|
16989
|
+
chart_type = str(
|
|
16990
|
+
resolved_chart.get("chart_type")
|
|
16991
|
+
or _public_chart_type_from_backend(section.config.get("chartType") or section.config.get("chart_type"))
|
|
16992
|
+
or ""
|
|
16993
|
+
).strip()
|
|
16994
|
+
chart_config = {
|
|
16995
|
+
"biChartId": resolved_chart["chart_id"],
|
|
16996
|
+
# title=None(存量空标题)不落库——_compact_dict 剔除,后端透传 NULL 保真;
|
|
16997
|
+
# beingShowTitle 以声明 show_title 为准(读面提取必有值),缺省回落产品默认 True。
|
|
16998
|
+
"chartComponentTitle": section.title,
|
|
16999
|
+
"beingShowTitle": section.show_title if section.show_title is not None else True,
|
|
17000
|
+
**deepcopy(section.config),
|
|
17001
|
+
}
|
|
17002
|
+
component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
|
|
17003
|
+
layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
|
|
16634
17004
|
elif section.source_type == "view":
|
|
16635
17005
|
resolved_view = _resolve_view_reference(
|
|
16636
17006
|
facade=self,
|
|
@@ -16644,34 +17014,58 @@ class AiBuilderFacade:
|
|
|
16644
17014
|
view_config = {
|
|
16645
17015
|
"appKey": resolved_view["app_key"],
|
|
16646
17016
|
"viewgraphKey": resolved_view["view_key"],
|
|
16647
|
-
|
|
17017
|
+
# viewgraphName=视图名(引用面锚,view_ref.view_name 优先);componentTitle=组件
|
|
17018
|
+
# 显示标题(title=None 的存量空标题形态经 _compact_dict 剔除,落库 NULL 保真,
|
|
17019
|
+
# 旧逻辑把视图名兑底进 componentTitle 会覆盖读链 chartName 派生分支)。
|
|
17020
|
+
"viewgraphName": getattr(section.view_ref, "view_name", None) or section.title,
|
|
16648
17021
|
"formTitle": app_title_cache[resolved_view["app_key"]],
|
|
16649
17022
|
"componentTitle": section.title,
|
|
16650
17023
|
"viewgraphType": resolved_view["view_type"],
|
|
16651
|
-
|
|
17024
|
+
# beingShowTitle 声明 show_title 优先(view_display.show_title 提取必有值;
|
|
17025
|
+
# 存量 false 被硬 True 覆写会漂移库表);缺省保持产品默认 True。
|
|
17026
|
+
"beingShowTitle": section.show_title if section.show_title is not None else True,
|
|
16652
17027
|
"dataManageStatus": True,
|
|
16653
17028
|
**deepcopy(section.config),
|
|
16654
17029
|
}
|
|
17030
|
+
if section.view_display:
|
|
17031
|
+
display = _compile_portal_view_display(section.view_display)
|
|
17032
|
+
for key, value in display.items():
|
|
17033
|
+
view_config[key] = value
|
|
16655
17034
|
component = {"type": 10, "position": position_payload, "viewgraphConfig": _compact_dict(view_config)}
|
|
16656
17035
|
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
16657
17036
|
elif section.source_type == "grid":
|
|
16658
17037
|
component = {
|
|
16659
17038
|
"type": 2,
|
|
16660
17039
|
"position": position_payload,
|
|
17040
|
+
# gridTitle=None(存量空标题)剔除不落库;beingShowTitle 缺省 True、声明 config 内
|
|
17041
|
+
# beingShowTitle 键后置覆盖(提取侧 grid config 保留该键,存量 false 保真)。
|
|
16661
17042
|
"gridConfig": _compact_dict({"gridTitle": section.title, "beingShowTitle": True, **deepcopy(section.config)}),
|
|
16662
17043
|
}
|
|
16663
17044
|
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
16664
17045
|
elif section.source_type == "filter":
|
|
16665
|
-
component = {"type": 6, "position": position_payload, "filterConfig":
|
|
17046
|
+
component = {"type": 6, "position": position_payload, "filterConfig": _compile_portal_filter_config(section.config)}
|
|
16666
17047
|
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
16667
17048
|
elif section.source_type == "text":
|
|
16668
17049
|
component = {"type": 5, "position": position_payload, "textConfig": {"text": section.text or "", **deepcopy(section.config)}}
|
|
16669
17050
|
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
17051
|
+
elif section.source_type == "carousel":
|
|
17052
|
+
carousel_config = self._compile_portal_carousel_config(profile=profile, section=section)
|
|
17053
|
+
component = {"type": 3, "position": position_payload, "carouselConfig": _compact_dict(carousel_config)}
|
|
17054
|
+
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
17055
|
+
elif section.source_type == "task":
|
|
17056
|
+
task_config = _compile_portal_task_config(section.title, section.task or {})
|
|
17057
|
+
component = {"type": 8, "position": position_payload, "taskConfig": _compact_dict(task_config)}
|
|
17058
|
+
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
17059
|
+
elif section.source_type == "favourite":
|
|
17060
|
+
favourite_config = _compile_portal_favourite_config(section.title, section.favourite or {})
|
|
17061
|
+
component = {"type": 7, "position": position_payload, "favouriteConfig": _compact_dict(favourite_config)}
|
|
17062
|
+
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
16670
17063
|
else:
|
|
17064
|
+
link_config = {"url": section.url or "", "beingLoginAuth": bool(section.login_auth) if section.login_auth is not None else False, **deepcopy(section.config)}
|
|
16671
17065
|
component = {
|
|
16672
17066
|
"type": 4,
|
|
16673
17067
|
"position": position_payload,
|
|
16674
|
-
"linkConfig":
|
|
17068
|
+
"linkConfig": _compact_dict(link_config),
|
|
16675
17069
|
}
|
|
16676
17070
|
layout_metadata.append({"source_type": section.source_type, "role": section.role})
|
|
16677
17071
|
if dash_style is not None:
|
|
@@ -16679,6 +17073,113 @@ class AiBuilderFacade:
|
|
|
16679
17073
|
resolved_components.append(component)
|
|
16680
17074
|
return resolved_components, layout_metadata, inline_chart_results
|
|
16681
17075
|
|
|
17076
|
+
def _compile_portal_carousel_config(self, *, profile: str, section: PortalSectionPatch) -> dict[str, Any]:
|
|
17077
|
+
"""Compile a carousel section declaration into the backend carouselConfig shape."""
|
|
17078
|
+
raw = section.carousel or {}
|
|
17079
|
+
if not isinstance(raw, dict):
|
|
17080
|
+
raise ValueError("carousel section config must be an object")
|
|
17081
|
+
app_key = str(raw.get("source_app_key") or raw.get("sourceAppKey") or "").strip()
|
|
17082
|
+
carousel_config: dict[str, Any] = {}
|
|
17083
|
+
fields_by_que_id: dict[int, dict[str, Any]] = {}
|
|
17084
|
+
field_name_to_field: dict[str, dict[str, Any]] = {}
|
|
17085
|
+
if app_key:
|
|
17086
|
+
carousel_config["sourceApp"] = {"appKey": app_key}
|
|
17087
|
+
if raw.get("source_form_title") or raw.get("sourceFormTitle"):
|
|
17088
|
+
carousel_config["sourceApp"]["formTitle"] = str(raw.get("source_form_title") or raw.get("sourceFormTitle"))
|
|
17089
|
+
# 字段索引从原始 form schema 面构建(_read_schema_with_fallback + schema 分支,
|
|
17090
|
+
# 与 views apply 同源):app_read_fields 解析面的字段不带 option_details,
|
|
17091
|
+
# _resolve_view_filter_values 的选项文本→ID 解析分支不生效,选项文本被
|
|
17092
|
+
# 原样透传进 formula,后端写后自读 Integer.parseInt("文本") 崩溃回滚
|
|
17093
|
+
# (r6 实测 9b91e277;七条约束之四:两读面锚定同一数据源)。
|
|
17094
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
17095
|
+
fields_by_que_id = _collect_fields_by_que_id([], schema=schema)
|
|
17096
|
+
field_name_to_field = {
|
|
17097
|
+
str(field.get("name") or ""): field
|
|
17098
|
+
for field in fields_by_que_id.values()
|
|
17099
|
+
if str(field.get("name") or "")
|
|
17100
|
+
}
|
|
17101
|
+
# 悬空轮播(存量形态:源表已删,related_form_id NULL,读面 sourceApp 空):后端写链
|
|
17102
|
+
# DraftComponentCarousel 构造对 sourceApp 空/无 appKey 不写 relatedFormId,读面回空
|
|
17103
|
+
# sourceApp——round-trip 保真;无源表则无从解析字段引用,仅保留运行参数(jump_mode 等)。
|
|
17104
|
+
|
|
17105
|
+
def _resolve_que_ref(field_name: Any, *, config_key: str) -> dict[str, Any] | None:
|
|
17106
|
+
raw_name = str(field_name or "").strip()
|
|
17107
|
+
if not raw_name:
|
|
17108
|
+
return None
|
|
17109
|
+
field: dict[str, Any] | None = None
|
|
17110
|
+
if raw_name.isdigit():
|
|
17111
|
+
que_id = _coerce_positive_int(raw_name)
|
|
17112
|
+
field = fields_by_que_id.get(que_id) if que_id is not None else None
|
|
17113
|
+
else:
|
|
17114
|
+
field = field_name_to_field.get(raw_name)
|
|
17115
|
+
if field is None:
|
|
17116
|
+
raise ValueError(
|
|
17117
|
+
f"carousel section '{section.title}' references unknown {config_key} '{raw_name}' in app '{app_key}'"
|
|
17118
|
+
)
|
|
17119
|
+
que_id = _coerce_positive_int(field.get("que_id"))
|
|
17120
|
+
if que_id is None:
|
|
17121
|
+
raise ValueError(
|
|
17122
|
+
f"carousel section '{section.title}' {config_key} '{raw_name}' has no que_id in app '{app_key}'"
|
|
17123
|
+
)
|
|
17124
|
+
return {"queId": que_id}
|
|
17125
|
+
|
|
17126
|
+
title_que = _resolve_que_ref(raw.get("title_field") or raw.get("titleField"), config_key="title_field")
|
|
17127
|
+
image_que = _resolve_que_ref(raw.get("image_field") or raw.get("imageField"), config_key="image_field")
|
|
17128
|
+
link_que = _resolve_que_ref(raw.get("link_field") or raw.get("linkField"), config_key="link_field")
|
|
17129
|
+
if title_que is not None:
|
|
17130
|
+
carousel_config["titleQue"] = title_que
|
|
17131
|
+
if image_que is not None:
|
|
17132
|
+
carousel_config["imageQue"] = image_que
|
|
17133
|
+
if link_que is not None:
|
|
17134
|
+
carousel_config["linkQue"] = link_que
|
|
17135
|
+
jump_mode = _coerce_positive_int(raw.get("jump_mode") or raw.get("jumpMode"))
|
|
17136
|
+
if jump_mode is not None:
|
|
17137
|
+
carousel_config["jumpMode"] = jump_mode
|
|
17138
|
+
if "scaling" in raw or "beingScaling" in raw:
|
|
17139
|
+
carousel_config["beingScaling"] = bool(raw.get("scaling", raw.get("beingScaling")))
|
|
17140
|
+
show_latest_limit = _coerce_positive_int(raw.get("show_latest_limit") or raw.get("showLatestLimit"))
|
|
17141
|
+
if show_latest_limit is not None:
|
|
17142
|
+
carousel_config["showLatestLimit"] = show_latest_limit
|
|
17143
|
+
scroll_interval = _coerce_positive_int(raw.get("scroll_interval") or raw.get("scrollInterval"))
|
|
17144
|
+
if scroll_interval is not None:
|
|
17145
|
+
carousel_config["scrollInterval"] = scroll_interval
|
|
17146
|
+
raw_filters = raw.get("filters")
|
|
17147
|
+
if isinstance(raw_filters, list) and raw_filters:
|
|
17148
|
+
translated_rules: list[dict[str, Any]] = []
|
|
17149
|
+
for raw_rule in raw_filters:
|
|
17150
|
+
if hasattr(raw_rule, "model_dump"):
|
|
17151
|
+
raw_rule = raw_rule.model_dump(mode="json")
|
|
17152
|
+
if not isinstance(raw_rule, dict):
|
|
17153
|
+
continue
|
|
17154
|
+
field_name = str(raw_rule.get("field_name") or raw_rule.get("fieldName") or "").strip()
|
|
17155
|
+
field: dict[str, Any] | None = None
|
|
17156
|
+
if field_name.isdigit():
|
|
17157
|
+
# 与 _resolve_que_ref 同型:queId 数字串优先按稳定锚解析(提取器 canonical 形态)
|
|
17158
|
+
que_id = _coerce_positive_int(field_name)
|
|
17159
|
+
field = fields_by_que_id.get(que_id) if que_id is not None else None
|
|
17160
|
+
if field is None:
|
|
17161
|
+
field = field_name_to_field.get(field_name)
|
|
17162
|
+
if field is None:
|
|
17163
|
+
raise ValueError(
|
|
17164
|
+
f"carousel section '{section.title}' filters reference unknown field '{field_name}' in app '{app_key}'"
|
|
17165
|
+
)
|
|
17166
|
+
rule_payload = {
|
|
17167
|
+
"que_id": field.get("que_id"),
|
|
17168
|
+
"operator": raw_rule.get("operator") or raw_rule.get("op") or "eq",
|
|
17169
|
+
"values": list(raw_rule.get("values") or []),
|
|
17170
|
+
}
|
|
17171
|
+
groups, filter_issues = _build_view_filter_groups(fields_by_que_id=fields_by_que_id, filters=[rule_payload])
|
|
17172
|
+
blocking = [issue for issue in filter_issues if issue.get("error_code")]
|
|
17173
|
+
if blocking:
|
|
17174
|
+
raise ValueError(
|
|
17175
|
+
f"carousel section '{section.title}' filters failed to compile: {blocking[0]}"
|
|
17176
|
+
)
|
|
17177
|
+
if groups:
|
|
17178
|
+
translated_rules.extend(groups[0])
|
|
17179
|
+
if translated_rules:
|
|
17180
|
+
carousel_config["filterConfig"] = [translated_rules]
|
|
17181
|
+
return carousel_config
|
|
17182
|
+
|
|
16682
17183
|
def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
|
|
16683
17184
|
session_profile = self.apps.sessions.get_profile(profile)
|
|
16684
17185
|
backend_session = self.apps.sessions.get_backend_session(profile)
|
|
@@ -20252,6 +20753,154 @@ def _chart_item_dataset_source_type(item: dict[str, Any]) -> str:
|
|
|
20252
20753
|
return ""
|
|
20253
20754
|
|
|
20254
20755
|
|
|
20756
|
+
def _compile_portal_global_config(raw: dict[str, Any] | None) -> dict[str, Any]:
|
|
20757
|
+
"""Compile declaration-named global_config keys to backend dashGlobalConfig keys."""
|
|
20758
|
+
config: dict[str, Any] = dict(raw or {})
|
|
20759
|
+
if "auto_refresh" in config:
|
|
20760
|
+
config["beingAutoRefresh"] = config.pop("auto_refresh")
|
|
20761
|
+
if "export" in config:
|
|
20762
|
+
config["beingExport"] = config.pop("export")
|
|
20763
|
+
return config
|
|
20764
|
+
|
|
20765
|
+
|
|
20766
|
+
def _compile_portal_view_display(raw: dict[str, Any] | None) -> dict[str, Any]:
|
|
20767
|
+
"""Compile declaration view_display keys to backend viewgraphConfig keys."""
|
|
20768
|
+
display: dict[str, Any] = {}
|
|
20769
|
+
payload = raw or {}
|
|
20770
|
+
if "show_title" in payload:
|
|
20771
|
+
display["beingShowTitle"] = bool(payload["show_title"])
|
|
20772
|
+
elif "beingShowTitle" in payload:
|
|
20773
|
+
display["beingShowTitle"] = bool(payload["beingShowTitle"])
|
|
20774
|
+
if payload.get("component_title") or payload.get("componentTitle"):
|
|
20775
|
+
display["componentTitle"] = str(payload.get("component_title") or payload.get("componentTitle"))
|
|
20776
|
+
return display
|
|
20777
|
+
|
|
20778
|
+
|
|
20779
|
+
def _compile_portal_task_config(section_title: str, raw: dict[str, Any]) -> dict[str, Any]:
|
|
20780
|
+
"""Compile a task section declaration into the backend taskConfig shape."""
|
|
20781
|
+
payload = raw or {}
|
|
20782
|
+
task_config: dict[str, Any] = {
|
|
20783
|
+
"componentTaskTitle": section_title,
|
|
20784
|
+
}
|
|
20785
|
+
if "show_title" in payload:
|
|
20786
|
+
task_config["beingShowTitle"] = bool(payload["show_title"])
|
|
20787
|
+
elif "beingShowTitle" in payload:
|
|
20788
|
+
task_config["beingShowTitle"] = bool(payload["beingShowTitle"])
|
|
20789
|
+
if "hint" in payload or "componentTaskHint" in payload:
|
|
20790
|
+
task_config["componentTaskHint"] = str(payload.get("hint") or payload.get("componentTaskHint") or "")
|
|
20791
|
+
if "show_hint" in payload:
|
|
20792
|
+
task_config["beingShowHint"] = bool(payload["show_hint"])
|
|
20793
|
+
elif "beingShowHint" in payload:
|
|
20794
|
+
task_config["beingShowHint"] = bool(payload["beingShowHint"])
|
|
20795
|
+
if "enabled" in payload:
|
|
20796
|
+
task_config["beingUsing"] = bool(payload["enabled"])
|
|
20797
|
+
elif "beingUsing" in payload:
|
|
20798
|
+
task_config["beingUsing"] = bool(payload["beingUsing"])
|
|
20799
|
+
raw_tasks = payload.get("tasks")
|
|
20800
|
+
if isinstance(raw_tasks, list) and raw_tasks:
|
|
20801
|
+
task_items: list[dict[str, Any]] = []
|
|
20802
|
+
for index, raw_item in enumerate(raw_tasks):
|
|
20803
|
+
if not isinstance(raw_item, dict):
|
|
20804
|
+
continue
|
|
20805
|
+
item: dict[str, Any] = {
|
|
20806
|
+
"title": str(raw_item.get("title") or ""),
|
|
20807
|
+
"type": _coerce_positive_int(raw_item.get("task_type") or raw_item.get("type")) or 1,
|
|
20808
|
+
"ordinal": _coerce_positive_int(raw_item.get("ordinal")) or index,
|
|
20809
|
+
"beingCheck": bool(raw_item.get("checked", raw_item.get("beingCheck", False))),
|
|
20810
|
+
}
|
|
20811
|
+
task_items.append(item)
|
|
20812
|
+
if task_items:
|
|
20813
|
+
task_config["dashTaskConfigList"] = task_items
|
|
20814
|
+
# dashTaskConfigList 必须始终携带:后端 DashTaskSvcImpl#addTaskInfos 对
|
|
20815
|
+
# taskConfig.getDashTaskConfigList() 直接迭代(ComponentTaskConfig 字段无默认
|
|
20816
|
+
# 初始化),键缺失 → null.size() NPE → 500(实测反馈「task 组件无论如何 500」
|
|
20817
|
+
# 根因)。声明未表达 tasks 时按前端新建缺省补 4 类标准任务(title 用后端 i18n 键,
|
|
20818
|
+
# 与存量/前端缺省一致);提取侧读面恒有条目,round-trip 不受影响。
|
|
20819
|
+
if not task_config.get("dashTaskConfigList"):
|
|
20820
|
+
task_config["dashTaskConfigList"] = [
|
|
20821
|
+
{"title": "all.taskTodo", "type": 1, "ordinal": 0, "beingCheck": True},
|
|
20822
|
+
{"title": "all.taskTimeout", "type": 2, "ordinal": 1, "beingCheck": True},
|
|
20823
|
+
{"title": "all.taskUpcomingTimeout", "type": 3, "ordinal": 2, "beingCheck": True},
|
|
20824
|
+
{"title": "all.taskRemind", "type": 4, "ordinal": 3, "beingCheck": True},
|
|
20825
|
+
]
|
|
20826
|
+
return task_config
|
|
20827
|
+
|
|
20828
|
+
|
|
20829
|
+
def _compile_portal_filter_config(raw: dict[str, Any] | None) -> dict[str, Any]:
|
|
20830
|
+
"""Compile a filter section config into the backend ComponentFilter shape.
|
|
20831
|
+
|
|
20832
|
+
Declaration form: ``{graphs: [{chart_key, chart_name}], rules: [...]}``.
|
|
20833
|
+
Legacy raw form (``{graphList: [...], filterConfig: [...]}`` 后端键) 透传兼容。
|
|
20834
|
+
"""
|
|
20835
|
+
payload = raw or {}
|
|
20836
|
+
compiled: dict[str, Any] = {}
|
|
20837
|
+
raw_graphs = payload.get("graphs")
|
|
20838
|
+
if isinstance(raw_graphs, list):
|
|
20839
|
+
graph_list: list[dict[str, Any]] = []
|
|
20840
|
+
for item in raw_graphs:
|
|
20841
|
+
if not isinstance(item, dict):
|
|
20842
|
+
continue
|
|
20843
|
+
graph: dict[str, Any] = {
|
|
20844
|
+
"graphKey": str(item.get("chart_key") or item.get("chartKey") or item.get("graphKey") or "").strip(),
|
|
20845
|
+
"graphName": str(item.get("chart_name") or item.get("chartName") or item.get("graphName") or "").strip(),
|
|
20846
|
+
"graphType": item.get("graph_type") or item.get("graphType") or "CHART",
|
|
20847
|
+
}
|
|
20848
|
+
if graph["graphKey"] or graph["graphName"]:
|
|
20849
|
+
graph_list.append(graph)
|
|
20850
|
+
compiled["graphList"] = graph_list
|
|
20851
|
+
elif isinstance(payload.get("graphList"), list):
|
|
20852
|
+
compiled["graphList"] = deepcopy(payload.get("graphList"))
|
|
20853
|
+
raw_rules = payload.get("rules")
|
|
20854
|
+
if isinstance(raw_rules, list):
|
|
20855
|
+
compiled["filterConfig"] = deepcopy(raw_rules)
|
|
20856
|
+
elif isinstance(payload.get("filterConfig"), list):
|
|
20857
|
+
compiled["filterConfig"] = deepcopy(payload.get("filterConfig"))
|
|
20858
|
+
return compiled
|
|
20859
|
+
|
|
20860
|
+
|
|
20861
|
+
def _compile_portal_favourite_config(section_title: str, raw: dict[str, Any]) -> dict[str, Any]:
|
|
20862
|
+
"""Compile a favourite section declaration into the backend favouriteConfig shape."""
|
|
20863
|
+
payload = raw or {}
|
|
20864
|
+
favourite_config: dict[str, Any] = {
|
|
20865
|
+
"favouriteTitle": section_title,
|
|
20866
|
+
}
|
|
20867
|
+
if "show_title" in payload:
|
|
20868
|
+
favourite_config["beingShowTitle"] = bool(payload["show_title"])
|
|
20869
|
+
elif "beingShowTitle" in payload:
|
|
20870
|
+
favourite_config["beingShowTitle"] = bool(payload["beingShowTitle"])
|
|
20871
|
+
if "enabled" in payload:
|
|
20872
|
+
favourite_config["beingUsing"] = bool(payload["enabled"])
|
|
20873
|
+
elif "beingUsing" in payload:
|
|
20874
|
+
favourite_config["beingUsing"] = bool(payload["beingUsing"])
|
|
20875
|
+
raw_items = payload.get("items")
|
|
20876
|
+
if isinstance(raw_items, list) and raw_items:
|
|
20877
|
+
favourite_items: list[dict[str, Any]] = []
|
|
20878
|
+
for index, raw_item in enumerate(raw_items):
|
|
20879
|
+
if not isinstance(raw_item, dict):
|
|
20880
|
+
continue
|
|
20881
|
+
item: dict[str, Any] = {
|
|
20882
|
+
"title": str(raw_item.get("title") or ""),
|
|
20883
|
+
"type": _coerce_positive_int(raw_item.get("item_type") or raw_item.get("type")) or 1,
|
|
20884
|
+
"ordinal": _coerce_positive_int(raw_item.get("ordinal")) or index,
|
|
20885
|
+
}
|
|
20886
|
+
link_key = str(raw_item.get("link_key") or raw_item.get("linkKey") or "").strip()
|
|
20887
|
+
if link_key:
|
|
20888
|
+
item["linkKey"] = link_key
|
|
20889
|
+
link_id = _coerce_positive_int(raw_item.get("link_id") or raw_item.get("linkId"))
|
|
20890
|
+
if link_id is not None:
|
|
20891
|
+
item["linkId"] = link_id
|
|
20892
|
+
icon_url = str(raw_item.get("icon_url") or raw_item.get("iconUrl") or "").strip()
|
|
20893
|
+
if icon_url:
|
|
20894
|
+
item["iconUrl"] = icon_url
|
|
20895
|
+
jump_mode = _coerce_positive_int(raw_item.get("jump_mode") or raw_item.get("jumpMode"))
|
|
20896
|
+
if jump_mode is not None:
|
|
20897
|
+
item["jumpMode"] = jump_mode
|
|
20898
|
+
favourite_items.append(item)
|
|
20899
|
+
if favourite_items:
|
|
20900
|
+
favourite_config["items"] = favourite_items
|
|
20901
|
+
return favourite_config
|
|
20902
|
+
|
|
20903
|
+
|
|
20255
20904
|
def _build_public_portal_base_payload(
|
|
20256
20905
|
*,
|
|
20257
20906
|
dash_name: str,
|
|
@@ -20261,19 +20910,49 @@ def _build_public_portal_base_payload(
|
|
|
20261
20910
|
auth: dict[str, Any] | None,
|
|
20262
20911
|
hide_copyright: bool | None,
|
|
20263
20912
|
dash_global_config: dict[str, Any] | None,
|
|
20913
|
+
style_template_id: int | None = None,
|
|
20914
|
+
overall_style: dict[str, Any] | None = None,
|
|
20264
20915
|
config: dict[str, Any] | None,
|
|
20265
20916
|
base_payload: dict[str, Any] | None,
|
|
20266
20917
|
) -> dict[str, Any]:
|
|
20267
20918
|
data = deepcopy(base_payload) if isinstance(base_payload, dict) else {}
|
|
20268
|
-
|
|
20919
|
+
# dash_name 语义:None=未指定(沿用 base_payload 读面名,缺省再回落未命名门户);
|
|
20920
|
+
# 空串=显式空名(后端 dash_name 允许空串,生产存量存在;round-trip 保真必须透传)。
|
|
20921
|
+
if dash_name is not None:
|
|
20922
|
+
effective_name = str(dash_name).strip()
|
|
20923
|
+
else:
|
|
20924
|
+
effective_name = str(data.get("dashName") or "").strip() or "未命名门户"
|
|
20269
20925
|
effective_hide_copyright = hide_copyright if hide_copyright is not None else bool(data.get("hideCopyright", False))
|
|
20270
|
-
|
|
20926
|
+
# dashIcon round-trip 保真(r4 全量实测「默认值补写」修复):
|
|
20927
|
+
# - 声明显式给 icon/color → 重编码(存量形态走最小 patch);
|
|
20928
|
+
# - 声明未指定 + 创建语义(base_payload 无) → 合成默认(创建行为不变);
|
|
20929
|
+
# - 声明未指定 + 更新语义 → 不碰 dashIcon:存量有则保留原值,存量空则**不合成**。
|
|
20930
|
+
# 后端 updateDashBaseInfo 对 dashIcon 无校验直接透传;合成默认会让「B0 无图标」
|
|
20931
|
+
# 门户在 apply 后长出默认图标/默认色(r4 实测 50 处 color diff 根因)。
|
|
20932
|
+
if icon or color:
|
|
20933
|
+
if base_payload is not None and isinstance(base_payload.get("dashIcon"), str):
|
|
20934
|
+
data["dashIcon"] = update_workspace_icon_from_existing(
|
|
20935
|
+
icon=icon,
|
|
20936
|
+
color=color,
|
|
20937
|
+
title=effective_name,
|
|
20938
|
+
existing_icon=str(data.get("dashIcon") or "").strip() or None,
|
|
20939
|
+
fallback_icon_name="view-grid",
|
|
20940
|
+
)
|
|
20941
|
+
else:
|
|
20942
|
+
data["dashIcon"] = encode_workspace_icon_with_defaults(
|
|
20943
|
+
icon=icon,
|
|
20944
|
+
color=color,
|
|
20945
|
+
title=effective_name,
|
|
20946
|
+
fallback_icon_name="view-grid",
|
|
20947
|
+
existing_icon=str(data.get("dashIcon") or "").strip() or None,
|
|
20948
|
+
)
|
|
20949
|
+
elif base_payload is None:
|
|
20271
20950
|
data["dashIcon"] = encode_workspace_icon_with_defaults(
|
|
20272
|
-
icon=
|
|
20273
|
-
color=
|
|
20951
|
+
icon=None,
|
|
20952
|
+
color=None,
|
|
20274
20953
|
title=effective_name,
|
|
20275
20954
|
fallback_icon_name="view-grid",
|
|
20276
|
-
existing_icon=
|
|
20955
|
+
existing_icon=None,
|
|
20277
20956
|
)
|
|
20278
20957
|
data["dashName"] = effective_name
|
|
20279
20958
|
data["auth"] = deepcopy(auth if auth is not None else data.get("auth") or default_member_auth())
|
|
@@ -20283,10 +20962,15 @@ def _build_public_portal_base_payload(
|
|
|
20283
20962
|
elif "tags" not in data:
|
|
20284
20963
|
data["tags"] = []
|
|
20285
20964
|
global_config = deepcopy(dash_global_config if isinstance(dash_global_config, dict) else data.get("dashGlobalConfig") or {})
|
|
20965
|
+
global_config = _compile_portal_global_config(global_config)
|
|
20286
20966
|
global_config.pop("layout", None)
|
|
20287
20967
|
global_config["interval"] = global_config.get("interval") or 60
|
|
20288
20968
|
global_config["beingAutoRefresh"] = bool(global_config.get("beingAutoRefresh", False))
|
|
20289
20969
|
data["dashGlobalConfig"] = global_config
|
|
20970
|
+
if style_template_id is not None:
|
|
20971
|
+
data["styleTemplateId"] = style_template_id
|
|
20972
|
+
if overall_style is not None:
|
|
20973
|
+
data["overallConfigBO"] = deepcopy(overall_style)
|
|
20290
20974
|
if isinstance(config, dict):
|
|
20291
20975
|
data.update(deepcopy(config))
|
|
20292
20976
|
return data
|
|
@@ -20392,18 +21076,37 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
|
|
|
20392
21076
|
|
|
20393
21077
|
def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
|
|
20394
21078
|
mobile_provided = bool(getattr(position, "mobile_provided", False))
|
|
20395
|
-
|
|
21079
|
+
|
|
21080
|
+
def _coord(raw: Any, *, explicit: bool, default: int) -> int | None:
|
|
21081
|
+
# explicit=False(mobile 未提供):忽略模型默认值,沿用调用方 default(布局推断),
|
|
21082
|
+
# 与旧实现 `int(v) if provided else default` 行为一致;
|
|
21083
|
+
# explicit=True:显式 null 原样透传(后端 DashPosition null 语义,筛选组件等原生
|
|
21084
|
+
# NULL 位置读写链双向透传),仅键缺省时才回落布局默认值。
|
|
21085
|
+
if not explicit:
|
|
21086
|
+
return default
|
|
21087
|
+
if raw is None:
|
|
21088
|
+
return None
|
|
21089
|
+
return int(raw)
|
|
21090
|
+
|
|
21091
|
+
mobile_h_raw = getattr(position, "mobile_h", None) if mobile_provided else None
|
|
21092
|
+
pc_h_raw = getattr(position, "pc_h", None)
|
|
21093
|
+
if mobile_provided:
|
|
21094
|
+
# mobile 显式提供:rows 以 mobile_h 为准,null 透传(后端语义)
|
|
21095
|
+
mobile_rows = None if mobile_h_raw is None else int(mobile_h_raw)
|
|
21096
|
+
else:
|
|
21097
|
+
# mobile 未提供:rows 跟随 pc 高度(与旧实现一致);pc_h 显式 null 时回落布局默认 8
|
|
21098
|
+
mobile_rows = int(pc_h_raw) if pc_h_raw is not None else 8
|
|
20396
21099
|
return {
|
|
20397
21100
|
"pc": {
|
|
20398
|
-
"x":
|
|
20399
|
-
"y":
|
|
20400
|
-
"cols":
|
|
20401
|
-
"rows":
|
|
21101
|
+
"x": _coord(getattr(position, "pc_x", None), explicit=True, default=0),
|
|
21102
|
+
"y": _coord(getattr(position, "pc_y", None), explicit=True, default=0),
|
|
21103
|
+
"cols": _coord(getattr(position, "pc_w", None), explicit=True, default=12),
|
|
21104
|
+
"rows": _coord(getattr(position, "pc_h", None), explicit=True, default=8),
|
|
20402
21105
|
},
|
|
20403
21106
|
"mobile": {
|
|
20404
|
-
"x":
|
|
20405
|
-
"y":
|
|
20406
|
-
"cols":
|
|
21107
|
+
"x": _coord(getattr(position, "mobile_x", None), explicit=mobile_provided, default=0),
|
|
21108
|
+
"y": _coord(getattr(position, "mobile_y", None), explicit=mobile_provided, default=inferred_mobile_y),
|
|
21109
|
+
"cols": _coord(getattr(position, "mobile_w", None), explicit=mobile_provided, default=6),
|
|
20407
21110
|
"rows": mobile_rows,
|
|
20408
21111
|
},
|
|
20409
21112
|
}
|
|
@@ -20434,6 +21137,15 @@ def _portal_component_position_public(
|
|
|
20434
21137
|
elif source_name == "link":
|
|
20435
21138
|
cols = 12
|
|
20436
21139
|
rows = 2
|
|
21140
|
+
elif source_name == "carousel":
|
|
21141
|
+
cols = 24
|
|
21142
|
+
rows = 7
|
|
21143
|
+
elif source_name == "task":
|
|
21144
|
+
cols = 12
|
|
21145
|
+
rows = 8
|
|
21146
|
+
elif source_name == "favourite":
|
|
21147
|
+
cols = 12
|
|
21148
|
+
rows = 6
|
|
20437
21149
|
else:
|
|
20438
21150
|
if layout_preset == "dashboard_2col":
|
|
20439
21151
|
cols = 12
|
|
@@ -23584,6 +24296,24 @@ def _ensure_field_temp_ids(fields: list[dict[str, Any]]) -> None:
|
|
|
23584
24296
|
temp_id -= 100
|
|
23585
24297
|
|
|
23586
24298
|
|
|
24299
|
+
def _field_index_by_ref(fields: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
|
|
24300
|
+
"""按稳定引用(que_id,新字段退化 que_temp_id)建索引——方案 A 同名场景
|
|
24301
|
+
下 by_name 会把不同 queId 的同名字段折叠成一个,relation 目标错位(实测
|
|
24302
|
+
a9lfp40imk02:两个同名 code_block 各自输出到同名不同 queId 的 text 字段,
|
|
24303
|
+
by_name 把两条 relation 撞到同一目标,触发 multiple default-value relations)。
|
|
24304
|
+
"""
|
|
24305
|
+
index: dict[int, dict[str, Any]] = {}
|
|
24306
|
+
for field in fields:
|
|
24307
|
+
if not isinstance(field, dict):
|
|
24308
|
+
continue
|
|
24309
|
+
ref = _coerce_positive_int(field.get("que_id"))
|
|
24310
|
+
if ref is None:
|
|
24311
|
+
ref = _coerce_any_int(field.get("que_temp_id"))
|
|
24312
|
+
if ref is not None and ref not in index:
|
|
24313
|
+
index[ref] = field
|
|
24314
|
+
return index
|
|
24315
|
+
|
|
24316
|
+
|
|
23587
24317
|
def _compile_code_block_binding_fields(
|
|
23588
24318
|
*,
|
|
23589
24319
|
fields: list[dict[str, Any]],
|
|
@@ -23596,6 +24326,7 @@ def _compile_code_block_binding_fields(
|
|
|
23596
24326
|
for field in next_fields
|
|
23597
24327
|
if isinstance(field, dict) and str(field.get("name") or "")
|
|
23598
24328
|
}
|
|
24329
|
+
by_ref = _field_index_by_ref(next_fields)
|
|
23599
24330
|
existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
|
|
23600
24331
|
relation_specs: list[dict[str, Any]] = []
|
|
23601
24332
|
affected_source_refs: set[int] = set()
|
|
@@ -23711,7 +24442,12 @@ def _compile_code_block_binding_fields(
|
|
|
23711
24442
|
target_payload = output_item.get("target_field")
|
|
23712
24443
|
if not isinstance(target_payload, dict):
|
|
23713
24444
|
continue
|
|
23714
|
-
target_field
|
|
24445
|
+
# 编译产物 target_field 已带按 queId 唯一解析出的 que_id(同名字段
|
|
24446
|
+
# 不折叠);以 ref 定位目标,que_id 缺失(新字段 temp 形态)才退化
|
|
24447
|
+
# by_name——by_name 对同名目标会错位到任意一个同名字段。
|
|
24448
|
+
target_field = by_ref.get(_coerce_any_int(target_payload.get("que_id")) or -1)
|
|
24449
|
+
if not isinstance(target_field, dict):
|
|
24450
|
+
target_field = by_name.get(str(target_payload.get("name") or ""))
|
|
23715
24451
|
if not isinstance(target_field, dict):
|
|
23716
24452
|
continue
|
|
23717
24453
|
target_ref = _coerce_positive_int(target_field.get("que_id"))
|
|
@@ -23779,6 +24515,7 @@ def _materialize_code_block_question_relations(
|
|
|
23779
24515
|
relation_specs: list[dict[str, Any]],
|
|
23780
24516
|
) -> list[dict[str, Any]]:
|
|
23781
24517
|
by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
|
|
24518
|
+
by_ref = _field_index_by_ref(fields)
|
|
23782
24519
|
materialized: list[dict[str, Any]] = []
|
|
23783
24520
|
for relation in relation_specs:
|
|
23784
24521
|
if not isinstance(relation, dict):
|
|
@@ -23786,8 +24523,15 @@ def _materialize_code_block_question_relations(
|
|
|
23786
24523
|
if _coerce_positive_int(relation.get("relationType")) == RELATION_TYPE_CODE_BLOCK:
|
|
23787
24524
|
materialized.append(deepcopy(relation))
|
|
23788
24525
|
continue
|
|
23789
|
-
|
|
23790
|
-
|
|
24526
|
+
# relation_spec 的 *_field_ref 来自编译期按 queId 唯一解析的结果,优先
|
|
24527
|
+
# 以 ref 定位源/目标字段;ref 缺失(新字段 temp 形态)退化 by_name——
|
|
24528
|
+
# 同名字段场景 by_name 会折叠错位(与 _compile_* 侧同口径)。
|
|
24529
|
+
source_field = by_ref.get(_coerce_any_int(relation.get("source_field_ref")) or -1)
|
|
24530
|
+
if not isinstance(source_field, dict):
|
|
24531
|
+
source_field = by_name.get(str(relation.get("source_field_name") or ""))
|
|
24532
|
+
target_field = by_ref.get(_coerce_any_int(relation.get("target_field_ref")) or -1)
|
|
24533
|
+
if not isinstance(target_field, dict):
|
|
24534
|
+
target_field = by_name.get(str(relation.get("target_field_name") or ""))
|
|
23791
24535
|
if not isinstance(source_field, dict) or not isinstance(target_field, dict):
|
|
23792
24536
|
continue
|
|
23793
24537
|
source_ref = _coerce_positive_int(source_field.get("que_id"))
|
|
@@ -23911,6 +24655,7 @@ def _compile_q_linker_binding_fields(
|
|
|
23911
24655
|
for field in next_fields
|
|
23912
24656
|
if isinstance(field, dict) and str(field.get("name") or "")
|
|
23913
24657
|
}
|
|
24658
|
+
by_ref = _field_index_by_ref(next_fields)
|
|
23914
24659
|
existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
|
|
23915
24660
|
relation_specs: list[dict[str, Any]] = []
|
|
23916
24661
|
affected_source_refs: set[int] = set()
|
|
@@ -24007,21 +24752,39 @@ def _compile_q_linker_binding_fields(
|
|
|
24007
24752
|
compiled_json_value = None if request.get("json_value") is None else str(request.get("json_value") or "")
|
|
24008
24753
|
compiled_xml_value = None if request.get("xml_value") is None else str(request.get("xml_value") or "")
|
|
24009
24754
|
for index, input_item in enumerate(cast(list[dict[str, Any]], binding.get("inputs") or [])):
|
|
24010
|
-
|
|
24011
|
-
|
|
24012
|
-
|
|
24013
|
-
|
|
24014
|
-
|
|
24015
|
-
|
|
24016
|
-
|
|
24017
|
-
|
|
24755
|
+
selector_payload = cast(dict[str, Any], input_item.get("field") or {})
|
|
24756
|
+
declared_que_id = _coerce_positive_int(selector_payload.get("que_id"))
|
|
24757
|
+
declared_name = str(selector_payload.get("name") or "").strip()
|
|
24758
|
+
try:
|
|
24759
|
+
source_field = _resolve_field_selector_with_uniqueness(
|
|
24760
|
+
fields=next_fields,
|
|
24761
|
+
selector_payload=selector_payload,
|
|
24762
|
+
location=f"q_linker_binding.inputs[{index}].field",
|
|
24763
|
+
)
|
|
24764
|
+
except AmbiguousFieldSelector:
|
|
24765
|
+
raise
|
|
24766
|
+
except ValueError:
|
|
24767
|
+
if declared_que_id is None:
|
|
24768
|
+
raise
|
|
24769
|
+
# 透传:queId 已锚定但字段不在当前表单(存量悬空/改名引用)——后端
|
|
24770
|
+
# 写链对 params_que_id 无存在性校验,按声明 queId 直写,名称用声明名
|
|
24771
|
+
source_field = None
|
|
24772
|
+
if source_field is None:
|
|
24773
|
+
source_que_id = declared_que_id
|
|
24774
|
+
else:
|
|
24775
|
+
source_que_id = _coerce_positive_int(source_field.get("que_id"))
|
|
24776
|
+
if source_que_id is None:
|
|
24777
|
+
source_que_id = _coerce_any_int(source_field.get("que_temp_id"))
|
|
24018
24778
|
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")
|
|
24779
|
+
raise ValueError(f"q_linker input field '{declared_name or (source_field or {}).get('name')}' does not have a stable que_id")
|
|
24780
|
+
# 引用重组优先用声明名:B0 模板引用名与当前字段名可能不同(存量字段名
|
|
24781
|
+
# 带尾随空格等,如 am4oh08v2401),按解析名重组会改写模板破坏 round-trip
|
|
24782
|
+
compose_name = declared_name or str((source_field or {}).get("name") or "")
|
|
24020
24783
|
key = str(input_item.get("key") or "").strip()
|
|
24021
24784
|
source_kind = str(input_item.get("source") or "").strip().lower()
|
|
24022
24785
|
compiled_inputs.append(
|
|
24023
24786
|
{
|
|
24024
|
-
"field": {"name":
|
|
24787
|
+
"field": {"name": compose_name, "que_id": source_que_id},
|
|
24025
24788
|
"key": key,
|
|
24026
24789
|
"source": source_kind,
|
|
24027
24790
|
}
|
|
@@ -24035,7 +24798,7 @@ def _compile_q_linker_binding_fields(
|
|
|
24035
24798
|
compiled_url_encoded.append(kv_item)
|
|
24036
24799
|
elif source_kind == "json_path":
|
|
24037
24800
|
token = "{{" + key + "}}"
|
|
24038
|
-
reference = _compose_qlinker_value_reference(field_name=
|
|
24801
|
+
reference = _compose_qlinker_value_reference(field_name=compose_name, que_id=source_que_id)
|
|
24039
24802
|
compiled_url = compiled_url.replace(token, reference)
|
|
24040
24803
|
if compiled_json_value is not None:
|
|
24041
24804
|
compiled_json_value = compiled_json_value.replace(token, reference)
|
|
@@ -24121,7 +24884,12 @@ def _compile_q_linker_binding_fields(
|
|
|
24121
24884
|
target_payload = output_item.get("target_field")
|
|
24122
24885
|
if not isinstance(target_payload, dict):
|
|
24123
24886
|
continue
|
|
24124
|
-
target_field
|
|
24887
|
+
# 编译产物 target_field 已带按 queId 唯一解析出的 que_id(同名字段
|
|
24888
|
+
# 不折叠);以 ref 定位目标,que_id 缺失(新字段 temp 形态)才退化
|
|
24889
|
+
# by_name——by_name 对同名目标会错位到任意一个同名字段。
|
|
24890
|
+
target_field = by_ref.get(_coerce_any_int(target_payload.get("que_id")) or -1)
|
|
24891
|
+
if not isinstance(target_field, dict):
|
|
24892
|
+
target_field = by_name.get(str(target_payload.get("name") or ""))
|
|
24125
24893
|
if not isinstance(target_field, dict):
|
|
24126
24894
|
continue
|
|
24127
24895
|
target_ref = _coerce_positive_int(target_field.get("que_id"))
|
|
@@ -24179,12 +24947,20 @@ def _materialize_q_linker_question_relations(
|
|
|
24179
24947
|
relation_specs: list[dict[str, Any]],
|
|
24180
24948
|
) -> list[dict[str, Any]]:
|
|
24181
24949
|
by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
|
|
24950
|
+
by_ref = _field_index_by_ref(fields)
|
|
24182
24951
|
materialized: list[dict[str, Any]] = []
|
|
24183
24952
|
for relation in relation_specs:
|
|
24184
24953
|
if not isinstance(relation, dict):
|
|
24185
24954
|
continue
|
|
24186
|
-
|
|
24187
|
-
|
|
24955
|
+
# relation_spec 的 *_field_ref 来自编译期按 queId 唯一解析的结果,优先
|
|
24956
|
+
# 以 ref 定位源/目标字段;ref 缺失(新字段 temp 形态)退化 by_name——
|
|
24957
|
+
# 同名字段场景 by_name 会折叠错位(与 _compile_* 侧同口径)。
|
|
24958
|
+
source_field = by_ref.get(_coerce_any_int(relation.get("source_field_ref")) or -1)
|
|
24959
|
+
if not isinstance(source_field, dict):
|
|
24960
|
+
source_field = by_name.get(str(relation.get("source_field_name") or ""))
|
|
24961
|
+
target_field = by_ref.get(_coerce_any_int(relation.get("target_field_ref")) or -1)
|
|
24962
|
+
if not isinstance(target_field, dict):
|
|
24963
|
+
target_field = by_name.get(str(relation.get("target_field_name") or ""))
|
|
24188
24964
|
if not isinstance(source_field, dict) or not isinstance(target_field, dict):
|
|
24189
24965
|
continue
|
|
24190
24966
|
source_ref = _coerce_positive_int(source_field.get("que_id"))
|
|
@@ -27814,6 +28590,385 @@ def _normalize_portal_components(components: Any) -> list[dict[str, Any]]:
|
|
|
27814
28590
|
return items
|
|
27815
28591
|
|
|
27816
28592
|
|
|
28593
|
+
def _portal_component_source_type_by_backend(value: Any) -> str:
|
|
28594
|
+
raw = value if isinstance(value, (int, str)) else str(value or "").strip()
|
|
28595
|
+
mapping = {
|
|
28596
|
+
1: "chart",
|
|
28597
|
+
2: "grid",
|
|
28598
|
+
3: "carousel",
|
|
28599
|
+
4: "link",
|
|
28600
|
+
5: "text",
|
|
28601
|
+
6: "filter",
|
|
28602
|
+
7: "favourite",
|
|
28603
|
+
8: "task",
|
|
28604
|
+
9: "chart",
|
|
28605
|
+
10: "view",
|
|
28606
|
+
}
|
|
28607
|
+
if isinstance(raw, int):
|
|
28608
|
+
return mapping.get(raw, "unknown")
|
|
28609
|
+
raw_str = str(raw or "").strip()
|
|
28610
|
+
if raw_str.isdigit():
|
|
28611
|
+
return mapping.get(int(raw_str), "unknown")
|
|
28612
|
+
return _normalize_portal_component_source_type(raw)
|
|
28613
|
+
|
|
28614
|
+
|
|
28615
|
+
def _portal_section_from_component(
|
|
28616
|
+
component: dict[str, Any],
|
|
28617
|
+
*,
|
|
28618
|
+
index: int,
|
|
28619
|
+
complete: bool,
|
|
28620
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
28621
|
+
"""Reverse-compile one DashComponentInfo into a canonical section."""
|
|
28622
|
+
source_type = _portal_component_source_type_by_backend(component.get("type"))
|
|
28623
|
+
config_key_map = {
|
|
28624
|
+
"grid": "gridConfig",
|
|
28625
|
+
"link": "linkConfig",
|
|
28626
|
+
"text": "textConfig",
|
|
28627
|
+
"filter": "filterConfig",
|
|
28628
|
+
"chart": "chartConfig",
|
|
28629
|
+
"view": "viewgraphConfig",
|
|
28630
|
+
"carousel": "carouselConfig",
|
|
28631
|
+
"task": "taskConfig",
|
|
28632
|
+
"favourite": "favouriteConfig",
|
|
28633
|
+
}
|
|
28634
|
+
if source_type == "unknown":
|
|
28635
|
+
return None, {
|
|
28636
|
+
"index": index,
|
|
28637
|
+
"code": "UNKNOWN_COMPONENT_TYPE",
|
|
28638
|
+
"message": f"component type {component.get('type')} is not part of the portals declaration contract",
|
|
28639
|
+
"blocking_field": "source_type",
|
|
28640
|
+
}
|
|
28641
|
+
config = component.get(config_key_map.get(source_type, "")) if isinstance(component.get(config_key_map.get(source_type, "")), dict) else {}
|
|
28642
|
+
section: dict[str, Any] = {"source_type": source_type}
|
|
28643
|
+
title = _extract_portal_component_title(component, source_type=source_type)
|
|
28644
|
+
gaps: list[dict[str, Any]] = []
|
|
28645
|
+
if source_type == "carousel":
|
|
28646
|
+
title = str(config.get("sourceApp", {}).get("formTitle") or "").strip() or f"轮播 {index + 1}"
|
|
28647
|
+
elif source_type == "task":
|
|
28648
|
+
title = str(config.get("componentTaskTitle") or f"待办 {index + 1}")
|
|
28649
|
+
elif source_type == "favourite":
|
|
28650
|
+
title = str(config.get("favouriteTitle") or f"收藏 {index + 1}")
|
|
28651
|
+
# chart/grid/view 的 title 语义=组件显示标题(component_title/grid_title 落库列),
|
|
28652
|
+
# 生产存量大量存在「显示标题开但标题空」形态(读链渲染时回退图表名/视图名):
|
|
28653
|
+
# 合成「组件 N」会把不存在的标题落库(读链 chartName 派生被覆盖 + 库表漂移),
|
|
28654
|
+
# 如实保留 None(编译不落库,round-trip 保真)。carousel/task/favourite 的
|
|
28655
|
+
# 读面自有真名回填(formTitle/componentTaskTitle/favouriteTitle),其合成兑底保留。
|
|
28656
|
+
# filter 同样不合成:后端 addComponentInfos 对筛选组件不落任何标题(无标题存储),
|
|
28657
|
+
# 读面按代理键 HashMap 迭代序返回——合成「组件 {index+1}」的标签是列表序号的
|
|
28658
|
+
# 函数,apply 删重建致代理键位移后标题跟着置换(r6 幂等差 3 的唯一根因)。
|
|
28659
|
+
if source_type not in {"chart", "grid", "view", "filter"}:
|
|
28660
|
+
title = title or f"组件 {index + 1}"
|
|
28661
|
+
section["title"] = title
|
|
28662
|
+
if source_type in {"chart", "view"} and complete:
|
|
28663
|
+
# beingShowTitle 落库列(draft_dash_chart.being_show_title):读面必有值;
|
|
28664
|
+
# 提取必须保真(存量 false 被「硬编码 True」覆写会漂移库表)。grid 的开关
|
|
28665
|
+
# 在 grid_config.beingShowTitle 键内随 config 保留,无需单独建模。
|
|
28666
|
+
if "beingShowTitle" in config:
|
|
28667
|
+
section["show_title"] = bool(config.get("beingShowTitle"))
|
|
28668
|
+
position = component.get("position")
|
|
28669
|
+
if isinstance(position, dict) and position:
|
|
28670
|
+
section["position"] = deepcopy(position)
|
|
28671
|
+
if complete:
|
|
28672
|
+
style = component.get("dashStyleConfigBO")
|
|
28673
|
+
if isinstance(style, dict) and style:
|
|
28674
|
+
section["style_config"] = deepcopy(style)
|
|
28675
|
+
if source_type == "chart":
|
|
28676
|
+
# 后端两类图表组件引用面不同(DashSvcImpl 写链):type=1 轻流报表锚 chart 表唯一键
|
|
28677
|
+
# chartKey(addChartInfos);type=9 轻析 BI 报表锚 bi_chart 整型 chart_id(addBiChartInfo)。
|
|
28678
|
+
# 提取时以读面 type 判别 ref_kind,保证 apply 回写同一引用面。
|
|
28679
|
+
backend_type = component.get("type")
|
|
28680
|
+
is_qingflow_chart = backend_type == 1 or (
|
|
28681
|
+
backend_type != 9 and str(config.get("chartKey") or "").strip() and not config.get("biChartId")
|
|
28682
|
+
)
|
|
28683
|
+
if is_qingflow_chart:
|
|
28684
|
+
chart_ref: dict[str, Any] = {
|
|
28685
|
+
"ref_kind": "chart",
|
|
28686
|
+
"chart_id": str(config.get("chartKey") or "").strip() or None,
|
|
28687
|
+
"chart_name": str(config.get("chartName") or title or "").strip() or None,
|
|
28688
|
+
}
|
|
28689
|
+
else:
|
|
28690
|
+
chart_ref = {
|
|
28691
|
+
"chart_id": str(config.get("biChartId") or "").strip() or None,
|
|
28692
|
+
"chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
|
|
28693
|
+
}
|
|
28694
|
+
chart_ref = {key: value for key, value in chart_ref.items() if value is not None}
|
|
28695
|
+
if not chart_ref.get("chart_id"):
|
|
28696
|
+
gaps.append({
|
|
28697
|
+
"index": index,
|
|
28698
|
+
"code": "CHART_REF_MISSING",
|
|
28699
|
+
"message": "chart component has no chartKey/biChartId in the read face",
|
|
28700
|
+
"blocking_field": "chart_ref.chart_id",
|
|
28701
|
+
})
|
|
28702
|
+
return None, gaps[0]
|
|
28703
|
+
section["chart_ref"] = chart_ref
|
|
28704
|
+
elif source_type == "view":
|
|
28705
|
+
view_ref: dict[str, Any] = {
|
|
28706
|
+
"app_key": str(config.get("appKey") or "").strip() or None,
|
|
28707
|
+
"view_key": str(config.get("viewgraphKey") or "").strip() or None,
|
|
28708
|
+
"view_name": str(config.get("viewgraphName") or title or "").strip() or None,
|
|
28709
|
+
}
|
|
28710
|
+
view_ref = {key: value for key, value in view_ref.items() if value is not None}
|
|
28711
|
+
if not view_ref.get("view_key"):
|
|
28712
|
+
gaps.append({
|
|
28713
|
+
"index": index,
|
|
28714
|
+
"code": "VIEW_REF_MISSING",
|
|
28715
|
+
"message": "view component has no viewgraphKey in the read face",
|
|
28716
|
+
"blocking_field": "view_ref.view_key",
|
|
28717
|
+
})
|
|
28718
|
+
return None, gaps[0]
|
|
28719
|
+
section["view_ref"] = view_ref
|
|
28720
|
+
if complete and ("beingShowTitle" in config or config.get("componentTitle")):
|
|
28721
|
+
display: dict[str, Any] = {}
|
|
28722
|
+
if "beingShowTitle" in config:
|
|
28723
|
+
display["show_title"] = bool(config.get("beingShowTitle"))
|
|
28724
|
+
if config.get("componentTitle"):
|
|
28725
|
+
display["component_title"] = str(config.get("componentTitle"))
|
|
28726
|
+
if display:
|
|
28727
|
+
section["view_display"] = display
|
|
28728
|
+
elif source_type == "grid":
|
|
28729
|
+
grid_config: dict[str, Any] = {}
|
|
28730
|
+
for key in ("gridTitle", "beingShowTitle", "items"):
|
|
28731
|
+
if key in config:
|
|
28732
|
+
grid_config[key] = deepcopy(config.get(key))
|
|
28733
|
+
if isinstance(grid_config.get("items"), list):
|
|
28734
|
+
# 归一到声明规范键集(DraftComponentGridItem 落库列 + 读面派生 title):
|
|
28735
|
+
# 丢弃运行态/代理键(id/gridId/beingShowTitle/beingTrial/trialExpireDate——
|
|
28736
|
+
# 前者是自增代理键(比对器另剔),后两者读面运行态不入库,title/iconUrl 由
|
|
28737
|
+
# setTitleAndIconByType 按引用派生);空 title 条目仍过滤(后端读面同型)。
|
|
28738
|
+
kept_item_keys = {
|
|
28739
|
+
"type", "jumpMode", "linkAppKey", "linkFormType", "linkDashKey",
|
|
28740
|
+
"linkTagId", "linkUrl", "title", "customTitle", "beingCustomTitle",
|
|
28741
|
+
"iconUrl", "ordinal",
|
|
28742
|
+
}
|
|
28743
|
+
normalized_items: list[dict[str, Any]] = []
|
|
28744
|
+
for item in grid_config["items"]:
|
|
28745
|
+
if not isinstance(item, dict) or not str(item.get("title") or "").strip():
|
|
28746
|
+
continue
|
|
28747
|
+
normalized = {
|
|
28748
|
+
key: deepcopy(value)
|
|
28749
|
+
for key, value in item.items()
|
|
28750
|
+
if key in kept_item_keys and value is not None
|
|
28751
|
+
}
|
|
28752
|
+
# iconUrl 语义按条目类型分裂(Java ComponentGridItemInfo#setTitleAndIconByType):
|
|
28753
|
+
# type 1/2/3(应用/门户/应用包)的 iconUrl 与 title 是读面按引用目标当前
|
|
28754
|
+
# 图标实时派生的投影——门户声明不拥有也无法保真(写侧不回写生效值),
|
|
28755
|
+
# 提取侧剔除;自定义条目 iconUrl 落库原样读回,保留 round-trip。
|
|
28756
|
+
# 空串 iconUrl 统一剔除(与 favourite 提取对称,消 ""↔缺键不对称)。
|
|
28757
|
+
if _coerce_positive_int(normalized.get("type")) in {1, 2, 3} or not str(normalized.get("iconUrl") or "").strip():
|
|
28758
|
+
normalized.pop("iconUrl", None)
|
|
28759
|
+
normalized_items.append(normalized)
|
|
28760
|
+
grid_config["items"] = normalized_items
|
|
28761
|
+
section["config"] = grid_config
|
|
28762
|
+
elif source_type == "text":
|
|
28763
|
+
section["text"] = str(config.get("text") or "")
|
|
28764
|
+
if complete and config:
|
|
28765
|
+
extra_config = {key: deepcopy(value) for key, value in config.items() if key not in {"text"}}
|
|
28766
|
+
if extra_config:
|
|
28767
|
+
section["config"] = extra_config
|
|
28768
|
+
elif source_type == "link":
|
|
28769
|
+
section["url"] = str(config.get("url") or "")
|
|
28770
|
+
if complete and "beingLoginAuth" in config:
|
|
28771
|
+
section["login_auth"] = bool(config.get("beingLoginAuth"))
|
|
28772
|
+
elif source_type == "filter":
|
|
28773
|
+
graph_list = config.get("graphList") if isinstance(config.get("graphList"), list) else []
|
|
28774
|
+
graphs = [
|
|
28775
|
+
{
|
|
28776
|
+
"chart_key": str(item.get("graphKey") or "").strip() or None,
|
|
28777
|
+
"chart_name": str(item.get("graphName") or "").strip() or None,
|
|
28778
|
+
}
|
|
28779
|
+
for item in graph_list
|
|
28780
|
+
if isinstance(item, dict)
|
|
28781
|
+
]
|
|
28782
|
+
graphs = [
|
|
28783
|
+
{key: value for key, value in item.items() if value is not None}
|
|
28784
|
+
for item in graphs
|
|
28785
|
+
]
|
|
28786
|
+
rules: list[dict[str, Any]] = []
|
|
28787
|
+
for group in config.get("filterConfig") or []:
|
|
28788
|
+
if not isinstance(group, dict):
|
|
28789
|
+
continue
|
|
28790
|
+
# 剔除后端删除重建的代理键(filterGroupId/filterItemId,自增 id 每次位移,七条约束之六):
|
|
28791
|
+
# 声明级比以此归一,规则语义真源是 filterGroupName/filterItemName/judge 系内容列。
|
|
28792
|
+
rules.append({
|
|
28793
|
+
key: deepcopy(value)
|
|
28794
|
+
for key, value in group.items()
|
|
28795
|
+
if key not in {"filterGroupId"}
|
|
28796
|
+
})
|
|
28797
|
+
for group in rules:
|
|
28798
|
+
for item in group.get("filterGroupConfig") or []:
|
|
28799
|
+
if isinstance(item, dict):
|
|
28800
|
+
item.pop("filterItemId", None)
|
|
28801
|
+
section["config"] = {"graphs": graphs, "rules": rules}
|
|
28802
|
+
elif source_type == "carousel":
|
|
28803
|
+
carousel: dict[str, Any] = {}
|
|
28804
|
+
source_app = config.get("sourceApp") if isinstance(config.get("sourceApp"), dict) else {}
|
|
28805
|
+
if source_app.get("appKey"):
|
|
28806
|
+
carousel["source_app_key"] = str(source_app.get("appKey"))
|
|
28807
|
+
if source_app.get("formTitle"):
|
|
28808
|
+
carousel["source_form_title"] = str(source_app.get("formTitle"))
|
|
28809
|
+
for raw_key, target_key in (("titleQue", "title_field"), ("imageQue", "image_field"), ("linkQue", "link_field")):
|
|
28810
|
+
que = config.get(raw_key) if isinstance(config.get(raw_key), dict) else {}
|
|
28811
|
+
if que.get("queId") is not None:
|
|
28812
|
+
carousel[target_key] = str(que.get("queId"))
|
|
28813
|
+
if "jumpMode" in config:
|
|
28814
|
+
carousel["jump_mode"] = config.get("jumpMode")
|
|
28815
|
+
if "beingScaling" in config:
|
|
28816
|
+
carousel["scaling"] = bool(config.get("beingScaling"))
|
|
28817
|
+
if "showLatestLimit" in config:
|
|
28818
|
+
carousel["show_latest_limit"] = config.get("showLatestLimit")
|
|
28819
|
+
if "scrollInterval" in config:
|
|
28820
|
+
carousel["scroll_interval"] = config.get("scrollInterval")
|
|
28821
|
+
filter_config = config.get("filterConfig")
|
|
28822
|
+
if isinstance(filter_config, list) and filter_config:
|
|
28823
|
+
# ComponentCarouselConfig.filterConfig 是单组 AND 的 MatchRule 列表。
|
|
28824
|
+
# DashSvcImpl#getDashDetail 会补 judgeValueDetails/judgeType;复用门户既有
|
|
28825
|
+
# match-rule 映射,优先以 details 作为选项值真源,避免只回传派生 judgeValues。
|
|
28826
|
+
raw_rules = filter_config[0] if isinstance(filter_config[0], list) else filter_config
|
|
28827
|
+
filters: list[dict[str, Any]] = []
|
|
28828
|
+
for rule in raw_rules:
|
|
28829
|
+
if not isinstance(rule, dict):
|
|
28830
|
+
continue
|
|
28831
|
+
que_id = _coerce_positive_int(rule.get("queId"))
|
|
28832
|
+
if que_id is None:
|
|
28833
|
+
gaps.append({
|
|
28834
|
+
"index": index,
|
|
28835
|
+
"code": "CAROUSEL_FILTER_FIELD_UNRESOLVED",
|
|
28836
|
+
"message": "carousel filter rule has no resolvable queId",
|
|
28837
|
+
"blocking_field": "carousel.filters",
|
|
28838
|
+
})
|
|
28839
|
+
continue
|
|
28840
|
+
field = {"que_id": que_id, "name": str(rule.get("queTitle") or que_id)}
|
|
28841
|
+
values = _public_view_filter_rule_values(rule, field=field)
|
|
28842
|
+
operator = _public_view_filter_operator_from_judge_type(rule.get("judgeType"), values=values)
|
|
28843
|
+
if operator.startswith("judge_type:"):
|
|
28844
|
+
gaps.append({
|
|
28845
|
+
"index": index,
|
|
28846
|
+
"code": "CAROUSEL_FILTER_OPERATOR_UNSUPPORTED",
|
|
28847
|
+
"message": "carousel filter rule judgeType is not expressible in v1alpha1",
|
|
28848
|
+
"blocking_field": "carousel.filters",
|
|
28849
|
+
"judge_type": rule.get("judgeType"),
|
|
28850
|
+
})
|
|
28851
|
+
continue
|
|
28852
|
+
item: dict[str, Any] = {"field_name": str(que_id), "operator": operator}
|
|
28853
|
+
if operator not in {"is_empty", "not_empty"}:
|
|
28854
|
+
item["values"] = values
|
|
28855
|
+
filters.append(item)
|
|
28856
|
+
if filters:
|
|
28857
|
+
carousel["filters"] = filters
|
|
28858
|
+
section["carousel"] = carousel
|
|
28859
|
+
elif source_type == "task":
|
|
28860
|
+
task: dict[str, Any] = {}
|
|
28861
|
+
if "beingShowTitle" in config:
|
|
28862
|
+
task["show_title"] = bool(config.get("beingShowTitle"))
|
|
28863
|
+
if config.get("componentTaskHint") is not None:
|
|
28864
|
+
task["hint"] = str(config.get("componentTaskHint") or "")
|
|
28865
|
+
if "beingShowHint" in config:
|
|
28866
|
+
task["show_hint"] = bool(config.get("beingShowHint"))
|
|
28867
|
+
if "beingUsing" in config:
|
|
28868
|
+
task["enabled"] = bool(config.get("beingUsing"))
|
|
28869
|
+
raw_tasks = config.get("dashTaskConfigList")
|
|
28870
|
+
if isinstance(raw_tasks, list) and raw_tasks:
|
|
28871
|
+
task["tasks"] = [
|
|
28872
|
+
{
|
|
28873
|
+
"title": str(item.get("title") or ""),
|
|
28874
|
+
"task_type": item.get("type"),
|
|
28875
|
+
"ordinal": item.get("ordinal"),
|
|
28876
|
+
"checked": bool(item.get("beingCheck")),
|
|
28877
|
+
}
|
|
28878
|
+
for item in raw_tasks
|
|
28879
|
+
if isinstance(item, dict)
|
|
28880
|
+
]
|
|
28881
|
+
section["task"] = task
|
|
28882
|
+
elif source_type == "favourite":
|
|
28883
|
+
favourite: dict[str, Any] = {}
|
|
28884
|
+
if "beingShowTitle" in config:
|
|
28885
|
+
favourite["show_title"] = bool(config.get("beingShowTitle"))
|
|
28886
|
+
if "beingUsing" in config:
|
|
28887
|
+
favourite["enabled"] = bool(config.get("beingUsing"))
|
|
28888
|
+
raw_items = config.get("items")
|
|
28889
|
+
if isinstance(raw_items, list) and raw_items:
|
|
28890
|
+
favourite["items"] = [
|
|
28891
|
+
{
|
|
28892
|
+
"title": str(item.get("title") or ""),
|
|
28893
|
+
"item_type": item.get("type"),
|
|
28894
|
+
"link_key": str(item.get("linkKey") or "") or None,
|
|
28895
|
+
"link_id": item.get("linkId"),
|
|
28896
|
+
"icon_url": str(item.get("iconUrl") or "") or None,
|
|
28897
|
+
"jump_mode": item.get("jumpMode"),
|
|
28898
|
+
"ordinal": item.get("ordinal"),
|
|
28899
|
+
}
|
|
28900
|
+
for item in raw_items
|
|
28901
|
+
if isinstance(item, dict)
|
|
28902
|
+
]
|
|
28903
|
+
favourite["items"] = [
|
|
28904
|
+
{key: value for key, value in item.items() if value is not None}
|
|
28905
|
+
for item in favourite["items"]
|
|
28906
|
+
]
|
|
28907
|
+
section["favourite"] = favourite
|
|
28908
|
+
return section, (gaps[0] if gaps else None)
|
|
28909
|
+
|
|
28910
|
+
|
|
28911
|
+
def _portal_declaration_from_dash_detail(
|
|
28912
|
+
detail: dict[str, Any],
|
|
28913
|
+
*,
|
|
28914
|
+
schema_kind: str,
|
|
28915
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
28916
|
+
"""Reverse-compile a DashDetailInfo read into a canonical portal declaration."""
|
|
28917
|
+
complete = schema_kind == "complete"
|
|
28918
|
+
gaps: list[dict[str, Any]] = []
|
|
28919
|
+
sections: list[dict[str, Any]] = []
|
|
28920
|
+
raw_components = detail.get("components") if isinstance(detail.get("components"), list) else []
|
|
28921
|
+
for index, component in enumerate(raw_components):
|
|
28922
|
+
if not isinstance(component, dict):
|
|
28923
|
+
continue
|
|
28924
|
+
section, gap = _portal_section_from_component(component, index=index, complete=complete)
|
|
28925
|
+
if gap is not None:
|
|
28926
|
+
gaps.append(gap)
|
|
28927
|
+
if section is not None:
|
|
28928
|
+
sections.append(section)
|
|
28929
|
+
declaration: dict[str, Any] = {"sections": sections}
|
|
28930
|
+
# 后端 dash_name 允许空串(生产存量存在,updateDashBaseInfo 无非空校验):
|
|
28931
|
+
# 提取侧始终写出 name 键,空名透传(round-trip 保真),不再把空名当成缺省。
|
|
28932
|
+
declaration["name"] = str(detail.get("dashName") or "").strip()
|
|
28933
|
+
tags = detail.get("tags") if isinstance(detail.get("tags"), list) else []
|
|
28934
|
+
first_tag = tags[0] if tags and isinstance(tags[0], dict) else {}
|
|
28935
|
+
tag_id = _coerce_positive_int(first_tag.get("tagId"))
|
|
28936
|
+
if tag_id is not None:
|
|
28937
|
+
declaration["package_tag_id"] = tag_id
|
|
28938
|
+
if not complete:
|
|
28939
|
+
return declaration, gaps
|
|
28940
|
+
dash_icon = str(detail.get("dashIcon") or "").strip() or None
|
|
28941
|
+
if dash_icon:
|
|
28942
|
+
icon_config = workspace_icon_config(dash_icon)
|
|
28943
|
+
icon = str(icon_config.get("icon_name") or "").strip() or None
|
|
28944
|
+
color = str(icon_config.get("icon_color") or "").strip() or None
|
|
28945
|
+
if icon:
|
|
28946
|
+
declaration["icon"] = icon
|
|
28947
|
+
if color:
|
|
28948
|
+
declaration["color"] = color
|
|
28949
|
+
if "hideCopyright" in detail:
|
|
28950
|
+
declaration["hide_copyright"] = bool(detail.get("hideCopyright"))
|
|
28951
|
+
global_config = detail.get("dashGlobalConfig") if isinstance(detail.get("dashGlobalConfig"), dict) else {}
|
|
28952
|
+
if global_config:
|
|
28953
|
+
declaration["global_config"] = {}
|
|
28954
|
+
if "interval" in global_config:
|
|
28955
|
+
declaration["global_config"]["interval"] = global_config.get("interval")
|
|
28956
|
+
if "beingAutoRefresh" in global_config:
|
|
28957
|
+
declaration["global_config"]["auto_refresh"] = bool(global_config.get("beingAutoRefresh"))
|
|
28958
|
+
if "beingExport" in global_config:
|
|
28959
|
+
declaration["global_config"]["export"] = bool(global_config.get("beingExport"))
|
|
28960
|
+
visibility = _public_visibility_from_member_auth(detail.get("auth"))
|
|
28961
|
+
if isinstance(visibility, dict) and visibility:
|
|
28962
|
+
declaration["visibility"] = visibility
|
|
28963
|
+
style_template_id = _coerce_positive_int(detail.get("styleTemplateId"))
|
|
28964
|
+
if style_template_id is not None:
|
|
28965
|
+
declaration["style_template_id"] = style_template_id
|
|
28966
|
+
overall = detail.get("overallConfigBO")
|
|
28967
|
+
if isinstance(overall, dict) and overall:
|
|
28968
|
+
declaration["overall_style"] = deepcopy(overall)
|
|
28969
|
+
return declaration, gaps
|
|
28970
|
+
|
|
28971
|
+
|
|
27817
28972
|
def _custom_button_view_configs_from_view_summaries(views: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
27818
28973
|
view_configs: list[dict[str, Any]] = []
|
|
27819
28974
|
for view in views:
|
|
@@ -27967,7 +29122,10 @@ def _extract_portal_component_title(component: dict[str, Any], *, source_type: s
|
|
|
27967
29122
|
"text": ["title", "textTitle"],
|
|
27968
29123
|
"filter": ["title", "filterTitle"],
|
|
27969
29124
|
"chart": ["chartComponentTitle", "componentTitle", "title"],
|
|
27970
|
-
|
|
29125
|
+
# view 的 title 语义=组件显示标题(component_title 落库列,可空);视图名
|
|
29126
|
+
# (viewgraphName)是引用面锚进 view_ref.view_name,不再混入 title 候选
|
|
29127
|
+
# (旧候选把它兑底进 title,编译时覆写 componentTitle 造成库表漂移)。
|
|
29128
|
+
"view": ["componentTitle", "title"],
|
|
27971
29129
|
}
|
|
27972
29130
|
for key in title_candidates.get(source_type, []):
|
|
27973
29131
|
title = str(config.get(key) or "").strip()
|
|
@@ -30529,6 +31687,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
|
|
|
30529
31687
|
elif operator == "in":
|
|
30530
31688
|
payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
|
|
30531
31689
|
payload["judgeValues"] = judge_values
|
|
31690
|
+
elif operator == "belongs_to":
|
|
31691
|
+
payload["judgeType"] = JUDGE_BELONG_TO
|
|
31692
|
+
payload["judgeValues"] = judge_values
|
|
30532
31693
|
elif operator == "contains":
|
|
30533
31694
|
payload["judgeType"] = JUDGE_INCLUDE
|
|
30534
31695
|
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
@@ -30850,6 +32011,8 @@ def _public_view_filter_operator_from_judge_type(judge_type: Any, *, values: lis
|
|
|
30850
32011
|
# ANY 判型+空值 = "该字段未选任何值"的合法存量形态(judgeType 保真回写,
|
|
30851
32012
|
# 转 is_empty 会丢判型——后端 9 与 0 是不同判定域,实测回读不同)
|
|
30852
32013
|
return "in"
|
|
32014
|
+
if normalized == JUDGE_BELONG_TO:
|
|
32015
|
+
return "belongs_to"
|
|
30853
32016
|
if normalized == JUDGE_GREATER_OR_EQUAL:
|
|
30854
32017
|
return "gte"
|
|
30855
32018
|
if normalized == JUDGE_LESS_OR_EQUAL:
|