发布
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
Docker Build & Deploy / Deploy to Production (push) Successful in 11s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
孙诚
2026-01-16 15:56:53 +08:00
parent f77cc57cab
commit 0c95b6aa6e
8 changed files with 1150 additions and 906 deletions

View File

@@ -0,0 +1,514 @@
<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>