""" 宜搭报表图表组件构造 — 按 chart.type 分发,产出 component dict + cube 查询模型。 支持 9 种图表: bar / line / pie / funnel / table / indicator / gauge / combo / pivot dataSetModelMap 结构严格匹配宜搭报表引擎格式: - 外层 key: chartData (bar/line/pie/funnel/gauge) / table / youshuData (indicator) / dataSetName (combo/pivot) - 内层: dataViewQueryModel{cubeCode, fieldDefinitionList, fieldList, cubeTenantId} + 外层 fieldList (完整字段对象) + xField/yField 等角色字段 """ from __future__ import annotations import math from typing import Any, Optional from yida_schema_common import ( i18n, next_node_id, generate_field_id, ) # --------------------------------------------------------------------------- # 图表类型 → componentName 映射 # --------------------------------------------------------------------------- CHART_TYPE_MAP: dict[str, str] = { "bar": "YoushuGroupedBarChart", "line": "YoushuLineChart", "pie": "YoushuPieChart", "funnel": "YoushuFunnelChart", "table": "YoushuTable", "indicator": "YoushuSimpleIndicatorCard", "gauge": "YoushuGauge", "combo": "YoushuComboChart", "pivot": "YoushuCrossPivotTable", } def _compute_dynamic_layout(chart_type: str, chart: dict[str, Any]) -> dict[str, Any]: """根据图表内容动态计算布局尺寸。""" if chart_type == "table": page_size = chart.get("pageSize", 20) h = max(16, min(40, round(page_size * 0.9) + 6)) return {"w": 6, "h": h, "minH": h, "maxH": h, "resizeHandles": ["w", "e"]} if chart_type == "indicator": kpi_count = len(chart.get("kpi", [])) col = min(kpi_count, 4) or 1 rows = math.ceil(kpi_count / col) h = max(11, 8 + rows * 5) w = min(2 * min(kpi_count, 3), 6) return {"w": w, "h": h, "minH": h, "maxH": h, "resizeHandles": ["w", "e"]} if chart_type in ("bar", "line"): y_raw = chart.get("yField", "") y_count = len(y_raw) if isinstance(y_raw, list) else 1 h = 22 if y_count <= 3 else min(30, 22 + (y_count - 3) * 2) return {"w": 3, "h": h} if chart_type == "gauge": return {"w": 2, "h": 18} if chart_type == "combo": return {"w": 6, "h": 22} if chart_type == "pivot": return {"w": 6, "h": 30} return {"w": 3, "h": 22} _VALUE_SUFFIX_PREFIXES = ("selectField_", "radioField_", "checkboxField_", "multiSelectField_", "employeeField_") _CUBE_LABEL_SUFFIX: dict[str, str] = { "selectField_": "_值", "radioField_": "_值", "checkboxField_": "_值", "multiSelectField_": "_值", "employeeField_": "_名称", "departmentSelectField_": "_名称", } _DS_KEY_CHART = "chartData" _DS_KEY_TABLE = "table" _DS_KEY_INDICATOR = "youshuData" _DS_KEY_DATASET = "dataSetName" _TIME_GRAN_ID_SUFFIX: dict[str, str] = { "YEAR": "1", "QUARTER": "2", "MONTH": "3", "WEEK": "4", "DAY": "5", "HOUR": "6", } _TIME_GRAN_FORMAT: dict[str, str] = { "YEAR": "yyyy", "QUARTER": "yyyy-Q", "MONTH": "yyyy-MM", "WEEK": "yyyy-w", "DAY": "yyyy-MM-dd", "HOUR": "yyyy-MM-dd HH", } _AGGREGATE_TYPES = frozenset({"COUNT", "SUM", "AVG", "MAX", "MIN", "COUNT_DISTINCT"}) # --------------------------------------------------------------------------- # 公共工具 # --------------------------------------------------------------------------- def normalize_cube_code(code: str) -> str: """FORM-E604838A-3121-43D1-... → FORM_E604838A312143D1...""" if code.startswith(("FORM-", "FORM_")): return "FORM_" + code[5:].replace("-", "") return code.replace("-", "_") _STRIP_SUFFIXES = ("_id", "_date") def normalize_field_code(field_code: str) -> str: fc = field_code for sfx in _STRIP_SUFFIXES: if fc.endswith(sfx): fc = fc[:-len(sfx)] break if any(fc.startswith(p) for p in _VALUE_SUFFIX_PREFIXES): if not fc.endswith("_value"): return fc + "_value" return fc def _apply_cube_label_suffix(field_code: str, text: str) -> str: """fieldCode 带 _value 后缀时,同步给显示标签追加中文后缀。 cube 元数据中 selectField_xxx_value 显示名为 "原标签_值", employeeField_xxx_value 显示名为 "原标签_名称"。 不追加会导致引擎报"找不到配置"。 """ if not field_code.endswith("_value"): return text base = field_code[:-6] for prefix, suffix in _CUBE_LABEL_SUFFIX.items(): if base.startswith(prefix): if not text.endswith(suffix): return text + suffix return text return text def _derive_label(field_code: str) -> str: """从 fieldCode 推导可读标签:去前缀、去 _value 后缀。""" fc = field_code for p in _VALUE_SUFFIX_PREFIXES: if fc.startswith(p): fc = fc[len(p):] break else: for prefix in ("textField_", "numberField_", "dateField_", "textareaField_", "countrySelectField_", "addressField_", "attachmentField_", "imageField_", "departmentSelectField_", "tableField_", "associationFormField_", "serialNumberField_", "rateField_", "cascadeDateField_"): if fc.startswith(prefix): fc = fc[len(prefix):] break if fc.endswith("_value"): fc = fc[:-6] return fc or field_code def _infer_data_type(field_code: str) -> str: if field_code.startswith("numberField_"): return "NUMBER" if field_code.startswith("dateField_"): return "DATE" if field_code.startswith(("employeeField_", "departmentSelectField_")): return "STRING" if field_code.endswith("_value") else "ARRAY" return "STRING" _ARRAY_FIELD_PREFIXES = ("employeeField_", "departmentSelectField_") def _fix_array_field_for_aggregate(field_code: str, data_type: str, aggregate_type: str) -> tuple[str, str]: """数组字段做聚合时保留 _value 后缀(cube 元数据只认 _value),仅将 ARRAY 类型转为 STRING。""" if aggregate_type == "NONE": return field_code, data_type if any(field_code.startswith(p) for p in _ARRAY_FIELD_PREFIXES): import warnings warnings.warn( f"⚠ {field_code} 是数组字段,不能做聚合({aggregate_type})——" f"cube SQL 会报 'aggregate cannot contain set-returning function'。" f"请改用非数组字段(如 numberField / textField)做 {aggregate_type}。", stacklevel=3, ) if data_type == "ARRAY": return field_code, "STRING" return field_code, data_type def _is_date_field(field_code: str) -> bool: return field_code.startswith("dateField_") def _gen_alias() -> str: return f"field_{next_node_id()}" # --------------------------------------------------------------------------- # 字段解析:支持 string 或 rich field dict # --------------------------------------------------------------------------- def _resolve_field(raw: Any, cube_code: str) -> dict[str, Any]: """把 string 或 dict 输入统一解析为标准字段元数据。""" if isinstance(raw, dict): fc = normalize_field_code(raw.get("fieldCode", "")) if not fc: raise ValueError( f"fieldCode 不能为空。请先通过 `dws yida form components` 获取源表字段定义," f"使用返回的 fieldCode 构造字段对象。收到: {raw!r}" ) text = raw.get("text") or raw.get("label") or _derive_label(fc) text = _apply_cube_label_suffix(fc, text) return { "fieldCode": fc, "dataType": raw.get("dataType") or _infer_data_type(fc), "text": text, "id": raw.get("id") or fc, "classifiedCode": raw.get("classifiedCode") or cube_code, "timeGranularityType": raw.get("timeGranularityType"), "timeFormat": raw.get("timeFormat"), } fc = normalize_field_code(str(raw)) if not fc: raise ValueError( "fieldCode 不能为空字符串。请先通过 `dws yida form components` 获取源表字段定义。" ) text = _apply_cube_label_suffix(fc, _derive_label(fc)) return { "fieldCode": fc, "dataType": _infer_data_type(fc), "text": text, "id": fc, "classifiedCode": cube_code, "timeGranularityType": None, "timeFormat": None, } # --------------------------------------------------------------------------- # 字段对象构造(匹配宜搭报表引擎格式) # --------------------------------------------------------------------------- def _build_query_field_def( alias: str, label: str, cube_code: str, field_code: str, data_type: str, aggregate_type: str = "NONE", time_gran: Optional[str] = None, classified_code: Optional[str] = None, ) -> dict[str, Any]: """dataViewQueryModel.fieldDefinitionList 中的单条定义。""" return { "classifiedCode": classified_code or cube_code, "cubeCode": cube_code, "fieldCode": field_code, "dataType": data_type, "isDim": False, "aggregateType": aggregate_type, "alias": alias, "aliasName": {"type": "i18n", "zh_CN": label, "en_US": label}, "timeGranularityType": time_gran, } def _build_full_field( alias: str, label: str, cube_code: str, field_code: str, data_type: str, aggregate_type: str = "NONE", time_gran: Optional[str] = None, classified_code: Optional[str] = None, field_id: Optional[str] = None, time_format: Optional[str] = None, ) -> dict[str, Any]: """外层 fieldList / xField / yField 中的完整字段对象。""" fid = field_id or field_code effective_dt = "DOUBLE" if aggregate_type in _AGGREGATE_TYPES else data_type result: dict[str, Any] = { "title": i18n(label), "classifiedCode": classified_code or cube_code, "cubeCode": cube_code, "fieldCode": field_code, "isDimension": "false", "dataType": effective_dt, "format": {"type": "NONE"}, "link": [{"type": "NONE"}], "drillList": [], "aggregateType": aggregate_type, "orderBy": {"type": "NONE", "reference": alias}, "fieldKey": alias, "visible": True, "beUsedTimes": 1, "isVisible": "y", "id": fid, "text": label, } if aggregate_type in _AGGREGATE_TYPES: result["measureType"] = "MEASURE_ATTRIBUTE" if time_gran: result["timeGranularityType"] = time_gran suffix = _TIME_GRAN_ID_SUFFIX.get(time_gran, "") if suffix: result["id"] = field_code + suffix fmt = time_format or _TIME_GRAN_FORMAT.get(time_gran) if fmt: result["timeFormat"] = fmt return result def _wrap_dataset( ds_key: str, cube_code: str, cube_tenant_id: str, query_field_defs: list[dict[str, Any]], aliases: list[str], full_fields: list[dict[str, Any]], extra: Optional[dict[str, Any]] = None, extra_query: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """组装完整 dataSetModelMap(匹配宜搭报表引擎结构)。""" query_model: dict[str, Any] = { "cubeCode": cube_code, "cubeTenantId": cube_tenant_id, "fieldDefinitionList": query_field_defs, "fieldList": aliases, "filterList": [], "orderByList": [], } if extra_query: query_model.update(extra_query) ds: dict[str, Any] = { "cubeCodes": [cube_code], "dataViewQueryModel": query_model, "fieldList": full_fields, "youshuDataType": "real", "filterList": [], "limit": "", "mockData": [], } if extra: ds.update(extra) return {ds_key: ds} # --------------------------------------------------------------------------- # 图表 settings 默认值 # --------------------------------------------------------------------------- _AXIS_LABEL_STYLE = { "labelType": "default", "color": "rgba(23,26,29,0.4)", "fontSize": 12, "limitLengthType": "percent", "percent": 30, "value": 100, "autoRotate": True, "rotate": "0", "autoHide": True, } _X_AXIS_DEFAULT = { "showXAxis": True, "showTitle": False, "title": {"type": "i18n", "zh_CN": "", "en_US": ""}, "line": True, "tickLine": True, "grid": False, "label": True, "labelStyle": _AXIS_LABEL_STYLE, "values": {"type": "i18n", "zh_CN": "", "en_US": ""}, } _Y_AXIS_DEFAULT = { "showYAxis": True, "showTitle": False, "title": {"type": "i18n", "zh_CN": "", "en_US": ""}, "line": False, "tickLine": False, "grid": True, "label": True, "labelStyle": _AXIS_LABEL_STYLE, "min": None, "max": None, "tickCount": 5, } _CUSTOM_COLOR = "#5894FF,#394B76,#F7B900,#E55F24,#80D5F5,#9849B0,#3BC88A,#0E869D,#F4A49E,#80563C" _LEGEND_DEFAULT = {"showLegend": True, "legendPosition": "top-left", "flipPage": True} def _get_chart_settings(chart_type: str, chart: dict[str, Any]) -> dict[str, Any]: if chart_type == "bar": return { "container": {"height": 248}, "style": { "mode": "group", "linkGroup": False, "transpose": False, "barStyle": "ai", "size": None, "maxSize": None, "minSize": None, "barBackground": None, "groupSpacing": 0, "radiusLeftTop": 4, "radiusRightTop": 4, "radiusRightBottom": 0, "radiusLeftBottom": 0, "colorType": "CUSTOM_COLOR", "chartColorsMode": "EHRColorsMode", "customColor": _CUSTOM_COLOR, }, "countLabel": {"showCountLabel": False, "fontSize": 12, "color": "#000"}, "axisType": "hz", "xAxis": _X_AXIS_DEFAULT, "yAxis": _Y_AXIS_DEFAULT, "legend": _LEGEND_DEFAULT, "label": { "showLabel": True, "labelShowStyle": "ai", "fontSize": 12, "autoColor": True, "color": "#000", "autoPosition": False, "position": "middle", "autoAdjust": True, "autoHide": True, }, "slider": {"showSlider": False}, "tooltip": {"showTooltip": True}, } elif chart_type == "line": return { "container": {"height": 248}, "style": { "mode": "none", "lineStyle": "ai", "lineSize": 2, "smooth": False, "showArea": False, "areaOpacity": 0.25, "showPoint": True, "pointSize": 4, "pointShape": "circle", "showLine": True, "lineWidth": 2, "colorType": "CUSTOM_COLOR", "chartColorsMode": "EHRColorsMode", "customColor": _CUSTOM_COLOR, }, "axisType": "hz", "xAxis": _X_AXIS_DEFAULT, "yAxis": _Y_AXIS_DEFAULT, "legend": _LEGEND_DEFAULT, "label": {"showLabel": True, "fontSize": 12, "color": "#000", "autoOverlap": True}, "slider": {"showSlider": False}, "tooltip": {"showTooltip": True}, } elif chart_type == "pie": return { "container": {"height": 248}, "style": { "radius": 75, "isRing": False, "innerRadius": 0, "colorType": "CUSTOM_COLOR", "chartColorsMode": "EHRColorsMode", "customColor": _CUSTOM_COLOR, }, "statistic": {"showStatistic": False}, "label": { "showLabel": True, "showLine": True, "labelAlign": "outer", "labelSize": 12, "labelColor": "#404040", "labelFormatType": "NAME_PERCENT", }, "legend": { "showLegend": True, "legendPosition": "right", "flipPage": True, "type": "item", "contentType": "NAME", "cardWidth": None, "ratio": 65, "layout": "vertical", "itemSpacing": 12, }, "tooltip": {"showTooltip": True, "contentType": None}, "percentDigits": 2, } elif chart_type == "funnel": return { "container": {"height": 248}, "style": { "colorType": "CUSTOM_COLOR", "chartColorsMode": "EHRColorsMode", "customColor": _CUSTOM_COLOR, }, "legend": _LEGEND_DEFAULT, "label": {"showLabel": True, "fontSize": 12, "color": "#000"}, "tooltip": {"showTooltip": True}, } elif chart_type == "gauge": return { "container": {"height": 248}, "useSingleColor": False, "singleColor": "#0089FF", "color": [], "tick": {"showTick": True, "min": None, "max": None, "tickInterval": None}, "assistValue": {"openAssistValue": True, "showCompare": False, "position": "bottom"}, "style": {"rounded": True, "pivot": True, "rangeSize": 16, "radius": 95, "innerRadius": 90}, } elif chart_type == "combo": return { "container": {"height": 248}, "style": { "sync": False, "chartType": "bar-line", "bar": { "size": None, "maxSize": None, "minSize": None, "mode": "group", "barBackground": None, "radiusLeftTop": 4, "radiusRightTop": 4, "radiusRightBottom": 0, "radiusLeftBottom": 0, }, "line": { "size": 2, "smooth": False, "showPoint": True, "pointSize": 4, "pointShape": "circle", }, "autoAdjust": True, "colorType": "CUSTOM_COLOR", "chartColorsMode": "EHRColorsMode", "customColor": _CUSTOM_COLOR, }, "xAxis": _X_AXIS_DEFAULT, "leftYAxis": { "showLeftYAxis": True, "showTitle": False, "title": {"type": "i18n", "zh_CN": "", "en_US": ""}, "line": False, "tickLine": False, "grid": True, "label": True, "labelStyle": _AXIS_LABEL_STYLE, "min": None, "max": None, "tickCount": 5, }, "rightYAxis": { "showRightYAxis": True, "showTitle": False, "title": {"type": "i18n", "zh_CN": "", "en_US": ""}, "line": False, "tickLine": False, "grid": False, "label": True, "labelStyle": _AXIS_LABEL_STYLE, "min": None, "max": None, "tickCount": 5, }, "legend": _LEGEND_DEFAULT, "leftLabel": {"showLabel": True, "fontSize": 12, "color": "#000"}, "rightLabel": {"showLabel": True, "fontSize": 12, "color": "#000"}, "slider": {"showSlider": False}, "tooltip": {"showTooltip": True}, } elif chart_type == "table": page_size = chart.get("pageSize", 20) return { "rglConfig": {"w": 6, "h": 21, "isHeightAuto": True}, "size": "medium", "wordSize": "medium:14", "theme": "split", "mergeCell": False, "fixedHeader": False, "maxBodyHeight": "300", "fixedColumnIndex": 1, "isReverseTable": False, "showReversedHeader": False, "isUniqueRows": False, "pagination": { "isPagination": True, "pageSize": page_size, "pageShowCount": 5, "showPageSelect": False, "size": "small", "type": "normal", }, "isTree": False, "idField": None, "pidField": None, "isLeaf": None, "drilldownFilterList": None, "defaultExpand": False, "rankStyle": False, "container": {"height": 472}, "titleTip": False, "showCopyData": False, "enableFieldSelect": False, "defaultSelectedFields": "", "hasFullscreen": False, "copyAsImg": False, "height": None, "isHeightAuto": True, } elif chart_type == "indicator": kpi_count = len(chart.get("kpi", [])) return { "showSideStyle": "NONE", "followTheme": False, "themeType": "dark", "showSideBorder": True, "sideBarColor": "#0089FF", "bgColorType": "single", "singleBgColor": "#F1F2F3", "colorType": "SCHEMA_COLOR", "multipleBgColor": "defaultColorsMode", "customColor": "#0089FF,#FF9200,#11AB4F,#FFD100,#7263EE,#67C5EB,#6B748C,#FF755A,#007E99,#FFA8A8", "size": "normal", "valueSize": "20px", "titleMaxRow": 0, "columnCount": min(kpi_count, 4) or 4, "columnCountForH5": min(kpi_count, 2) or 2, "popoverAlign": "b", "container": {"height": 72}, "titleTip": False, "enableFieldSelect": False, "hasFullscreen": False, "copyAsImg": False, "height": None, "isHeightAuto": True, } elif chart_type == "pivot": return { "rglConfig": {"w": 6, "h": 21, "isHeightAuto": True}, "maxBodyHeight": 500, "size": "normal", "rows": [], "columns": [], "measures": [], "details": [], "supportExport": False, "exportType": "XJZ", "dialogWidth": 850, "dialogPageSize": 10, "baseInfo": { "isShowSetter": True, "isShowFilter": False, "isShowReload": False, "isHideTitle": False, "isMeasureOrder": True, "isZebra": True, "rowMaxSize": 3000, "columnsMaxSize": 500, "columnWidth": 100, "dialogWidth": 850, "dialogPageSize": 10, "detailExportData": {"supportExport": False, "exportType": "BROWSER"}, }, "mode": "summary", "summaryInfo": { "isRowTotal": True, "rowTotalWidth": 130, "rowTotalPosition": "end", "isColumnTotal": True, "isSubTotal": False, "rowMaxSize": 3000, "columnsMaxSize": 500, }, "paginationInfo": { "size": "small", "type": "normal", "pageShowCount": 5, "pageSize": 10, "showPageSelect": False, }, "container": {"height": 232}, "titleTip": False, "hasFullscreen": False, "copyAsImg": False, "height": None, "isHeightAuto": True, } return {} # --------------------------------------------------------------------------- # userConfig 构建(设计器数据配置面板) # --------------------------------------------------------------------------- def _setter(name: str, title: str, **extra_props: Any) -> dict[str, Any]: item: dict[str, Any] = {"setterName": "ColumnFieldSetter", "name": name, "title": title} if extra_props: item["setterProps"] = extra_props return item def _build_user_config(chart_type: str) -> list[dict[str, Any]]: if chart_type in ("bar", "line"): return [{"name": _DS_KEY_CHART, "title": "配置数据", "items": [ _setter("xField", "横轴", single=True, showFormatTab=True, showFormulaEditor=True, showFieldInfo=True, showAggregateTab=False, showDrillTab=True, showEditTab=True, showSortTab=True), _setter("yField", "纵轴", showFormatTab=True, showFormulaEditor=True, showFieldInfo=True, showEditTab=True, showSortTab=True, showDataLink=True), _setter("groupField", "分组", single=True, showFormatTab=True, showEditTab=True), _setter("annotationField", "参考线", showFormatTab=True, showEditTab=True), ]}] if chart_type == "pie": return [{"name": _DS_KEY_CHART, "title": "配置数据", "items": [ _setter("xField", "分类字段", single=True, showAggregateTab=False, showDrillTab=True, showColorTab=True), _setter("yField", "数值字段", single=True, showDataLink=True), _setter("ratio", "趋势值字段"), _setter("totalValue", "总值字段"), _setter("totalRatio", "总趋势值字段"), ]}] if chart_type == "funnel": return [{"name": _DS_KEY_CHART, "title": "配置数据", "items": [ _setter("xField", "横轴", single=True, showFormatTab=True, showAggregateTab=False, showDrillTab=True, showEditTab=True, showSortTab=True), _setter("yField", "纵轴", showFormatTab=True, showEditTab=True, showSortTab=True, showDataLink=True), ]}] if chart_type == "combo": return [{"name": _DS_KEY_DATASET, "title": "配置数据", "items": [ _setter("xField", "横轴", single=True), _setter("leftYFields", "左纵轴", showDataLink=True), _setter("rightYFields", "右纵轴", showDataLink=True), _setter("annotationField", "参考线"), ]}] if chart_type == "table": return [{"name": _DS_KEY_TABLE, "title": "配置数据", "items": [ _setter("columnFields", "列", showFormulaEditor=True, showFieldInfo=True, showDataLink=True, supportDynamicAlias=True, showBatchSet=True, batchSetFields=["text", "title", "aggregateType", "format_type", "format_decimalDigit"]), ]}] if chart_type == "indicator": return [{"name": _DS_KEY_INDICATOR, "title": "指标数据", "items": [ _setter("kpi", "指标", single=False, showDataLink=True, supportDynamicAlias=True, showBatchSet=True, batchSetFields=["text", "title", "titleTip", "aggregateType", "format_type", "format_decimalDigit", "unit"]), _setter("helpKpi", "辅助指标", single=False, showDataLink=True), ]}] if chart_type == "pivot": return [{"name": _DS_KEY_DATASET, "title": "配置数据", "items": [ _setter("columnList", "列", showFormatTab=True, showEditTab=True), ]}] if chart_type == "gauge": return [{"name": _DS_KEY_CHART, "title": "配置数据", "items": [ _setter("valueField", "指标值", single=True), _setter("assitValueField", "辅助值", single=True), ]}] return [] # --------------------------------------------------------------------------- # mockData 构建(设计器预览数据) # --------------------------------------------------------------------------- def _build_mock_data(chart_type: str) -> list[dict[str, Any]]: if chart_type == "bar": return [{"name": _DS_KEY_CHART, "data": {"data": [ {"month": "Jan.", "value": 18.9}, {"month": "Feb.", "value": 28.8}, {"month": "Mar.", "value": 39.3}, {"month": "Apr.", "value": 81.4}, {"month": "May", "value": 47}, ], "meta": [ {"fieldKey": "xField", "dataType": "STRING", "title": "month"}, {"fieldKey": "yField", "dataType": "NUMBER", "title": "value"}, ], "currentPage": 1, "totalCount": 5}}] if chart_type == "line": return [{"name": _DS_KEY_CHART, "data": {"data": [ {"xField": "2020", "yField": 3}, {"xField": "2021", "yField": 4}, {"xField": "2022", "yField": 3.5}, {"xField": "2023", "yField": 5}, {"xField": "2024", "yField": 4.9}, ], "meta": [ {"fieldKey": "xField", "dataType": "STRING", "title": "xField"}, {"fieldKey": "yField", "dataType": "NUMBER", "title": "yField"}, ], "currentPage": 1, "totalCount": 5}}] if chart_type == "pie": return [{"name": _DS_KEY_CHART, "data": {"data": [ {"xField": "分类A", "yField": 63, "ratio": 0.8, "totalValue": 202, "totalRatio": 0.32}, {"xField": "分类B", "yField": 72, "ratio": 0.5, "totalValue": 202, "totalRatio": 0.36}, {"xField": "分类C", "yField": 67, "ratio": 0.3, "totalValue": 202, "totalRatio": 0.33}, ], "meta": [ {"fieldKey": "xField", "dataType": "STRING", "title": "xField"}, {"fieldKey": "yField", "dataType": "NUMBER", "title": "yField"}, {"fieldKey": "ratio", "dataType": "NUMBER", "title": "ratio"}, {"fieldKey": "totalValue", "dataType": "NUMBER", "title": "totalValue"}, {"fieldKey": "totalRatio", "dataType": "NUMBER", "title": "totalRatio"}, ], "currentPage": 1, "totalCount": 3}}] if chart_type == "funnel": return [{"name": _DS_KEY_CHART, "data": {"data": [ {"xField": "展示", "yField": 100}, {"xField": "点击", "yField": 80}, {"xField": "访问", "yField": 60}, {"xField": "咨询", "yField": 40}, {"xField": "订单", "yField": 20}, ], "meta": [ {"fieldKey": "xField", "dataType": "STRING", "title": "xField"}, {"fieldKey": "yField", "dataType": "NUMBER", "title": "yField"}, ], "currentPage": 1, "totalCount": 5}}] if chart_type == "gauge": return [{"name": _DS_KEY_CHART, "data": {"data": [ {"value": 75, "assitValue": 100}, ], "meta": [ {"fieldKey": "valueField", "dataType": "NUMBER", "title": "value"}, {"fieldKey": "assitValueField", "dataType": "NUMBER", "title": "assitValue"}, ], "currentPage": 1, "totalCount": 1}}] if chart_type == "combo": return [{"name": _DS_KEY_DATASET, "data": {"data": [ {"xField": "Jan.", "leftY": 18.9, "rightY": 5}, {"xField": "Feb.", "leftY": 28.8, "rightY": 8}, {"xField": "Mar.", "leftY": 39.3, "rightY": 12}, {"xField": "Apr.", "leftY": 81.4, "rightY": 15}, {"xField": "May", "leftY": 47, "rightY": 10}, ], "meta": [ {"fieldKey": "xField", "dataType": "STRING", "title": "xField"}, {"fieldKey": "leftYFields", "dataType": "NUMBER", "title": "leftY"}, {"fieldKey": "rightYFields", "dataType": "NUMBER", "title": "rightY"}, ], "currentPage": 1, "totalCount": 5}}] if chart_type == "table": return [{"name": _DS_KEY_TABLE, "data": {"data": [ {"col1": "数据1", "col2": "数据2", "col3": 100}, {"col1": "数据4", "col2": "数据5", "col3": 200}, {"col1": "数据7", "col2": "数据8", "col3": 300}, ], "meta": [ {"fieldKey": "columnFields", "dataType": "STRING", "title": "col1"}, {"fieldKey": "columnFields", "dataType": "STRING", "title": "col2"}, {"fieldKey": "columnFields", "dataType": "NUMBER", "title": "col3"}, ], "currentPage": 1, "totalCount": 3}}] if chart_type == "indicator": return [{"name": _DS_KEY_INDICATOR, "data": {"data": [ {"kpi1": 23123, "kpi2": 7712}, ], "meta": [ {"fieldKey": "kpi1", "dataType": "NUMBER", "title": "指标1", "category": "kpi"}, {"fieldKey": "kpi2", "dataType": "NUMBER", "title": "指标2", "category": "kpi"}, ], "currentPage": 1, "totalCount": 1}}] if chart_type == "pivot": return [{"name": _DS_KEY_DATASET, "data": {"data": [ {"col1": 74, "col2": 9, "col3": 79}, {"col1": 85, "col2": 15, "col3": 62}, {"col1": 93, "col2": 28, "col3": 44}, ], "meta": [ {"fieldKey": "columnList", "dataType": "NUMBER", "title": "col1"}, {"fieldKey": "columnList", "dataType": "NUMBER", "title": "col2"}, {"fieldKey": "columnList", "dataType": "NUMBER", "title": "col3"}, ], "currentPage": 1, "totalCount": 3}}] return [] # --------------------------------------------------------------------------- # afterFetch / exportData / link 构建 # --------------------------------------------------------------------------- _AFTER_FETCH = { "type": "JSFunction", "value": "function afterFetch(data, extraInfo) { return data; }", } _EXPORT_DATA = { "supportExport": False, "passType": "NO_PASS", "exportType": "BROWSER", } _LINK = { "hasLink": False, "content": {"type": "i18n", "zh_CN": "更多", "en_US": "More"}, "onlyIcon": True, } # --------------------------------------------------------------------------- # 顶层字段属性提取 # --------------------------------------------------------------------------- def _extract_top_level_field_props(chart_type: str, ds_model_map: dict[str, Any]) -> dict[str, Any]: """从 dataSetModelMap 提取需要写入 props 顶层的字段属性。""" result: dict[str, Any] = {} if chart_type in ("bar", "line", "pie", "funnel"): ds = ds_model_map.get(_DS_KEY_CHART, {}) result["xField"] = ds.get("xField", []) result["yField"] = ds.get("yField", []) if chart_type not in ("pie", "funnel"): result["groupField"] = ds.get("groupField", []) elif chart_type == "gauge": ds = ds_model_map.get(_DS_KEY_CHART, {}) result["valueField"] = ds.get("valueField", []) result["assitValueField"] = ds.get("assitValueField", []) elif chart_type == "indicator": ds = ds_model_map.get(_DS_KEY_INDICATOR, {}) result["kpiField"] = ds.get("kpi", []) result["helpKpiField"] = ds.get("helpKpi", []) elif chart_type == "table": ds = ds_model_map.get(_DS_KEY_TABLE, {}) result["columnField"] = ds.get("columnFields", []) elif chart_type == "combo": ds = ds_model_map.get(_DS_KEY_DATASET, {}) result["xField"] = ds.get("xField", []) result["leftYFields"] = ds.get("leftYFields", []) result["rightYFields"] = ds.get("rightYFields", []) elif chart_type == "pivot": ds = ds_model_map.get(_DS_KEY_DATASET, {}) result["columnList"] = ds.get("columnList", []) return result # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- def build_chart_component( chart: dict[str, Any], cube_tenant_id: str = "", ) -> tuple[dict[str, Any], str, dict[str, Any]]: """ 构造图表组件。 Returns: (component_node, fieldId, default_layout) """ chart_type = chart.get("type", "bar") if chart_type not in CHART_TYPE_MAP: raise ValueError(f"不支持的图表类型: {chart_type}(支持: {list(CHART_TYPE_MAP.keys())})") component_name = CHART_TYPE_MAP[chart_type] title = chart.get("title", "图表") field_id = generate_field_id(component_name) node_id = next_node_id() cube_code = normalize_cube_code(chart.get("cubeCode", "")) if not cube_code: raise ValueError(f"图表 '{title}' 缺少 cubeCode") data_set_model_map = _build_data_set_model_map(chart, cube_code, cube_tenant_id) props: dict[str, Any] = { "fieldId": field_id, "cid": node_id, "showComponentTitle": True, "componentTitle": i18n(title), "componentTitleTextAlign": "LEFT", "titleTipContent": i18n(""), "titleTipIconName": "help", "headerSize": "medium", "link": _LINK, "exportData": _EXPORT_DATA, "openRefresh": True, "enabledCache": True, "auth": [], "afterFetch": _AFTER_FETCH, "__style__": {}, "mockData": _build_mock_data(chart_type), "dataSetModelMap": data_set_model_map, "userConfig": _build_user_config(chart_type), "settings": _get_chart_settings(chart_type, chart), "titleTip": False, "hasFullscreen": False, "copyAsImg": False, "height": None, "isHeightAuto": True, "datasetModel": {"filterList": []}, } top_level = _extract_top_level_field_props(chart_type, data_set_model_map) props.update(top_level) if chart_type in ("table", "indicator"): props["showFieldSelectIcon"] = True if chart_type == "table": props["pageSize"] = chart.get("pageSize", 20) node: dict[str, Any] = { "componentName": component_name, "id": node_id, "props": props, } computed = _compute_dynamic_layout(chart_type, chart) layout: dict[str, Any] = { "w": chart.get("w", computed["w"]), "h": chart.get("h", computed["h"]), } for k in ("minH", "maxH", "resizeHandles"): if k in computed: layout[k] = computed[k] return node, field_id, layout # --------------------------------------------------------------------------- # dataSetModelMap 构造 # --------------------------------------------------------------------------- def _build_data_set_model_map( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: chart_type = chart.get("type", "bar") if chart_type in ("bar", "line", "pie", "funnel"): return _build_xy_data_model(chart, cube_code, cube_tenant_id) elif chart_type == "table": return _build_table_data_model(chart, cube_code, cube_tenant_id) elif chart_type == "indicator": return _build_indicator_data_model(chart, cube_code, cube_tenant_id) elif chart_type == "gauge": return _build_gauge_data_model(chart, cube_code, cube_tenant_id) elif chart_type == "combo": return _build_combo_data_model(chart, cube_code, cube_tenant_id) elif chart_type == "pivot": return _build_pivot_data_model(chart, cube_code, cube_tenant_id) return {} def _build_xy_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: chart_type = chart.get("type", "bar") x_raw = chart.get("xField", "") x_resolved = _resolve_field(x_raw, cube_code) y_raw = chart.get("yField", "") if isinstance(y_raw, (str, dict)): y_raw = [y_raw] y_resolved_list = [_resolve_field(f, cube_code) for f in y_raw] agg_type = chart.get("aggregateType", "COUNT").upper() x_label = _apply_cube_label_suffix( x_resolved["fieldCode"], chart.get("xLabel", "") or x_resolved["text"] ) y_labels_raw = chart.get("yLabel", []) if isinstance(y_labels_raw, str): y_labels_raw = [y_labels_raw] x_alias = _gen_alias() x_time_gran = x_resolved["timeGranularityType"] or ( chart.get("timeGranularityType", "DAY") if _is_date_field(x_resolved["fieldCode"]) else None ) query_defs = [_build_query_field_def( x_alias, x_label, cube_code, x_resolved["fieldCode"], x_resolved["dataType"], "NONE", x_time_gran, classified_code=x_resolved["classifiedCode"], )] x_full = _build_full_field( x_alias, x_label, cube_code, x_resolved["fieldCode"], x_resolved["dataType"], "NONE", x_time_gran, classified_code=x_resolved["classifiedCode"], field_id=x_resolved["id"], time_format=x_resolved["timeFormat"], ) all_full = [x_full] y_aliases: list[str] = [] y_fulls: list[dict[str, Any]] = [] for i, yr in enumerate(y_resolved_list): y_label = _apply_cube_label_suffix( yr["fieldCode"], (y_labels_raw[i] if i < len(y_labels_raw) else "") or yr["text"] ) y_alias = _gen_alias() y_aliases.append(y_alias) y_fc, y_dt = _fix_array_field_for_aggregate(yr["fieldCode"], yr["dataType"], agg_type) query_defs.append(_build_query_field_def( y_alias, y_label, cube_code, y_fc, y_dt, agg_type, classified_code=yr["classifiedCode"], )) y_full = _build_full_field( y_alias, y_label, cube_code, y_fc, y_dt, agg_type, classified_code=yr["classifiedCode"], field_id=yr["id"], ) y_fulls.append(y_full) all_full.append(y_full) extra: dict[str, Any] = {"xField": [x_full], "yField": y_fulls} if chart_type == "pie": extra.update({"ratio": [], "totalValue": [], "totalRatio": [], "trailingIconField": []}) elif chart_type != "funnel": extra.update({"groupField": [], "annotationField": []}) limit_val = chart.get("limit") if limit_val is not None: extra["limit"] = int(limit_val) return _wrap_dataset( _DS_KEY_CHART, cube_code, cube_tenant_id, query_defs, [x_alias] + y_aliases, all_full, extra=extra, ) def _build_table_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: raw_columns = chart.get("columnFields", []) or chart.get("columns", []) or chart.get("fields", []) if not raw_columns: x_field = chart.get("xField", "") if x_field: raw_columns = [x_field] if not raw_columns: raise ValueError( "table 图表必须提供 columnFields(或 columns / fields / xField)。" "请先通过 `dws yida form components` 获取源表字段定义。" ) column_labels_raw = chart.get("columnLabels", []) resolved_columns = [_resolve_field(col, cube_code) for col in raw_columns] query_defs: list[dict[str, Any]] = [] aliases: list[str] = [] full_fields: list[dict[str, Any]] = [] for i, rc in enumerate(resolved_columns): label = _apply_cube_label_suffix( rc["fieldCode"], (column_labels_raw[i] if i < len(column_labels_raw) else "") or rc["text"] ) alias = _gen_alias() aliases.append(alias) time_gran = rc["timeGranularityType"] or ( chart.get("timeGranularityType", "DAY") if _is_date_field(rc["fieldCode"]) else None ) query_defs.append(_build_query_field_def( alias, label, cube_code, rc["fieldCode"], rc["dataType"], "NONE", time_gran, classified_code=rc["classifiedCode"], )) full = _build_full_field( alias, label, cube_code, rc["fieldCode"], rc["dataType"], "NONE", time_gran, classified_code=rc["classifiedCode"], field_id=rc["id"], time_format=rc["timeFormat"], ) full["sortable"] = False full["hidden"] = False full_fields.append(full) column_fields: list[dict[str, Any]] = [] for i, full in enumerate(full_fields): col: dict[str, Any] = { "aggregateType": full["aggregateType"], "beUsedTimes": 1, "classifiedCode": full["classifiedCode"], "cubeCode": full["cubeCode"], "dataType": full["dataType"], "fieldCode": full["fieldCode"], "fieldKey": full["fieldKey"], "id": full["id"], "text": full["text"], "title": full["title"], "visible": True, "width": 120, "align": "left", "fixed": "left" if i == 0 else None, "timeGranularityType": full.get("timeGranularityType"), "timeFormat": full.get("timeFormat"), "orderBy": full["orderBy"], } column_fields.append(col) return _wrap_dataset( _DS_KEY_TABLE, cube_code, cube_tenant_id, query_defs, aliases, full_fields, extra={"columnFields": column_fields}, ) def _build_indicator_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: kpi_list = chart.get("kpi", []) or chart.get("kpiField", []) or chart.get("yField", []) or chart.get("fields", []) if not kpi_list: raise ValueError("indicator 图表必须提供 kpi(或 kpiField / yField / fields)数组") query_defs: list[dict[str, Any]] = [] aliases: list[str] = [] full_fields: list[dict[str, Any]] = [] kpi_rich: list[dict[str, Any]] = [] for kpi in kpi_list: if isinstance(kpi, str): kpi = {"fieldCode": kpi} fc = normalize_field_code(kpi.get("fieldCode", "")) agg = kpi.get("aggregateType", "COUNT").upper() dt = kpi.get("dataType") or _infer_data_type(fc) fc, dt = _fix_array_field_for_aggregate(fc, dt, agg) label = kpi.get("aliasName", "") or kpi.get("text", "") or kpi.get("label", "") or _derive_label(fc) label = _apply_cube_label_suffix(fc, label) field_id = kpi.get("id") or fc classified_code = kpi.get("classifiedCode") or cube_code alias = _gen_alias() aliases.append(alias) query_defs.append(_build_query_field_def( alias, label, cube_code, fc, dt, agg, classified_code=classified_code, )) full = _build_full_field( alias, label, cube_code, fc, dt, agg, classified_code=classified_code, field_id=field_id, ) full_fields.append(full) kpi_item: dict[str, Any] = { **full, "visible": True, "isVisible": "y", } kpi_rich.append(kpi_item) help_kpi_list = chart.get("helpKpi", []) help_kpi_rich: list[dict[str, Any]] = [] for hk in help_kpi_list: if isinstance(hk, str): hk = {"fieldCode": hk} fc = normalize_field_code(hk.get("fieldCode", "")) agg = hk.get("aggregateType", "COUNT").upper() dt = hk.get("dataType") or _infer_data_type(fc) fc, dt = _fix_array_field_for_aggregate(fc, dt, agg) label = hk.get("aliasName", "") or hk.get("text", "") or hk.get("label", "") or _derive_label(fc) label = _apply_cube_label_suffix(fc, label) field_id = hk.get("id") or fc classified_code = hk.get("classifiedCode") or cube_code alias = _gen_alias() aliases.append(alias) query_defs.append(_build_query_field_def( alias, label, cube_code, fc, dt, agg, classified_code=classified_code, )) full = _build_full_field( alias, label, cube_code, fc, dt, agg, classified_code=classified_code, field_id=field_id, ) full_fields.append(full) help_kpi_rich.append({**full, "visible": True, "isVisible": "y"}) return _wrap_dataset( _DS_KEY_INDICATOR, cube_code, cube_tenant_id, query_defs, aliases, full_fields, extra={"kpi": kpi_rich, "helpKpi": help_kpi_rich}, ) def _build_gauge_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: value_raw = chart.get("valueField") or chart.get("yField") if isinstance(value_raw, list): value_raw = value_raw[0] if value_raw else None if not value_raw: raise ValueError("gauge 图表必须提供 valueField(或 yField)") value_resolved = _resolve_field(value_raw, cube_code) agg_type = chart.get("aggregateType", "AVG").upper() v_alias = _gen_alias() v_label = _apply_cube_label_suffix( value_resolved["fieldCode"], chart.get("valueLabel", "") or value_resolved["text"] ) v_fc, v_dt = _fix_array_field_for_aggregate(value_resolved["fieldCode"], value_resolved["dataType"], agg_type) query_defs = [_build_query_field_def( v_alias, v_label, cube_code, v_fc, v_dt, agg_type, classified_code=value_resolved["classifiedCode"], )] v_full = _build_full_field( v_alias, v_label, cube_code, v_fc, v_dt, agg_type, classified_code=value_resolved["classifiedCode"], field_id=value_resolved["id"], ) all_full = [v_full] aliases = [v_alias] assit_fulls: list[dict[str, Any]] = [] assit_raw = chart.get("assitValueField") if assit_raw: if isinstance(assit_raw, list): assit_raw = assit_raw[0] if assit_raw else None if assit_raw: assit_resolved = _resolve_field(assit_raw, cube_code) a_alias = _gen_alias() a_label = _apply_cube_label_suffix( assit_resolved["fieldCode"], chart.get("assitLabel", "") or assit_resolved["text"] ) a_fc, a_dt = _fix_array_field_for_aggregate( assit_resolved["fieldCode"], assit_resolved["dataType"], agg_type ) query_defs.append(_build_query_field_def( a_alias, a_label, cube_code, a_fc, a_dt, agg_type, classified_code=assit_resolved["classifiedCode"], )) a_full = _build_full_field( a_alias, a_label, cube_code, a_fc, a_dt, agg_type, classified_code=assit_resolved["classifiedCode"], field_id=assit_resolved["id"], ) all_full.append(a_full) aliases.append(a_alias) assit_fulls.append(a_full) return _wrap_dataset( _DS_KEY_CHART, cube_code, cube_tenant_id, query_defs, aliases, all_full, extra={"valueField": [v_full], "assitValueField": assit_fulls}, ) def _build_combo_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: x_raw = chart.get("xField", "") x_resolved = _resolve_field(x_raw, cube_code) left_raw = chart.get("leftYFields", []) if isinstance(left_raw, (str, dict)): left_raw = [left_raw] right_raw = chart.get("rightYFields", []) if isinstance(right_raw, (str, dict)): right_raw = [right_raw] if not left_raw and not right_raw: raise ValueError("combo 图表必须提供 leftYFields 或 rightYFields") left_resolved = [_resolve_field(f, cube_code) for f in left_raw] right_resolved = [_resolve_field(f, cube_code) for f in right_raw] left_agg = chart.get("leftAggregateType", "SUM").upper() right_agg = chart.get("rightAggregateType", "SUM").upper() x_label = _apply_cube_label_suffix( x_resolved["fieldCode"], chart.get("xLabel", "") or x_resolved["text"] ) x_alias = _gen_alias() x_time_gran = x_resolved["timeGranularityType"] or ( chart.get("timeGranularityType", "DAY") if _is_date_field(x_resolved["fieldCode"]) else None ) query_defs = [_build_query_field_def( x_alias, x_label, cube_code, x_resolved["fieldCode"], x_resolved["dataType"], "NONE", x_time_gran, classified_code=x_resolved["classifiedCode"], )] x_full = _build_full_field( x_alias, x_label, cube_code, x_resolved["fieldCode"], x_resolved["dataType"], "NONE", x_time_gran, classified_code=x_resolved["classifiedCode"], field_id=x_resolved["id"], time_format=x_resolved["timeFormat"], ) all_full = [x_full] all_aliases = [x_alias] left_fulls: list[dict[str, Any]] = [] for lr in left_resolved: alias = _gen_alias() all_aliases.append(alias) label = _apply_cube_label_suffix(lr["fieldCode"], lr["text"]) fc, dt = _fix_array_field_for_aggregate(lr["fieldCode"], lr["dataType"], left_agg) query_defs.append(_build_query_field_def( alias, label, cube_code, fc, dt, left_agg, classified_code=lr["classifiedCode"], )) full = _build_full_field( alias, label, cube_code, fc, dt, left_agg, classified_code=lr["classifiedCode"], field_id=lr["id"], ) left_fulls.append(full) all_full.append(full) right_fulls: list[dict[str, Any]] = [] for rr in right_resolved: alias = _gen_alias() all_aliases.append(alias) label = _apply_cube_label_suffix(rr["fieldCode"], rr["text"]) fc, dt = _fix_array_field_for_aggregate(rr["fieldCode"], rr["dataType"], right_agg) query_defs.append(_build_query_field_def( alias, label, cube_code, fc, dt, right_agg, classified_code=rr["classifiedCode"], )) full = _build_full_field( alias, label, cube_code, fc, dt, right_agg, classified_code=rr["classifiedCode"], field_id=rr["id"], ) right_fulls.append(full) all_full.append(full) return _wrap_dataset( _DS_KEY_DATASET, cube_code, cube_tenant_id, query_defs, all_aliases, all_full, extra={ "xField": [x_full], "leftYFields": left_fulls, "rightYFields": right_fulls, "annotationField": [], }, ) def _build_pivot_data_model( chart: dict[str, Any], cube_code: str, cube_tenant_id: str, ) -> dict[str, Any]: raw_columns = chart.get("columnList", []) or chart.get("columns", []) if not raw_columns: raise ValueError( "pivot 图表必须提供 columnList(或 columns)。" "请先通过 `dws yida form components` 获取源表字段定义。" ) resolved_columns = [_resolve_field(col, cube_code) for col in raw_columns] query_defs: list[dict[str, Any]] = [] aliases: list[str] = [] full_fields: list[dict[str, Any]] = [] for rc in resolved_columns: label = _apply_cube_label_suffix(rc["fieldCode"], rc["text"]) alias = _gen_alias() aliases.append(alias) time_gran = rc["timeGranularityType"] or ( chart.get("timeGranularityType", "DAY") if _is_date_field(rc["fieldCode"]) else None ) query_defs.append(_build_query_field_def( alias, label, cube_code, rc["fieldCode"], rc["dataType"], "NONE", time_gran, classified_code=rc["classifiedCode"], )) full = _build_full_field( alias, label, cube_code, rc["fieldCode"], rc["dataType"], "NONE", time_gran, classified_code=rc["classifiedCode"], field_id=rc["id"], time_format=rc["timeFormat"], ) full_fields.append(full) return _wrap_dataset( _DS_KEY_DATASET, cube_code, cube_tenant_id, query_defs, aliases, full_fields, extra={"columnList": full_fields}, extra_query={"filterMode": "PROFESSIONAL"}, )