first commit
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
"""
|
||||
宜搭报表 schema 构造 — 多 chart 装配进 Page > RootContent 栅格布局。
|
||||
|
||||
主入口:
|
||||
build_report_schema_with_filters(report_title, charts, filters, report_id, corp_id)
|
||||
apply_chart_changes_to_schema(schema, changes, cube_tenant_id)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Optional
|
||||
|
||||
from yida_schema_common import (
|
||||
build_report_components_map,
|
||||
DATA_SOURCE_FIT_COMPILED,
|
||||
DATA_SOURCE_FIT_SOURCE,
|
||||
i18n,
|
||||
next_node_id,
|
||||
generate_field_id,
|
||||
)
|
||||
from yida_report_charts import (
|
||||
build_chart_component,
|
||||
CHART_TYPE_MAP,
|
||||
normalize_cube_code,
|
||||
normalize_field_code,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 报表骨架常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PAGE_STYLE = ":root {\n background-color: #f2f3f5;\n}\n"
|
||||
|
||||
_PAGE_PROPS = {
|
||||
"pageStyle": _PAGE_STYLE,
|
||||
"containerStyle": {},
|
||||
"userVariables": [
|
||||
{"text": "工号", "id": "varWorkNo"},
|
||||
{"text": "部门名称", "id": "varDeptName"},
|
||||
{"text": "所属公司编号", "id": "varCorpNo"},
|
||||
{"text": "部门编码", "id": "varDeptNo"},
|
||||
],
|
||||
"templateVersion": "1.0.0",
|
||||
"className": "page_m9o7d9ml",
|
||||
"params": [],
|
||||
}
|
||||
|
||||
_INIT_METHODS = {
|
||||
"__initMethods__": {
|
||||
"type": "js",
|
||||
"source": "function (exports, module) { /*set actions code here*/ }",
|
||||
"compiled": "function (exports, module) { /*set actions code here*/ }",
|
||||
}
|
||||
}
|
||||
|
||||
_CONSTRUCTOR = {
|
||||
"type": "js",
|
||||
"compiled": (
|
||||
"function constructor() {\n"
|
||||
"var module = { exports: {} };\n"
|
||||
"var _this = this;\n"
|
||||
"this.__initMethods__(module.exports, module);\n"
|
||||
"Object.keys(module.exports).forEach(function(item) {\n"
|
||||
" if(typeof module.exports[item] === 'function'){\n"
|
||||
" _this[item] = module.exports[item];\n"
|
||||
" }\n"
|
||||
"});\n"
|
||||
"}"
|
||||
),
|
||||
"source": (
|
||||
"function constructor() {\n"
|
||||
"var module = { exports: {} };\n"
|
||||
"var _this = this;\n"
|
||||
"this.__initMethods__(module.exports, module);\n"
|
||||
"Object.keys(module.exports).forEach(function(item) {\n"
|
||||
" if(typeof module.exports[item] === 'function'){\n"
|
||||
" _this[item] = module.exports[item];\n"
|
||||
" }\n"
|
||||
"});\n"
|
||||
"}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 布局计算
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _next_layout_position(layout: list[dict[str, Any]], w: int) -> tuple[int, int]:
|
||||
"""Calculate next (x, y) for a new item in the 6-column react-grid-layout grid.
|
||||
|
||||
Finds the last row, checks if ``w`` fits to the right of existing items;
|
||||
if yes returns (right_edge, row_y), otherwise wraps to a new row.
|
||||
"""
|
||||
if not layout:
|
||||
return 0, 0
|
||||
max_y = max(li.get("y", 0) for li in layout)
|
||||
last_row = [li for li in layout if li.get("y", 0) == max_y]
|
||||
last_row_right = max(li.get("x", 0) + li.get("w", 0) for li in last_row)
|
||||
last_row_h = max(li.get("h", 0) for li in last_row)
|
||||
if last_row_right + w <= 6:
|
||||
return last_row_right, max_y
|
||||
return 0, max_y + last_row_h
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_report_schema_with_filters(
|
||||
report_title: str,
|
||||
charts: list[dict[str, Any]],
|
||||
filters: Optional[list[dict[str, Any]]] = None,
|
||||
report_id: str = "",
|
||||
corp_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""构造完整报表 schema(含图表 + 筛选器)。"""
|
||||
if filters is None:
|
||||
filters = []
|
||||
|
||||
# 构造所有图表节点
|
||||
chart_nodes: list[dict[str, Any]] = []
|
||||
chart_field_ids: list[str] = []
|
||||
layout_items: list[dict[str, Any]] = []
|
||||
component_names: set[str] = {"Page", "RootHeader", "RootContent", "RootFooter"}
|
||||
|
||||
x = 0
|
||||
y = 0
|
||||
row_max_h = 0
|
||||
for chart in charts:
|
||||
node, field_id, default_layout = build_chart_component(chart, cube_tenant_id=corp_id)
|
||||
chart_nodes.append(node)
|
||||
chart_field_ids.append(field_id)
|
||||
component_names.add(node["componentName"])
|
||||
|
||||
w = default_layout["w"]
|
||||
h = default_layout["h"]
|
||||
if x + w > 6:
|
||||
x = 0
|
||||
y += row_max_h
|
||||
row_max_h = 0
|
||||
item: dict[str, Any] = {"i": field_id, "x": x, "y": y, "w": w, "h": h,
|
||||
"moved": False, "static": False}
|
||||
for k in ("minH", "maxH", "resizeHandles"):
|
||||
if k in default_layout:
|
||||
item[k] = default_layout[k]
|
||||
layout_items.append(item)
|
||||
row_max_h = max(row_max_h, h)
|
||||
x += w
|
||||
if x >= 6:
|
||||
x = 0
|
||||
y += row_max_h
|
||||
row_max_h = 0
|
||||
|
||||
# 筛选器组件
|
||||
filter_nodes: list[dict[str, Any]] = []
|
||||
for flt in filters:
|
||||
f_node = _build_select_filter(flt, corp_id, chart_nodes)
|
||||
if f_node:
|
||||
filter_nodes.append(f_node)
|
||||
component_names.add("YoushuSelect")
|
||||
|
||||
# 筛选器 layout(顶部一行)
|
||||
if filter_nodes:
|
||||
filter_layout: list[dict[str, Any]] = []
|
||||
fx = 0
|
||||
for fn in filter_nodes:
|
||||
fn_field_id = fn.get("props", {}).get("fieldId", fn["id"])
|
||||
filter_layout.append({"i": fn_field_id, "x": fx, "y": 0, "w": 2, "h": 2})
|
||||
fx += 2
|
||||
# 把图表 layout 的 y 下移
|
||||
offset_y = 2
|
||||
for li in layout_items:
|
||||
li["y"] += offset_y
|
||||
layout_items = filter_layout + layout_items
|
||||
|
||||
all_children = filter_nodes + chart_nodes
|
||||
components_map = build_report_components_map(sorted(component_names))
|
||||
|
||||
schema: dict[str, Any] = {
|
||||
"schemaType": "superform",
|
||||
"schemaVersion": "5.0",
|
||||
"pages": [
|
||||
{
|
||||
"utils": [],
|
||||
"componentsMap": components_map,
|
||||
"componentsTree": [
|
||||
{
|
||||
"componentName": "Page",
|
||||
"id": next_node_id(),
|
||||
"props": copy.deepcopy(_PAGE_PROPS),
|
||||
"css": "body {\n background-color: #f2f3f5;\n}\n",
|
||||
"dataSource": {
|
||||
"offline": [],
|
||||
"globalConfig": {
|
||||
"fit": {
|
||||
"compiled": DATA_SOURCE_FIT_COMPILED,
|
||||
"source": DATA_SOURCE_FIT_SOURCE,
|
||||
"type": "js",
|
||||
"error": {},
|
||||
},
|
||||
},
|
||||
"online": [],
|
||||
"list": [],
|
||||
"sync": True,
|
||||
},
|
||||
"methods": copy.deepcopy(_INIT_METHODS),
|
||||
"lifeCycles": {
|
||||
"constructor": copy.deepcopy(_CONSTRUCTOR),
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"componentName": "RootHeader",
|
||||
"id": next_node_id(),
|
||||
"props": {},
|
||||
},
|
||||
{
|
||||
"componentName": "RootContent",
|
||||
"id": next_node_id(),
|
||||
"props": {
|
||||
"layout": layout_items,
|
||||
"rglSwitch": True,
|
||||
"contentBgColor": "transparent",
|
||||
},
|
||||
"children": all_children,
|
||||
},
|
||||
{
|
||||
"componentName": "RootFooter",
|
||||
"id": next_node_id(),
|
||||
"props": {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"id": report_id or next_node_id(),
|
||||
"connectComponent": [],
|
||||
},
|
||||
],
|
||||
"actions": {
|
||||
"module": {"source": "", "compiled": ""},
|
||||
"list": [],
|
||||
},
|
||||
}
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 增量修改
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_chart_changes_to_schema(
|
||||
schema: dict[str, Any],
|
||||
changes: list[dict[str, Any]],
|
||||
cube_tenant_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""增量修改报表 schema(add/remove/replace/update-props)。"""
|
||||
root_content = _find_root_content(schema)
|
||||
if root_content is None:
|
||||
raise ValueError("schema 中找不到 RootContent")
|
||||
|
||||
children: list[dict[str, Any]] = root_content.get("children", [])
|
||||
layout: list[dict[str, Any]] = root_content.get("props", {}).get("layout", [])
|
||||
|
||||
for change in changes:
|
||||
action = change.get("action", "")
|
||||
|
||||
if action == "add":
|
||||
chart_def = change.get("chart", {})
|
||||
node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id)
|
||||
|
||||
after_title = change.get("after")
|
||||
insert_idx = len(children)
|
||||
if after_title:
|
||||
idx = _find_chart_index_by_title(children, after_title)
|
||||
if idx is not None:
|
||||
insert_idx = idx + 1
|
||||
|
||||
children.insert(insert_idx, node)
|
||||
nx, ny = _next_layout_position(layout, default_layout["w"])
|
||||
item: dict[str, Any] = {"i": field_id, "x": nx, "y": ny,
|
||||
"w": default_layout["w"], "h": default_layout["h"],
|
||||
"moved": False, "static": False}
|
||||
for lk in ("minH", "maxH", "resizeHandles"):
|
||||
if lk in default_layout:
|
||||
item[lk] = default_layout[lk]
|
||||
layout.append(item)
|
||||
_ensure_component_in_map(schema, node["componentName"])
|
||||
|
||||
elif action == "remove":
|
||||
title = change.get("title", "")
|
||||
idx = _find_chart_index_by_title(children, title)
|
||||
if idx is not None:
|
||||
removed = children.pop(idx)
|
||||
remove_keys = {removed.get("id"), removed.get("props", {}).get("fieldId")}
|
||||
layout[:] = [li for li in layout if li.get("i") not in remove_keys]
|
||||
|
||||
elif action == "replace":
|
||||
title = change.get("title", "")
|
||||
chart_def = change.get("chart", {})
|
||||
idx = _find_chart_index_by_title(children, title)
|
||||
if idx is not None:
|
||||
old_node = children[idx]
|
||||
old_keys = {old_node.get("id"), old_node.get("props", {}).get("fieldId")}
|
||||
node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id)
|
||||
for li in layout:
|
||||
if li.get("i") in old_keys:
|
||||
li["i"] = field_id
|
||||
break
|
||||
children[idx] = node
|
||||
_ensure_component_in_map(schema, node["componentName"])
|
||||
|
||||
elif action == "update-props":
|
||||
title = change.get("title", "")
|
||||
props_patch = change.get("props", {})
|
||||
idx = _find_chart_index_by_title(children, title)
|
||||
if idx is not None:
|
||||
children[idx].setdefault("props", {}).update(props_patch)
|
||||
|
||||
root_content["children"] = children
|
||||
root_content.setdefault("props", {})["layout"] = layout
|
||||
return schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 筛选器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def auto_generate_filters(charts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""从图表字段中自动提取 select-like 字段作为筛选器。"""
|
||||
seen: set[str] = set()
|
||||
filters: list[dict[str, Any]] = []
|
||||
prefixes = ("selectField_", "radioField_", "checkboxField_", "multiSelectField_")
|
||||
|
||||
_SCAN_KEYS = ("xField", "yField", "columnFields", "kpi", "kpiField",
|
||||
"leftYFields", "rightYFields", "columnList", "valueField")
|
||||
|
||||
for chart in charts:
|
||||
for key in _SCAN_KEYS:
|
||||
val = chart.get(key, "")
|
||||
if not val:
|
||||
continue
|
||||
fields = val if isinstance(val, list) else [val]
|
||||
for f in fields:
|
||||
fc = f.get("fieldCode", "") if isinstance(f, dict) else str(f)
|
||||
if not fc:
|
||||
continue
|
||||
base = fc.replace("_value", "") if fc.endswith("_value") else fc
|
||||
if any(base.startswith(p) for p in prefixes) and base not in seen:
|
||||
seen.add(base)
|
||||
filters.append({
|
||||
"type": "select",
|
||||
"cubeCode": chart.get("cubeCode", ""),
|
||||
"fieldCode": fc if fc.endswith("_value") else normalize_field_code(fc),
|
||||
"label": base,
|
||||
})
|
||||
return filters
|
||||
|
||||
|
||||
def _build_select_filter(
|
||||
flt: dict[str, Any],
|
||||
cube_tenant_id: str,
|
||||
chart_nodes: list[dict[str, Any]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
if flt.get("type") != "select":
|
||||
return None
|
||||
|
||||
node_id = next_node_id()
|
||||
field_code = normalize_field_code(flt.get("fieldCode", ""))
|
||||
cube_code = normalize_cube_code(flt.get("cubeCode", ""))
|
||||
label = flt.get("label", field_code)
|
||||
|
||||
node: dict[str, Any] = {
|
||||
"componentName": "YoushuSelect",
|
||||
"id": node_id,
|
||||
"props": {
|
||||
"fieldId": generate_field_id("YoushuSelect"),
|
||||
"cid": node_id,
|
||||
"componentTitle": i18n(label),
|
||||
"dataSetModelMap": {
|
||||
"selectFilter": {
|
||||
"dataViewQueryModel": {
|
||||
"cubeCode": cube_code,
|
||||
"fieldDefinitionList": [{
|
||||
"classifiedCode": cube_code,
|
||||
"cubeCode": cube_code,
|
||||
"fieldCode": field_code,
|
||||
"dataType": "VARCHAR",
|
||||
"isDim": False,
|
||||
"aggregateType": "NONE",
|
||||
"alias": "filter_dim",
|
||||
"aliasName": {"type": "i18n", "zh_CN": label, "en_US": label},
|
||||
"timeGranularityType": None,
|
||||
}],
|
||||
"fieldList": ["filter_dim"],
|
||||
"filterList": [],
|
||||
"orderByList": [],
|
||||
"cubeTenantId": cube_tenant_id,
|
||||
},
|
||||
"fieldList": [],
|
||||
"youshuDataType": "real",
|
||||
"cubeCodes": [cube_code] if cube_code else "",
|
||||
"filterList": [],
|
||||
"limit": "",
|
||||
}
|
||||
},
|
||||
"filterLinkage": _build_filter_linkage(flt, chart_nodes, field_code),
|
||||
},
|
||||
}
|
||||
return node
|
||||
|
||||
|
||||
def _build_filter_linkage(
|
||||
flt: dict[str, Any],
|
||||
chart_nodes: list[dict[str, Any]],
|
||||
field_code: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
link_to = flt.get("linkTo")
|
||||
linkage: list[dict[str, Any]] = []
|
||||
|
||||
targets = chart_nodes if link_to is None else [chart_nodes[i] for i in link_to if i < len(chart_nodes)]
|
||||
for target in targets:
|
||||
linkage.append({
|
||||
"targetComponentId": target.get("id", ""),
|
||||
"targetFieldCode": field_code,
|
||||
})
|
||||
return linkage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_root_content(schema: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
pages = schema.get("pages", [])
|
||||
if not pages:
|
||||
return None
|
||||
tree = pages[0].get("componentsTree", [])
|
||||
if not tree:
|
||||
return None
|
||||
|
||||
for child in tree[0].get("children", []):
|
||||
if child.get("componentName") == "RootContent":
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _find_chart_index_by_title(nodes: list[dict[str, Any]], title: str) -> Optional[int]:
|
||||
for idx, node in enumerate(nodes):
|
||||
ct = node.get("props", {}).get("componentTitle", {})
|
||||
zh = ct.get("zh_CN", "") if isinstance(ct, dict) else str(ct)
|
||||
if zh == title:
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_component_in_map(schema: dict[str, Any], component_name: str) -> None:
|
||||
pages = schema.get("pages", [])
|
||||
if not pages:
|
||||
return
|
||||
cm = pages[0].get("componentsMap", [])
|
||||
from yida_schema_common import REPORT_COMPONENT_PACKAGE, REPORT_COMPONENT_VERSION
|
||||
if not any(c.get("componentName") == component_name for c in cm):
|
||||
cm.append({
|
||||
"package": REPORT_COMPONENT_PACKAGE,
|
||||
"version": REPORT_COMPONENT_VERSION,
|
||||
"componentName": component_name,
|
||||
})
|
||||
|
||||
|
||||
def parse_report_config(raw: Any) -> tuple[list[dict[str, Any]], Optional[list[dict[str, Any]]]]:
|
||||
"""解析入参:支持 [charts] 数组 或 {charts, filters} 对象。"""
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
if isinstance(raw, dict):
|
||||
return raw.get("charts", []), raw.get("filters")
|
||||
raise ValueError("charts 入参必须是数组或 {charts, filters} 对象")
|
||||
|
||||
|
||||
def count_report_children(schema: dict[str, Any]) -> int:
|
||||
"""Return children count in RootContent, or -1 if RootContent not found."""
|
||||
rc = _find_root_content(schema)
|
||||
if rc is None:
|
||||
return -1
|
||||
return len(rc.get("children", []))
|
||||
|
||||
|
||||
def is_empty_report_skeleton(schema: dict[str, Any]) -> bool:
|
||||
return count_report_children(schema) == 0
|
||||
Reference in New Issue
Block a user