Commit e1ea9e38 authored by 水玉婷's avatar 水玉婷
Browse files

feat:添加折线图

parent 85a6b6c0
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
<template v-for="(item, i) in msg.contentBlocks" :key="i"> <template v-for="(item, i) in msg.contentBlocks" :key="i">
<!-- 图表内容块 --> <!-- 图表内容块 -->
<div v-if="item.chartData" class="chart-block"> <div v-if="item.chartData" class="chart-block">
<ChartComponent :chart-data="item.chartData" :chart-type="item.chartType || 3" <ChartComponent :chart-data="item.chartData" :chart-type="item.chartType || 'column'"
:title="item.chartData.title || '图表数据'" /> :title="item.chartData.title || '图表数据'" />
</div> </div>
<!-- 音频内容块 --> <!-- 音频内容块 -->
...@@ -332,6 +332,10 @@ const sseService = createSSEService({ ...@@ -332,6 +332,10 @@ const sseService = createSSEService({
onReconnect: (newDialogSessionId) => { onReconnect: (newDialogSessionId) => {
console.log('🔄 SSE重连成功,新的dialogSessionId:', newDialogSessionId); console.log('🔄 SSE重连成功,新的dialogSessionId:', newDialogSessionId);
dialogSessionId.value = newDialogSessionId; dialogSessionId.value = newDialogSessionId;
// 标记重连成功,避免心跳检测重复触发重连
sseService.markReconnectSuccess();
// 优化:只在有错误消息的情况下显示重连成功消息 // 优化:只在有错误消息的情况下显示重连成功消息
if (lastNetworkStatusMessage.value?.type === 'error') { if (lastNetworkStatusMessage.value?.type === 'error') {
addNetworkStatusMessage('success', '服务重连成功,对话已恢复!'); addNetworkStatusMessage('success', '服务重连成功,对话已恢复!');
...@@ -405,6 +409,19 @@ const validateMessageParams = (type: MessageType, params: MessageParams): boolea ...@@ -405,6 +409,19 @@ const validateMessageParams = (type: MessageType, params: MessageParams): boolea
// 统一发送消息函数 // 统一发送消息函数
const sendMessage = async (type: MessageType = 'text', params: MessageParams = {}) => { const sendMessage = async (type: MessageType = 'text', params: MessageParams = {}) => {
// 如果消息文本为空且是文本类型,则延迟1秒后模拟折线图消息进行测试
if (type === 'text' && !messageText.value.trim() && !params.message) {
loading.value = true;
console.log('📊 检测到空消息,1秒后发送折线图测试...');
setTimeout(() => {
simulateLineChartMessage();
loading.value = false;
}, 1000);
return;
}
loading.value = true; loading.value = true;
const { message, audioUrl, durationTime } = params; const { message, audioUrl, durationTime } = params;
...@@ -650,6 +667,50 @@ onMounted(() => { ...@@ -650,6 +667,50 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
closeSSE(); closeSSE();
}); });
// 模拟折线图消息
const simulateLineChartMessage = () => {
console.log('模拟折线图消息');
// 使用真实医院数据创建折线图数据
const lineChartData = {
title:"2023年第三季度武汉各医院每月住院患者总数及费用汇总",
dimFields: ['月份', '地区'],
indexFields: ['用户总数', '费用汇总'],
rows: [
{ '月份': '1月', '地区': '北京', '用户总数': 100, '费用汇总': 5000 },
{ '月份': '2月', '地区': '北京', '用户总数': 150, '费用汇总': 6000 },
{ '月份': '3月', '地区': '北京', '用户总数': 120, '费用汇总': 5500 },
{ '月份': '4月', '地区': '北京', '用户总数': 180, '费用汇总': 8000 },
{ '月份': '5月', '地区': '北京', '用户总数': 200, '费用汇总': 9000 },
{ '月份': '6月', '地区': '北京', '用户总数': 250, '费用汇总': 11000 },
{ '月份': '1月', '地区': '上海', '用户总数': 80, '费用汇总': 4000 },
{ '月份': '2月', '地区': '上海', '用户总数': 120, '费用汇总': 5000 },
{ '月份': '3月', '地区': '上海', '用户总数': 100, '费用汇总': 4500 },
{ '月份': '4月', '地区': '上海', '用户总数': 150, '费用汇总': 7000 },
{ '月份': '5月', '地区': '上海', '用户总数': 180, '费用汇总': 8000 },
{ '月份': '6月', '地区': '上海', '用户总数': 220, '费用汇总': 10000 },
{ '月份': '1月', '地区': '广州', '用户总数': 60, '费用汇总': 3000 },
{ '月份': '2月', '地区': '广州', '用户总数': 90, '费用汇总': 4000 },
{ '月份': '3月', '地区': '广州', '用户总数': 80, '费用汇总': 3500 },
{ '月份': '4月', '地区': '广州', '用户总数': 120, '费用汇总': 6000 },
{ '月份': '5月', '地区': '广州', '用户总数': 150, '费用汇总': 7000 },
{ '月份': '6月', '地区': '广州', '用户总数': 180, '费用汇总': 9000 }
],
chartType: 'line' // 将chartType放在message对象内部
};
// 正确的SSE消息格式:status 3(图表数据),type 2(表格数据)
const simulatedMessage = {
message: lineChartData,
status: 3, // 图表数据
type: 2 // 表格数据
};
// 调用handleSSEMessage处理模拟消息
handleSSEMessage(simulatedMessage);
console.log('折线图消息已发送,使用正确的SSE格式:status=3, type=2');
};
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@import './style.less'; @import './style.less';
......
<template> <template>
<div class="message-chart"> <div class="chart-component">
<div class="chart-title">{{ title }}</div> <ColumnChart
<div v-if="isEmpty" class="chart-empty"> v-if="chartType === CHART_TYPES.COLUMN"
<div class="empty-icon">📊</div> :chart-data="chartData"
<div class="empty-text">暂无数据</div> :title="title"
<div class="empty-desc">当前查询条件下没有找到相关数据</div> :width="width"
:height="height"
/>
<LineChart
v-else-if="chartType === CHART_TYPES.LINE"
:chart-data="chartData"
:title="title"
:width="width"
:height="height"
/>
<div v-else class="chart-error">
不支持的图表类型: {{ chartType }}
</div> </div>
<div v-else ref="chartContainer" class="chart-container"></div>
<div v-if="error" class="chart-error">{{ error }}</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'; import ColumnChart from './ColumnChart.vue';
import * as echarts from 'echarts'; import LineChart from './LineChart.vue';
// 在模块作用域中定义图表类型常量
const CHART_TYPES = {
COLUMN: 'column',
LINE: 'line'
};
// 定义组件属性 // 定义组件属性
interface Props { interface Props {
chartData: any; chartData: any;
chartType?: any; chartType?: number | string;
title?: string; title?: string;
width?: number | string; width?: number | string;
height?: number | string; height?: number | string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
chartType: { type: 'column' }, chartType: 'column',
title: '数据图表', title: '数据图表',
width: '100%', width: '100%',
height: 'auto' // 设置为auto,完全自适应 height: 'auto'
});
const chartContainer = ref<HTMLElement>();
const chartInstance = ref<echarts.ECharts | null>(null);
const error = ref<string>('');
const isEmpty = ref<boolean>(false);
// ========== 工具函数抽离 ==========
/**
* 检查数据是否为空
*/
const checkDataEmpty = (data: any): boolean => {
if (!data) return true;
if (!data.rows || !Array.isArray(data.rows)) return true;
if (data.rows.length === 0) return true;
// 检查所有行是否都是空数据
const hasValidData = data.rows.some((row: any) => {
if (data.indexFields && Array.isArray(data.indexFields)) {
return data.indexFields.some((field: string) => {
const value = row[field];
return value !== null && value !== undefined && value !== '' && !isNaN(Number(value));
});
}
return false;
});
return !hasValidData;
};
/**
* 数字格式化工具
*/
const formatNumber = (value: any): string => {
if (value === null || value === undefined || isNaN(value) || value === '') {
return '0';
}
const numValue = Number(value);
if (isNaN(numValue)) return '0';
if (numValue === 0) return '0';
const roundedValue = Math.ceil(numValue * 100) / 100;
if (Math.abs(roundedValue) >= 100000000) {
return `${(roundedValue / 100000000).toFixed(2)}亿`;
} else if (Math.abs(roundedValue) >= 10000) {
return `${(roundedValue / 10000).toFixed(2)}万`;
} else {
return roundedValue.toFixed(2);
}
};
/**
* 获取颜色方案
*/
const getColors = () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#fa8c16'];
/**
* 获取tooltip数据值
*/
const getTooltipValue = (param: any): any => {
if (param.data !== undefined && param.data !== null) return param.data;
if (param.value !== undefined && param.value !== null) return param.value;
return null;
};
/**
* 数据格式化工具
*/
const formatData = (data: any) => {
if (data && data.rows && Array.isArray(data.rows) && data.dimFields && data.indexFields) {
const { rows, dimFields, indexFields } = data;
if (rows.length === 0) {
return { isEmpty: true, data: [], chartType: 3 };
}
const chartConfig: any = { data: rows, chartType: 3 };
// 维度字段处理
if (dimFields.length === 1) {
chartConfig.xField = dimFields[0];
chartConfig.isGroup = false;
} else if (dimFields.length >= 2) {
chartConfig.xField = dimFields[0];
chartConfig.groupField = dimFields[1];
chartConfig.isGroup = true;
}
// 指标字段处理
if (indexFields.length === 1) {
chartConfig.yField = indexFields[0];
chartConfig.isMultiY = false;
} else if (indexFields.length >= 2) {
chartConfig.isMultiY = true;
chartConfig.yFields = indexFields;
chartConfig.xField = dimFields[0];
chartConfig.isGroup = dimFields.length >= 2;
}
return chartConfig;
} else {
throw new Error('不支持的数据格式,请使用新的结构化数据格式');
}
};
// ========== 图表配置生成器 ==========
/**
* 基础图表配置
*/
const getBaseChartConfig = () => ({
responsive: true,
animation: true,
animationDuration: 500,
animationEasing: 'cubicOut' as const,
grid: {
left: '3%',
right: '3%',
bottom: '3%',
top: '70px', // 改为固定值,为legend留出空间
containLabel: true
}
});
/**
* 基础tooltip配置
*/
const getBaseTooltipConfig = () => ({
trigger: 'item' as const,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
fontSize: 12
}
});
/**
* 基础x轴配置
*/
const getXAxisConfig = (xAxisData: string[]) => ({
type: 'category' as const,
data: xAxisData,
axisLabel: {
interval: 0,
rotate: xAxisData.length > 6 ? 45 : 0,
margin: 8,
fontSize: 12
}
});
/**
* 基础y轴配置
*/
const getYAxisConfig = (name?: string) => ({
type: 'value' as const,
...(name && { name }),
axisLabel: {
formatter: (value: number) => formatNumber(value)
},
splitLine: {
lineStyle: {
type: 'dashed' as const,
color: '#ccc',
width: 1
}
}
});
/**
* 双y轴配置
*/
const getDualYAxisConfig = (yFields: string[]) => [
{
...getYAxisConfig(yFields[0]),
position: 'left' as const,
splitLine: { lineStyle: { type: 'dashed' as const, color: '#ccc', width: 1 } }
},
{
...getYAxisConfig(yFields[1]),
position: 'right' as const,
splitLine: { lineStyle: { type: 'solid' as const, color: '#e8e8e8', width: 1 } }
}
];
/**
* 基础系列配置
*/
const getBaseSeriesConfig = (name: string, data: any[], color: string, yAxisIndex = 0) => ({
name,
type: 'bar' as const,
data,
yAxisIndex,
itemStyle: { color },
barWidth: 'auto' as const,
barGap: '30%',
barCategoryGap: '40%'
});
// ========== 图表类型配置生成器 ==========
/**
* 单指标柱状图配置生成器
*/
const createSingleColumnOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yField]);
return getBaseSeriesConfig(group, groupData, colors[index % colors.length]);
});
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig(),
formatter: createTooltipFormatter()
},
legend: {
data: groups,
orient: 'horizontal',
left: 'center',
top: '0',
padding: [15, 20, 20, 25], // 增加四周的padding
itemGap: 15,
type: 'scroll',
textStyle: {
fontSize: 12
},
pageTextStyle: {
fontSize: 10
},
pageIconSize: 12,
pageButtonItemGap: 5
},
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series
};
} else {
const seriesData = chartConfig.data.map((item: any) => item[chartConfig.yField]);
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig(),
formatter: createTooltipFormatter()
},
legend: { show: false },
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series: [getBaseSeriesConfig(chartConfig.yField, seriesData, colors[0])]
};
}
};
/**
* 双轴柱状图配置生成器
*/
const createDualColumnOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series1 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[0]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[0]}`, groupData, colors[index % colors.length], 0);
});
const series2 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[1]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[1]}`, groupData, colors[(index + 3) % colors.length], 1);
});
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig(),
formatter: createTooltipFormatter()
},
legend: {
data: [...series1, ...series2].map(s => s.name),
orient: 'horizontal',
left: 'center',
top: '0',
padding: [15, 20, 20, 25], // 增加四周的padding
itemGap: 15,
type: 'scroll',
textStyle: {
fontSize: 12
},
pageTextStyle: {
fontSize: 10
},
pageIconSize: 12,
pageButtonItemGap: 5
},
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [...series1, ...series2]
};
} else {
const series1Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[0]]);
const series2Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[1]]);
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig(),
formatter: createTooltipFormatter()
},
legend: { data: [chartConfig.yFields[0], chartConfig.yFields[1]] },
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [
getBaseSeriesConfig(chartConfig.yFields[0], series1Data, colors[0], 0),
getBaseSeriesConfig(chartConfig.yFields[1], series2Data, colors[1], 1)
]
};
}
};
/**
* 创建tooltip格式化器
*/
const createTooltipFormatter = () => {
return (params: any) => {
// 在item模式下,params是单个对象
const dataValue = getTooltipValue(params);
const seriesName = params.seriesName;
const xAxisValue = params.name || params.axisValue;
if (dataValue === null || dataValue === undefined) return '';
const formattedValue = formatNumber(dataValue);
return `${xAxisValue}<br/>${seriesName}: ${formattedValue}`;
};
};
// ========== 响应式处理逻辑 ==========
/**
* 创建响应式处理函数
*/
const createResizeHandler = (chartConfig: any, chartTypeForLogic: string) => {
let resizeTimer: NodeJS.Timeout;
return () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (chartInstance.value && chartContainer.value) {
const newOption = chartTypeForLogic === 'column'
? createSingleColumnOption(chartConfig)
: createDualColumnOption(chartConfig);
// 完全自适应配置
chartInstance.value.setOption({
...newOption,
animation: false
});
chartInstance.value.resize(); // ECharts会自动适应容器大小
}
}, 100);
};
};
/**
* 初始化图表大小监听
*/
const initChartResizeListener = (chartConfig: any, chartTypeForLogic: string) => {
const handleResize = createResizeHandler(chartConfig, chartTypeForLogic);
// 窗口大小变化监听
window.addEventListener('resize', handleResize);
// ResizeObserver监听
if (typeof ResizeObserver !== 'undefined' && chartContainer.value) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartContainer.value);
onUnmounted(() => {
resizeObserver.disconnect();
});
}
// 清理函数
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
};
// ========== 主图表创建函数 ==========
/**
* 创建ECharts图表
*/
const createChart = () => {
try {
if (!chartContainer.value) return;
// 检查数据是否为空
isEmpty.value = checkDataEmpty(props.chartData);
if (isEmpty.value) {
// 清理之前的图表实例
if (chartInstance.value) {
chartInstance.value.dispose();
chartInstance.value = null;
}
error.value = '';
return;
}
const chartConfig = formatData(props.chartData);
// 根据数据特征自动选择图表类型
const chartTypeForLogic = chartConfig.isMultiY && chartConfig.yFields && chartConfig.yFields.length > 1
? 'dualColumn'
: 'column';
// 创建ECharts实例
chartInstance.value = echarts.init(chartContainer.value);
// 生成图表配置
const option = chartTypeForLogic === 'column'
? createSingleColumnOption(chartConfig)
: createDualColumnOption(chartConfig);
// 设置图表选项
chartInstance.value.setOption(option);
error.value = '';
// 初始化响应式监听
initChartResizeListener(chartConfig, chartTypeForLogic);
} catch (err: any) {
error.value = `图表渲染失败: ${err.message}`;
console.error('图表渲染失败:', err);
}
};
// ========== 生命周期和响应式 ==========
// 监听数据变化
watch(() => props.chartData, createChart);
onMounted(() => {
nextTick(createChart);
});
// 组件卸载时清理
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.dispose();
}
}); });
</script> </script>
<style scoped> <style scoped>
.message-chart { .chart-component {
margin: 16px 0; margin: 16px 0;
/* 完全自适应,不设置固定高度 */
/* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框和内边距在宽度计算中 */
} }
.chart-title { .chart-error {
font-size: 16px; padding: 20px;
font-weight: bold; text-align: center;
margin-bottom: 8px; color: #f5222d;
color: #333; background-color: #fff2f0;
width: 100%; /* 标题宽度限制 */ border: 1px solid #ffccc7;
box-sizing: border-box;
}
.chart-container {
border: 1px solid #e8e8e8;
border-radius: 4px;
/* 正常图表保持宽高比自适应 */
aspect-ratio: 3 / 2; /* 宽高比 3:2 */
min-height: 200px; /* 最小高度确保显示效果 */
width: 100%; /* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框在宽度计算中 */
overflow: hidden; /* 防止内容溢出导致滚动条 */
}
/* 空状态样式 */
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 350px;
border: 1px solid #e8e8e8;
border-radius: 4px; border-radius: 4px;
background-color: #fafafa;
color: #999;
width: 100%; /* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框在宽度计算中 */
overflow: hidden; /* 防止内容溢出 */
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e8e8e8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-text {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
color: #666;
text-align: center; /* 文字居中,避免溢出 */
max-width: 100%; /* 文字宽度限制 */
}
.empty-desc {
font-size: 14px;
color: #999;
text-align: center; /* 文字居中,避免溢出 */
max-width: 100%; /* 文字宽度限制 */
} }
</style> </style>
\ No newline at end of file
<template>
<div class="message-chart">
<div class="chart-title">{{ title }}</div>
<div v-if="isEmpty" class="chart-empty">
<div class="empty-icon">📊</div>
<div class="empty-text">暂无数据</div>
<div class="empty-desc">当前查询条件下没有找到相关数据</div>
</div>
<div v-else ref="chartContainer" class="chart-container"></div>
<div v-if="error" class="chart-error">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, onMounted, onUnmounted, watch, nextTick } from 'vue';
import * as echarts from 'echarts';
// 定义组件属性
interface Props {
chartData: any;
title?: string;
width?: number | string;
height?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
title: '柱状图',
width: '100%',
height: 'auto' // 设置为auto,完全自适应
});
const chartContainer = ref<HTMLElement>();
// 使用shallowRef避免Vue 3 Proxy代理与ECharts的兼容性问题
const chartInstance = shallowRef<echarts.ECharts | null>(null);
const error = ref<string>('');
const isEmpty = ref<boolean>(false);
// ========== 工具函数抽离 ==========
/**
* 检查数据是否为空
*/
const checkDataEmpty = (data: any): boolean => {
if (!data) return true;
if (!data.rows || !Array.isArray(data.rows)) return true;
if (data.rows.length === 0) return true;
// 检查所有行是否都是空数据
const hasValidData = data.rows.some((row: any) => {
if (data.indexFields && Array.isArray(data.indexFields)) {
return data.indexFields.some((field: string) => {
const value = row[field];
return value !== null && value !== undefined && value !== '' && !isNaN(Number(value));
});
}
return false;
});
return !hasValidData;
};
/**
* 数字格式化工具
*/
const formatNumber = (value: any): string => {
if (value === null || value === undefined || isNaN(value) || value === '') {
return '0';
}
const numValue = Number(value);
if (isNaN(numValue)) return '0';
if (numValue === 0) return '0';
const roundedValue = Math.ceil(numValue * 100) / 100;
if (Math.abs(roundedValue) >= 100000000) {
return `${(roundedValue / 100000000).toFixed(2)}亿`;
} else if (Math.abs(roundedValue) >= 10000) {
return `${(roundedValue / 10000).toFixed(2)}万`;
} else {
return roundedValue.toFixed(2);
}
};
/**
* 获取颜色方案 - 使用ECharts内置颜色方案
*/
const getColors = () => {
return [
'#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2',
'#fa541c', '#eb2f96', '#a0d911', '#2f54eb', '#fa8c16', '#52c41a',
'#13c2c2', '#1890ff', '#722ed1', '#faad14', '#f5222d', '#eb2f96',
'#a0d911', '#2f54eb', '#fa8c16', '#52c41a', '#13c2c2', '#1890ff'
];
};
/**
* 数据格式化工具
*/
const formatData = (data: any) => {
if (data && data.rows && Array.isArray(data.rows) && data.dimFields && data.indexFields) {
const { rows, dimFields, indexFields } = data;
if (rows.length === 0) {
return { isEmpty: true, data: [], chartType: 'column' };
}
const chartConfig: any = { data: rows, chartType: 'column' };
// 维度字段处理
if (dimFields.length === 1) {
chartConfig.xField = dimFields[0];
chartConfig.isGroup = false;
} else if (dimFields.length >= 2) {
chartConfig.xField = dimFields[0];
chartConfig.groupField = dimFields[1];
chartConfig.isGroup = true;
}
// 指标字段处理
if (indexFields.length === 1) {
chartConfig.yField = indexFields[0];
chartConfig.isMultiY = false;
} else if (indexFields.length >= 2) {
chartConfig.isMultiY = true;
chartConfig.yFields = indexFields;
chartConfig.xField = dimFields[0];
chartConfig.isGroup = dimFields.length >= 2;
}
return chartConfig;
} else {
throw new Error('不支持的数据格式,请使用新的结构化数据格式');
}
};
// ========== 图表配置生成器 ==========
/**
* 基础图表配置
*/
const getBaseChartConfig = () => ({
responsive: true,
animation: true,
animationDuration: 500,
animationEasing: 'cubicOut' as const,
grid: {
left: '3%',
right: '3%',
bottom: '3%',
top: '70px', // 改为固定值,为legend留出空间
containLabel: true
}
});
/**
* 基础tooltip配置
*/
const getBaseTooltipConfig = () => ({
trigger: 'axis' as const,
axisPointer: {
type: 'line',
lineStyle: {
color: '#1890ff',
width: 2
},
label: {
show: true,
backgroundColor: '#1890ff',
color: '#fff',
fontSize: 12,
padding: [4, 6],
borderRadius: 2
}
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
fontSize: 12
},
confine: false,
triggerOn: 'mousemove',
show: true,
alwaysShowContent: false,
position: 'top',
padding: 8,
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 9999 !important;',
formatter: function(params: any) {
if (!params || params.length === 0) {
return '';
}
const firstParam = Array.isArray(params) ? params[0] : params;
const axisValue = firstParam.axisValue || firstParam.name || '';
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${axisValue}</div>`;
const paramsArray = Array.isArray(params) ? params : [params];
paramsArray.forEach((param: any) => {
const value = param.value !== undefined && param.value !== null
? (Array.isArray(param.value) ? formatNumber(param.value[param.componentIndex || 0]) : formatNumber(param.value))
: '0';
const color = param.color || '#5470c6';
const seriesName = param.seriesName || '数据';
result += `
<div style="display: flex; align-items: center; margin: 4px 0;">
<span style="display: inline-block; width: 12px; height: 12px; background-color: ${color}; border-radius: 6px; margin-right: 8px;"></span>
<span style="flex: 1;">${seriesName}:</span>
<span style="font-weight: bold; margin-left: 8px;">${value}</span>
</div>
`;
});
return result;
}
});
/**
* 基础x轴配置
*/
const getXAxisConfig = (xAxisData: string[]) => ({
type: 'category' as const,
data: xAxisData,
axisLabel: {
interval: 0,
rotate: xAxisData.length > 6 ? 45 : 0,
margin: 8,
fontSize: 12
}
});
/**
* 基础y轴配置
*/
const getYAxisConfig = (name?: string) => ({
type: 'value' as const,
...(name && { name }),
axisLabel: {
formatter: (value: number) => formatNumber(value)
},
axisTick: {
show: true,
alignWithLabel: true,
lineStyle: {
width: 1
}
},
axisLine: {
show: true,
lineStyle: {
width: 1
}
},
splitLine: {
lineStyle: {
type: 'dashed' as const,
color: '#ccc',
width: 1
}
}
});
/**
* 双y轴配置
*/
const getDualYAxisConfig = (yFields: string[]) => [
{
...getYAxisConfig(yFields[0]),
position: 'left' as const,
nameTextStyle: {
align: 'right'
},
splitLine: { lineStyle: { type: 'dashed' as const, color: '#ccc', width: 1 } }
},
{
...getYAxisConfig(yFields[1]),
position: 'right' as const,
nameTextStyle: {
align: 'left'
},
splitLine: { lineStyle: { type: 'solid' as const, color: '#e8e8e8', width: 1 } }
}
];
/**
* 基础系列配置
*/
const getBaseSeriesConfig = (name: string, data: any[], color: string, yAxisIndex = 0) => ({
name,
type: 'bar' as const,
data,
yAxisIndex,
itemStyle: { color },
barWidth: 'auto' as const,
barGap: '30%',
barCategoryGap: '40%'
});
// ========== 图表类型配置生成器 ==========
/**
* 单指标柱状图配置生成器
*/
const createSingleColumnOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yField]);
return getBaseSeriesConfig(group, groupData, colors[index % colors.length]);
});
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig()
},
legend: {
data: groups,
orient: 'horizontal',
left: 'center',
top: '0',
padding: [15, 20, 20, 25], // 增加四周的padding
itemGap: 15,
type: 'scroll',
textStyle: {
fontSize: 12
},
pageTextStyle: {
fontSize: 10
},
pageIconSize: 12,
pageButtonItemGap: 5
},
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series
};
} else {
const seriesData = chartConfig.data.map((item: any) => item[chartConfig.yField]);
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig()
},
legend: { show: false },
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series: [getBaseSeriesConfig(chartConfig.yField, seriesData, colors[0])]
};
}
};
/**
* 双轴柱状图配置生成器
*/
const createDualColumnOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series1 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[0]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[0]}`, groupData, colors[index % colors.length], 0);
});
const series2 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[1]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[1]}`, groupData, colors[(index + 3) % colors.length], 1);
});
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig()
},
legend: {
data: [...series1, ...series2].map(s => s.name),
orient: 'horizontal',
left: 'center',
top: '0',
padding: [15, 20, 20, 25], // 增加四周的padding
itemGap: 15,
type: 'scroll',
textStyle: {
fontSize: 12
},
pageTextStyle: {
fontSize: 10
},
pageIconSize: 12,
pageButtonItemGap: 5
},
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [...series1, ...series2]
};
} else {
const series1Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[0]]);
const series2Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[1]]);
return {
...getBaseChartConfig(),
tooltip: {
...getBaseTooltipConfig()
},
legend: { data: [chartConfig.yFields[0], chartConfig.yFields[1]] },
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [
getBaseSeriesConfig(chartConfig.yFields[0], series1Data, colors[0], 0),
getBaseSeriesConfig(chartConfig.yFields[1], series2Data, colors[1], 1)
]
};
}
};
// ========== 响应式处理逻辑 ==========
/**
* 创建响应式处理函数
*/
const createResizeHandler = (chartConfig: any, chartTypeForLogic: string) => {
let resizeTimer: NodeJS.Timeout;
return () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (chartInstance.value && chartContainer.value) {
const newOption = chartTypeForLogic === 'column'
? createSingleColumnOption(chartConfig)
: createDualColumnOption(chartConfig);
// 完全自适应配置
chartInstance.value.setOption({
...newOption,
animation: false
});
chartInstance.value.resize(); // ECharts会自动适应容器大小
}
}, 100);
};
};
/**
* 初始化图表大小监听
*/
const initChartResizeListener = (chartConfig: any, chartTypeForLogic: string) => {
const handleResize = createResizeHandler(chartConfig, chartTypeForLogic);
// 窗口大小变化监听
window.addEventListener('resize', handleResize);
// ResizeObserver监听
if (typeof ResizeObserver !== 'undefined' && chartContainer.value) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartContainer.value);
onUnmounted(() => {
resizeObserver.disconnect();
});
}
// 清理函数
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
};
// ========== 主图表创建函数 ==========
/**
* 创建ECharts图表
*/
const createChart = () => {
if (!chartContainer.value) return;
// 检查数据是否为空
isEmpty.value = checkDataEmpty(props.chartData);
if (isEmpty.value) {
// 清理之前的图表实例
if (chartInstance.value) {
chartInstance.value.dispose();
chartInstance.value = null;
}
error.value = '';
return;
}
const chartConfig = formatData(props.chartData);
// 根据数据特征自动选择图表类型
const chartTypeForLogic = chartConfig.isMultiY && chartConfig.yFields && chartConfig.yFields.length > 1
? 'dualColumn'
: 'column';
// 创建ECharts实例
chartInstance.value = echarts.init(chartContainer.value);
// 生成图表配置
const option = chartTypeForLogic === 'column'
? createSingleColumnOption(chartConfig)
: createDualColumnOption(chartConfig);
// 设置图表选项
chartInstance.value.setOption(option);
error.value = '';
// 初始化响应式监听
initChartResizeListener(chartConfig, chartTypeForLogic);
};
// ========== 生命周期和响应式 ==========
// 监听数据变化
watch(() => props.chartData, createChart);
onMounted(() => {
nextTick(createChart);
});
// 组件卸载时清理
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.dispose();
}
});
</script>
<style scoped>
.message-chart {
margin: 16px 0;
/* 完全自适应,不设置固定高度 */
/* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框和内边距在宽度计算中 */
}
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
color: #333;
width: 100%; /* 标题宽度限制 */
box-sizing: border-box;
text-align: center; /* 标题居中 */
}
.chart-container {
border: 1px solid #e8e8e8;
border-radius: 4px;
/* 正常图表保持宽高比自适应 */
aspect-ratio: 3 / 2; /* 宽高比 3:2 */
min-height: 200px; /* 最小高度确保显示效果 */
width: 100%; /* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框在宽度计算中 */
overflow: hidden; /* 防止内容溢出导致滚动条 */
}
/* 空状态样式 */
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 350px;
border: 1px solid #e8e8e8;
border-radius: 4px;
background-color: #fafafa;
color: #999;
width: 100%; /* 确保宽度不超过父容器 */
box-sizing: border-box; /* 包含边框在宽度计算中 */
overflow: hidden; /* 防止内容溢出 */
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e8e8e8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-text {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
color: #666;
text-align: center; /* 文字居中,避免溢出 */
max-width: 100%; /* 文字宽度限制 */
}
.empty-desc {
font-size: 14px;
color: #999;
text-align: center; /* 文字居中,避免溢出 */
max-width: 100%; /* 文字宽度限制 */
}
</style>
\ No newline at end of file
<template>
<div class="message-chart">
<div class="chart-title">{{ title }}</div>
<div v-if="isEmpty" class="chart-empty">
<div class="empty-icon">📊</div>
<div class="empty-text">暂无数据</div>
<div class="empty-desc">当前查询条件下没有找到相关数据</div>
</div>
<div v-else ref="chartContainer" class="chart-container"></div>
<div v-if="error" class="chart-error">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, onMounted, onUnmounted, nextTick } from 'vue';
import * as echarts from 'echarts';
// 定义组件属性 - 与ChartComponent保持一致
interface Props {
chartData: any;
title?: string;
width?: number | string;
height?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
title: '折线图',
width: '100%',
height: 'auto'
});
const chartContainer = ref<HTMLElement>();
// 使用shallowRef避免Vue 3 Proxy代理与ECharts的兼容性问题
const chartInstance = shallowRef<echarts.ECharts | null>(null);
const error = ref<string>('');
const isEmpty = ref<boolean>(false);
// ========== 工具函数抽离 ==========
/**
* 检查数据是否为空
*/
const checkDataEmpty = (data: any): boolean => {
if (!data) return true;
if (!data.rows || !Array.isArray(data.rows)) return true;
if (data.rows.length === 0) return true;
// 检查所有行是否都是空数据
const hasValidData = data.rows.some((row: any) => {
if (data.indexFields && Array.isArray(data.indexFields)) {
return data.indexFields.some((field: string) => {
const value = row[field];
return value !== null && value !== undefined && value !== '' && !isNaN(Number(value));
});
}
return false;
});
return !hasValidData;
};
/**
* 数字格式化工具
*/
const formatNumber = (value: any): string => {
if (value === null || value === undefined || isNaN(value) || value === '') {
return '0';
}
const numValue = Number(value);
if (isNaN(numValue)) return '0';
if (numValue === 0) return '0';
const roundedValue = Math.ceil(numValue * 100) / 100;
if (Math.abs(roundedValue) >= 100000000) {
return `${(roundedValue / 100000000).toFixed(2)}亿`;
} else if (Math.abs(roundedValue) >= 10000) {
return `${(roundedValue / 10000).toFixed(2)}万`;
} else {
return roundedValue.toFixed(2);
}
};
/**
* 获取颜色方案 - 使用ECharts内置颜色方案
*/
const getColors = () => {
return [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272',
'#fc8452', '#9a60b4', '#ea7ccc', '#c23531', '#2f4554', '#61a0a8',
'#d48265', '#91c7ae', '#749f83', '#ca8622', '#bda29a', '#6e7074',
'#546570', '#c4ccd3', '#f4eccc', '#76bca0', '#e69d87', '#8dc1a9'
];
};
/**
* 数据格式化工具
*/
const formatData = (data: any) => {
if (data && data.rows && Array.isArray(data.rows) && data.dimFields && data.indexFields) {
const { rows, dimFields, indexFields } = data;
if (rows.length === 0) {
return { isEmpty: true, data: [], chartType: 'line' };
}
const chartConfig: any = { data: rows, chartType: 'line' };
// 维度字段处理
if (dimFields.length === 1) {
chartConfig.xField = dimFields[0];
chartConfig.isGroup = false;
} else if (dimFields.length >= 2) {
chartConfig.xField = dimFields[0];
chartConfig.groupField = dimFields[1];
chartConfig.isGroup = true;
}
// 指标字段处理
if (indexFields.length === 1) {
chartConfig.yField = indexFields[0];
chartConfig.isMultiY = false;
} else if (indexFields.length >= 2) {
chartConfig.isMultiY = true;
chartConfig.yFields = indexFields;
chartConfig.xField = dimFields[0];
chartConfig.isGroup = dimFields.length >= 2;
}
return chartConfig;
} else {
throw new Error('不支持的数据格式,请使用新的结构化数据格式');
}
};
// ========== 图表配置生成器 ==========
/**
* 基础图表配置
*/
const getBaseChartConfig = () => ({
responsive: true,
animation: true,
animationDuration: 500,
animationEasing: 'cubicOut' as const,
grid: {
left: '3%',
right: '3%',
bottom: '3%',
top: '80px', // 增加顶部间距,为legend留出更多空间
containLabel: true
}
});
/**
* 基础legend配置 - 始终放在顶部
*/
const getBaseLegendConfig = (data: string[]) => ({
data,
orient: 'horizontal' as const,
left: 'center' as const,
top: '0' as const,
padding: [10, 20, 0, 20], // 调整padding,确保顶部有足够空间
itemGap: 15,
type: 'scroll' as const,
textStyle: {
fontSize: 12
},
pageTextStyle: {
fontSize: 10
},
pageIconSize: 12,
pageButtonItemGap: 5
});
/**
* 基础tooltip配置 - 简化版本,确保兼容性
*/
const getBaseTooltipConfig = () => ({
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: {
color: '#1890ff',
width: 2
},
label: {
show: true,
backgroundColor: '#1890ff',
color: '#fff',
fontSize: 12,
padding: [4, 6],
borderRadius: 2
}
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
fontSize: 12
},
confine: false,
triggerOn: 'mousemove',
show: true,
alwaysShowContent: false,
position: 'top',
padding: 8,
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 9999 !important;',
formatter: function(params: any) {
if (!params || params.length === 0) {
return '';
}
const firstParam = Array.isArray(params) ? params[0] : params;
const axisValue = firstParam.axisValue || firstParam.name || '';
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${axisValue}</div>`;
const paramsArray = Array.isArray(params) ? params : [params];
paramsArray.forEach((param: any) => {
const value = param.value !== undefined && param.value !== null
? (Array.isArray(param.value) ? formatNumber(param.value[param.componentIndex || 0]) : formatNumber(param.value))
: '0';
const color = param.color || '#5470c6';
const seriesName = param.seriesName || '数据';
result += `
<div style="display: flex; align-items: center; margin: 4px 0;">
<span style="display: inline-block; width: 12px; height: 12px; background-color: ${color}; border-radius: 6px; margin-right: 8px;"></span>
<span style="flex: 1;">${seriesName}:</span>
<span style="font-weight: bold; margin-left: 8px;">${value}</span>
</div>
`;
});
return result;
}
});
/**
* 基础x轴配置
*/
const getXAxisConfig = (xAxisData: string[]) => ({
type: 'category' as const,
data: xAxisData,
axisLabel: {
interval: 0,
rotate: xAxisData.length > 6 ? 45 : 0,
margin: 8,
fontSize: 12
}
});
/**
* 基础y轴配置
*/
const getYAxisConfig = (name?: string) => ({
type: 'value' as const,
...(name && { name }),
axisLabel: {
formatter: (value: number) => formatNumber(value)
},
splitLine: {
lineStyle: {
type: 'dashed' as const,
color: '#ccc',
width: 1
}
},
axisTick: {
show: true,
alignWithLabel: true,
lineStyle: {
width: 1
}
},
axisLine: {
show: true,
lineStyle: {
width: 1
}
}
});
/**
* 双y轴配置
*/
const getDualYAxisConfig = (yFields: string[]) => [
{
...getYAxisConfig(yFields[0]),
position: 'left' as const,
nameTextStyle: {
align: 'right'
},
splitLine: { lineStyle: { type: 'dashed' as const, color: '#ccc', width: 1 } }
},
{
...getYAxisConfig(yFields[1]),
position: 'right' as const,
nameTextStyle: {
align: 'left'
},
splitLine: { lineStyle: { type: 'solid' as const, color: '#e8e8e8', width: 1 } }
}
];
/**
* 基础系列配置 - 折线图专用
*/
const getBaseSeriesConfig = (name: string, data: any[], color: string, yAxisIndex = 0) => ({
name,
type: 'line' as const,
data,
yAxisIndex,
smooth: true,
showSymbol: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
color,
width: 3
},
itemStyle: {
color,
borderColor: '#fff',
borderWidth: 2
}
});
// ========== 图表类型配置生成器 ==========
/**
* 单指标折线图配置生成器
*/
const createSingleLineOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yField]);
return getBaseSeriesConfig(group, groupData, colors[index % colors.length]);
});
return {
...getBaseChartConfig(),
tooltip: getBaseTooltipConfig(),
legend: getBaseLegendConfig(groups),
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series
};
} else {
const seriesData = chartConfig.data.map((item: any) => item[chartConfig.yField]);
return {
...getBaseChartConfig(),
tooltip: getBaseTooltipConfig(),
legend: { show: false },
xAxis: getXAxisConfig(xAxisData),
yAxis: getYAxisConfig(),
series: [getBaseSeriesConfig(chartConfig.yField, seriesData, colors[0])]
};
}
};
/**
* 双轴折线图配置生成器
*/
const createDualLineOption = (chartConfig: any): echarts.EChartsOption => {
const colors = getColors();
const xAxisData = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.xField]))];
if (chartConfig.isGroup && chartConfig.groupField) {
const groups = [...new Set(chartConfig.data.map((item: any) => item[chartConfig.groupField]))];
const series1 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[0]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[0]}`, groupData, colors[index % colors.length], 0);
});
const series2 = groups.map((group, index) => {
const groupData = chartConfig.data
.filter((item: any) => item[chartConfig.groupField] === group)
.map((item: any) => item[chartConfig.yFields[1]]);
return getBaseSeriesConfig(`${group} - ${chartConfig.yFields[1]}`, groupData, colors[(index + 3) % colors.length], 1);
});
const legendData = [...series1, ...series2].map(s => s.name);
return {
...getBaseChartConfig(),
tooltip: getBaseTooltipConfig(),
legend: getBaseLegendConfig(legendData),
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [...series1, ...series2]
};
} else {
const series1Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[0]]);
const series2Data = chartConfig.data.map((item: any) => item[chartConfig.yFields[1]]);
return {
...getBaseChartConfig(),
tooltip: getBaseTooltipConfig(),
legend: getBaseLegendConfig([chartConfig.yFields[0], chartConfig.yFields[1]]),
xAxis: getXAxisConfig(xAxisData),
yAxis: getDualYAxisConfig(chartConfig.yFields),
series: [
getBaseSeriesConfig(chartConfig.yFields[0], series1Data, colors[0], 0),
getBaseSeriesConfig(chartConfig.yFields[1], series2Data, colors[1], 1)
]
};
}
};
// ========== 响应式处理逻辑 ==========
/**
* 创建响应式处理函数
*/
const createResizeHandler = (chartConfig: any, chartTypeForLogic: string) => {
let resizeTimer: NodeJS.Timeout;
return () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (chartInstance.value && chartContainer.value) {
const newOption = chartTypeForLogic === 'line'
? createSingleLineOption(chartConfig)
: createDualLineOption(chartConfig);
chartInstance.value.setOption({
...newOption,
animation: false
});
chartInstance.value.resize();
}
}, 100);
};
};
/**
* 初始化图表大小监听
*/
const initChartResizeListener = (chartConfig: any, chartTypeForLogic: string) => {
const handleResize = createResizeHandler(chartConfig, chartTypeForLogic);
// 窗口大小变化监听
window.addEventListener('resize', handleResize);
// ResizeObserver监听
if (typeof ResizeObserver !== 'undefined' && chartContainer.value) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartContainer.value);
onUnmounted(() => {
resizeObserver.disconnect();
});
}
// 清理函数
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
};
// ========== 主图表创建函数 ==========
/**
* 创建ECharts图表
*/
const createChart = () => {
if (!chartContainer.value) return;
// 检查数据是否为空
isEmpty.value = checkDataEmpty(props.chartData);
if (isEmpty.value) {
// 清理之前的图表实例
if (chartInstance.value) {
chartInstance.value.dispose();
chartInstance.value = null;
}
error.value = '';
return;
}
const chartConfig = formatData(props.chartData);
// 根据数据特征自动选择图表类型
const chartTypeForLogic = chartConfig.isMultiY && chartConfig.yFields && chartConfig.yFields.length > 1
? 'dualLine'
: 'line';
// 创建ECharts实例
chartInstance.value = echarts.init(chartContainer.value);
// 生成图表配置
const option = chartTypeForLogic === 'line'
? createSingleLineOption(chartConfig)
: createDualLineOption(chartConfig);
// 设置图表选项
chartInstance.value.setOption(option);
error.value = '';
// 初始化响应式监听
initChartResizeListener(chartConfig, chartTypeForLogic);
};
// ========== 生命周期和响应式 ==========
// 监听数据变化
watch(() => props.chartData, createChart);
onMounted(() => {
nextTick(createChart);
});
// 组件卸载时清理
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.dispose();
}
});
</script>
<style scoped>
.message-chart {
margin: 16px 0;
box-sizing: border-box;
}
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
color: #333;
width: 100%;
box-sizing: border-box;
text-align: center; /* 标题居中 */
}
.chart-container {
border: 1px solid #e8e8e8;
border-radius: 4px;
aspect-ratio: 3 / 2;
min-height: 200px;
width: 100%;
box-sizing: border-box;
overflow: visible !important; /* 确保tooltip可以正常显示 */
position: relative;
z-index: 1;
/* 确保tooltip可以正常显示 */
isolation: isolate;
}
/* 空状态样式 */
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 350px;
border: 1px solid #e8e8e8;
border-radius: 4px;
background-color: #fafafa;
color: #999;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e8e8e8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-text {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
color: #666;
text-align: center;
max-width: 100%;
}
.empty-desc {
font-size: 14px;
color: #999;
text-align: center;
max-width: 100%;
}
.chart-error {
padding: 10px;
text-align: center;
color: #f5222d;
background-color: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
margin-top: 10px;
}
</style>
\ No newline at end of file
...@@ -597,7 +597,7 @@ li { ...@@ -597,7 +597,7 @@ li {
font-size: 14px; font-size: 14px;
span { span {
color: #2eb0a1; color:@primary-color;
font-weight: bold; font-weight: bold;
} }
} }
......
...@@ -2,6 +2,12 @@ import dayjs from 'dayjs'; ...@@ -2,6 +2,12 @@ import dayjs from 'dayjs';
import { tableTemplate } from './tableTemplate'; import { tableTemplate } from './tableTemplate';
import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, mergeMarkdownContent } from './markdownTemplate'; import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, mergeMarkdownContent } from './markdownTemplate';
// 图表类型常量定义
const CHART_TYPES = {
COLUMN: 'column',
LINE: 'line'
} as const;
// 内容模板类型定义 // 内容模板类型定义
export interface ContentTemplates { export interface ContentTemplates {
text: (content: string) => string; text: (content: string) => string;
...@@ -300,7 +306,16 @@ export class ContentTemplateService { ...@@ -300,7 +306,16 @@ export class ContentTemplateService {
thinkContent: '', thinkContent: '',
thinkBoxExpanded: false, thinkBoxExpanded: false,
chartData: messageContent, chartData: messageContent,
chartType: 3, chartType: CHART_TYPES.COLUMN,
});
// 图表数据处理
updatedResponse.contentBlocks.push({
content: '',
hasThinkBox: false,
thinkContent: '',
thinkBoxExpanded: false,
chartData: messageContent,
chartType: messageContent.chartType || CHART_TYPES.COLUMN,
}); });
break; break;
...@@ -620,7 +635,7 @@ export class ContentTemplateService { ...@@ -620,7 +635,7 @@ export class ContentTemplateService {
let currentBlockIdx = -1; let currentBlockIdx = -1;
// 历史数据处理,isHistoryData设为true,思考框折叠 // 历史数据处理,isHistoryData设为true,思考框折叠
data.answerInfoList.forEach((answer) => { data.answerInfoList.forEach((answer: any) => {
const sseData: SSEData = { const sseData: SSEData = {
message: answer.message || '', message: answer.message || '',
status: answer.status || 0, status: answer.status || 0,
......
...@@ -75,8 +75,7 @@ export class SSEService { ...@@ -75,8 +75,7 @@ export class SSEService {
'x-app-code': this.config.appCode || '', 'x-app-code': this.config.appCode || '',
}, },
withCredentials: true, withCredentials: true,
connectionTimeout: 60000, connectionTimeout: 60000
heartbeatTimeout: 45000, // 心跳超时时间
}); });
this.eventSource.onopen = (event) => { this.eventSource.onopen = (event) => {
...@@ -183,6 +182,13 @@ export class SSEService { ...@@ -183,6 +182,13 @@ export class SSEService {
console.log('⏳ 重连已启动,等待连接建立...'); console.log('⏳ 重连已启动,等待连接建立...');
} }
// 标记重连成功(供外部调用)
public markReconnectSuccess(): void {
this.isReconnecting.value = false;
this.reconnectAttempts = 0;
console.log('✅ 重连成功,重置重连状态');
}
// 关闭SSE连接 // 关闭SSE连接
public closeSSE(): void { public closeSSE(): void {
if (this.eventSource) { if (this.eventSource) {
...@@ -385,6 +391,9 @@ export class SSEService { ...@@ -385,6 +391,9 @@ export class SSEService {
// 不再自动触发重连,避免重复消息 // 不再自动触发重连,避免重复消息
// 网络恢复事件会由网络状态监听器自动处理 // 网络恢复事件会由网络状态监听器自动处理
} }
} else {
// 正在重连中,避免重复触发
console.log('⏳ 心跳检测 - 正在重连中,跳过本次检测');
} }
} }
} }
......
...@@ -42,7 +42,7 @@ export const generateTableHTML = (tableData: TableData[], config: TableConfig = ...@@ -42,7 +42,7 @@ export const generateTableHTML = (tableData: TableData[], config: TableConfig =
}); });
}; };
// 数字格式化函数 - 万/亿格式化,保留两位小数,向上取整 // 数字格式化函数 - 万/亿格式化,保留两位小数,四舍五入
const formatNumber = (value: any) => { const formatNumber = (value: any) => {
if (value === null || value === undefined || value === '') return ''; if (value === null || value === undefined || value === '') return '';
...@@ -51,15 +51,15 @@ export const generateTableHTML = (tableData: TableData[], config: TableConfig = ...@@ -51,15 +51,15 @@ export const generateTableHTML = (tableData: TableData[], config: TableConfig =
if (num >= 100000000) { if (num >= 100000000) {
// 亿级别 // 亿级别
const result = Math.ceil((num / 100000000) * 100) / 100; const result = Math.round((num / 100000000) * 100) / 100;
return result.toFixed(2) + '亿'; return result.toFixed(2) + '亿';
} else if (num >= 10000) { } else if (num >= 10000) {
// 万级别 // 万级别
const result = Math.ceil((num / 10000) * 100) / 100; const result = Math.round((num / 10000) * 100) / 100;
return result.toFixed(2) + ''; return result.toFixed(2) + '';
} else { } else {
// 小于万 // 小于万
return Math.ceil(num).toString(); return Math.round(num).toString();
} }
}; };
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment