562 lines
16 KiB
Vue
562 lines
16 KiB
Vue
<template>
|
||
<el-dialog :visible.sync="visible" width="80%" @close="handleClose" title-class="dialog-title">
|
||
<small-title slot="title" :no-padding="true">
|
||
{{ dataForm.lineId + '·' + dataForm.equipmentName }}
|
||
</small-title>
|
||
<search-bar removeBlue :formConfigs="formConfig" ref="searchBarForm" @headBtnClick="buttonClick" />
|
||
<el-tabs class="custom-tabs" v-model="activeLabel" :stretch="true" @tab-click="handleTabClick">
|
||
<el-tab-pane :label="'\u3000报警时长\u3000'" name="duration"></el-tab-pane>
|
||
<el-tab-pane :label="'\u3000报警次数\u3000'" name="times"></el-tab-pane>
|
||
</el-tabs>
|
||
<div class="content">
|
||
<div class="visual-part">
|
||
<div v-if="hasData" style="display: flex; justify-content: space-around; gap: 20px; padding: 10px 0;">
|
||
<!-- 移除 v-if,始终渲染两个图表容器 -->
|
||
<div id="barChart" style="width: 48%; height: 400px;"></div>
|
||
<div id="pieChart" style="width: 48%; height: 400px;"></div>
|
||
</div>
|
||
<div v-if="!hasData" class="no-data">
|
||
<el-empty description="暂无相关报警数据"></el-empty>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script>
|
||
import { getAlarmDet } from '@/api/base/equipment';
|
||
import * as echarts from 'echarts';
|
||
import SmallTitle from './SmallTitle';
|
||
|
||
const CHART_CONFIG = {
|
||
barColor: '#288AFF',
|
||
pieColors: [
|
||
'#288AFF', '#4096FF', '#69B1FF', '#91CFFF', '#B8E0FF',
|
||
'#E0F2FF', '#1890FF', '#096DD9', '#0050B3', '#003A8C'
|
||
],
|
||
fontColor: '#333',
|
||
lightFontColor: '#666',
|
||
borderRadius: 4
|
||
};
|
||
|
||
export default {
|
||
components: { SmallTitle },
|
||
data() {
|
||
return {
|
||
visible: false,
|
||
hasData: false,
|
||
listQuery: {
|
||
pageNo: 1,
|
||
pageSize: 100,
|
||
equipmentId: undefined,
|
||
startTime: undefined,
|
||
endTime: undefined
|
||
},
|
||
formConfig: [
|
||
{
|
||
type: 'datePicker',
|
||
label: '时间段',
|
||
dateType: 'daterange',
|
||
format: 'yyyy-MM-dd',
|
||
valueFormat: 'timestamp',
|
||
rangeSeparator: '-',
|
||
startPlaceholder: '开始时间',
|
||
endPlaceholder: '结束时间',
|
||
param: 'timeVal',
|
||
defaultTime: ['00:00:00', '23:59:59'],
|
||
defaultSelect: []
|
||
},
|
||
{
|
||
type: 'button',
|
||
btnName: '查询',
|
||
name: 'search',
|
||
color: 'primary'
|
||
}
|
||
],
|
||
activeLabel: 'duration', // 默认选中「报警时长」
|
||
dataForm: {
|
||
equipmentId: undefined,
|
||
equipmentName: undefined,
|
||
lineId: undefined
|
||
},
|
||
chartInstances: {
|
||
bar: null,
|
||
pie: null
|
||
},
|
||
isDomReady: false,
|
||
originData: null // 存储原始数据
|
||
};
|
||
},
|
||
mounted() {
|
||
this.$nextTick(() => {
|
||
this.isDomReady = true;
|
||
if (this.listQuery.equipmentId) {
|
||
this.getDataList();
|
||
}
|
||
});
|
||
},
|
||
watch: {
|
||
// Tab 切换时自动刷新图表(无需额外操作,依赖 handleTabClick 触发查询)
|
||
activeLabel() {
|
||
if (this.isDomReady && this.originData) {
|
||
this.$nextTick(() => {
|
||
this.renderBothCharts(); // 切换 Tab 后重新渲染两个图表
|
||
});
|
||
}
|
||
}
|
||
},
|
||
methods: {
|
||
initDefaultDate() {
|
||
const today = new Date();
|
||
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime();
|
||
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 0).getTime();
|
||
|
||
this.formConfig[0].defaultSelect = [start, end];
|
||
this.listQuery.startTime = start;
|
||
this.listQuery.endTime = end;
|
||
|
||
if (this.$refs.searchBarForm) {
|
||
this.$refs.searchBarForm.form.timeVal = [start, end];
|
||
}
|
||
},
|
||
|
||
handleTabClick() {
|
||
// 切换 Tab 时重新查询数据(或直接复用已有数据渲染)
|
||
this.getDataList();
|
||
},
|
||
|
||
buttonClick(val) {
|
||
switch (val.btnName) {
|
||
case 'search':
|
||
this.listQuery.startTime = val.timeVal?.[0];
|
||
this.listQuery.endTime = val.timeVal?.[1];
|
||
this.getDataList();
|
||
break;
|
||
default:
|
||
}
|
||
},
|
||
|
||
async getDataList() {
|
||
try {
|
||
if (!this.listQuery.equipmentId) {
|
||
console.warn('设备ID不能为空');
|
||
this.hasData = false;
|
||
return;
|
||
}
|
||
|
||
const queryParams = {
|
||
equipmentId: this.listQuery.equipmentId,
|
||
startTime: this.listQuery.startTime,
|
||
endTime: this.listQuery.endTime,
|
||
};
|
||
|
||
const res = await getAlarmDet(queryParams);
|
||
const originData = res.data || [];
|
||
this.originData = originData;
|
||
this.hasData = originData.length > 0;
|
||
|
||
if (this.hasData && this.isDomReady) {
|
||
this.$nextTick(() => {
|
||
this.renderBothCharts(); // 数据查询成功后,同时渲染两个图表
|
||
});
|
||
} else {
|
||
this.destroyAllCharts();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取报警数据失败:', error);
|
||
this.hasData = false;
|
||
this.destroyAllCharts();
|
||
}
|
||
},
|
||
|
||
// 核心方法:同时渲染柱状图和饼图(根据当前 Tab 类型)
|
||
renderBothCharts() {
|
||
if (this.activeLabel === 'duration') {
|
||
// 报警时长:柱状图(时长排序)+ 饼图(时长占比)
|
||
this.renderBarChart('duration');
|
||
this.renderPieChart('duration');
|
||
} else {
|
||
// 报警次数:柱状图(次数排序)+ 饼图(次数占比)
|
||
this.renderBarChart('times');
|
||
this.renderPieChart('times');
|
||
}
|
||
},
|
||
|
||
// 渲染柱状图(支持两种数据类型)
|
||
renderBarChart(type) {
|
||
this.destroyChart('bar');
|
||
const chartDom = document.getElementById('barChart');
|
||
if (!chartDom || !this.originData.length) return;
|
||
|
||
// 根据类型排序和提取数据
|
||
let sortedData, xData, seriesData, yAxisName;
|
||
if (type === 'duration') {
|
||
// 报警时长:按时长降序
|
||
sortedData = [...this.originData].sort((a, b) => b.alarmDuration - a.alarmDuration);
|
||
seriesData = sortedData.map(item => item.alarmDuration);
|
||
yAxisName = '报警时长';
|
||
} else {
|
||
// 报警次数:按次数降序
|
||
sortedData = [...this.originData].sort((a, b) => b.alarmCount - a.alarmCount);
|
||
seriesData = sortedData.map(item => item.alarmCount);
|
||
yAxisName = '报警次数';
|
||
}
|
||
|
||
xData = sortedData.map(item => this.truncateText(item.alarmContent, 8));
|
||
|
||
try {
|
||
this.chartInstances.bar = echarts.init(chartDom);
|
||
const option = {
|
||
title: {
|
||
text: `${yAxisName}统计(柱状图)`,
|
||
left: 'center',
|
||
textStyle: { fontSize: 14, color: CHART_CONFIG.fontColor }
|
||
},
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
padding: 10,
|
||
textStyle: { fontSize: 11 },
|
||
formatter: (params) => {
|
||
const index = params[0].dataIndex;
|
||
const item = sortedData[index];
|
||
return `
|
||
<div style="text-align: left;">
|
||
<div>${item.alarmContent}</div>
|
||
<div>${yAxisName}:${type === 'duration' ? item.alarmDuration : item.alarmCount}</div>
|
||
<div>占比:${type === 'duration' ? item.alarmDurationRatio.toFixed(2) : item.alarmCountRatio.toFixed(2)}%</div>
|
||
</div>
|
||
`;
|
||
}
|
||
},
|
||
grid: {
|
||
left: '5%',
|
||
right: '5%',
|
||
bottom: '18%',
|
||
top: '15%',
|
||
containLabel: true
|
||
},
|
||
xAxis: [
|
||
{
|
||
type: 'category',
|
||
data: xData,
|
||
axisTick: { alignWithLabel: true },
|
||
axisLabel: {
|
||
interval: 0,
|
||
fontSize: 12,
|
||
color: CHART_CONFIG.lightFontColor
|
||
},
|
||
axisLine: { lineStyle: { color: '#e8e8e8' } }
|
||
}
|
||
],
|
||
yAxis: [
|
||
{
|
||
type: 'value',
|
||
name: yAxisName,
|
||
nameTextStyle: { fontSize: 11, color: CHART_CONFIG.lightFontColor },
|
||
axisLabel: {
|
||
fontSize: 11,
|
||
color: CHART_CONFIG.lightFontColor,
|
||
},
|
||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||
splitLine: { lineStyle: { color: '#f5f5f5' } },
|
||
max: (value) => value.max * 1.2
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: yAxisName,
|
||
type: 'bar',
|
||
itemStyle: {
|
||
color: CHART_CONFIG.barColor,
|
||
borderRadius: [CHART_CONFIG.borderRadius, CHART_CONFIG.borderRadius, 0, 0],
|
||
shadowBlur: 3,
|
||
shadowColor: 'rgba(40, 138, 255, 0.2)',
|
||
shadowOffsetY: 2
|
||
},
|
||
barWidth: '16',
|
||
data: seriesData,
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
distance: 6,
|
||
fontSize: 11,
|
||
color: CHART_CONFIG.fontColor,
|
||
formatter: (params) => `${params.value}`
|
||
}
|
||
}
|
||
]
|
||
};
|
||
|
||
this.chartInstances.bar.setOption(option);
|
||
this.addResizeListener('bar');
|
||
} catch (error) {
|
||
console.error(`${yAxisName}柱状图初始化失败:`, error);
|
||
setTimeout(() => this.renderBarChart(type), 200);
|
||
}
|
||
},
|
||
|
||
// 渲染饼图(支持两种数据类型)
|
||
renderPieChart(type) {
|
||
this.destroyChart('pie');
|
||
const chartDom = document.getElementById('pieChart');
|
||
if (!chartDom || !this.originData.length) return;
|
||
|
||
// 根据类型处理饼图数据
|
||
let pieData, seriesName;
|
||
if (type === 'duration') {
|
||
// 报警时长:按时长占比处理
|
||
seriesName = '报警时长';
|
||
pieData = this.handlePieData(this.originData, 'alarmDuration', 'alarmDurationRatio');
|
||
} else {
|
||
// 报警次数:按次数占比处理
|
||
seriesName = '报警次数';
|
||
pieData = this.handlePieData(this.originData, 'alarmCount', 'alarmCountRatio');
|
||
}
|
||
|
||
try {
|
||
this.chartInstances.pie = echarts.init(chartDom);
|
||
const option = {
|
||
title: {
|
||
text: `${seriesName}统计(饼图)`,
|
||
left: 'center',
|
||
textStyle: { fontSize: 14, color: CHART_CONFIG.fontColor }
|
||
},
|
||
tooltip: {
|
||
trigger: 'item',
|
||
padding: 10,
|
||
textStyle: { fontSize: 11 },
|
||
formatter: (params) => {
|
||
return `
|
||
<div style="text-align: left;">
|
||
<div>${params.name}</div>
|
||
<div>${seriesName}:${params.value}${type === 'duration' ? '' : '次'}</div>
|
||
<div>占比:${params.percent.toFixed(2)}%</div>
|
||
</div>
|
||
`;
|
||
}
|
||
},
|
||
series: [
|
||
{
|
||
name: seriesName,
|
||
type: 'pie',
|
||
radius: ['50%', '70%'],
|
||
center: ['50%', '55%'],
|
||
color: CHART_CONFIG.pieColors,
|
||
label: {
|
||
show: true,
|
||
position: 'outside',
|
||
distance: 15,
|
||
fontSize: 11,
|
||
color: CHART_CONFIG.lightFontColor,
|
||
formatter: (params) => {
|
||
const truncatedName = this.truncateText(params.name, 8);
|
||
return `${truncatedName}(${params.value}${type === 'duration' ? '' : '次'}, ${params.percent.toFixed(1)}%)`;
|
||
},
|
||
align: 'center',
|
||
baseline: 'middle'
|
||
},
|
||
labelLine: {
|
||
show: true,
|
||
length: 15,
|
||
length2: 20,
|
||
lineStyle: {
|
||
color: '#ccc',
|
||
width: 1,
|
||
type: 'solid'
|
||
},
|
||
smooth: 0.2
|
||
},
|
||
data: pieData,
|
||
emphasis: {
|
||
itemStyle: {
|
||
shadowBlur: 10,
|
||
shadowColor: 'rgba(0, 0, 0, 0.1)'
|
||
},
|
||
label: {
|
||
color: CHART_CONFIG.fontColor,
|
||
fontSize: 12,
|
||
fontWeight: 500
|
||
},
|
||
labelLine: {
|
||
lineStyle: {
|
||
color: CHART_CONFIG.barColor,
|
||
width: 1.5
|
||
}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
};
|
||
|
||
this.chartInstances.pie.setOption(option);
|
||
this.addResizeListener('pie');
|
||
} catch (error) {
|
||
console.error(`${seriesName}饼图初始化失败:`, error);
|
||
setTimeout(() => this.renderPieChart(type), 200);
|
||
}
|
||
},
|
||
|
||
// 通用饼图数据处理(支持动态字段)
|
||
handlePieData(data, valueKey, ratioKey) {
|
||
const threshold = 5; // 占比低于5%合并为「其他」
|
||
let otherCount = 0;
|
||
const mainData = data.filter(item => {
|
||
if (item[ratioKey] >= threshold) {
|
||
return true;
|
||
} else {
|
||
otherCount += item[valueKey];
|
||
return false;
|
||
}
|
||
}).map(item => ({
|
||
name: item.alarmContent,
|
||
value: item[valueKey],
|
||
ratio: item[ratioKey]
|
||
}));
|
||
|
||
if (otherCount > 0) {
|
||
mainData.push({
|
||
name: '其他',
|
||
value: otherCount,
|
||
ratio: 100 - mainData.reduce((sum, item) => sum + item.ratio, 0)
|
||
});
|
||
}
|
||
|
||
return mainData;
|
||
},
|
||
|
||
truncateText(text, maxLength) {
|
||
if (!text) return '';
|
||
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
|
||
},
|
||
|
||
addResizeListener(type) {
|
||
const chart = this.chartInstances[type];
|
||
if (chart) {
|
||
const resizeHandler = () => chart.resize();
|
||
window.addEventListener('resize', resizeHandler);
|
||
chart.resizeHandler = resizeHandler;
|
||
}
|
||
},
|
||
|
||
destroyChart(type) {
|
||
const chart = this.chartInstances[type];
|
||
if (chart) {
|
||
window.removeEventListener('resize', chart.resizeHandler);
|
||
chart.dispose();
|
||
this.chartInstances[type] = null;
|
||
}
|
||
},
|
||
|
||
destroyAllCharts() {
|
||
Object.keys(this.chartInstances).forEach(type => {
|
||
this.destroyChart(type);
|
||
});
|
||
},
|
||
|
||
handleClose() {
|
||
this.destroyAllCharts();
|
||
this.formConfig[0].defaultSelect = [];
|
||
this.listQuery.startTime = undefined;
|
||
this.listQuery.endTime = undefined;
|
||
this.originData = null;
|
||
this.hasData = true;
|
||
if (this.$refs.searchBarForm) {
|
||
// this.$refs.searchBarForm.form.timeVal = [];
|
||
}
|
||
},
|
||
|
||
init(data) {
|
||
this.dataForm = {
|
||
equipmentId: data.equipmentId || '',
|
||
equipmentName: data.equipmentName || '',
|
||
lineId: data.lineId || ''
|
||
};
|
||
this.activeLabel = 'duration'
|
||
this.listQuery.equipmentId = data.equipmentId || undefined;
|
||
this.visible = true;
|
||
this.originData = null;
|
||
this.hasData = false;
|
||
|
||
this.initDefaultDate();
|
||
|
||
this.$nextTick(() => {
|
||
this.$nextTick(() => {
|
||
this.isDomReady = true;
|
||
this.getDataList();
|
||
});
|
||
});
|
||
}
|
||
},
|
||
|
||
beforeDestroy() {
|
||
this.destroyAllCharts();
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 保持原有样式,优化图表容器布局 */
|
||
.drawer>>>.el-drawer {
|
||
border-radius: 8px 0 0 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.drawer>>>.el-form-item__label {
|
||
padding: 0;
|
||
}
|
||
|
||
.drawer>>>.el-drawer__header {
|
||
margin: 0;
|
||
padding: 32px 32px 24px;
|
||
border-bottom: 1px solid #dcdfe6;
|
||
}
|
||
|
||
.drawer>>>.el-drawer__body {
|
||
flex: 1;
|
||
height: 1px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.drawer>>>.content {
|
||
padding: 30px 24px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.drawer>>>.visual-part {
|
||
flex: 1 auto;
|
||
max-height: 76vh;
|
||
overflow: hidden;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
/* 优化图表容器响应式布局 */
|
||
@media (max-width: 1200px) {
|
||
.visual-part>div {
|
||
flex-direction: column;
|
||
}
|
||
|
||
#barChart,
|
||
#pieChart {
|
||
width: 100% !important;
|
||
height: 350px !important;
|
||
margin-bottom: 20px;
|
||
}
|
||
}
|
||
|
||
.drawer>>>.el-form,
|
||
.drawer>>>.attr-list {
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.drawer-body__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 18px;
|
||
}
|
||
</style>
|