2026-01-16 15:56:53 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="chart-analysis-container">
|
|
|
|
|
|
<!-- 仪表盘:整体健康度 -->
|
|
|
|
|
|
<div class="gauges-row">
|
|
|
|
|
|
<!-- 月度仪表盘 -->
|
|
|
|
|
|
<div class="chart-card gauge-card">
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<!-- 月度健康度 -->
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
|
|
|
|
|
(月度)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-21 16:09:38 +08:00
|
|
|
|
<div class="gauge-wrapper">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="monthGaugeRef"
|
|
|
|
|
|
class="chart-body gauge-chart"
|
|
|
|
|
|
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="gauge-text-overlay">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="remaining-label"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="remaining-value"
|
|
|
|
|
|
>
|
|
|
|
|
|
¥{{ formatMoney(Math.max(0, overallStats.month.limit - overallStats.month.current)) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<div class="gauge-footer compact">
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<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">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
|
|
|
|
|
(年度)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-21 16:09:38 +08:00
|
|
|
|
<div class="gauge-wrapper">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="yearGaugeRef"
|
|
|
|
|
|
class="chart-body gauge-chart"
|
|
|
|
|
|
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="gauge-text-overlay">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="remaining-label"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="remaining-value"
|
|
|
|
|
|
>
|
|
|
|
|
|
¥{{ formatMoney(Math.max(0, overallStats.year.limit - overallStats.year.current)) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<div class="gauge-footer compact">
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<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">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
预算剩余消耗趋势
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="burndownChartRef"
|
|
|
|
|
|
class="chart-body burndown-chart"
|
|
|
|
|
|
/>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</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
|
2026-01-16 17:52:40 +08:00
|
|
|
|
ref="yearBurndownChartRef"
|
|
|
|
|
|
class="chart-body burndown-chart"
|
|
|
|
|
|
/>
|
2026-01-20 19:56:29 +08:00
|
|
|
|
</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">
|
|
|
|
|
|
预算执行偏差排行
|
2026-01-16 17:52:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-01-20 19:56:29 +08:00
|
|
|
|
ref="varianceChartRef"
|
2026-01-17 14:38:40 +08:00
|
|
|
|
class="chart-body variance-chart"
|
2026-01-20 19:56:29 +08:00
|
|
|
|
:style="{ height: calculateChartHeight(budgets) + 'px' }"
|
2026-01-16 17:52:40 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<!-- 空状态占位 -->
|
|
|
|
|
|
<div
|
2026-01-16 17:52:40 +08:00
|
|
|
|
v-else-if="budgets.length === 0"
|
2026-01-16 15:56:53 +08:00
|
|
|
|
class="chart-card empty-card"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-empty
|
|
|
|
|
|
description="暂无预算数据"
|
|
|
|
|
|
image="search"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-01-17 14:38:40 +08:00
|
|
|
|
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
|
2026-01-16 15:56:53 +08:00
|
|
|
|
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
|
2026-01-21 18:52:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
selectedDate: {
|
|
|
|
|
|
type: Date,
|
|
|
|
|
|
default: () => new Date()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const monthGaugeRef = ref(null)
|
|
|
|
|
|
const yearGaugeRef = ref(null)
|
2026-01-20 19:56:29 +08:00
|
|
|
|
const varianceChartRef = ref(null)
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const burndownChartRef = ref(null)
|
|
|
|
|
|
const yearBurndownChartRef = ref(null)
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
let monthGaugeChart = null
|
|
|
|
|
|
let yearGaugeChart = null
|
2026-01-20 19:56:29 +08:00
|
|
|
|
let varianceChart = null
|
2026-01-16 17:52:40 +08:00
|
|
|
|
let burndownChart = null
|
|
|
|
|
|
let yearBurndownChart = null
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
|
|
|
|
|
const monthBudgets = computed(() => (props.budgets || []).filter(b => b.type === 1))
|
|
|
|
|
|
const yearBudgets = computed(() => (props.budgets || []).filter(b => b.type === 2))
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
const formatMoney = (val) => {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (Math.abs(val) >= 10000) {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
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)
|
2026-01-18 13:06:16 +08:00
|
|
|
|
// 展示逻辑:支出显示剩余,收入显示已积累
|
|
|
|
|
|
let displayRate
|
2026-01-16 15:56:53 +08:00
|
|
|
|
if (isExpense) {
|
2026-01-18 13:06:16 +08:00
|
|
|
|
// 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗
|
|
|
|
|
|
displayRate = Math.max(0, 100 - rate)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
} else {
|
2026-01-18 13:06:16 +08:00
|
|
|
|
// 收入:显示已积累 (%),随收入增多逐渐增多
|
|
|
|
|
|
displayRate = Math.min(100, rate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 颜色逻辑:支出从绿色消耗到红色,收入从红色积累到绿色
|
|
|
|
|
|
let color
|
|
|
|
|
|
if (isExpense) {
|
|
|
|
|
|
// 支出:满格绿色,随消耗逐渐变红 (根据剩余容量)
|
|
|
|
|
|
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色
|
|
|
|
|
|
else if (displayRate <= 65) { color = getCssVar('--chart-warning') } // 橙色
|
|
|
|
|
|
else { color = getCssVar('--chart-success') } // 绿色
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 收入:空红色,随积累逐渐变绿 (根据已积累)
|
|
|
|
|
|
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色
|
|
|
|
|
|
else if (displayRate <= 65) { color = getCssVar('--chart-warning') } // 橙色
|
|
|
|
|
|
else { color = getCssVar('--chart-success') } // 绿色
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const option = {
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'gauge',
|
|
|
|
|
|
startAngle: 180,
|
|
|
|
|
|
endAngle: 0,
|
|
|
|
|
|
min: 0,
|
2026-01-18 13:06:16 +08:00
|
|
|
|
max: 100,
|
2026-01-16 15:56:53 +08:00
|
|
|
|
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],
|
2026-01-21 16:09:38 +08:00
|
|
|
|
color: getCssVar('--van-text-color'),
|
2026-01-16 15:56:53 +08:00
|
|
|
|
formatter: '{value}%',
|
|
|
|
|
|
fontWeight: 'bold',
|
2026-01-21 16:09:38 +08:00
|
|
|
|
fontFamily: 'DIN Alternate, system-ui',
|
|
|
|
|
|
show: false
|
2026-01-16 15:56:53 +08:00
|
|
|
|
},
|
2026-01-21 16:09:38 +08:00
|
|
|
|
data: [{ value: displayRate.toFixed(0) }]
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
chart.setOption(option, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const disposeBudgetCharts = () => {
|
|
|
|
|
|
varianceChart?.dispose()
|
|
|
|
|
|
varianceChart = null
|
|
|
|
|
|
burndownChart?.dispose()
|
|
|
|
|
|
burndownChart = null
|
|
|
|
|
|
yearBurndownChart?.dispose()
|
|
|
|
|
|
yearBurndownChart = null
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateCharts = () => {
|
|
|
|
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
2026-01-21 18:52:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 仪表盘总是存在的
|
|
|
|
|
|
if (!monthGaugeChart && monthGaugeRef.value) {
|
|
|
|
|
|
monthGaugeChart = echarts.init(monthGaugeRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (monthGaugeChart) {
|
|
|
|
|
|
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!yearGaugeChart && yearGaugeRef.value) {
|
|
|
|
|
|
yearGaugeChart = echarts.init(yearGaugeRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (yearGaugeChart) {
|
|
|
|
|
|
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
if (props.budgets.length > 0) {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 等待 v-if 相关的 DOM 更新
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
// 偏差分析图
|
|
|
|
|
|
if (varianceChartRef.value) {
|
|
|
|
|
|
const existing = echarts.getInstanceByDom(varianceChartRef.value)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
varianceChart = existing
|
|
|
|
|
|
} else {
|
|
|
|
|
|
varianceChart?.dispose()
|
|
|
|
|
|
varianceChart = echarts.init(varianceChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateVarianceChart(varianceChart, props.budgets)
|
|
|
|
|
|
varianceChart.resize()
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 月度燃尽图
|
|
|
|
|
|
if (burndownChartRef.value) {
|
|
|
|
|
|
const existing = echarts.getInstanceByDom(burndownChartRef.value)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
burndownChart = existing
|
|
|
|
|
|
} else {
|
|
|
|
|
|
burndownChart?.dispose()
|
|
|
|
|
|
burndownChart = echarts.init(burndownChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateBurndownChart()
|
|
|
|
|
|
burndownChart.resize()
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 年度燃尽图
|
|
|
|
|
|
if (yearBurndownChartRef.value) {
|
|
|
|
|
|
const existing = echarts.getInstanceByDom(yearBurndownChartRef.value)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
yearBurndownChart = existing
|
|
|
|
|
|
} else {
|
|
|
|
|
|
yearBurndownChart?.dispose()
|
|
|
|
|
|
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateYearBurndownChart()
|
|
|
|
|
|
yearBurndownChart.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 预算数据为空,DOM 已移除,清理实例
|
|
|
|
|
|
disposeBudgetCharts()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const updateVarianceChart = (chart, budgets) => {
|
|
|
|
|
|
if (!chart || !budgets || budgets.length === 0) { return }
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const data = budgets.map(b => {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const diff = current - limit
|
|
|
|
|
|
return {
|
2026-01-20 19:56:29 +08:00
|
|
|
|
name: b.name + (b.type === 2 ? ' (年)' : ''),
|
2026-01-17 14:38:40 +08:00
|
|
|
|
value: diff,
|
|
|
|
|
|
limit: limit,
|
|
|
|
|
|
current: current
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
// Sort by absolute variance
|
|
|
|
|
|
data.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const categories = data.map(item => item.name)
|
|
|
|
|
|
const values = data.map(item => item.value)
|
2026-01-17 15:03:19 +08:00
|
|
|
|
const maxVal = Math.max(...values.map(v => Math.abs(v))) || 1
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
const textColor = getCssVar('--van-text-color')
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const item = data[params[0].dataIndex]
|
|
|
|
|
|
let html = `${item.name}<br/>`
|
|
|
|
|
|
html += `预算: ¥${formatMoney(item.limit)}<br/>`
|
|
|
|
|
|
html += `实际: ¥${formatMoney(item.current)}<br/>`
|
|
|
|
|
|
const diffText = item.value > 0 ? `超支: ¥${formatMoney(item.value)}` : `结余: ¥${formatMoney(Math.abs(item.value))}`
|
|
|
|
|
|
const color = item.value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
|
|
|
|
|
|
html += `<span style="color:${color};font-weight:bold">${diffText}</span>`
|
|
|
|
|
|
return html
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
position: 'top',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
axisLabel: { show: false },
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
axisTick: { show: false },
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
data: categories,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: textColor,
|
|
|
|
|
|
width: 60,
|
|
|
|
|
|
overflow: 'truncate'
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '偏差',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
stack: 'Total',
|
|
|
|
|
|
barWidth: 20, // Fixed bar width
|
2026-01-17 15:10:37 +08:00
|
|
|
|
data: values.map((val) => {
|
|
|
|
|
|
// 如果柱子太短(小于25%),文字显示在外部,否则显示在内部
|
2026-01-17 15:03:19 +08:00
|
|
|
|
const ratio = Math.abs(val) / maxVal
|
2026-01-17 15:10:37 +08:00
|
|
|
|
const isShort = ratio < 0.25
|
2026-01-17 15:03:19 +08:00
|
|
|
|
let position
|
|
|
|
|
|
let color
|
|
|
|
|
|
|
|
|
|
|
|
if (val >= 0) {
|
|
|
|
|
|
position = isShort ? 'right' : 'insideLeft'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
position = isShort ? 'left' : 'insideRight'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 15:10:37 +08:00
|
|
|
|
// eslint-disable-next-line prefer-const
|
2026-01-17 15:03:19 +08:00
|
|
|
|
color = isShort ? textColor : '#fff'
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
return {
|
|
|
|
|
|
value: val,
|
|
|
|
|
|
label: {
|
2026-01-17 15:03:19 +08:00
|
|
|
|
position: position,
|
|
|
|
|
|
color: color
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}),
|
|
|
|
|
|
label: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const val = params.value
|
|
|
|
|
|
return val > 0 ? `+${formatMoney(val)}` : formatMoney(val)
|
|
|
|
|
|
},
|
|
|
|
|
|
color: textColor
|
2026-01-16 15:56:53 +08:00
|
|
|
|
},
|
2026-01-17 14:38:40 +08:00
|
|
|
|
itemStyle: {
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
color: (params) => {
|
|
|
|
|
|
const val = params.value
|
|
|
|
|
|
if (props.activeTab === BudgetCategory.Expense) {
|
|
|
|
|
|
return val > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return val > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color')
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
chart.setOption(option, true)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const calculateChartHeight = (budgets) => {
|
|
|
|
|
|
if (!budgets) { return 100 }
|
|
|
|
|
|
const dataCount = budgets.length
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const minHeight = 100
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const heightPerItem = 30 // Fixed height per bar item
|
2026-01-16 15:56:53 +08:00
|
|
|
|
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
|
2026-01-17 14:38:40 +08:00
|
|
|
|
return calculatedHeight
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const updateBurndownChart = () => {
|
|
|
|
|
|
if (!burndownChart) { return }
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 使用传入的所选日期作为参考日期
|
|
|
|
|
|
const refDate = props.selectedDate
|
|
|
|
|
|
const year = refDate.getFullYear()
|
|
|
|
|
|
const month = refDate.getMonth()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
2026-01-21 18:52:31 +08:00
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const isCurrentMonth = now.getFullYear() === year && now.getMonth() === month
|
|
|
|
|
|
const isPastMonth =
|
|
|
|
|
|
now.getFullYear() > year || (now.getFullYear() === year && now.getMonth() > month)
|
|
|
|
|
|
// 如果是过去月份,显示完整数据;如果是当前月,显示到今天;如果是将来月,不显示实际数据
|
|
|
|
|
|
const currentDay = isCurrentMonth ? now.getDate() : isPastMonth ? daysInMonth : 0
|
2026-01-16 23:18:04 +08:00
|
|
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
// 生成日期和理想燃尽线/积累线
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const dates = []
|
|
|
|
|
|
const idealBurndown = []
|
|
|
|
|
|
const actualBurndown = []
|
|
|
|
|
|
|
|
|
|
|
|
const totalBudget = props.overallStats.month.limit || 0
|
|
|
|
|
|
const currentExpense = props.overallStats.month.current || 0
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const trend = props.overallStats.month.trend || []
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
|
|
|
|
dates.push(`${i}日`)
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
if (isExpense) {
|
|
|
|
|
|
// 支出:燃尽图(向下走)
|
|
|
|
|
|
// 理想燃尽:每天均匀消耗
|
|
|
|
|
|
const idealRemaining = Math.max(0, totalBudget * (1 - i / daysInMonth))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealRemaining))
|
|
|
|
|
|
|
|
|
|
|
|
// 实际燃尽:根据当前日期显示
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (trend.length > 0) {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 后端返回了趋势数据
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const dayValue = trend[i - 1]
|
|
|
|
|
|
if (dayValue !== undefined && dayValue !== null) {
|
|
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - dayValue)
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
} else {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 后端没有趋势数据, fallback 到线性估算
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (i <= currentDay && totalBudget > 0) {
|
|
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
} else {
|
2026-01-16 23:18:04 +08:00
|
|
|
|
// 收入:积累图(向上走)
|
|
|
|
|
|
// 理想积累:每天均匀积累
|
|
|
|
|
|
const idealAccumulated = Math.min(totalBudget, totalBudget * (i / daysInMonth))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealAccumulated))
|
|
|
|
|
|
|
|
|
|
|
|
// 实际积累:根据当前日期显示
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (trend.length > 0) {
|
|
|
|
|
|
const dayValue = trend[i - 1]
|
|
|
|
|
|
if (dayValue !== undefined && dayValue !== null) {
|
|
|
|
|
|
actualBurndown.push(Math.round(dayValue))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
} else {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (i <= currentDay && totalBudget > 0) {
|
|
|
|
|
|
const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay)
|
|
|
|
|
|
actualBurndown.push(Math.round(actualAccumulated))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
const idealSeriesName = isExpense ? '理想燃尽' : '理想积累'
|
|
|
|
|
|
const actualSeriesName = isExpense ? '实际燃尽' : '实际积累'
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: dates,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
2026-01-17 15:03:19 +08:00
|
|
|
|
interval: 'auto',
|
|
|
|
|
|
rotate: 0
|
2026-01-16 17:52:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
formatter: (value) => {
|
|
|
|
|
|
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
let result = params[0].name + '<br/>'
|
|
|
|
|
|
params.forEach(param => {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (param.value !== null && param.value !== undefined) {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
2026-01-16 23:18:04 +08:00
|
|
|
|
name: idealSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: idealBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning'),
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
type: 'dashed'
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 1
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-01-16 23:18:04 +08:00
|
|
|
|
name: actualSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: actualBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary'),
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
burndownChart.setOption(option, true)
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateYearBurndownChart = () => {
|
|
|
|
|
|
if (!yearBurndownChart) { return }
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 使用参考日期
|
|
|
|
|
|
const refDate = props.selectedDate
|
|
|
|
|
|
const year = refDate.getFullYear()
|
|
|
|
|
|
const refMonth = refDate.getMonth()
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const currentYear = now.getFullYear()
|
|
|
|
|
|
const currentMonth = now.getMonth()
|
|
|
|
|
|
const currentDay = now.getDate()
|
|
|
|
|
|
const daysInCurrentMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
2026-01-16 23:18:04 +08:00
|
|
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
// 生成月份和理想燃尽线/积累线
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const months = []
|
|
|
|
|
|
const idealBurndown = []
|
|
|
|
|
|
const actualBurndown = []
|
|
|
|
|
|
|
|
|
|
|
|
const totalBudget = props.overallStats.year.limit || 0
|
|
|
|
|
|
const currentExpense = props.overallStats.year.current || 0
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const trend = props.overallStats.year.trend || []
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
|
months.push(`${i + 1}月`)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算当前时间进度(基于天数)
|
|
|
|
|
|
let daysPassedInYear = 0
|
|
|
|
|
|
let daysInYear = 0
|
|
|
|
|
|
|
|
|
|
|
|
for (let j = 0; j < i; j++) {
|
|
|
|
|
|
daysInYear += new Date(year, j + 1, 0).getDate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
if (year < currentYear || (year === currentYear && i < currentMonth)) {
|
|
|
|
|
|
// 以前的年/月
|
2026-01-16 17:52:40 +08:00
|
|
|
|
daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
|
2026-01-21 18:52:31 +08:00
|
|
|
|
} else if (year === currentYear && i === currentMonth) {
|
|
|
|
|
|
// 当前月
|
2026-01-16 17:52:40 +08:00
|
|
|
|
daysPassedInYear = daysInYear + currentDay
|
|
|
|
|
|
} else {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 未来的年/月
|
|
|
|
|
|
daysPassedInYear = 0
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
const daysInYearTotal = (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) ? 366 : 365
|
|
|
|
|
|
const yearProgress = daysPassedInYear / daysInYearTotal
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
if (isExpense) {
|
|
|
|
|
|
// 支出:燃尽图(向下走)
|
|
|
|
|
|
// 理想燃尽:每月均匀消耗
|
|
|
|
|
|
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealRemaining))
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 实际燃尽:根据日期显示
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (trend.length > 0) {
|
|
|
|
|
|
const monthValue = trend[i]
|
|
|
|
|
|
if (monthValue !== undefined && monthValue !== null) {
|
|
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - monthValue)
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
} else {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// Fallback: 如果是今年且月份未开始,或者去年,做线性统计
|
|
|
|
|
|
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
|
|
|
|
|
|
if (!isFuture && totalBudget > 0) {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
} else {
|
2026-01-16 23:18:04 +08:00
|
|
|
|
// 收入:积累图(向上走)
|
|
|
|
|
|
// 理想积累:每月均匀积累
|
|
|
|
|
|
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealAccumulated))
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
// 实际积累:根据参数显示
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (trend.length > 0) {
|
|
|
|
|
|
const monthValue = trend[i]
|
|
|
|
|
|
if (monthValue !== undefined && monthValue !== null) {
|
|
|
|
|
|
actualBurndown.push(Math.round(monthValue))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
} else {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
|
|
|
|
|
|
if (!isFuture && totalBudget > 0) {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress)
|
|
|
|
|
|
actualBurndown.push(Math.round(actualAccumulated))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const idealSeriesName = isExpense ? '理想支出' : '理想收入'
|
|
|
|
|
|
const actualSeriesName = isExpense ? '实际支出' : '实际收入'
|
2026-01-16 23:18:04 +08:00
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: months,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
formatter: (value) => {
|
|
|
|
|
|
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
let result = params[0].name + '<br/>'
|
|
|
|
|
|
params.forEach(param => {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (param.value !== null && param.value !== undefined) {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
2026-01-16 23:18:04 +08:00
|
|
|
|
name: idealSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: idealBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning'),
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
type: 'dashed'
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 1
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-01-16 23:18:04 +08:00
|
|
|
|
name: actualSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: actualBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary'),
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
yearBurndownChart.setOption(option, true)
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
|
|
|
|
|
|
watch(() => props.budgets, () => {
|
|
|
|
|
|
nextTick(() => {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
updateCharts()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
}, { deep: true })
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
2026-01-21 18:52:31 +08:00
|
|
|
|
watch(() => props.selectedDate, () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
updateCharts()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
watch(() => props.activeTab, () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
updateCharts()
|
|
|
|
|
|
// 切换标签后延迟 resize,确保 DOM 已更新
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
burndownChart?.resize()
|
|
|
|
|
|
yearBurndownChart?.resize()
|
2026-01-20 19:56:29 +08:00
|
|
|
|
varianceChart?.resize()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}, 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
const handleResize = () => {
|
|
|
|
|
|
monthGaugeChart?.resize()
|
|
|
|
|
|
yearGaugeChart?.resize()
|
2026-01-20 19:56:29 +08:00
|
|
|
|
varianceChart?.resize()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
burndownChart?.resize()
|
|
|
|
|
|
yearBurndownChart?.resize()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
nextTick(() => {
|
2026-01-21 18:52:31 +08:00
|
|
|
|
updateCharts()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('resize', handleResize)
|
|
|
|
|
|
monthGaugeChart?.dispose()
|
2026-01-21 18:52:31 +08:00
|
|
|
|
monthGaugeChart = null
|
2026-01-16 15:56:53 +08:00
|
|
|
|
yearGaugeChart?.dispose()
|
2026-01-21 18:52:31 +08:00
|
|
|
|
yearGaugeChart = null
|
|
|
|
|
|
disposeBudgetCharts()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
</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;
|
|
|
|
|
|
/* 减小内边距 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 16:09:38 +08:00
|
|
|
|
.gauge-wrapper {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 120px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-text-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 20%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remaining-value {
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
font-family: DIN Alternate, system-ui;
|
|
|
|
|
|
color: var(--van-text-color);
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
transform-origin: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.remaining-label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
font-family: system-ui;
|
|
|
|
|
|
transform-origin: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
/* 调小高度 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
.variance-chart {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
min-height: 200px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
.burndown-chart {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
height: 230px;
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-01-20 19:56:29 +08:00
|
|
|
|
/* expand styles removed as they are no longer used */
|
|
|
|
|
|
</style>
|