Files
Situation-Awareness-Platfor…/src/views/Jyh/ecosystem/components/VitalityWaveChart.vue

446 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="index-module__NV_5cW__vitalitySection">
<h3 class="vitality__title">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span>开发者活力波形图</span>
</h3>
<div class="vitality__container">
<div class="num-empty" v-if="loading">加载中...</div>
<div id="vitalityChart" class="chart" v-else></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, nextTick, watch } from 'vue';
import * as echarts from 'echarts';
import { usePageResize } from '@/utils/hooks/usePageResize';
import { vitalityPage2 } from '@/api/jyh/situation';
// 页面尺寸响应式
const { widthType } = usePageResize();
// 定义数据结构
interface VitalityItem {
timeData: string;
total: number;
}
// 开发者活力数据
const vitalityData = ref({
timeData: [] as string[],
total: [] as number[],
new: [] as number[] // 默认数据因为API只返回timeData和total
});
const loading = ref(true);
// 从API获取数据
const fetchDataFromApi = async () => {
try {
loading.value = true;
const response = await vitalityPage2();
const businessData = response.data; // Axios响应格式业务数据在response.data中
if (businessData?.code === 200 && businessData.data) {
// 提取API返回的数据
const apiTimeData: string[] = [];
const apiTotalData: number[] = [];
businessData.data.forEach((item: VitalityItem) => {
apiTimeData.push(item.timeData);
apiTotalData.push(item.total);
});
// 计算年度新增数据(相邻年份的差值)
const apiNewData: number[] = [];
for (let i = 0; i < apiTotalData.length; i++) {
if (i === 0) {
apiNewData.push(apiTotalData[i]); // 第一年的新增就是当年总数
} else {
apiNewData.push(apiTotalData[i] - apiTotalData[i - 1]); // 后续年份为差值
}
}
vitalityData.value = {
timeData: apiTimeData,
total: apiTotalData,
new: apiNewData
};
} else {
console.error('获取开发者活力数据失败:', businessData?.msg || '未知错误');
// 设置默认数据
vitalityData.value = {
timeData: ["2014", "2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024"],
total: [20, 50, 100, 200, 300, 400, 600, 800, 1000, 1200, 1350],
new: [20, 30, 50, 100, 100, 100, 200, 200, 200, 200, 150]
};
}
} catch (error) {
console.error('获取开发者活力数据异常:', error);
// 设置默认数据
vitalityData.value = {
timeData: ["2014", "2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022", "2023", "2024"],
total: [20, 50, 100, 200, 300, 400, 600, 800, 1000, 1200, 1350],
new: [20, 30, 50, 100, 100, 100, 200, 200, 200, 200, 150]
};
} finally {
loading.value = false;
}
};
// 图表实例存储用于resize时销毁重绘
const chartInstances = ref<{ [key: string]: echarts.ECharts | null }>({
vitalityChart: null
});
// 初始化开发者活力波形图
const initVitalityChart = () => {
const el = document.getElementById('vitalityChart');
if (!el) return;
if (chartInstances.value.vitalityChart) {
chartInstances.value.vitalityChart.dispose();
}
const myChart = echarts.init(el);
chartInstances.value.vitalityChart = myChart;
myChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
width: 1,
type: 'dashed'
},
lineStyle: {
color: '#3b82f6',
width: 1,
type: 'dashed'
}
},
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: '#10b981',
borderWidth: 2,
borderRadius: 8,
padding: 16,
textStyle: { color: '#333', fontSize: 14 },
extraCssText: 'box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);',
formatter: (params: any) => {
if (!Array.isArray(params) || params.length === 0) {
return '<div>暂无数据</div>';
}
const totalItem = params.find((item: any) => item.seriesName === '累计开发者');
const newItem = params.find((item: any) => item.seriesName === '年度新增');
const totalValue = totalItem?.value || 0;
const newValue = newItem?.value || 0;
const time = totalItem?.axisValue || newItem?.axisValue || '未知时间';
// 计算增长率
const prevIndex = vitalityData.value.timeData.indexOf(time) - 1;
let growthRate = 0;
if (prevIndex >= 0) {
const prevTotal = vitalityData.value.total[prevIndex];
growthRate = ((totalValue - prevTotal) / prevTotal * 100).toFixed(1);
}
return `
<div style="line-height: 2;">
<div style="font-weight: bold; color: #10b981; font-size: 16px; margin-bottom: 8px; border-bottom: 2px solid #10b981; padding-bottom: 4px;">📅 ${time}年</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; width: 8px; height: 8px; background: #10b981; border-radius: 50%;"></span>
累计开发者:<span style="color: #10b981; font-weight: 700; font-size: 15px;">${totalValue}</span> 万人
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; width: 8px; height: 8px; background: #f59e0b; border-radius: 50%;"></span>
年度新增:<span style="color: #f59e0b; font-weight: 700; font-size: 15px;">${newValue}</span> 万人
</div>
${growthRate > 0 ? `<div style="margin-top: 6px; padding: 4px 8px; background: rgba(16, 185, 129, 0.1); border-radius: 4px; color: #10b981; font-size: 12px;">📈 同比增长 ${growthRate}%</div>` : ''}
</div>
`;
}
},
legend: {
left: 'center',
top: '0%',
itemWidth: 16,
itemHeight: 16,
itemGap: 20,
textStyle: { color: '#333', fontSize: 14, fontWeight: '600' },
data: ['累计开发者', '年度新增'],
icon: 'roundRect',
borderRadius: 4
},
grid: {
top: '18%',
left: '3%',
right: '4%',
bottom: '8%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
axisTick: { show: false },
axisLine: {
lineStyle: { color: '#e5e7eb', width: 2 },
onZero: false
},
axisLabel: {
color: '#666',
fontSize: 13,
fontWeight: '500',
interval: 0,
margin: 12
},
data: vitalityData.value.timeData
},
yAxis: {
type: 'value',
name: '开发者数量(万人)',
nameTextStyle: { color: '#666', fontSize: 12 },
axisLabel: { color: '#666', fontSize: 12 },
splitLine: {
show: true,
lineStyle: { color: 'rgba(0, 0, 0, 0.06)', type: 'dashed' }
}
},
series: [
{
name: '累计开发者',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { color: '#10b981', width: 3 },
itemStyle: {
color: '#10b981',
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16, 185, 129, 0.4)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0.05)' }
]),
shadowBlur: 10,
shadowColor: 'rgba(16, 185, 129, 0.2)'
},
data: vitalityData.value.total,
emphasis: {
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(16, 185, 129, 0.6)',
scale: 1.2
},
label: {
show: true,
formatter: '{c} 万',
position: 'top',
color: '#10b981',
fontSize: 13,
fontWeight: 'bold'
}
},
markPoint: {
data: [
{
type: 'max', name: '最大值',
itemStyle: { color: '#10b981' },
label: {
color: '#fff',
fontSize: 12,
fontWeight: 'bold'
}
}
],
symbolSize: 60
},
markLine: {
silent: true,
lineStyle: {
color: '#10b981',
type: 'dashed',
width: 1
},
label: {
color: '#10b981',
fontSize: 11
},
data: [{ type: 'average', name: '平均值' }]
}
},
{
name: '年度新增',
type: 'bar',
barWidth: '40%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#f59e0b' },
{ offset: 1, color: '#fbbf24' }
]),
borderRadius: [4, 4, 0, 0],
shadowBlur: 8,
shadowColor: 'rgba(245, 158, 11, 0.3)'
},
data: vitalityData.value.new,
emphasis: {
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(245, 158, 11, 0.6)'
},
label: {
show: true,
formatter: '{c} 万',
position: 'top',
color: '#f59e0b',
fontSize: 12,
fontWeight: 'bold'
}
},
markPoint: {
data: [
{
type: 'max', name: '峰值',
itemStyle: { color: '#f59e0b' },
label: {
color: '#fff',
fontSize: 12,
fontWeight: 'bold'
}
}
],
symbolSize: 60
},
animationDelay: (idx: number) => idx * 50
}
]
});
};
// 页面resize时重绘图表
watch(widthType, () => {
nextTick(() => initVitalityChart());
});
// 组件挂载时获取数据
onMounted(() => {
fetchDataFromApi();
// 数据加载完成后初始化图表
setTimeout(() => {
initVitalityChart();
}, 100);
});
</script>
<style lang="scss" scoped>
.num-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #999;
}
.index-module__NV_5cW__vitalitySection {
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background: #ffffffe6;
border: 1px solid #ffffff4d;
border-radius: 1.5rem;
margin-bottom: 2rem;
padding: 2rem;
box-shadow: 0 8px 24px #00000014;
transition: all 0.3s;
}
.index-module__NV_5cW__vitalitySection:hover {
border-color: #3b82f64d;
transform: translateY(-2px);
box-shadow: 0 12px 32px #0000001f;
}
.vitality__title {
color: #333;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 1.5rem 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
position: relative;
}
.vitality__title::before {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 3px;
background: linear-gradient(90deg, #10b981, #f59e0b);
border-radius: 2px;
}
.vitality__title svg {
color: #10b981;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
.vitality__container {
width: 100%;
height: 400px;
position: relative;
background: linear-gradient(135deg,
rgba(16, 185, 129, 0.02) 0%,
rgba(245, 158, 11, 0.02) 100%);
border-radius: 0.75rem;
padding: 1rem;
}
.chart {
width: 100%;
height: 100%;
}
/* 响应式适配 */
@media (max-width: 768px) {
.vitality__container {
height: 300px;
}
.vitality__title {
font-size: 1.25rem;
}
}
@media (max-width: 480px) {
.vitality__container {
height: 250px;
}
.vitality__title {
font-size: 1.2rem;
}
}
</style>