f
This commit is contained in:
543
src/components/statistics/ChartCard.vue
Normal file
543
src/components/statistics/ChartCard.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user