514 lines
13 KiB
Vue
514 lines
13 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="chart-analysis-container">
|
|||
|
|
<!-- 仪表盘:整体健康度 -->
|
|||
|
|
<div class="gauges-row">
|
|||
|
|
<!-- 月度仪表盘 -->
|
|||
|
|
<div class="chart-card gauge-card">
|
|||
|
|
<div class="chart-header">
|
|||
|
|
<div class="chart-title">
|
|||
|
|
月度健康度
|
|||
|
|
</div>
|
|||
|
|
<div class="chart-subtitle">
|
|||
|
|
本月预算
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
ref="monthGaugeRef"
|
|||
|
|
class="chart-body gauge-chart"
|
|||
|
|
/>
|
|||
|
|
<div class="gauge-footer compact">
|
|||
|
|
<div class="gauge-item">
|
|||
|
|
<span class="label">已用</span>
|
|||
|
|
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="gauge-item">
|
|||
|
|
<span class="label">预算</span>
|
|||
|
|
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 年度仪表盘 -->
|
|||
|
|
<div class="chart-card gauge-card">
|
|||
|
|
<div class="chart-header">
|
|||
|
|
<div class="chart-title">
|
|||
|
|
年度健康度
|
|||
|
|
</div>
|
|||
|
|
<div class="chart-subtitle">
|
|||
|
|
本年预算
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
ref="yearGaugeRef"
|
|||
|
|
class="chart-body gauge-chart"
|
|||
|
|
/>
|
|||
|
|
<div class="gauge-footer compact">
|
|||
|
|
<div class="gauge-item">
|
|||
|
|
<span class="label">已用</span>
|
|||
|
|
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="gauge-item">
|
|||
|
|
<span class="label">预算</span>
|
|||
|
|
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 月度预算进度 -->
|
|||
|
|
<div
|
|||
|
|
v-if="budgets.length > 0"
|
|||
|
|
class="chart-card"
|
|||
|
|
>
|
|||
|
|
<div class="chart-header">
|
|||
|
|
<div class="chart-title">
|
|||
|
|
预算进度(月度)
|
|||
|
|
</div>
|
|||
|
|
<div class="chart-subtitle">
|
|||
|
|
本月各预算执行情况
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
ref="monthBarChartRef"
|
|||
|
|
class="chart-body bar-chart"
|
|||
|
|
:style="{ height: calculateChartHeight() + 'px' }"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 年度预算进度 -->
|
|||
|
|
<div
|
|||
|
|
v-if="budgets.length > 0"
|
|||
|
|
class="chart-card"
|
|||
|
|
style="margin-top: 12px"
|
|||
|
|
>
|
|||
|
|
<div class="chart-header">
|
|||
|
|
<div class="chart-title">
|
|||
|
|
预算进度(年度)
|
|||
|
|
</div>
|
|||
|
|
<div class="chart-subtitle">
|
|||
|
|
本年各预算执行情况
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
ref="yearBarChartRef"
|
|||
|
|
class="chart-body bar-chart"
|
|||
|
|
:style="{ height: calculateChartHeight() + 'px' }"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 空状态占位 -->
|
|||
|
|
<div
|
|||
|
|
v-else
|
|||
|
|
class="chart-card empty-card"
|
|||
|
|
>
|
|||
|
|
<van-empty
|
|||
|
|
description="暂无预算数据"
|
|||
|
|
image="search"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue'
|
|||
|
|
import * as echarts from 'echarts'
|
|||
|
|
import { BudgetCategory } from '@/constants/enums'
|
|||
|
|
import { getCssVar } from '@/utils/theme'
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
overallStats: {
|
|||
|
|
type: Object,
|
|||
|
|
required: true
|
|||
|
|
},
|
|||
|
|
budgets: {
|
|||
|
|
type: Array,
|
|||
|
|
default: () => []
|
|||
|
|
},
|
|||
|
|
activeTab: {
|
|||
|
|
type: [Number, String],
|
|||
|
|
default: BudgetCategory.Expense
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const monthGaugeRef = ref(null)
|
|||
|
|
const yearGaugeRef = ref(null)
|
|||
|
|
const monthBarChartRef = ref(null)
|
|||
|
|
const yearBarChartRef = ref(null)
|
|||
|
|
let monthGaugeChart = null
|
|||
|
|
let yearGaugeChart = null
|
|||
|
|
let monthBarChart = null
|
|||
|
|
let yearBarChart = null
|
|||
|
|
|
|||
|
|
const formatMoney = (val) => {
|
|||
|
|
if (val >= 10000) {
|
|||
|
|
return (val / 10000).toFixed(1) + 'w'
|
|||
|
|
}
|
|||
|
|
return parseFloat(val || 0).toLocaleString(undefined, {
|
|||
|
|
minimumFractionDigits: 0,
|
|||
|
|
maximumFractionDigits: 0
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const initGaugeChart = (chartInstance, dom, data, isExpense) => {
|
|||
|
|
if (!dom) { return null }
|
|||
|
|
|
|||
|
|
const chart = echarts.init(dom)
|
|||
|
|
updateSingleGauge(chart, data, isExpense)
|
|||
|
|
return chart
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateSingleGauge = (chart, data, isExpense) => {
|
|||
|
|
if (!chart) { return }
|
|||
|
|
|
|||
|
|
const rate = parseFloat(data.rate || 0)
|
|||
|
|
// 颜色逻辑
|
|||
|
|
let color = getCssVar('--chart-success') // 绿色
|
|||
|
|
if (isExpense) {
|
|||
|
|
if (rate >= 100) { color = getCssVar('--chart-danger') } // 红色
|
|||
|
|
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
|
|||
|
|
} else {
|
|||
|
|
if (rate >= 100) { color = getCssVar('--chart-success') } // 绿色
|
|||
|
|
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
|
|||
|
|
else { color = getCssVar('--chart-danger') } // 红色
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const option = {
|
|||
|
|
series: [
|
|||
|
|
{
|
|||
|
|
type: 'gauge',
|
|||
|
|
startAngle: 180,
|
|||
|
|
endAngle: 0,
|
|||
|
|
min: 0,
|
|||
|
|
max: Math.max(100, rate * 1.2),
|
|||
|
|
splitNumber: 5,
|
|||
|
|
radius: '110%', // 放大一点以适应小卡片
|
|||
|
|
center: ['50%', '75%'],
|
|||
|
|
itemStyle: {
|
|||
|
|
color: color,
|
|||
|
|
shadowColor: getCssVar('--chart-shadow'),
|
|||
|
|
shadowBlur: 10,
|
|||
|
|
shadowOffsetX: 2,
|
|||
|
|
shadowOffsetY: 2
|
|||
|
|
},
|
|||
|
|
progress: {
|
|||
|
|
show: true,
|
|||
|
|
roundCap: true,
|
|||
|
|
width: 12 // 变细一点
|
|||
|
|
},
|
|||
|
|
pointer: { show: false },
|
|||
|
|
axisLine: {
|
|||
|
|
roundCap: true,
|
|||
|
|
lineStyle: {
|
|||
|
|
width: 12,
|
|||
|
|
color: [[1, getCssVar('--chart-axis')]]
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
splitLine: { show: false },
|
|||
|
|
axisLabel: { show: false },
|
|||
|
|
detail: {
|
|||
|
|
valueAnimation: true,
|
|||
|
|
fontSize: 24, // 字体调小
|
|||
|
|
offsetCenter: [0, -5],
|
|||
|
|
color: 'var(--van-text-color)',
|
|||
|
|
formatter: '{value}%',
|
|||
|
|
fontWeight: 'bold',
|
|||
|
|
fontFamily: 'DIN Alternate, system-ui'
|
|||
|
|
},
|
|||
|
|
data: [{ value: rate }]
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
chart.setOption(option)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateCharts = () => {
|
|||
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
|||
|
|
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
|
|||
|
|
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
|
|||
|
|
|
|||
|
|
// 确保 barChart 已初始化,如果还未初始化则先初始化
|
|||
|
|
if (props.budgets.length > 0) {
|
|||
|
|
if (!monthBarChart && monthBarChartRef.value) {
|
|||
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|||
|
|
}
|
|||
|
|
if (!yearBarChart && yearBarChartRef.value) {
|
|||
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|||
|
|
}
|
|||
|
|
updateBarChart()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateBarChart = () => {
|
|||
|
|
if (!monthBarChart || !yearBarChart) { return }
|
|||
|
|
|
|||
|
|
const sortedBudgets = [...props.budgets].sort((a, b) => b.current - a.current).slice(0, 10)
|
|||
|
|
const categories = sortedBudgets.map(b => b.name)
|
|||
|
|
// 月度数据
|
|||
|
|
const monthLimits = sortedBudgets.map(b => b.monthLimit || b.limit)
|
|||
|
|
const monthCurrents = sortedBudgets.map(b => b.monthCurrent || b.current)
|
|||
|
|
// 年度数据
|
|||
|
|
const yearLimits = sortedBudgets.map(b => b.yearLimit || b.limit)
|
|||
|
|
const yearCurrents = sortedBudgets.map(b => b.yearCurrent || b.current)
|
|||
|
|
|
|||
|
|
const getColors = (data, limits) => {
|
|||
|
|
return data.map((current, idx) => {
|
|||
|
|
const limit = limits[idx]
|
|||
|
|
const rate = limit > 0 ? (current / limit) : 0
|
|||
|
|
if (props.activeTab === BudgetCategory.Expense) {
|
|||
|
|
if (rate >= 1) { return getCssVar('--chart-danger') }
|
|||
|
|
if (rate >= 0.8) { return getCssVar('--chart-warning') }
|
|||
|
|
return getCssVar('--chart-primary')
|
|||
|
|
} else {
|
|||
|
|
if (rate >= 1) { return getCssVar('--chart-success') }
|
|||
|
|
return getCssVar('--chart-primary')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const monthColors = getColors(monthCurrents, monthLimits)
|
|||
|
|
const yearColors = getColors(yearCurrents, yearLimits)
|
|||
|
|
|
|||
|
|
// 获取当前主题下的颜色值
|
|||
|
|
const textColor = getCssVar('--van-text-color')
|
|||
|
|
const textColor2 = getCssVar('--van-text-color-2')
|
|||
|
|
const bgColor3 = getCssVar('--van-background-3')
|
|||
|
|
const splitLineColor = getCssVar('--chart-split')
|
|||
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|||
|
|
|
|||
|
|
const createOption = (limits, currents, colors) => {
|
|||
|
|
return {
|
|||
|
|
grid: {
|
|||
|
|
left: '3%',
|
|||
|
|
right: '8%',
|
|||
|
|
bottom: '3%',
|
|||
|
|
top: '3%',
|
|||
|
|
containLabel: true
|
|||
|
|
},
|
|||
|
|
xAxis: {
|
|||
|
|
type: 'value',
|
|||
|
|
splitLine: {
|
|||
|
|
lineStyle: {
|
|||
|
|
type: 'dashed',
|
|||
|
|
color: splitLineColor
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
axisLabel: {
|
|||
|
|
color: axisLabelColor,
|
|||
|
|
formatter: (value) => {
|
|||
|
|
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
yAxis: {
|
|||
|
|
type: 'category',
|
|||
|
|
data: categories,
|
|||
|
|
axisLine: { show: false },
|
|||
|
|
axisTick: { show: false },
|
|||
|
|
axisLabel: {
|
|||
|
|
color: textColor,
|
|||
|
|
width: 50,
|
|||
|
|
overflow: 'truncate',
|
|||
|
|
interval: 0
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
series: [
|
|||
|
|
{
|
|||
|
|
name: '预算',
|
|||
|
|
type: 'bar',
|
|||
|
|
data: limits,
|
|||
|
|
barWidth: 10,
|
|||
|
|
itemStyle: {
|
|||
|
|
color: bgColor3,
|
|||
|
|
borderRadius: 5
|
|||
|
|
},
|
|||
|
|
z: 1,
|
|||
|
|
label: {
|
|||
|
|
show: true,
|
|||
|
|
position: 'right',
|
|||
|
|
formatter: (params) => {
|
|||
|
|
const val = params.value
|
|||
|
|
return val >= 10000 ? (val / 10000).toFixed(1) + 'w' : val
|
|||
|
|
},
|
|||
|
|
color: textColor2,
|
|||
|
|
fontSize: 10
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: '实际',
|
|||
|
|
type: 'bar',
|
|||
|
|
data: currents,
|
|||
|
|
barGap: '-100%',
|
|||
|
|
barWidth: 10,
|
|||
|
|
itemStyle: {
|
|||
|
|
color: (params) => colors[params.dataIndex],
|
|||
|
|
borderRadius: 5
|
|||
|
|
},
|
|||
|
|
z: 2
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
monthBarChart.setOption(createOption(monthLimits, monthCurrents, monthColors))
|
|||
|
|
yearBarChart.setOption(createOption(yearLimits, yearCurrents, yearColors))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const calculateChartHeight = () => {
|
|||
|
|
// 根据数据数量动态计算图表高度
|
|||
|
|
// 每个类别占用 60px,最少显示 200px,最多 400px
|
|||
|
|
const dataCount = Math.min(props.budgets.length, 10) // 最多显示10条
|
|||
|
|
const minHeight = 150
|
|||
|
|
const maxHeight = 400
|
|||
|
|
const heightPerItem = 60
|
|||
|
|
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
|
|||
|
|
return Math.min(calculatedHeight, maxHeight)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
|
|||
|
|
watch(() => props.budgets, () => {
|
|||
|
|
nextTick(() => {
|
|||
|
|
if (props.budgets.length > 0 && (!monthBarChart || !yearBarChart) && monthBarChartRef.value && yearBarChartRef.value) {
|
|||
|
|
// budgets 从空到有值,需要初始化图表
|
|||
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|||
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|||
|
|
updateBarChart()
|
|||
|
|
} else {
|
|||
|
|
updateCharts()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}, { deep: true })
|
|||
|
|
watch(() => props.activeTab, () => nextTick(updateCharts))
|
|||
|
|
|
|||
|
|
const handleResize = () => {
|
|||
|
|
monthGaugeChart?.resize()
|
|||
|
|
yearGaugeChart?.resize()
|
|||
|
|
monthBarChart?.resize()
|
|||
|
|
yearBarChart?.resize()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
nextTick(() => {
|
|||
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
|||
|
|
monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense)
|
|||
|
|
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
|
|||
|
|
// 只在有数据时初始化柱状图
|
|||
|
|
if (props.budgets.length > 0) {
|
|||
|
|
if (monthBarChartRef.value) {
|
|||
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|||
|
|
}
|
|||
|
|
if (yearBarChartRef.value) {
|
|||
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|||
|
|
}
|
|||
|
|
updateBarChart()
|
|||
|
|
}
|
|||
|
|
window.addEventListener('resize', handleResize)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
window.removeEventListener('resize', handleResize)
|
|||
|
|
monthGaugeChart?.dispose()
|
|||
|
|
yearGaugeChart?.dispose()
|
|||
|
|
monthBarChart?.dispose()
|
|||
|
|
yearBarChart?.dispose()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.chart-analysis-container {
|
|||
|
|
padding: 12px;
|
|||
|
|
padding-bottom: 80px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauges-row {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-card {
|
|||
|
|
background: var(--van-background-2);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 16px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-card {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
/* 防止 flex 子项溢出 */
|
|||
|
|
padding: 12px;
|
|||
|
|
/* 减小内边距 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-header {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-title {
|
|||
|
|
font-size: 14px;
|
|||
|
|
/* 调小标题 */
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--van-text-color);
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-subtitle {
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: var(--van-text-color-2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chart-body {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-chart {
|
|||
|
|
height: 120px;
|
|||
|
|
/* 调小高度 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar-chart {
|
|||
|
|
min-height: 200px;
|
|||
|
|
max-height: 400px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-footer {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-around;
|
|||
|
|
/* 分散对齐 */
|
|||
|
|
align-items: center;
|
|||
|
|
margin-top: -20px;
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-item {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-item .label {
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: var(--van-text-color-2);
|
|||
|
|
transform: scale(0.9);
|
|||
|
|
/* 视觉上更小 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-item .value {
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-family: DIN Alternate, system-ui;
|
|||
|
|
color: var(--van-text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gauge-item .value.expense {
|
|||
|
|
color: var(--van-primary-color);
|
|||
|
|
}
|
|||
|
|
</style>
|