Files
hyapi-consoleweb/src/components/statistics/ChartCard.vue
2026-05-27 16:57:43 +08:00

544 lines
11 KiB
Vue

<template>
<div class="chart-card">
<!-- 图表头部 -->
<div class="chart-header">
<div class="header-left">
<h3 class="chart-title">{{ title }}</h3>
<p class="chart-subtitle" v-if="subtitle">{{ subtitle }}</p>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<el-button type="text" icon="el-icon-more">
<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="export">导出</el-dropdown-item>
<el-dropdown-item command="fullscreen">全屏</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<!-- 图表内容 -->
<div class="chart-content" v-loading="loading">
<!-- 图表容器 -->
<div
ref="chartContainer"
class="chart-container"
:style="{ height: chartHeight + 'px' }"
></div>
<!-- 无数据状态 -->
<div class="no-data" v-if="!loading && (!data || data.length === 0)">
<i class="el-icon-data-line"></i>
<p>暂无数据</p>
</div>
<!-- 图表加载失败 -->
<div class="chart-error" v-if="error">
<i class="el-icon-warning"></i>
<p>{{ error }}</p>
<el-button type="text" @click="retry">重试</el-button>
</div>
</div>
<!-- 图表底部信息 -->
<div class="chart-footer" v-if="footerInfo">
<div class="footer-info">
<span v-for="(info, index) in footerInfo" :key="index" class="info-item">
<span class="info-label">{{ info.label }}:</span>
<span class="info-value">{{ info.value }}</span>
</span>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'ChartCard',
props: {
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
type: {
type: String,
default: 'line',
validator: value => ['line', 'bar', 'pie', 'scatter', 'gauge', 'funnel'].includes(value)
},
data: {
type: Array,
default: () => []
},
options: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 300
},
footerInfo: {
type: Array,
default: () => []
}
},
data() {
return {
chart: null,
error: null,
chartHeight: this.height
}
},
computed: {
// 合并默认配置和用户配置
chartOptions() {
const defaultOptions = this.getDefaultOptions()
return this.mergeOptions(defaultOptions, this.options)
}
},
watch: {
data: {
handler() {
this.updateChart()
},
deep: true
},
options: {
handler() {
this.updateChart()
},
deep: true
},
loading(newVal) {
if (!newVal && this.chart) {
this.updateChart()
}
}
},
mounted() {
this.initChart()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
if (this.chart) {
this.chart.dispose()
}
window.removeEventListener('resize', this.handleResize)
},
methods: {
// 初始化图表
initChart() {
if (!this.$refs.chartContainer) return
try {
this.chart = echarts.init(this.$refs.chartContainer)
this.updateChart()
this.error = null
} catch (error) {
console.error('图表初始化失败:', error)
this.error = '图表初始化失败'
}
},
// 更新图表
updateChart() {
if (!this.chart || this.loading) return
try {
const options = this.chartOptions
this.chart.setOption(options, true)
this.error = null
} catch (error) {
console.error('图表更新失败:', error)
this.error = '图表更新失败'
}
},
// 获取默认配置
getDefaultOptions() {
const baseOptions = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
}
switch (this.type) {
case 'line':
return {
...baseOptions,
xAxis: {
type: 'category',
boundaryGap: false,
data: this.data.map(item => item.time || item.name || item.x)
},
yAxis: {
type: 'value'
},
series: [{
type: 'line',
data: this.data.map(item => item.value || item.y),
smooth: true,
areaStyle: {
opacity: 0.1
}
}]
}
case 'bar':
return {
...baseOptions,
xAxis: {
type: 'category',
data: this.data.map(item => item.name || item.x)
},
yAxis: {
type: 'value'
},
series: [{
type: 'bar',
data: this.data.map(item => item.value || item.y),
barWidth: '60%'
}]
}
case 'pie':
return {
...baseOptions,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: this.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
case 'gauge':
return {
...baseOptions,
series: [{
type: 'gauge',
data: this.data,
radius: '80%',
startAngle: 200,
endAngle: -20,
min: 0,
max: 100,
splitNumber: 10,
axisLine: {
lineStyle: {
width: 6
}
},
axisTick: {
distance: -30,
splitNumber: 5,
lineStyle: {
width: 2,
color: '#999'
}
},
axisLabel: {
distance: -20,
color: '#999',
fontSize: 12
},
pointer: {
itemStyle: {
color: 'auto'
}
},
title: {
color: '#999'
},
detail: {
valueAnimation: true,
formatter: '{value}%',
color: 'auto'
}
}]
}
default:
return baseOptions
}
},
// 合并配置
mergeOptions(defaultOptions, userOptions) {
return {
...defaultOptions,
...userOptions,
series: userOptions.series || defaultOptions.series
}
},
// 处理窗口大小变化
handleResize() {
if (this.chart) {
this.chart.resize()
}
},
// 处理下拉菜单命令
handleCommand(command) {
switch (command) {
case 'refresh':
this.$emit('refresh')
break
case 'export':
this.exportChart()
break
case 'fullscreen':
this.toggleFullscreen()
break
}
},
// 导出图表
exportChart() {
if (!this.chart) return
try {
const url = this.chart.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff'
})
const link = document.createElement('a')
link.download = `${this.title}_${new Date().getTime()}.png`
link.href = url
link.click()
this.$message.success('图表导出成功')
} catch (error) {
console.error('图表导出失败:', error)
this.$message.error('图表导出失败')
}
},
// 切换全屏
toggleFullscreen() {
this.$emit('fullscreen', this.title)
},
// 重试
retry() {
this.error = null
this.initChart()
}
}
}
</script>
<style scoped>
.chart-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.chart-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.header-left {
flex: 1;
}
.chart-title {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.chart-subtitle {
margin: 0;
font-size: 12px;
color: #909399;
}
.header-right {
display: flex;
align-items: center;
}
.chart-content {
position: relative;
padding: 20px;
}
.chart-container {
width: 100%;
min-height: 200px;
}
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #c0c4cc;
}
.no-data i {
font-size: 48px;
margin-bottom: 12px;
}
.no-data p {
margin: 0;
font-size: 14px;
}
.chart-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #f56c6c;
}
.chart-error i {
font-size: 48px;
margin-bottom: 12px;
}
.chart-error p {
margin: 0 0 12px 0;
font-size: 14px;
}
.chart-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fafafa;
}
.footer-info {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.info-item {
font-size: 12px;
color: #606266;
}
.info-label {
font-weight: 500;
margin-right: 4px;
}
.info-value {
color: #909399;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chart-header {
padding: 12px 16px;
}
.chart-title {
font-size: 14px;
}
.chart-content {
padding: 16px;
}
.chart-container {
min-height: 150px;
}
.chart-footer {
padding: 8px 16px;
}
.footer-info {
gap: 8px;
}
}
/* 动画效果 */
.chart-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 加载状态样式 */
.chart-content .el-loading-mask {
background-color: rgba(255, 255, 255, 0.8);
}
.chart-content .el-loading-spinner {
margin-top: -25px;
}
.chart-content .el-loading-spinner .el-icon-loading {
font-size: 24px;
color: #409EFF;
}
</style>