关键配置:
// 页面配置
xAxis.triggerEvent: true // 启用鼠标事件
yAxis.triggerEvent: true // 启用鼠标事件
series: [{
label: {
formatter: (params) => {
return params.value.length > 8 ? params.value.substr(0, 8) + '...' : params.value
}
}
}]
// 鼠标悬浮提示
// 封装设置坐标轴悬浮提示的方法
setupAxisHoverExtension(myChart) {
const elementDiv = this.createOrGetExtensionDiv();
myChart.on("mouseover", (params) => {
if (["xAxis", "yAxis"].includes(params.componentType)) {
this.showAxisTooltip(elementDiv, params.value);
}
});
myChart.on("mouseout", (params) => {
if (["xAxis", "yAxis"].includes(params.componentType)) {
this.hideAxisTooltip(elementDiv);
}
});
document.querySelector("html").onmousemove = (event) => {
if (elementDiv.style.display !== "none") {
this.updateTooltipPosition(elementDiv, event);
}
};
},
// 创建或获取悬浮提示的 div 元素
createOrGetExtensionDiv() {
let elementDiv = document.getElementById("extension");
if (!elementDiv) {
elementDiv = document.createElement("div");
elementDiv.setAttribute("id", "extension");
elementDiv.style.display = "none";
document.querySelector("html").appendChild(elementDiv);
}
return elementDiv;
},
// 显示坐标轴悬浮提示
showAxisTooltip(elementDiv, value) {
const elementStyle =
"position: absolute;z-index: 99999;color: #fff;font-size: 12px;padding: 5px;display: inline;border-radius: 4px;background-color: #303133;box-shadow: rgba(0, 0, 0, 0.3) 2px 2px 8px";
elementDiv.style.cssText = elementStyle;
elementDiv.innerHTML = value;
},
// 隐藏坐标轴悬浮提示
hideAxisTooltip(elementDiv) {
elementDiv.style.display = "none";
},
// 更新悬浮提示的位置
updateTooltipPosition(elementDiv, event) {
const xx = event.pageX + 15;
const yy = event.pageY + 15;
elementDiv.style.top = `${yy}px`;
elementDiv.style.left = `${xx}px`;
},
场景完整代码:
(我当前的场景是用数据库的数据去喂n8n,让n8n根据数据生成apach-echats的option配置,然后前端根据配置进行渲染)
getChatHtml2(answerStr = "") {
// 如果已经有缓存,直接返回缓存结果
if (this.chartDataCache[answerStr]) {
return this.chartDataCache[answerStr];
}
let htmlStr = [];
const chartsTypeList = [
"bar",
"line",
"scatter",
"area",
"heatmap",
"boxplot",
"candlestick",
];
try {
let data = JSON.parse(answerStr);
// 非流式
// htmlStr = JSON.parse(data.data[0].html);
// 流式
htmlStr = JSON.parse(JSON.parse(data.data)[0].html);
} catch (error) {
console.log("getChatHtml2-error:", error);
return false;
}
if (!Array.isArray(htmlStr) && typeof htmlStr === "object") {
htmlStr = [htmlStr];
}
// htmlStr = TEST5;
// 为数组每一项添加唯一的 uuid
let newArray = htmlStr.map((item) => {
return {
...item,
id: uuidv4(),
};
});
this.$nextTick(() => {
try {
newArray.forEach((item) => {
// 基于准备好的dom,初始化echarts实例
const myChart = echarts.init(this.$refs[`chartRef_${item.id}`][0]);
// 判断是否为需要 xAxis 和 yAxis 配置的图表类型(使用笛卡尔直角坐标系的图表)
const isCartesianChart = () => {
if (Array.isArray(item.series)) {
return item.series.some((series) => {
return chartsTypeList.includes(series.type);
});
} else if (
typeof item.yAxis === "object" &&
item.yAxis !== null
) {
return chartsTypeList.includes(item.series.type);
} else {
return false;
}
};
const isShowLine = () => {
if (isCartesianChart()) {
return {
splitLine: {
show: true, // 显示网格线
opacity: 0.1, // 坐标轴网格线透明度设置为 0.1
},
};
} else {
return {};
}
};
// 使用笛卡尔直角坐标系的图表没有{d}参数,所以tooltip的formatter需要特殊处理
if (isCartesianChart()) {
if (Array.isArray(item.tooltip)) {
item.tooltip = item.tooltip.map((tooltipItem) => ({
...(tooltipItem || {}),
trigger: "item",
formatter: "{a} <br/>{b}: {c} ({d}%)",
confine: true, // 防止超出可视区域
}));
} else {
item.tooltip = {
...(item.tooltip || {}),
trigger: "item",
formatter: "{a} <br/>{b}: {c} ({d}%)",
confine: true, // 防止超出可视区域
};
}
} else {
item.tooltip = {
...(item.tooltip || {}),
trigger: "item",
formatter: "{a} <br/>{b}: {c}%",
confine: true, // 防止超出可视区域
};
}
item.title = {
...(item.title || {}),
top: 0, // 让标题上对齐
left: "left", //让标题左对齐
textStyle: {
fontSize: 14, //将标题字体大小设为 14px
},
};
if (Array.isArray(item.legend)) {
item.legend = item.legend.map((legendItem) => ({
...(legendItem || {}),
textStyle: {
fontSize: 10, //将图例文字大小设为 10px。
},
top: 36, //保证其在标题下方且不重叠
type: "scroll", //启用滚动条,当图例项过多时,用户可以通过滚动条查看。
itemGap: 20,
}));
} else {
item.legend = {
...(item.legend || {}),
textStyle: {
fontSize: 10, //将图例文字大小设为 10px。
},
top: 36, //保证其在标题下方且不重叠
type: "scroll", //启用滚动条,当图例项过多时,用户可以通过滚动条查看。
itemGap: 20,
};
}
if (isCartesianChart()) {
if (Array.isArray(item.xAxis)) {
item.xAxis = item.xAxis.map((xAxisItem) => ({
...(xAxisItem || {}),
axisLabel: {
...(xAxisItem.axisLabel || {}),
fontSize: 10, //将 x 轴标签的字体大小设为 10px。
// overflow: "truncate", //当标签内容过长时,将其截断显示。
// width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
},
triggerEvent: true, // 启用鼠标事件
...isShowLine(),
}));
} else {
item.xAxis = {
...(item.xAxis || {}),
axisLabel: {
...(item.axisLabel || {}),
fontSize: 10, //将 x 轴标签的字体大小设为 10px。
// overflow: "truncate", //当标签内容过长时,将其截断显示。
// width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
},
triggerEvent: true, // 启用鼠标事件
...isShowLine(),
};
}
// 多个y轴,是数组,需要遍历
if (Array.isArray(item.yAxis)) {
item.yAxis = item.yAxis.map((yAxisItem) => {
return {
...(yAxisItem || {}),
axisLabel: {
...(yAxisItem.axisLabel || {}),
fontSize: 10, //将 x 轴标签的字体大小设为 10px。
// overflow: "truncate", //当标签内容过长时,将其截断显示。
// width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
},
triggerEvent: true, // 启用鼠标事件
...isShowLine(),
};
});
} else {
// 单个y轴,是对象
item.yAxis = {
...(item.yAxis || {}),
axisLabel: {
...(item.axisLabel || {}),
fontSize: 10, //将 x 轴标签的字体大小设为 10px。
// overflow: "truncate", //当标签内容过长时,将其截断显示。
// width: 72, // 指定标签宽度,超出部分显示为省略号,可按需调整
},
triggerEvent: true, // 启用鼠标事件
...isShowLine(),
};
}
}
item.dataZoom = [
{
type: "slider", //滑动条
show: true, //开启
yAxisIndex: [0],
left: "93%", //滑动条位置
start: 1,
end: 50,
},
{
type: "slider", //滑动条
show: true, //开启
xAxisIndex: [0],
left: "93%", //滑动条位置
start: 1, //初始化时,滑动条宽度开始标度
end: 50, //初始化时,滑动条宽度结束标度
},
];
myChart.setOption(item);
if (isCartesianChart()) {
this.setupAxisHoverExtension(myChart);
}
// 防止重复添加同一个实例
const existingChart = this.chartInstances.find(
(chart) => chart === myChart
);
if (!existingChart) {
this.chartInstances.push(myChart); // 将实例添加到数组
}
});
} catch (error) {
console.log("图表初始化失败:", error);
}
});
// 过滤掉暂无数据的
newArray = newArray.filter((item) => {
return !item.error;
});
// 缓存结果
this.chartDataCache[answerStr] = newArray;
return newArray;
},
// 封装设置坐标轴悬浮提示的方法
setupAxisHoverExtension(myChart) {
const elementDiv = this.createOrGetExtensionDiv();
myChart.on("mouseover", (params) => {
if (["xAxis", "yAxis"].includes(params.componentType)) {
this.showAxisTooltip(elementDiv, params.value);
}
});
myChart.on("mouseout", (params) => {
if (["xAxis", "yAxis"].includes(params.componentType)) {
this.hideAxisTooltip(elementDiv);
}
});
document.querySelector("html").onmousemove = (event) => {
if (elementDiv.style.display !== "none") {
this.updateTooltipPosition(elementDiv, event);
}
};
},
// 创建或获取悬浮提示的 div 元素
createOrGetExtensionDiv() {
let elementDiv = document.getElementById("extension");
if (!elementDiv) {
elementDiv = document.createElement("div");
elementDiv.setAttribute("id", "extension");
elementDiv.style.display = "none";
document.querySelector("html").appendChild(elementDiv);
}
return elementDiv;
},
// 显示坐标轴悬浮提示
showAxisTooltip(elementDiv, value) {
const elementStyle =
"position: absolute;z-index: 99999;color: #fff;font-size: 12px;padding: 5px;display: inline;border-radius: 4px;background-color: #303133;box-shadow: rgba(0, 0, 0, 0.3) 2px 2px 8px";
elementDiv.style.cssText = elementStyle;
elementDiv.innerHTML = value;
},
// 隐藏坐标轴悬浮提示
hideAxisTooltip(elementDiv) {
elementDiv.style.display = "none";
},
// 更新悬浮提示的位置
updateTooltipPosition(elementDiv, event) {
const xx = event.pageX + 15;
const yy = event.pageY + 15;
elementDiv.style.top = `${yy}px`;
elementDiv.style.left = `${xx}px`;
},
场景方案一:n8n根据数据库的数据生成图表html字符串给前端,前端去把html字符串拆成dom、css、js、js脚本这四个部分,然后在前端分别渲染
// chat.vue
-<template>
- <div class="chart-container">
- <!-- 循环渲染每个图表项 -->
- <div v-for="(item, index) in chatData" :key="`bi_chat_${index}`">
- <div>问题改写:{{ item.question }}</div>
- <div v-html="getDaemionData(item.daemion)"></div>
- <!-- 右上角按钮区域 -->
- <div class="chart-container__button-group">
- <el-button
- @click="downloadData"
- class="chart-container__button--download"
- >下载数据明细</el-button
- >
- <el-button
- @click="showSqlDialog(index)"
- class="chart-container__button--sql"
- >SQL</el-button
- >
- <svg-icon
- icon-class="maximize"
- class-name="chart-container__button--maximize"
- @click="showMaximize(index)"
- />
- </div>
- <!-- 渲染 HTML 内容 -->
- <!-- 动态创建报表容器,添加唯一类名 -->
- <div class="chart-container__content">
- <div
- :class="[
- 'chart-container__report-wrapper',
- `chart-container__report-wrapper--${index}`,
- ]"
- >
- <div :id="item.id + '-report-container-' + index"></div>
- </div>
- </div>
- </div>
-
- <!-- SQL 弹窗 -->
- <el-dialog
- :visible.sync="isSqlDialogVisible"
- class="chart-container__sql-dialog"
- >
- <div slot="title" class="chart-container__sql-dialog-title">
- <span> 查看SQL代码 </span>
- <el-link type="primary" @click="handleCopySql(currentSql)"
- >复制</el-link
- >
- </div>
- <pre><code>
- {{currentSql}}
- </code></pre>
- </el-dialog>
-
- <el-dialog
- v-if="isMaximizeDialogVisible"
- :visible.sync="isMaximizeDialogVisible"
- title="最大化折线图"
- width="90%"
- class="chart-container__maximize-dialog"
- >
- <MaximizeChart
- :chat-data="chatData"
- :currentIndex="currentMaximizeIndex"
- />
- </el-dialog>
- </div>
-</template>
-
-<script>
-// 尝试使用默认导入
-import { data } from "./const";
-import { extractHtmlParts } from "./utils";
-import MarkdownIt from "markdown-it";
-import markdownItFootnote from "markdown-it-footnote";
-import markdownItTaskLists from "markdown-it-task-lists";
-import markdownItAbbr from "markdown-it-abbr";
-import markdownItContainer from "markdown-it-container";
-import hljs from "highlight.js";
-import markdownItHighlightjs from "markdown-it-highlightjs";
-import { handleCopy } from "@/utils/copy";
-import MaximizeChart from "./MaximizeChart.vue"; // 引入组件
-
-export default {
- components: {
- MaximizeChart,
- },
- data() {
- return {
- // 移除 showSqlDialog 定义
- showMaximizeDialog: false,
- md: new MarkdownIt({
- html: true, // 启用HTML标签解析
- })
- .use(markdownItFootnote)
- .use(markdownItTaskLists, { enabled: true })
- .use(markdownItAbbr)
- .use(markdownItContainer, "warning")
- .use(markdownItHighlightjs, { hljs }), // 添加 markdown-it-highlightjs 插件
- normalChartInstances: [], // 存储普通图表实例
- isSqlDialogVisible: false,
- currentSql: "",
- isMaximizeDialogVisible: false,
- currentMaximizeIndex: -1,
- };
- },
- props: {
- chatData: {
- type: Array,
- default: () => [],
- },
- },
- watch: {
- chatData: {
- handler(newVal) {
- if (!newVal.length) {
- return;
- }
- newVal.forEach((item) => {
- if (item.html) {
- this.renderHtml(item.html, item.id);
- }
- });
- },
- deep: true,
- immediate: true,
- },
- },
- methods: {
- renderHtml(reportItems, id) {
- const resData = extractHtmlParts(reportItems, id);
- console.log("html拆分后的结构:", resData);
-
- // 检查 resData 是否有 scriptJsSrc
- // 这里不检查了,统一加载本地的脚本文件
- this.$nextTick(() => {
- // 没有 scriptJsSrc 时直接调用渲染方法
- this.renderReports([resData]);
- });
- },
- getDaemionData(data = "") {
- let dataStr = data.replace(/\n/g, "<br>");
- return this.md.render(dataStr);
- },
- getHtmlData(data = "") {
- return [extractHtmlParts(data)];
- },
- // 提取渲染报表的逻辑到单独方法
- renderReports(reportItems) {
- const allScripts = [];
- const styleElements = [];
-
- reportItems.forEach((item, index) => {
- // 销毁旧的图表实例
- const oldChartInstance = this.normalChartInstances[index];
- if (oldChartInstance && oldChartInstance.destroy) {
- oldChartInstance.destroy();
- this.normalChartInstances[index] = null;
- }
-
- // 动态创建容器
- const reportContainer = document.getElementById(
- `${item.id}-report-container-${index}`
- );
- // 插入 HTML 内容,检查 item.dom 是否存在
- if (item.dom) {
- reportContainer.innerHTML = item.dom;
- }
-
- // 插入 CSS 样式,添加唯一类名前缀,模拟scoped效果,防止style全局污染
- if (item.style) {
- const uniqueClass = `${item.id}-report-container-${index}`;
- const scopedStyle = item.style.replace(
- /([^\r\n,{}]+)(,|\s*{)/g,
- `#${uniqueClass} $1$2`
- );
- const styleElement = document.createElement("style");
- styleElement.textContent = scopedStyle;
- document.head.appendChild(styleElement);
- styleElements.push(styleElement);
- }
-
- // 收集 item.js 代码
- if (item.js) {
- const scriptElement = document.createElement("script");
- scriptElement.textContent = item.js;
- scriptElement.type = "module"; // 设置为模块类型,隔离作用域
- document.body.appendChild(scriptElement);
- }
- });
- },
- downloadData() {
- this.$message.info("敬请期待!");
- },
- showSqlDialog(index) {
- this.currentSql = this.chatData[index].sql;
- this.isSqlDialogVisible = true;
- },
- showMaximize(index) {
- this.currentMaximizeIndex = index;
- this.isMaximizeDialogVisible = true;
- },
- handleCopySql(sql) {
- handleCopy(
- sql,
- () => {
- this.$message.success("内容已复制");
- },
- () => {
- this.$message.success("复制失败,请手动复制");
- }
- );
- },
- },
-};
-</script>
-
-<style lang="scss" scoped>
-.chart-container {
- position: relative;
- padding-bottom: 20px;
- line-height: 1.5;
-
- &__button-group {
- top: 10px;
- right: 10px;
- z-index: 1;
- display: flex;
- align-items: center;
- justify-content: flex-end;
- margin-top: 20px;
- }
-
- &__content {
- padding-top: 40px;
- }
-
- &__button {
- &--maximize {
- font-size: 26px;
- cursor: pointer;
- }
-
- &--maximize:hover {
- color: #1890ff;
- }
-
- &--sql {
- margin: 0 4px;
- }
- }
-
- &__sql-dialog {
- word-wrap: break-word !important;
- word-break: break-all !important;
- line-height: 1.5;
-
- ::v-deep .el-dialog__body {
- height: 600px;
- max-height: 600px;
- overflow-x: hidden;
- overflow-y: auto;
- }
-
- // 保留代码部分的制表符、空格、空行,并且自动换行
- pre,
- code {
- white-space: pre-wrap;
- }
-
- &-title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-right: 40px;
- }
- }
-
- &__maximize-dialog {
- ::v-deep .el-dialog__body {
- height: 800px;
- max-height: 800px;
- overflow-x: hidden;
- overflow-y: auto;
- }
- }
-}
-</style>
// util.js
// 提取 HTML 中的不同部分
-export const extractHtmlParts = (html, id) => {
- const domRegex = /<body>([\s\S]*?)<\/body>/;
- const styleRegex = /<style>([\s\S]*?)<\/style>/;
- const jsRegex = /<script>([\s\S]*?)<\/script>/g;
- const scriptSrcRegex = /<script\s+src="([^"]+)"\s*><\/script>/g;
-
- let dom = '';
- let style = '';
- let js = '';
- let scriptJsSrc = [];
-
- // 提取 DOM 部分
- const domMatch = domRegex.exec(html);
- if (domMatch) {
- dom = domMatch[1].trim();
- // 移除 style 标签
- dom = dom.replace(/<style>([\s\S]*?)<\/style>/gi, '');
- // 移除 script 标签
- dom = dom.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
- // 移除外部 script 标签
- dom = dom.replace(/<script\s+src="([^"]+)"\s*><\/script>/gi, '');
- }
-
- // 提取样式部分
- const styleMatch = styleRegex.exec(html);
- if (styleMatch) {
- style = styleMatch[1].trim();
- }
-
- // 提取 JS 代码部分
- let jsMatch;
- while ((jsMatch = jsRegex.exec(html)) !== null) {
- js += jsMatch[1].trim() + '\n';
- }
-
- // 提取外部脚本链接
- let scriptSrcMatch;
- while ((scriptSrcMatch = scriptSrcRegex.exec(html)) !== null) {
- scriptJsSrc.push(scriptSrcMatch[1]);
+/**
+ * 替换所有外部脚本引用为本地Chart.js路径
+ * @param {string} html - 原始HTML字符串
+ * @returns {string} 处理后的HTML字符串
+ */
+export const replaceAllExternalScripts = (html = "") => {
+ try {
+ if (typeof html !== 'string' || !html) {
+ return html;
}
- return { dom, style, js, scriptJsSrc, id };
-}
\ No newline at end of file
+ // 去除代码块标记,防止大模型不遵从规则仍然给出代码块
+ html = html.replace(/```html/g, '').replace(/```/g, '');
+
+ // 更精确的正则匹配,确保只匹配有效的script标签,替换成本地Chart.js路径
+ return html.replace(
+ /<script\b(?:\s+[^>]*)?\bsrc\s*=\s*(["'])(?:(?!\1).)*\1(?:\s+[^>]*)?\s*><\/script>/gi,
+ '<script src="/chart.umd.min.js"></script>'
+ );
+ } catch (error) {
+ console.error('替换脚本出错:', error);
+ return html; // 出错时返回原内容
+ }
+};
场景方案二:n8n根据数据库的数据生成图表html字符串给前端,前端去把html字符串放到iframe进行渲染
<!-- <iframe
:srcdoc="getChatHtml(message.questionAnswer)"
frameborder="0"
style="width: 90%"
:ref="'iframeRef_' + index"
@load="handleIframeLoad(index)"
></iframe> -->
// 处理批量iframe加载完成事件
handleIframeLoad(index) {
this.$nextTick(() => {
const iframe = this.$refs[`iframeRef_${index}`][0];
if (iframe) {
try {
const iframeDoc =
iframe.contentDocument || iframe.contentWindow.document;
const height = iframeDoc.body.scrollHeight + 40;
iframe.style.height = `${height}px`;
// 设置iframe内部body的margin和padding为0
iframeDoc.body.style.margin = "0";
iframeDoc.body.style.padding = "0";
iframeDoc.body.style.overflow = "hidden";
} catch (e) {
console.error("获取iframe高度失败:", e);
}
}
});
},
场景方案三:n8n根据数据库的数据生成图表的option配置给前端,前端去初始化echats