Showing
1 changed file
with
77 additions
and
6 deletions
| @@ -1028,6 +1028,75 @@ class HTMLRenderer: | @@ -1028,6 +1028,75 @@ class HTMLRenderer: | ||
| 1028 | """ | 1028 | """ |
| 1029 | return f'<div class="kpi-grid">{cards}</div>' | 1029 | return f'<div class="kpi-grid">{cards}</div>' |
| 1030 | 1030 | ||
| 1031 | + def _merge_dicts( | ||
| 1032 | + self, base: Dict[str, Any] | None, override: Dict[str, Any] | None | ||
| 1033 | + ) -> Dict[str, Any]: | ||
| 1034 | + """ | ||
| 1035 | + 递归合并两个字典,override覆盖base,均为新副本,避免副作用。 | ||
| 1036 | + """ | ||
| 1037 | + result = copy.deepcopy(base) if isinstance(base, dict) else {} | ||
| 1038 | + if not isinstance(override, dict): | ||
| 1039 | + return result | ||
| 1040 | + for key, value in override.items(): | ||
| 1041 | + if isinstance(value, dict) and isinstance(result.get(key), dict): | ||
| 1042 | + result[key] = self._merge_dicts(result[key], value) | ||
| 1043 | + else: | ||
| 1044 | + result[key] = copy.deepcopy(value) | ||
| 1045 | + return result | ||
| 1046 | + | ||
| 1047 | + def _looks_like_chart_dataset(self, candidate: Any) -> bool: | ||
| 1048 | + """启发式判断对象是否包含Chart.js常见的labels/datasets结构""" | ||
| 1049 | + if not isinstance(candidate, dict): | ||
| 1050 | + return False | ||
| 1051 | + labels = candidate.get("labels") | ||
| 1052 | + datasets = candidate.get("datasets") | ||
| 1053 | + return isinstance(labels, list) or isinstance(datasets, list) | ||
| 1054 | + | ||
| 1055 | + def _coerce_chart_data_structure(self, data: Dict[str, Any]) -> Dict[str, Any]: | ||
| 1056 | + """ | ||
| 1057 | + 兼容LLM输出的Chart.js完整配置(含type/data/options)。 | ||
| 1058 | + 若data中嵌套一个真正的labels/datasets结构,则提取并返回该结构。 | ||
| 1059 | + """ | ||
| 1060 | + if not isinstance(data, dict): | ||
| 1061 | + return {} | ||
| 1062 | + if self._looks_like_chart_dataset(data): | ||
| 1063 | + return data | ||
| 1064 | + for key in ("data", "chartData", "payload"): | ||
| 1065 | + nested = data.get(key) | ||
| 1066 | + if self._looks_like_chart_dataset(nested): | ||
| 1067 | + return copy.deepcopy(nested) | ||
| 1068 | + return data | ||
| 1069 | + | ||
| 1070 | + def _prepare_widget_payload( | ||
| 1071 | + self, block: Dict[str, Any] | ||
| 1072 | + ) -> tuple[Dict[str, Any], Dict[str, Any]]: | ||
| 1073 | + """ | ||
| 1074 | + 预处理widget数据,兼容部分block将Chart.js配置写入data字段的情况。 | ||
| 1075 | + | ||
| 1076 | + 返回: | ||
| 1077 | + tuple(props, data): 归一化后的props与chart数据 | ||
| 1078 | + """ | ||
| 1079 | + props = copy.deepcopy(block.get("props") or {}) | ||
| 1080 | + raw_data = block.get("data") | ||
| 1081 | + data_copy = copy.deepcopy(raw_data) if isinstance(raw_data, dict) else raw_data | ||
| 1082 | + widget_type = block.get("widgetType") or "" | ||
| 1083 | + chart_like = isinstance(widget_type, str) and widget_type.startswith("chart.js") | ||
| 1084 | + | ||
| 1085 | + if chart_like and isinstance(data_copy, dict): | ||
| 1086 | + inline_options = data_copy.pop("options", None) | ||
| 1087 | + inline_type = data_copy.pop("type", None) | ||
| 1088 | + normalized_data = self._coerce_chart_data_structure(data_copy) | ||
| 1089 | + if isinstance(inline_options, dict): | ||
| 1090 | + props["options"] = self._merge_dicts(props.get("options"), inline_options) | ||
| 1091 | + if isinstance(inline_type, str) and inline_type and not props.get("type"): | ||
| 1092 | + props["type"] = inline_type | ||
| 1093 | + elif isinstance(data_copy, dict): | ||
| 1094 | + normalized_data = data_copy | ||
| 1095 | + else: | ||
| 1096 | + normalized_data = {} | ||
| 1097 | + | ||
| 1098 | + return props, normalized_data | ||
| 1099 | + | ||
| 1031 | def _render_widget(self, block: Dict[str, Any]) -> str: | 1100 | def _render_widget(self, block: Dict[str, Any]) -> str: |
| 1032 | """ | 1101 | """ |
| 1033 | 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 | 1102 | 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 |
| @@ -1042,11 +1111,12 @@ class HTMLRenderer: | @@ -1042,11 +1111,12 @@ class HTMLRenderer: | ||
| 1042 | canvas_id = f"chart-{self.chart_counter}" | 1111 | canvas_id = f"chart-{self.chart_counter}" |
| 1043 | config_id = f"chart-config-{self.chart_counter}" | 1112 | config_id = f"chart-config-{self.chart_counter}" |
| 1044 | 1113 | ||
| 1114 | + props, normalized_data = self._prepare_widget_payload(block) | ||
| 1045 | payload = { | 1115 | payload = { |
| 1046 | "widgetId": block.get("widgetId"), | 1116 | "widgetId": block.get("widgetId"), |
| 1047 | "widgetType": block.get("widgetType"), | 1117 | "widgetType": block.get("widgetType"), |
| 1048 | - "props": block.get("props", {}), | ||
| 1049 | - "data": block.get("data", {}), | 1118 | + "props": props, |
| 1119 | + "data": normalized_data, | ||
| 1050 | "dataRef": block.get("dataRef"), | 1120 | "dataRef": block.get("dataRef"), |
| 1051 | } | 1121 | } |
| 1052 | config_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/") | 1122 | config_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/") |
| @@ -1054,9 +1124,9 @@ class HTMLRenderer: | @@ -1054,9 +1124,9 @@ class HTMLRenderer: | ||
| 1054 | f'<script type="application/json" id="{config_id}">{config_json}</script>' | 1124 | f'<script type="application/json" id="{config_id}">{config_json}</script>' |
| 1055 | ) | 1125 | ) |
| 1056 | 1126 | ||
| 1057 | - title = block.get("props", {}).get("title") | 1127 | + title = props.get("title") |
| 1058 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" | 1128 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" |
| 1059 | - fallback_html = self._render_widget_fallback(block) | 1129 | + fallback_html = self._render_widget_fallback(normalized_data) |
| 1060 | return f""" | 1130 | return f""" |
| 1061 | <div class="chart-card"> | 1131 | <div class="chart-card"> |
| 1062 | {title_html} | 1132 | {title_html} |
| @@ -1067,9 +1137,10 @@ class HTMLRenderer: | @@ -1067,9 +1137,10 @@ class HTMLRenderer: | ||
| 1067 | </div> | 1137 | </div> |
| 1068 | """ | 1138 | """ |
| 1069 | 1139 | ||
| 1070 | - def _render_widget_fallback(self, block: Dict[str, Any]) -> str: | 1140 | + def _render_widget_fallback(self, data: Dict[str, Any]) -> str: |
| 1071 | """渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白""" | 1141 | """渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白""" |
| 1072 | - data = block.get("data") or {} | 1142 | + if not isinstance(data, dict): |
| 1143 | + return "" | ||
| 1073 | labels = data.get("labels") or [] | 1144 | labels = data.get("labels") or [] |
| 1074 | datasets = data.get("datasets") or [] | 1145 | datasets = data.get("datasets") or [] |
| 1075 | if not labels or not datasets: | 1146 | if not labels or not datasets: |
-
Please register or login to post a comment