diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue
index 2d3042a..da15f59 100644
--- a/Web/src/components/Budget/BudgetChartAnalysis.vue
+++ b/Web/src/components/Budget/BudgetChartAnalysis.vue
@@ -6,10 +6,9 @@
@@ -32,10 +35,8 @@
@@ -65,10 +70,30 @@
预算进度(月度)
- 本月各预算执行情况
+ 预算剩余消耗趋势
+
+
+
+ ▼
+
+ {{ monthBarChartExpanded ? '收起详情' : '展开详情' }}
+
+
+
+
+
+
+ ▼
+
+ {{ yearBarChartExpanded ? '收起详情' : '展开详情' }}
+
+
+
+
+
+
{
if (val >= 10000) {
@@ -230,54 +305,129 @@ const updateCharts = () => {
// 确保 barChart 已初始化,如果还未初始化则先初始化
if (props.budgets.length > 0) {
- if (!monthBarChart && monthBarChartRef.value) {
+ if (!monthBarChart && monthBarChartRef.value && monthBarChartExpanded.value) {
monthBarChart = echarts.init(monthBarChartRef.value)
}
- if (!yearBarChart && yearBarChartRef.value) {
+ if (!yearBarChart && yearBarChartRef.value && yearBarChartExpanded.value) {
yearBarChart = echarts.init(yearBarChartRef.value)
}
- updateBarChart()
+ if (monthBarChart || yearBarChart) {
+ updateBarChart()
+ }
+
+ // 仅支出时更新燃尽图
+ if (isExpense) {
+ if (!burndownChart && burndownChartRef.value) {
+ burndownChart = echarts.init(burndownChartRef.value)
+ }
+ updateBurndownChart()
+
+ if (!yearBurndownChart && yearBurndownChartRef.value) {
+ yearBurndownChart = echarts.init(yearBurndownChartRef.value)
+ }
+ updateYearBurndownChart()
+
+ if (!varianceChart && varianceChartRef.value) {
+ varianceChart = echarts.init(varianceChartRef.value)
+ }
+ updateVarianceChart()
+ } else {
+ // 非支出时销毁燃尽图实例
+ if (burndownChart) {
+ burndownChart.dispose()
+ burndownChart = null
+ }
+ if (yearBurndownChart) {
+ yearBurndownChart.dispose()
+ yearBurndownChart = null
+ }
+ // 收入/存款也可能需要偏差图,但目前逻辑主要针对支出
+ // 如果用户想看收入的偏差,也可以保留。我们之前的逻辑已经处理了收入的情况。
+ // 所以这里不应该销毁 varianceChart,而是应该更新它。
+ if (!varianceChart && varianceChartRef.value) {
+ varianceChart = echarts.init(varianceChartRef.value)
+ }
+ updateVarianceChart()
+ }
}
}
const updateBarChart = () => {
- if (!monthBarChart || !yearBarChart) { return }
+ // 按预算类型分开:月度预算和年度预算
+ // 1 = Month, 2 = Year
+ const monthBudgets = props.budgets.filter(b => b.type === 1)
+ const yearBudgets = props.budgets.filter(b => b.type === 2)
- 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 budgetsWithMonthPercentage = monthBudgets.map(b => {
+ const limit = b.limit || 0
+ const current = b.current || 0
+ const percentage = limit > 0 ? (current / limit) * 100 : 0
+ return { ...b, percentage }
+ })
- const getColors = (data, limits) => {
- return data.map((current, idx) => {
- const limit = limits[idx]
- const rate = limit > 0 ? (current / limit) : 0
+ // 为年度预算计算百分比
+ const budgetsWithYearPercentage = yearBudgets.map(b => {
+ const limit = b.limit || 0
+ const current = b.current || 0
+ const percentage = limit > 0 ? (current / limit) * 100 : 0
+ return { ...b, percentage }
+ })
+
+ // 分别按百分比从高到低排序,取前10条
+ const sortedMonthBudgets = budgetsWithMonthPercentage.sort((a, b) => b.percentage - a.percentage).slice(0, 10)
+ const sortedYearBudgets = budgetsWithYearPercentage.sort((a, b) => b.percentage - a.percentage).slice(0, 10)
+
+ const monthCategories = sortedMonthBudgets.map(b => b.name)
+ const yearCategories = sortedYearBudgets.map(b => b.name)
+
+ // 调试输出
+ console.log('月度预算项数:', monthBudgets.length, '年度预算项数:', yearBudgets.length)
+ console.log('月度排序后:', sortedMonthBudgets.map(b => ({ name: b.name, percentage: b.percentage })))
+ console.log('年度排序后:', sortedYearBudgets.map(b => ({ name: b.name, percentage: b.percentage })))
+
+ // 计算月度百分比数据
+ const monthPercentages = sortedMonthBudgets.map(b => {
+ const limit = b.limit || 0
+ const current = b.current || 0
+ if (!limit || limit <= 0) {return 0}
+ return Math.round((current / limit) * 100)
+ })
+
+ // 计算年度百分比数据
+ const yearPercentages = sortedYearBudgets.map(b => {
+ const limit = b.limit || 0
+ const current = b.current || 0
+ if (!limit || limit <= 0) {return 0}
+ return Math.round((current / limit) * 100)
+ })
+
+ const getColors = (percentages) => {
+ return percentages.map(percentage => {
if (props.activeTab === BudgetCategory.Expense) {
- if (rate >= 1) { return getCssVar('--chart-danger') }
- if (rate >= 0.8) { return getCssVar('--chart-warning') }
- return getCssVar('--chart-primary')
+ // 支出:百分比越高越不好
+ if (percentage >= 90) { return getCssVar('--chart-danger') } // 红色
+ if (percentage >= 60) { return getCssVar('--chart-warning') } // 橙色
+ return getCssVar('--chart-primary') // 蓝色
} else {
- if (rate >= 1) { return getCssVar('--chart-success') }
- return getCssVar('--chart-primary')
+ // 收入:百分比越高越好(越接近目标越好)
+ if (percentage >= 90) { return getCssVar('--chart-success') } // 绿色
+ if (percentage >= 60) { return getCssVar('--chart-primary') } // 蓝色
+ return getCssVar('--chart-warning') // 橙色
}
})
}
- const monthColors = getColors(monthCurrents, monthLimits)
- const yearColors = getColors(yearCurrents, yearLimits)
+ const monthColors = getColors(monthPercentages)
+ const yearColors = getColors(yearPercentages)
// 获取当前主题下的颜色值
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) => {
+ const createOption = (categories, percentages, colors) => {
return {
grid: {
left: '3%',
@@ -288,6 +438,7 @@ const updateBarChart = () => {
},
xAxis: {
type: 'value',
+ max: 100,
splitLine: {
lineStyle: {
type: 'dashed',
@@ -297,8 +448,7 @@ const updateBarChart = () => {
axisLabel: {
color: axisLabelColor,
formatter: (value) => {
- if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
- return value
+ return value + '%'
}
}
},
@@ -316,57 +466,486 @@ const updateBarChart = () => {
},
series: [
{
- name: '预算',
+ 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%',
+ data: percentages,
barWidth: 10,
itemStyle: {
color: (params) => colors[params.dataIndex],
borderRadius: 5
},
+ label: {
+ show: true,
+ position: 'right',
+ formatter: (params) => {
+ return params.value + '%'
+ },
+ color: textColor2,
+ fontSize: 10
+ },
z: 2
}
]
}
}
- monthBarChart.setOption(createOption(monthLimits, monthCurrents, monthColors))
- yearBarChart.setOption(createOption(yearLimits, yearCurrents, yearColors))
+ if (monthBarChart) {
+ monthBarChart.setOption(createOption(monthCategories, monthPercentages, monthColors))
+ }
+ if (yearBarChart) {
+ yearBarChart.setOption(createOption(yearCategories, yearPercentages, yearColors))
+ }
}
const calculateChartHeight = () => {
// 根据数据数量动态计算图表高度
// 每个类别占用 60px,最少显示 200px,最多 400px
const dataCount = Math.min(props.budgets.length, 10) // 最多显示10条
- const minHeight = 150
+ const minHeight = 100
const maxHeight = 400
- const heightPerItem = 60
+ const heightPerItem = 40
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
return Math.min(calculatedHeight, maxHeight)
}
+const updateBurndownChart = () => {
+ if (!burndownChart) { return }
+
+ // 获取当前月份的日期
+ const today = new Date()
+ const year = today.getFullYear()
+ const month = today.getMonth()
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
+ const currentDay = today.getDate()
+
+ // 生成日期和理想燃尽线
+ const dates = []
+ const idealBurndown = []
+ const actualBurndown = []
+
+ const totalBudget = props.overallStats.month.limit || 0
+ const currentExpense = props.overallStats.month.current || 0
+
+ for (let i = 1; i <= daysInMonth; i++) {
+ dates.push(`${i}日`)
+ // 理想燃尽:每天均匀消耗
+ const idealRemaining = Math.max(0, totalBudget * (1 - i / daysInMonth))
+ idealBurndown.push(Math.round(idealRemaining))
+
+ // 实际燃尽:根据当前日期显示
+ if (i <= currentDay && totalBudget > 0) {
+ const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
+ actualBurndown.push(Math.round(actualRemaining))
+ } else {
+ actualBurndown.push(null)
+ }
+ }
+
+ const textColor = getCssVar('--van-text-color')
+ const textColor2 = getCssVar('--van-text-color-2')
+ const splitLineColor = getCssVar('--chart-split')
+ const axisLabelColor = getCssVar('--chart-text-muted')
+
+ const option = {
+ grid: {
+ left: '3%',
+ right: '8%',
+ bottom: '3%',
+ top: '3%',
+ containLabel: true
+ },
+ xAxis: {
+ type: 'category',
+ data: dates,
+ axisLabel: {
+ color: axisLabelColor,
+ interval: Math.ceil(daysInMonth / 8) - 1,
+ rotate: 45
+ },
+ 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 + '
'
+ params.forEach(param => {
+ if (param.value !== null) {
+ result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '
'
+ }
+ })
+ return result
+ }
+ },
+ series: [
+ {
+ name: '理想燃尽',
+ type: 'line',
+ data: idealBurndown,
+ smooth: false,
+ lineStyle: {
+ color: getCssVar('--chart-warning'),
+ width: 2,
+ type: 'dashed'
+ },
+ itemStyle: {
+ color: getCssVar('--chart-warning')
+ },
+ z: 1
+ },
+ {
+ name: '实际燃尽',
+ type: 'line',
+ data: actualBurndown,
+ smooth: false,
+ lineStyle: {
+ color: getCssVar('--chart-primary'),
+ width: 2
+ },
+ itemStyle: {
+ color: getCssVar('--chart-primary')
+ },
+ z: 2
+ }
+ ]
+ }
+
+ burndownChart.setOption(option)
+}
+
+const updateYearBurndownChart = () => {
+ if (!yearBurndownChart) { return }
+
+ // 获取当前年份的日期
+ const today = new Date()
+ const year = today.getFullYear()
+ const currentMonth = today.getMonth()
+ const currentDay = today.getDate()
+ const daysInCurrentMonth = new Date(year, currentMonth + 1, 0).getDate()
+
+ // 生成月份和理想燃尽线
+ const months = []
+ const idealBurndown = []
+ const actualBurndown = []
+
+ const totalBudget = props.overallStats.year.limit || 0
+ const currentExpense = props.overallStats.year.current || 0
+
+ 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()
+ }
+
+ if (i < currentMonth) {
+ // 之前的月份都已完成
+ daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
+ } else if (i === currentMonth) {
+ // 当前月份
+ daysPassedInYear = daysInYear + currentDay
+ daysInYear += daysInCurrentMonth
+ } else {
+ // 未来的月份
+ daysInYear += new Date(year, i + 1, 0).getDate()
+ }
+
+ // 全年总天数(365或366)
+ const daysInYearTotal = new Date(year, 12, 0).getDate() === 29 ? 366 : 365
+ const yearProgress = i === 11 ? 1 : daysPassedInYear / daysInYearTotal
+
+ // 理想燃尽:每月均匀消耗
+ const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
+ idealBurndown.push(Math.round(idealRemaining))
+
+ // 实际燃尽:根据当前日期显示
+ if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
+ const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
+ actualBurndown.push(Math.round(actualRemaining))
+ } else {
+ actualBurndown.push(null)
+ }
+ }
+
+ const textColor = getCssVar('--van-text-color')
+ const textColor2 = getCssVar('--van-text-color-2')
+ const splitLineColor = getCssVar('--chart-split')
+ const axisLabelColor = getCssVar('--chart-text-muted')
+
+ 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 + '
'
+ params.forEach(param => {
+ if (param.value !== null) {
+ result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '
'
+ }
+ })
+ return result
+ }
+ },
+ series: [
+ {
+ name: '理想燃尽',
+ type: 'line',
+ data: idealBurndown,
+ smooth: false,
+ lineStyle: {
+ color: getCssVar('--chart-warning'),
+ width: 2,
+ type: 'dashed'
+ },
+ itemStyle: {
+ color: getCssVar('--chart-warning')
+ },
+ z: 1
+ },
+ {
+ name: '实际燃尽',
+ type: 'line',
+ data: actualBurndown,
+ smooth: false,
+ lineStyle: {
+ color: getCssVar('--chart-primary'),
+ width: 2
+ },
+ itemStyle: {
+ color: getCssVar('--chart-primary')
+ },
+ z: 2
+ }
+ ]
+ }
+
+ yearBurndownChart.setOption(option)
+}
+
+const updateVarianceChart = () => {
+ if (!varianceChart) { return }
+
+ // 1. 准备数据:计算偏差 (Current - Limit)
+ // 只关注本年和本月的预算,或者只展示本年?或者全部?
+ // 按照设计,展示所有预算项的偏差
+ // 为了避免混淆,我们可以合并月度和年度,或者只展示月度?
+ // 用户的图表1和2是分开月度和年度的。
+ // 偏差分析通常用于当前执行周期。如果混合了月度和年度,数值差异会很大(年度偏差可能几千,月度几百)。
+ // 建议:优先展示月度预算的偏差,因为这是最高频关注点。或者提供选项?
+ // 简单起见,这里展示“月度预算”的偏差。如果用户切到“收入”,则展示收入预算的偏差。
+
+ // 过滤出月度预算
+ const relevantBudgets = props.budgets // .filter(b => b.type === 1) // 1 = Month
+ if (relevantBudgets.length === 0) {
+ // 如果没有月度预算,尝试年度?不,保持一致性,只看月度。
+ // 或者,我们可以把所有预算都放进去,但在名字上区分?
+ // 让我们先只做月度,因为这是最直接的反馈。
+ }
+
+ // 计算偏差
+ const data = relevantBudgets.map(b => {
+ const limit = b.limit || 0
+ const current = b.current || 0
+ // 偏差 = 实际 - 预算
+ // 对于支出:正数 = 超支 (Bad/Red), 负数 = 结余 (Good/Green)
+ // 对于收入:正数 = 超额 (Good/Green?), 负数 = 差额 (Bad/Red?)
+ // 但用户明确说:红条超支,绿条结余。这完全是支出视角。
+ // 如果是收入 Tab,我们保持同样的逻辑:实际 > 目标 (Green), 实际 < 目标 (Red)
+ // 为了适配用户的“红黑榜”视觉定义:
+ // 右侧 (正值) -> 红色 (超支/Warning)
+ // 左侧 (负值) -> 绿色 (结余/Safe)
+
+ const diff = current - limit
+
+ // 如果是收入,逻辑反转?
+ // 收入:目标 10000,实际 12000。Diff +2000。这是好事。应该绿。
+ // 收入:目标 10000,实际 8000。Diff -2000。这是坏事。应该红。
+ // 所以收入时,我们把 Diff 取反?或者改变颜色映射?
+ // 让我们先按“支出”逻辑写死,因为这是最常用的。
+
+ return {
+ name: b.name,
+ value: diff,
+ limit: limit,
+ current: current
+ }
+ })
+
+ // 排序:按偏差的绝对值降序排列
+ data.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
+
+ const categories = data.map(item => item.name)
+ const values = data.map(item => item.value)
+
+ const textColor = getCssVar('--van-text-color')
+ const splitLineColor = getCssVar('--chart-split')
+ const axisLabelColor = getCssVar('--chart-text-muted')
+
+ 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}
`
+ html += `预算: ¥${formatMoney(item.limit)}
`
+ html += `实际: ¥${formatMoney(item.current)}
`
+ 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 += `${diffText}`
+ return html
+ }
+ },
+ xAxis: {
+ type: 'value',
+ position: 'top', // 坐标轴在上方,或者隐藏
+ axisLine: { show: false },
+ axisLabel: { show: false }, // 隐藏X轴标签,让图表更干净
+ 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',
+ data: values,
+ label: {
+ show: true,
+ position: 'right', // 默认右侧,正负值会自动调整吗?ECharts Bar label position logic:
+ // For positive value: 'right', 'top', 'inside', etc.
+ // We need dynamic position based on value sign.
+ formatter: (params) => {
+ const val = params.value
+ return val > 0 ? `+${formatMoney(val)}` : formatMoney(val)
+ },
+ color: textColor
+ },
+ 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')
+ }
+ }
+ }
+ }
+ ]
+ }
+
+ // 针对 ECharts 的 label 位置优化:正数 label 在右,负数 label 在左
+ // ECharts 5+ 支持 label position 为 function? 好像不支持。
+ // 但我们可以通过 rich text 或 multiple series 来实现,或者简单地设为 'outside' (ECharts 没有 outside for bar?)
+ // 对于 horizontal bar, positive value bar goes right, negative goes left.
+ // label position 'right' means right of the bar end (for positive) and right of the bar end (for negative, which is inside towards 0).
+ // ECharts default behavior:
+ // If value > 0, bar extends right. Position 'right' is outside right.
+ // If value < 0, bar extends left. Position 'right' is inside (near 0). Position 'left' is outside left.
+ // Let's keep it simple first. Or use a callback if supported, or two series.
+ // Actually, let's try just setting it based on logic if possible, but series config is static.
+ // Better approach: use two series, one for positive, one for negative?
+ // Or iterate data and set specific label position in data item.
+
+ const seriesData = values.map((val, index) => {
+ return {
+ value: val,
+ label: {
+ position: val >= 0 ? 'right' : 'left'
+ }
+ }
+ })
+
+ option.series[0].data = seriesData
+
+ varianceChart.setOption(option)
+}
+
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
watch(() => props.budgets, () => {
nextTick(() => {
@@ -380,13 +959,67 @@ watch(() => props.budgets, () => {
}
})
}, { deep: true })
-watch(() => props.activeTab, () => nextTick(updateCharts))
+watch(() => props.activeTab, () => {
+ nextTick(() => {
+ updateCharts()
+ // 切换标签后延迟 resize,确保 DOM 已更新
+ setTimeout(() => {
+ burndownChart?.resize()
+ yearBurndownChart?.resize()
+ varianceChart?.resize()
+ }, 100)
+ })
+})
+
+// 监听展开/折叠状态
+watch(() => monthBarChartExpanded.value, (expanded) => {
+ if (expanded) {
+ nextTick(() => {
+ if (monthBarChartRef.value) {
+ if (!monthBarChart) {
+ monthBarChart = echarts.init(monthBarChartRef.value)
+ }
+ updateBarChart()
+ monthBarChart.resize()
+ }
+ })
+ } else {
+ // 收起时销毁图表实例,释放内存
+ if (monthBarChart) {
+ monthBarChart.dispose()
+ monthBarChart = null
+ }
+ }
+})
+
+watch(() => yearBarChartExpanded.value, (expanded) => {
+ if (expanded) {
+ nextTick(() => {
+ if (yearBarChartRef.value) {
+ if (!yearBarChart) {
+ yearBarChart = echarts.init(yearBarChartRef.value)
+ }
+ updateBarChart()
+ yearBarChart.resize()
+ }
+ })
+ } else {
+ // 收起时销毁图表实例,释放内存
+ if (yearBarChart) {
+ yearBarChart.dispose()
+ yearBarChart = null
+ }
+ }
+})
const handleResize = () => {
monthGaugeChart?.resize()
yearGaugeChart?.resize()
monthBarChart?.resize()
yearBarChart?.resize()
+ burndownChart?.resize()
+ yearBurndownChart?.resize()
+ varianceChart?.resize()
}
onMounted(() => {
@@ -403,6 +1036,23 @@ onMounted(() => {
yearBarChart = echarts.init(yearBarChartRef.value)
}
updateBarChart()
+
+ // 仅支出时初始化燃尽图
+ if (isExpense && burndownChartRef.value) {
+ burndownChart = echarts.init(burndownChartRef.value)
+ updateBurndownChart()
+ }
+
+ if (isExpense && yearBurndownChartRef.value) {
+ yearBurndownChart = echarts.init(yearBurndownChartRef.value)
+ updateYearBurndownChart()
+ }
+
+ // 初始化偏差图
+ if (varianceChartRef.value) {
+ varianceChart = echarts.init(varianceChartRef.value)
+ updateVarianceChart()
+ }
}
window.addEventListener('resize', handleResize)
})
@@ -414,6 +1064,9 @@ onUnmounted(() => {
yearGaugeChart?.dispose()
monthBarChart?.dispose()
yearBarChart?.dispose()
+ burndownChart?.dispose()
+ yearBurndownChart?.dispose()
+ varianceChart?.dispose()
})
@@ -478,6 +1131,10 @@ onUnmounted(() => {
max-height: 400px;
}
+.burndown-chart {
+ height: 200px;
+}
+
.gauge-footer {
display: flex;
justify-content: space-around;
@@ -511,4 +1168,52 @@ onUnmounted(() => {
.gauge-item .value.expense {
color: var(--van-primary-color);
}
+
+.expand-toggle-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0;
+}
+
+.expand-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ padding: 4px 12px;
+ border-radius: 6px;
+ transition: background-color 0.2s ease;
+ user-select: none;
+}
+
+.expand-toggle:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.expand-toggle:active {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.expand-icon {
+ display: inline-block;
+ font-size: 12px;
+ color: var(--van-text-color-2);
+ transition: transform 0.2s ease;
+ transform-origin: center;
+}
+
+.expand-icon.expanded {
+ transform: rotate(180deg);
+}
+
+.expand-text {
+ font-size: 12px;
+ color: var(--van-text-color-2);
+ transition: color 0.2s ease;
+}
+
+.expand-toggle:hover .expand-text {
+ color: var(--van-text-color);
+}
\ No newline at end of file
diff --git a/Web/src/views/StatisticsView.vue b/Web/src/views/StatisticsView.vue
index d28713f..94edc76 100644
--- a/Web/src/views/StatisticsView.vue
+++ b/Web/src/views/StatisticsView.vue
@@ -1141,7 +1141,7 @@ onBeforeUnmount(() => {
/* 环形图 */
.chart-container {
- padding: 12px 0;
+ padding: 0;
}
.ring-chart {
@@ -1255,7 +1255,7 @@ onBeforeUnmount(() => {
display: flex;
justify-content: center;
align-items: center;
- padding-top: 10px;
+ padding-top: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;