mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(datasource): add auto ECharts visualization for SQL query results
This commit is contained in:
parent
10dfe35d49
commit
3940cfd1ce
@ -0,0 +1,283 @@
|
||||
package vip.mate.datasource.service;
|
||||
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ECharts Option 自动生成器
|
||||
* <p>
|
||||
* 根据 SQL 查询结果的列类型和数据特征,自动选择图表类型并生成 ECharts option JSON。
|
||||
* 参考 SQLBot 的图表选择规则,使用确定性逻辑代替 LLM 推理。
|
||||
* <p>
|
||||
* 规则:
|
||||
* <ul>
|
||||
* <li>日期/时间列 + 数值列 → 折线图 (line)</li>
|
||||
* <li>分类列 + 数值列(≤15 个类别)→ 柱状图 (bar)</li>
|
||||
* <li>分类列 + 单个数值列(≤8 个类别)→ 饼图 (pie)</li>
|
||||
* <li>其他情况(单行、纯文本、列太多)→ 不生成图表</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public class EChartsOptionBuilder {
|
||||
|
||||
private static final int MIN_ROWS = 2;
|
||||
private static final int MAX_ROWS_FOR_CHART = 50;
|
||||
private static final int MAX_PIE_CATEGORIES = 8;
|
||||
private static final int MAX_BAR_CATEGORIES = 15;
|
||||
|
||||
/**
|
||||
* 尝试根据查询结果生成 ECharts option JSON 字符串。
|
||||
*
|
||||
* @param columns 列名列表
|
||||
* @param rows 数据行(每行是字符串列表)
|
||||
* @return ECharts option JSON 字符串,如果数据不适合可视化则返回 null
|
||||
*/
|
||||
public static String tryBuild(List<String> columns, List<List<String>> rows) {
|
||||
if (columns == null || rows == null || rows.size() < MIN_ROWS || rows.size() > MAX_ROWS_FOR_CHART) {
|
||||
return null;
|
||||
}
|
||||
if (columns.size() < 2 || columns.size() > 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 分析每列的类型
|
||||
List<ColumnInfo> colInfos = analyzeColumns(columns, rows);
|
||||
|
||||
// 找出维度列(第一个非数值列)和数值列
|
||||
ColumnInfo dimensionCol = null;
|
||||
List<ColumnInfo> metricCols = new ArrayList<>();
|
||||
for (ColumnInfo ci : colInfos) {
|
||||
if (ci.type == ColType.NUMERIC) {
|
||||
metricCols.add(ci);
|
||||
} else if (dimensionCol == null) {
|
||||
dimensionCol = ci;
|
||||
}
|
||||
}
|
||||
|
||||
// 必须有至少一个维度列和一个数值列
|
||||
if (dimensionCol == null || metricCols.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int categoryCount = rows.size();
|
||||
|
||||
// 决定图表类型
|
||||
if (metricCols.size() == 1 && categoryCount <= MAX_PIE_CATEGORIES && dimensionCol.type == ColType.CATEGORY) {
|
||||
return buildPie(dimensionCol, metricCols.get(0), rows, columns);
|
||||
} else if (dimensionCol.type == ColType.DATE) {
|
||||
return buildLine(dimensionCol, metricCols, rows, columns);
|
||||
} else if (categoryCount <= MAX_BAR_CATEGORIES) {
|
||||
return buildBar(dimensionCol, metricCols, rows, columns);
|
||||
} else {
|
||||
return buildLine(dimensionCol, metricCols, rows, columns);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图表构建 ====================
|
||||
|
||||
private static String buildLine(ColumnInfo dim, List<ColumnInfo> metrics, List<List<String>> rows, List<String> columns) {
|
||||
JSONObject option = new JSONObject(new LinkedHashMap<>());
|
||||
option.set("title", new JSONObject().set("text", buildTitle(dim, metrics, "趋势")).set("left", "center"));
|
||||
option.set("tooltip", new JSONObject().set("trigger", "axis"));
|
||||
|
||||
if (metrics.size() > 1) {
|
||||
JSONArray legendData = new JSONArray();
|
||||
metrics.forEach(m -> legendData.add(m.name));
|
||||
option.set("legend", new JSONObject().set("data", legendData).set("bottom", 0));
|
||||
}
|
||||
|
||||
option.set("grid", defaultGrid());
|
||||
option.set("xAxis", new JSONObject()
|
||||
.set("type", "category")
|
||||
.set("data", extractColumn(rows, columns.indexOf(dim.name))));
|
||||
option.set("yAxis", new JSONObject().set("type", "value"));
|
||||
|
||||
JSONArray series = new JSONArray();
|
||||
for (ColumnInfo m : metrics) {
|
||||
series.add(new JSONObject()
|
||||
.set("name", m.name)
|
||||
.set("type", "line")
|
||||
.set("data", extractNumericColumn(rows, columns.indexOf(m.name)))
|
||||
.set("smooth", true));
|
||||
}
|
||||
option.set("series", series);
|
||||
return option.toString();
|
||||
}
|
||||
|
||||
private static String buildBar(ColumnInfo dim, List<ColumnInfo> metrics, List<List<String>> rows, List<String> columns) {
|
||||
JSONObject option = new JSONObject(new LinkedHashMap<>());
|
||||
option.set("title", new JSONObject().set("text", buildTitle(dim, metrics, "对比")).set("left", "center"));
|
||||
option.set("tooltip", new JSONObject().set("trigger", "axis"));
|
||||
|
||||
if (metrics.size() > 1) {
|
||||
JSONArray legendData = new JSONArray();
|
||||
metrics.forEach(m -> legendData.add(m.name));
|
||||
option.set("legend", new JSONObject().set("data", legendData).set("bottom", 0));
|
||||
}
|
||||
|
||||
option.set("grid", defaultGrid());
|
||||
option.set("xAxis", new JSONObject()
|
||||
.set("type", "category")
|
||||
.set("data", extractColumn(rows, columns.indexOf(dim.name))));
|
||||
option.set("yAxis", new JSONObject().set("type", "value"));
|
||||
|
||||
JSONArray series = new JSONArray();
|
||||
for (ColumnInfo m : metrics) {
|
||||
series.add(new JSONObject()
|
||||
.set("name", m.name)
|
||||
.set("type", "bar")
|
||||
.set("data", extractNumericColumn(rows, columns.indexOf(m.name))));
|
||||
}
|
||||
option.set("series", series);
|
||||
return option.toString();
|
||||
}
|
||||
|
||||
private static String buildPie(ColumnInfo dim, ColumnInfo metric, List<List<String>> rows, List<String> columns) {
|
||||
JSONObject option = new JSONObject(new LinkedHashMap<>());
|
||||
option.set("title", new JSONObject().set("text", buildTitle(dim, List.of(metric), "占比")).set("left", "center"));
|
||||
option.set("tooltip", new JSONObject().set("trigger", "item"));
|
||||
|
||||
int dimIdx = columns.indexOf(dim.name);
|
||||
int metricIdx = columns.indexOf(metric.name);
|
||||
|
||||
JSONArray pieData = new JSONArray();
|
||||
for (List<String> row : rows) {
|
||||
JSONObject item = new JSONObject();
|
||||
item.set("name", row.get(dimIdx));
|
||||
item.set("value", parseNumber(row.get(metricIdx)));
|
||||
pieData.add(item);
|
||||
}
|
||||
|
||||
JSONArray series = new JSONArray();
|
||||
series.add(new JSONObject()
|
||||
.set("name", metric.name)
|
||||
.set("type", "pie")
|
||||
.set("radius", "60%")
|
||||
.set("data", pieData));
|
||||
option.set("series", series);
|
||||
return option.toString();
|
||||
}
|
||||
|
||||
// ==================== 列分析 ====================
|
||||
|
||||
private enum ColType { NUMERIC, DATE, CATEGORY }
|
||||
|
||||
private static class ColumnInfo {
|
||||
String name;
|
||||
ColType type;
|
||||
|
||||
ColumnInfo(String name, ColType type) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ColumnInfo> analyzeColumns(List<String> columns, List<List<String>> rows) {
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
String colName = columns.get(i);
|
||||
ColType type = detectColumnType(colName, rows, i);
|
||||
result.add(new ColumnInfo(colName, type));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ColType detectColumnType(String colName, List<List<String>> rows, int colIdx) {
|
||||
// 列名启发式判断日期
|
||||
String lowerName = colName.toLowerCase();
|
||||
if (lowerName.contains("date") || lowerName.contains("time") || lowerName.contains("day")
|
||||
|| lowerName.contains("month") || lowerName.contains("year") || lowerName.contains("week")
|
||||
|| lowerName.contains("日期") || lowerName.contains("时间") || lowerName.contains("月份")) {
|
||||
return ColType.DATE;
|
||||
}
|
||||
|
||||
// 采样数据判断类型
|
||||
int numericCount = 0;
|
||||
int dateCount = 0;
|
||||
int sampleSize = Math.min(rows.size(), 10);
|
||||
for (int i = 0; i < sampleSize; i++) {
|
||||
String val = rows.get(i).get(colIdx);
|
||||
if (val == null || "NULL".equals(val)) continue;
|
||||
if (isNumeric(val)) {
|
||||
numericCount++;
|
||||
} else if (isDateLike(val)) {
|
||||
dateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (numericCount >= sampleSize * 0.8) return ColType.NUMERIC;
|
||||
if (dateCount >= sampleSize * 0.5) return ColType.DATE;
|
||||
return ColType.CATEGORY;
|
||||
}
|
||||
|
||||
private static boolean isNumeric(String val) {
|
||||
if (val == null || val.isEmpty()) return false;
|
||||
try {
|
||||
new BigDecimal(val.trim());
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDateLike(String val) {
|
||||
if (val == null || val.length() < 6) return false;
|
||||
// 匹配常见日期格式:2024-01-01, 2024/01/01, 2024-01, 01-01, 20240101 等
|
||||
return val.matches("\\d{4}[-/]\\d{1,2}([-/]\\d{1,2})?.*")
|
||||
|| val.matches("\\d{1,2}[-/]\\d{1,2}([-/]\\d{2,4})?");
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private static JSONArray extractColumn(List<List<String>> rows, int idx) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (List<String> row : rows) {
|
||||
arr.add(row.get(idx));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JSONArray extractNumericColumn(List<List<String>> rows, int idx) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (List<String> row : rows) {
|
||||
arr.add(parseNumber(row.get(idx)));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static Object parseNumber(String val) {
|
||||
if (val == null || "NULL".equals(val)) return 0;
|
||||
try {
|
||||
BigDecimal bd = new BigDecimal(val.trim());
|
||||
// 如果没有小数部分,返回整数
|
||||
if (bd.scale() <= 0 || bd.stripTrailingZeros().scale() <= 0) {
|
||||
return bd.longValue();
|
||||
}
|
||||
return bd.doubleValue();
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildTitle(ColumnInfo dim, List<ColumnInfo> metrics, String suffix) {
|
||||
if (metrics.size() == 1) {
|
||||
return metrics.get(0).name + " " + suffix;
|
||||
}
|
||||
return dim.name + " " + suffix;
|
||||
}
|
||||
|
||||
private static JSONObject defaultGrid() {
|
||||
return new JSONObject()
|
||||
.set("left", "3%")
|
||||
.set("right", "4%")
|
||||
.set("bottom", "12%")
|
||||
.set("containLabel", true);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@ package vip.mate.tool.builtin;
|
||||
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
@ -11,6 +10,7 @@ import org.springframework.stereotype.Component;
|
||||
import vip.mate.datasource.model.DatasourceEntity;
|
||||
import vip.mate.datasource.service.DatasourceConnectionManager;
|
||||
import vip.mate.datasource.service.DatasourceService;
|
||||
import vip.mate.datasource.service.EChartsOptionBuilder;
|
||||
import vip.mate.datasource.service.SqlValidationService;
|
||||
|
||||
import java.sql.*;
|
||||
@ -22,6 +22,7 @@ import java.util.List;
|
||||
* <p>
|
||||
* 仅允许 SELECT 语句。自动注入 LIMIT 保护。
|
||||
* 查询超时 30 秒。结果格式化为 Markdown 表格或 JSON。
|
||||
* 自动分析数据特征并生成 ECharts 图表配置。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -43,6 +44,7 @@ public class SqlQueryTool {
|
||||
仅允许 SELECT 语句,禁止 INSERT/UPDATE/DELETE/DROP 等写操作。
|
||||
如果 SQL 没有 LIMIT 子句会自动添加 LIMIT 500。
|
||||
返回查询结果(Markdown 表格或 JSON 格式)以及行数和执行耗时。
|
||||
如果数据适合可视化,会自动附带一个 echarts 图表配置代码块,前端会自动渲染为交互式图表。
|
||||
""")
|
||||
public String execute_sql(
|
||||
@ToolParam(description = "目标数据源 ID") Long datasourceId,
|
||||
@ -137,6 +139,12 @@ public class SqlQueryTool {
|
||||
sb.append("\n\n> 结果已截断至 ").append(MAX_ROWS).append(" 行,实际数据可能更多。");
|
||||
}
|
||||
|
||||
// 自动生成 ECharts 图表配置
|
||||
String chartOption = EChartsOptionBuilder.tryBuild(columns, rows);
|
||||
if (chartOption != null) {
|
||||
sb.append("\n\n```echarts\n").append(chartOption).append("\n```");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,8 @@ dependencies:
|
||||
- 用自然语言总结查询结果的要点
|
||||
- 如果结果为空,分析可能的原因(表名/列名/条件有误等)
|
||||
- 如果需要,可以调整 SQL 重新查询
|
||||
- 如果查询结果包含数值列,系统会自动生成 ECharts 图表,无需你手动生成图表代码
|
||||
- **重要:不要使用 write_file 工具生成 HTML 图表文件,系统已内置图表渲染能力**
|
||||
|
||||
## 错误处理
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.3.3",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
||||
23
mateclaw-ui/pnpm-lock.yaml
generated
23
mateclaw-ui/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
element-plus:
|
||||
specifier: ^2.9.1
|
||||
version: 2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3))
|
||||
@ -823,6 +826,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.331:
|
||||
resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==}
|
||||
|
||||
@ -1361,6 +1367,9 @@ packages:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@ -1480,6 +1489,9 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
@ -2089,6 +2101,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.331: {}
|
||||
|
||||
element-plus@2.13.6(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)):
|
||||
@ -2625,6 +2642,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@ -2712,3 +2731,7 @@ snapshots:
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@ -302,6 +302,24 @@ body {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ECharts chart block */
|
||||
.markdown-body .echarts-block {
|
||||
margin: 14px 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 350px;
|
||||
width: 100%;
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.markdown-body .echarts-block.echarts-error {
|
||||
min-height: auto;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* highlight.js token colors (One Dark inspired — works on dark bg) */
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; }
|
||||
.hljs-string, .hljs-attr { color: #98c379; }
|
||||
|
||||
219
mateclaw-ui/src/composables/useEChartsRenderer.ts
Normal file
219
mateclaw-ui/src/composables/useEChartsRenderer.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { type Ref, watch, nextTick } from 'vue'
|
||||
import { useThemeStore } from '@/stores/useThemeStore'
|
||||
|
||||
// Lazy-load echarts to keep initial bundle small (~1MB saved)
|
||||
let echartsModule: typeof import('echarts') | null = null
|
||||
async function getECharts() {
|
||||
if (!echartsModule) {
|
||||
echartsModule = await import('echarts')
|
||||
}
|
||||
return echartsModule
|
||||
}
|
||||
|
||||
/** Top-level keys allowed in ECharts option (security whitelist) */
|
||||
const ALLOWED_KEYS = new Set([
|
||||
'title', 'tooltip', 'legend', 'xAxis', 'yAxis', 'series',
|
||||
'grid', 'color', 'dataset', 'graphic', 'radar', 'polar',
|
||||
'angleAxis', 'radiusAxis', 'visualMap',
|
||||
])
|
||||
|
||||
const MAX_OPTION_SIZE = 100 * 1024 // 100KB
|
||||
|
||||
/**
|
||||
* Recursively strip function-like values from an ECharts option object
|
||||
* to prevent XSS via ECharts formatter evaluation.
|
||||
*/
|
||||
function sanitizeOption(obj: Record<string, any>): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key]
|
||||
if (typeof val === 'string' && val.trimStart().startsWith('function')) {
|
||||
delete obj[key]
|
||||
} else if (typeof val === 'function') {
|
||||
delete obj[key]
|
||||
} else if (val && typeof val === 'object') {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((item: any) => {
|
||||
if (item && typeof item === 'object') sanitizeOption(item)
|
||||
})
|
||||
} else {
|
||||
sanitizeOption(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterTopLevelKeys(option: Record<string, any>): Record<string, any> {
|
||||
const filtered: Record<string, any> = {}
|
||||
for (const key of Object.keys(option)) {
|
||||
if (ALLOWED_KEYS.has(key)) {
|
||||
filtered[key] = option[key]
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that observes a container for `.echarts-block` placeholder divs
|
||||
* and mounts ECharts instances on them.
|
||||
*/
|
||||
export function useEChartsRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
const themeStore = useThemeStore()
|
||||
const instanceMap = new WeakMap<HTMLElement, any>() // echarts.ECharts
|
||||
const trackedElements: Set<HTMLElement> = new Set()
|
||||
const mountingSet = new Set<HTMLElement>() // guard against concurrent mounts
|
||||
let observer: MutationObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
async function mountChart(el: HTMLElement) {
|
||||
if (instanceMap.has(el) || mountingSet.has(el)) return
|
||||
mountingSet.add(el)
|
||||
|
||||
const encoded = el.getAttribute('data-echarts-option')
|
||||
if (!encoded) {
|
||||
mountingSet.delete(el)
|
||||
return
|
||||
}
|
||||
|
||||
// Size guard
|
||||
if (encoded.length > MAX_OPTION_SIZE) {
|
||||
el.textContent = 'Chart option too large'
|
||||
mountingSet.delete(el)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = decodeURIComponent(encoded)
|
||||
let option = JSON.parse(raw)
|
||||
|
||||
// Must be an object with series
|
||||
if (!option || typeof option !== 'object' || !option.series) {
|
||||
el.textContent = 'Invalid chart option'
|
||||
mountingSet.delete(el)
|
||||
return
|
||||
}
|
||||
|
||||
// Security: filter keys and strip functions
|
||||
option = filterTopLevelKeys(option)
|
||||
sanitizeOption(option)
|
||||
|
||||
// Ensure the element has explicit dimensions
|
||||
if (!el.style.height) {
|
||||
el.style.height = '350px'
|
||||
}
|
||||
if (!el.style.width) {
|
||||
el.style.width = '100%'
|
||||
}
|
||||
|
||||
const echarts = await getECharts()
|
||||
const theme = themeStore.isDark ? 'dark' : undefined
|
||||
const chart = echarts.init(el, theme)
|
||||
chart.setOption(option)
|
||||
instanceMap.set(el, chart)
|
||||
trackedElements.add(el)
|
||||
} catch (e) {
|
||||
console.error('[EChartsRenderer] mount error:', e)
|
||||
el.textContent = 'Chart render error'
|
||||
el.classList.add('echarts-error')
|
||||
} finally {
|
||||
mountingSet.delete(el)
|
||||
}
|
||||
}
|
||||
|
||||
function scanAndMount() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const blocks = container.querySelectorAll('.echarts-block:not(.echarts-error)')
|
||||
blocks.forEach((el) => {
|
||||
if (!instanceMap.has(el as HTMLElement) && !mountingSet.has(el as HTMLElement)) {
|
||||
mountChart(el as HTMLElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rebuildAll() {
|
||||
trackedElements.forEach((el) => {
|
||||
const chart = instanceMap.get(el)
|
||||
if (chart) {
|
||||
chart.dispose()
|
||||
instanceMap.delete(el)
|
||||
}
|
||||
})
|
||||
trackedElements.clear()
|
||||
scanAndMount()
|
||||
}
|
||||
|
||||
function resizeAll() {
|
||||
trackedElements.forEach((el) => {
|
||||
const chart = instanceMap.get(el)
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.resize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function attachObserver(container: HTMLElement) {
|
||||
// Clean up previous observers
|
||||
observer?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
|
||||
// MutationObserver to detect new echarts blocks in the DOM
|
||||
observer = new MutationObserver(() => {
|
||||
// Use nextTick to ensure DOM is settled after Vue updates
|
||||
nextTick(() => scanAndMount())
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
|
||||
// ResizeObserver for container width changes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
resizeAll()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
// Initial scan
|
||||
scanAndMount()
|
||||
}
|
||||
|
||||
function startObserving() {
|
||||
const container = containerRef.value
|
||||
if (container) {
|
||||
attachObserver(container)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch containerRef — if it's null at mount time, attach when it becomes available
|
||||
const stopContainerWatch = watch(
|
||||
() => containerRef.value,
|
||||
(newContainer) => {
|
||||
if (newContainer && !observer) {
|
||||
attachObserver(newContainer)
|
||||
}
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// Theme reactivity
|
||||
const stopThemeWatch = watch(
|
||||
() => themeStore.isDark,
|
||||
() => {
|
||||
rebuildAll()
|
||||
},
|
||||
)
|
||||
|
||||
function dispose() {
|
||||
stopContainerWatch()
|
||||
stopThemeWatch()
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
trackedElements.forEach((el) => {
|
||||
const chart = instanceMap.get(el)
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.dispose()
|
||||
}
|
||||
})
|
||||
trackedElements.clear()
|
||||
}
|
||||
|
||||
return { startObserving, dispose, scanAndMount }
|
||||
}
|
||||
@ -42,6 +42,13 @@ const customRenderer = {
|
||||
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
|
||||
const rawCode = text || ''
|
||||
const infoStr = (lang || '').split(/\s/)[0]
|
||||
|
||||
// ECharts chart block: render as a placeholder div
|
||||
if (infoStr === 'echarts') {
|
||||
const encodedOption = encodeURIComponent(rawCode)
|
||||
return `<div class="echarts-block" data-echarts-option="${encodedOption}"></div>`
|
||||
}
|
||||
|
||||
const detectedLang = extractLang(infoStr)
|
||||
const hasLanguage = detectedLang && hljs.getLanguage(detectedLang)
|
||||
|
||||
@ -81,7 +88,7 @@ const markedInstance = new Marked({
|
||||
|
||||
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
|
||||
const purifyConfig = {
|
||||
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
|
||||
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'data-echarts-option', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
|
||||
ADD_TAGS: ['input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span'],
|
||||
}
|
||||
|
||||
|
||||
@ -205,6 +205,7 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
|
||||
import MessageList from '@/components/chat/MessageList.vue'
|
||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
|
||||
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||||
|
||||
// ============ 移动端状态 ============
|
||||
const isMobile = ref(false)
|
||||
@ -353,6 +354,10 @@ async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]):
|
||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||
|
||||
// ECharts: extract DOM element from MessageList component ref
|
||||
const echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
|
||||
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
|
||||
|
||||
// 使用 useChat composable
|
||||
const {
|
||||
messages,
|
||||
@ -463,6 +468,7 @@ const eligibleModels = computed(() => {
|
||||
// ============ 生命周期 ============
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', handleCodeCopy)
|
||||
startECharts()
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
@ -472,6 +478,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleCodeCopy)
|
||||
disposeECharts()
|
||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||
stopChatGeneration()
|
||||
// 释放所有附件的 ObjectURL,防止内存泄漏
|
||||
|
||||
Loading…
Reference in New Issue
Block a user