Files
EmailBill/Web/src/components/Budget/BudgetChartAnalysis.vue

1117 lines
31 KiB
Vue
Raw Normal View History

2026-01-25 13:22:51 +08:00
<template>
2026-01-16 15:56:53 +08:00
<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
<!-- 月度健康度 -->
2026-01-22 21:03:00 +08:00
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'month'"
/>
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"
>
2026-02-01 10:27:04 +08:00
{{
activeTab === BudgetCategory.Expense
? (
overallStats.month.current > overallStats.month.limit
? '超支'
: '余额'
)
: overallStats.month.current > overallStats.month.limit
? '超额'
: '差额'
}}
2026-01-21 16:09:38 +08:00
</div>
<div
class="remaining-value"
2026-02-01 10:27:04 +08:00
:style="{ color:
overallStats.month.current > overallStats.month.limit
? activeTab === BudgetCategory.Expense ? 'var(--van-danger-color)' : 'var(--van-success-color)'
: ''
}"
2026-01-21 16:09:38 +08:00
>
2026-02-01 10:27:04 +08:00
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
2026-01-21 16:09:38 +08:00
</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-22 21:03:00 +08:00
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'year'"
/>
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"
>
2026-02-01 10:27:04 +08:00
{{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
2026-01-21 16:09:38 +08:00
</div>
<div
class="remaining-value"
2026-02-01 10:27:04 +08:00
:style="{ color: activeTab === BudgetCategory.Expense && overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '' }"
2026-01-21 16:09:38 +08:00
>
2026-02-01 10:27:04 +08:00
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
2026-01-21 16:09:38 +08:00
</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"
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>
2026-01-22 21:03:00 +08:00
<!-- 详细描述弹窗 -->
<PopupContainer
v-model="showDescriptionPopup"
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
height="70%"
>
<div
class="rich-html-content"
style="padding: 16px"
v-html="activeDescTab === 'month' ? (overallStats.month?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>') : (overallStats.year?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>')"
/>
</PopupContainer>
2026-01-16 15:56:53 +08:00
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
2026-01-16 15:56:53 +08:00
import * as echarts from 'echarts'
2026-01-25 13:22:51 +08:00
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
2026-01-16 15:56:53 +08:00
import { getCssVar } from '@/utils/theme'
2026-01-22 21:03:00 +08:00
import PopupContainer from '@/components/PopupContainer.vue'
2026-01-16 15:56:53 +08:00
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-22 21:03:00 +08:00
// 弹窗状态
const showDescriptionPopup = ref(false)
const activeDescTab = ref('month')
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-16 15:56:53 +08:00
const formatMoney = (val) => {
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 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-02-01 10:27:04 +08:00
// 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗;超支时显示超出部分
2026-01-18 13:06:16 +08:00
displayRate = Math.max(0, 100 - rate)
2026-02-01 10:27:04 +08:00
// 如果超支(rate > 100)显示超支部分例如110% -> 显示10%超支)
if (rate > 100) {
displayRate = rate - 100
}
2026-01-16 15:56:53 +08:00
} else {
2026-02-01 10:27:04 +08:00
// 收入:显示已积累 (%)随收入增多逐渐增多可以超过100%
displayRate = rate
2026-01-18 13:06:16 +08:00
}
// 颜色逻辑:支出从绿色消耗到红色,收入从红色积累到绿色
let color
if (isExpense) {
// 支出:满格绿色,随消耗逐渐变红 (根据剩余容量)
2026-02-01 10:27:04 +08:00
if (rate > 100) {
color = getCssVar('--chart-danger') // 超支显示红色
} else if (displayRate <= 30) {
color = getCssVar('--chart-danger') // 红色(剩余很少)
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning') // 橙色
} else {
color = getCssVar('--chart-success') // 绿色(剩余充足)
}
2026-01-18 13:06:16 +08:00
} 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-02-01 10:27:04 +08:00
max: isExpense && rate > 100 ? 50 : 100, // 超支时显示0-50%范围实际代表0-150%
2026-01-16 15:56:53 +08:00
splitNumber: 5,
2026-01-22 21:03:00 +08:00
radius: '120%', // 放大一点以适应小卡片
center: ['50%', '70%'],
2026-01-16 15:56:53 +08:00
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
}
}
const updateVarianceChart = (chart, budgets) => {
if (!chart || !budgets || budgets.length === 0) { return }
2026-01-16 17:52: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
const diff = current - limit
return {
2026-01-25 13:22:51 +08:00
name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
value: diff,
limit: limit,
2026-01-25 13:22:51 +08:00
current: current,
type: b.type
}
2026-01-16 17:52:40 +08:00
})
2026-01-25 13:22:51 +08:00
// 先月度再年度,各自按偏差绝对值排序
const monthlyData = data.filter(item => item.type === BudgetPeriodType.Month)
const annualData = data.filter(item => item.type === BudgetPeriodType.Year)
2026-01-16 15:56:53 +08:00
2026-01-25 13:22:51 +08:00
monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
// 确保月度在前,年度在后
const sortedData = [...annualData,...monthlyData]
const categories = sortedData.map(item => item.name)
const values = sortedData.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')
const option = {
grid: {
left: '3%',
right: '8%',
bottom: '3%',
top: '3%',
containLabel: true
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
2026-01-28 17:00:58 +08:00
const item = sortedData[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'
return {
value: val,
label: {
2026-01-17 15:03:19 +08:00
position: position,
color: color
}
2026-01-16 15:56:53 +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
},
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-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
}
const calculateChartHeight = (budgets) => {
if (!budgets) { return 100 }
const dataCount = budgets.length
2026-01-16 17:52:40 +08:00
const minHeight = 100
const heightPerItem = 30 // Fixed height per bar item
2026-01-16 15:56:53 +08:00
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
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
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-16 23:18:04 +08:00
if (isExpense) {
// 支出:燃尽图(向下走)
// 理想燃尽:每天均匀消耗
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - i / daysInMonth)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
2026-02-01 10:27:04 +08:00
// 实际燃尽:根据当前日期显示,允许负值以表示超支
if (trend.length > 0) {
2026-01-21 18:52:31 +08:00
// 后端返回了趋势数据
const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = 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 到线性估算
if (i <= currentDay && totalBudget > 0) {
2026-02-01 10:27:04 +08:00
// 允许显示负值以表示超支
const actualRemaining = 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))
// 实际积累:根据当前日期显示
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 {
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 => {
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
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) {
// 支出:燃尽图(向下走)
// 理想燃尽:每月均匀消耗
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - (i + 1) / 12)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
2026-02-01 10:27:04 +08:00
// 实际燃尽:根据日期显示,允许负值以表示超支
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = 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-02-01 10:27:04 +08:00
const actualRemaining = 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
// 实际积累:根据参数显示
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) {
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')
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 => {
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(() => {
updateCharts()
2026-01-16 15:56:53 +08:00
})
}, { deep: true })
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;
/* 调小高度 */
}
.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-25 13:27:55 +08:00
height: 190px;
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>