feat(预算): 添加预算趋势分析功能并优化图表展示
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 38s
Docker Build & Deploy / Deploy to Production (push) Successful in 10s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

重构预算分析图表组件,添加每日/每月趋势统计功能:
1. 在Repository层添加趋势统计接口
2. 在Service层实现趋势数据计算逻辑
3. 优化前端图表展示,合并偏差分析图表
4. 改进燃尽图使用实际趋势数据
5. 调整UI样式和交互体验
This commit is contained in:
SunCheng
2026-01-17 14:38:40 +08:00
parent 3cbc868e9b
commit 2043976998
5 changed files with 372 additions and 441 deletions

View File

@@ -198,6 +198,16 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <returns>影响行数</returns> /// <returns>影响行数</returns>
Task<int> ConfirmAllUnconfirmedAsync(long[] ids); Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
/// <summary>
/// 获取指定分类在指定时间范围内的每日/每月统计趋势
/// </summary>
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false);
/// <summary> /// <summary>
/// 更新分类名称 /// 更新分类名称
/// </summary> /// </summary>
@@ -719,6 +729,37 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.Where(t => ids.Contains(t.Id)) .Where(t => ids.Contains(t.Id))
.ExecuteAffrowsAsync(); .ExecuteAffrowsAsync();
} }
public async Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
DateTime startDate,
DateTime endDate,
TransactionType type,
IEnumerable<string> classifies,
bool groupByMonth = false)
{
var query = FreeSql.Select<TransactionRecord>()
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type);
if (classifies != null && classifies.Any())
{
query = query.Where(t => classifies.Contains(t.Classify));
}
var list = await query.ToListAsync(t => new { t.OccurredAt, t.Amount });
if (groupByMonth)
{
return list
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
else
{
return list
.GroupBy(t => t.OccurredAt.Date)
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
}
} }
/// <summary> /// <summary>

View File

@@ -230,6 +230,91 @@ public class BudgetService(
result.Current = totalCurrent; result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
// 计算每日/每月趋势
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
if (transactionType != TransactionType.None)
{
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories == null || b.SelectedCategories.Length == 0);
var allClassifies = hasGlobalBudget
? new List<string>()
: relevant
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
DateTime startDate, endDate;
bool groupByMonth = false;
if (statType == BudgetPeriodType.Month)
{
startDate = new DateTime(referenceDate.Year, referenceDate.Month, 1);
endDate = startDate.AddMonths(1).AddDays(-1);
groupByMonth = false;
}
else // Year
{
startDate = new DateTime(referenceDate.Year, 1, 1);
endDate = startDate.AddYears(1).AddDays(-1);
groupByMonth = true;
}
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
startDate,
endDate,
transactionType,
allClassifies,
groupByMonth);
decimal accumulated = 0;
var now = DateTime.Now;
if (statType == BudgetPeriodType.Month)
{
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
for (int i = 1; i <= daysInMonth; i++)
{
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
if (currentDate.Date > now.Date)
{
result.Trend.Add(null);
continue;
}
if (dailyStats.TryGetValue(currentDate.Date, out var amount))
{
accumulated += amount;
}
result.Trend.Add(accumulated);
}
}
else // Year
{
for (int i = 1; i <= 12; i++)
{
var currentMonthDate = new DateTime(startDate.Year, i, 1);
if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month))
{
result.Trend.Add(null);
continue;
}
if (dailyStats.TryGetValue(currentMonthDate, out var amount))
{
accumulated += amount;
}
result.Trend.Add(accumulated);
}
}
}
return result; return result;
} }
@@ -896,6 +981,11 @@ public class BudgetStatsDto
/// 预算项数量 /// 预算项数量
/// </summary> /// </summary>
public int Count { get; set; } public int Count { get; set; }
/// <summary>
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
/// </summary>
public List<decimal?> Trend { get; set; } = new();
} }
/// <summary> /// <summary>

View File

@@ -80,22 +80,22 @@
<div class="expand-toggle-row"> <div class="expand-toggle-row">
<div <div
class="expand-toggle" class="expand-toggle"
@click="monthBarChartExpanded = !monthBarChartExpanded" @click="monthVarianceExpanded = !monthVarianceExpanded"
> >
<span <span
class="expand-icon" class="expand-icon"
:class="{ expanded: monthBarChartExpanded }" :class="{ expanded: monthVarianceExpanded }"
> >
</span> </span>
<span class="expand-text">{{ monthBarChartExpanded ? '收起详情' : '展开详情' }}</span> <span class="expand-text">{{ monthVarianceExpanded ? '收起偏差分析' : '展开偏差分析' }}</span>
</div> </div>
</div> </div>
<div <div
v-if="monthBarChartExpanded" v-if="monthVarianceExpanded"
ref="monthBarChartRef" ref="monthVarianceChartRef"
class="chart-body bar-chart" class="chart-body variance-chart"
:style="{ height: calculateChartHeight() + 'px' }" :style="{ height: calculateChartHeight(monthBudgets) + 'px' }"
/> />
</div> </div>
@@ -120,44 +120,22 @@
<div class="expand-toggle-row"> <div class="expand-toggle-row">
<div <div
class="expand-toggle" class="expand-toggle"
@click="yearBarChartExpanded = !yearBarChartExpanded" @click="yearVarianceExpanded = !yearVarianceExpanded"
> >
<span <span
class="expand-icon" class="expand-icon"
:class="{ expanded: yearBarChartExpanded }" :class="{ expanded: yearVarianceExpanded }"
> >
</span> </span>
<span class="expand-text">{{ yearBarChartExpanded ? '收起详情' : '展开详情' }}</span> <span class="expand-text">{{ yearVarianceExpanded ? '收起偏差分析' : '展开偏差分析' }}</span>
</div> </div>
</div> </div>
<div <div
v-if="yearBarChartExpanded" v-if="yearVarianceExpanded"
ref="yearBarChartRef" ref="yearVarianceChartRef"
class="chart-body bar-chart" class="chart-body variance-chart"
:style="{ height: calculateChartHeight() + 'px' }" :style="{ height: calculateChartHeight(yearBudgets) + '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">
<span style="color: var(--van-danger-color)">红条超支</span> /
<span style="color: var(--van-success-color)">绿条结余</span>
</div>
</div>
<div
ref="varianceChartRef"
class="chart-body bar-chart"
:style="{ height: calculateChartHeight() + 'px' }"
/> />
</div> </div>
@@ -175,7 +153,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue' import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { BudgetCategory } from '@/constants/enums' import { BudgetCategory } from '@/constants/enums'
import { getCssVar } from '@/utils/theme' import { getCssVar } from '@/utils/theme'
@@ -197,23 +175,26 @@ const props = defineProps({
const monthGaugeRef = ref(null) const monthGaugeRef = ref(null)
const yearGaugeRef = ref(null) const yearGaugeRef = ref(null)
const monthBarChartRef = ref(null) const monthVarianceChartRef = ref(null)
const yearBarChartRef = ref(null) const yearVarianceChartRef = ref(null)
const burndownChartRef = ref(null) const burndownChartRef = ref(null)
const yearBurndownChartRef = ref(null) const yearBurndownChartRef = ref(null)
const varianceChartRef = ref(null)
const monthBarChartExpanded = ref(false) const monthVarianceExpanded = ref(false)
const yearBarChartExpanded = ref(false) const yearVarianceExpanded = ref(false)
let monthGaugeChart = null let monthGaugeChart = null
let yearGaugeChart = null let yearGaugeChart = null
let monthBarChart = null let monthVarianceChart = null
let yearBarChart = null let yearVarianceChart = null
let burndownChart = null let burndownChart = null
let yearBurndownChart = null let yearBurndownChart = null
let varianceChart = null
const monthBudgets = computed(() => (props.budgets || []).filter(b => b.type === 1))
const yearBudgets = computed(() => (props.budgets || []).filter(b => b.type === 2))
const formatMoney = (val) => { const formatMoney = (val) => {
if (val >= 10000) { if (Math.abs(val) >= 10000) {
return (val / 10000).toFixed(1) + 'w' return (val / 10000).toFixed(1) + 'w'
} }
return parseFloat(val || 0).toLocaleString(undefined, { return parseFloat(val || 0).toLocaleString(undefined, {
@@ -301,16 +282,20 @@ const updateCharts = () => {
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense) updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense) updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
// 确保 barChart 已初始化,如果还未初始化则先初始化
if (props.budgets.length > 0) { if (props.budgets.length > 0) {
if (!monthBarChart && monthBarChartRef.value && monthBarChartExpanded.value) { // Update Variance Charts
monthBarChart = echarts.init(monthBarChartRef.value) if (!monthVarianceChart && monthVarianceChartRef.value && monthVarianceExpanded.value) {
monthVarianceChart = echarts.init(monthVarianceChartRef.value)
} }
if (!yearBarChart && yearBarChartRef.value && yearBarChartExpanded.value) { if (monthVarianceChart) {
yearBarChart = echarts.init(yearBarChartRef.value) updateVarianceChart(monthVarianceChart, monthBudgets.value)
} }
if (monthBarChart || yearBarChart) {
updateBarChart() if (!yearVarianceChart && yearVarianceChartRef.value && yearVarianceExpanded.value) {
yearVarianceChart = echarts.init(yearVarianceChartRef.value)
}
if (yearVarianceChart) {
updateVarianceChart(yearVarianceChart, yearBudgets.value)
} }
// 更新燃尽图/积累图 // 更新燃尽图/积累图
@@ -323,91 +308,34 @@ const updateCharts = () => {
yearBurndownChart = echarts.init(yearBurndownChartRef.value) yearBurndownChart = echarts.init(yearBurndownChartRef.value)
} }
updateYearBurndownChart() updateYearBurndownChart()
if (!varianceChart && varianceChartRef.value) {
varianceChart = echarts.init(varianceChartRef.value)
}
updateVarianceChart()
} }
} }
const updateBarChart = () => { const updateVarianceChart = (chart, budgets) => {
// 按预算类型分开:月度预算和年度预算 if (!chart || !budgets || budgets.length === 0) { return }
// 1 = Month, 2 = Year
const monthBudgets = props.budgets.filter(b => b.type === 1)
const yearBudgets = props.budgets.filter(b => b.type === 2)
// 为月度预算计算百分比 const data = budgets.map(b => {
const budgetsWithMonthPercentage = monthBudgets.map(b => {
const limit = b.limit || 0 const limit = b.limit || 0
const current = b.current || 0 const current = b.current || 0
const percentage = limit > 0 ? (current / limit) * 100 : 0 const diff = current - limit
return { ...b, percentage }
})
// 为年度预算计算百分比
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 (percentage >= 90) { return getCssVar('--chart-danger') } // 红色
if (percentage >= 60) { return getCssVar('--chart-warning') } // 橙色
return getCssVar('--chart-primary') // 蓝色
} else {
// 收入:百分比越高越好(越接近目标越好)
if (percentage >= 90) { return getCssVar('--chart-success') } // 绿色
if (percentage >= 60) { return getCssVar('--chart-primary') } // 蓝色
return getCssVar('--chart-warning') // 橙色
}
})
}
const monthColors = getColors(monthPercentages)
const yearColors = getColors(yearPercentages)
// 获取当前主题下的颜色值
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 createOption = (categories, percentages, colors) => {
return { return {
name: b.name,
value: diff,
limit: limit,
current: current
}
})
// Sort by absolute variance
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 option = {
grid: { grid: {
left: '3%', left: '3%',
right: '8%', right: '8%',
@@ -415,76 +343,91 @@ const updateBarChart = () => {
top: '3%', top: '3%',
containLabel: true 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: { xAxis: {
type: 'value', type: 'value',
max: 100, position: 'top',
axisLine: { show: false },
axisLabel: { show: false },
splitLine: { splitLine: {
show: true,
lineStyle: { lineStyle: {
type: 'dashed', type: 'dashed',
color: splitLineColor color: splitLineColor
} }
},
axisLabel: {
color: axisLabelColor,
formatter: (value) => {
return value + '%'
}
} }
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: categories,
axisLine: { show: false },
axisTick: { show: false }, axisTick: { show: false },
axisLine: { show: false },
data: categories,
axisLabel: { axisLabel: {
color: textColor, color: textColor,
width: 50, width: 60,
overflow: 'truncate', overflow: 'truncate'
interval: 0
} }
}, },
series: [ series: [
{ {
name: '预算使用率', name: '偏差',
type: 'bar', type: 'bar',
data: percentages, stack: 'Total',
barWidth: 10, barWidth: 20, // Fixed bar width
itemStyle: { data: values.map((val, index) => {
color: (params) => colors[params.dataIndex], return {
borderRadius: 5 value: val,
}, label: {
position: val >= 0 ? 'insideLeft' : 'insideRight',
color: '#fff'
}
}
}),
label: { label: {
show: true, show: true,
position: 'right',
formatter: (params) => { formatter: (params) => {
return params.value + '%' const val = params.value
return val > 0 ? `+${formatMoney(val)}` : formatMoney(val)
}, },
color: textColor2, color: textColor
fontSize: 10
}, },
z: 2 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')
}
}
}
} }
] ]
} }
chart.setOption(option)
} }
if (monthBarChart) { const calculateChartHeight = (budgets) => {
monthBarChart.setOption(createOption(monthCategories, monthPercentages, monthColors)) if (!budgets) { return 100 }
} const dataCount = budgets.length
if (yearBarChart) {
yearBarChart.setOption(createOption(yearCategories, yearPercentages, yearColors))
}
}
const calculateChartHeight = () => {
// 根据数据数量动态计算图表高度
// 每个类别占用 60px最少显示 200px最多 400px
const dataCount = Math.min(props.budgets.length, 10) // 最多显示10条
const minHeight = 100 const minHeight = 100
const maxHeight = 400 const heightPerItem = 30 // Fixed height per bar item
const heightPerItem = 40
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem) const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
return Math.min(calculatedHeight, maxHeight) return calculatedHeight
} }
const updateBurndownChart = () => { const updateBurndownChart = () => {
@@ -505,6 +448,7 @@ const updateBurndownChart = () => {
const totalBudget = props.overallStats.month.limit || 0 const totalBudget = props.overallStats.month.limit || 0
const currentExpense = props.overallStats.month.current || 0 const currentExpense = props.overallStats.month.current || 0
const trend = props.overallStats.month.trend || []
for (let i = 1; i <= daysInMonth; i++) { for (let i = 1; i <= daysInMonth; i++) {
dates.push(`${i}`) dates.push(`${i}`)
@@ -516,12 +460,22 @@ const updateBurndownChart = () => {
idealBurndown.push(Math.round(idealRemaining)) idealBurndown.push(Math.round(idealRemaining))
// 实际燃尽:根据当前日期显示 // 实际燃尽:根据当前日期显示
if (trend.length > 0) {
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)
}
} else {
if (i <= currentDay && totalBudget > 0) { if (i <= currentDay && totalBudget > 0) {
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay)) const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
} }
}
} else { } else {
// 收入:积累图(向上走) // 收入:积累图(向上走)
// 理想积累:每天均匀积累 // 理想积累:每天均匀积累
@@ -529,6 +483,14 @@ const updateBurndownChart = () => {
idealBurndown.push(Math.round(idealAccumulated)) 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)
}
} else {
if (i <= currentDay && totalBudget > 0) { if (i <= currentDay && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay) const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay)
actualBurndown.push(Math.round(actualAccumulated)) actualBurndown.push(Math.round(actualAccumulated))
@@ -537,6 +499,7 @@ const updateBurndownChart = () => {
} }
} }
} }
}
const textColor = getCssVar('--van-text-color') const textColor = getCssVar('--van-text-color')
const textColor2 = getCssVar('--van-text-color-2') const textColor2 = getCssVar('--van-text-color-2')
@@ -590,7 +553,7 @@ const updateBurndownChart = () => {
formatter: (params) => { formatter: (params) => {
let result = params[0].name + '<br/>' let result = params[0].name + '<br/>'
params.forEach(param => { params.forEach(param => {
if (param.value !== null) { if (param.value !== null && param.value !== undefined) {
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>' result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
} }
}) })
@@ -651,6 +614,7 @@ const updateYearBurndownChart = () => {
const totalBudget = props.overallStats.year.limit || 0 const totalBudget = props.overallStats.year.limit || 0
const currentExpense = props.overallStats.year.current || 0 const currentExpense = props.overallStats.year.current || 0
const trend = props.overallStats.year.trend || []
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
months.push(`${i + 1}`) months.push(`${i + 1}`)
@@ -686,12 +650,22 @@ const updateYearBurndownChart = () => {
idealBurndown.push(Math.round(idealRemaining)) idealBurndown.push(Math.round(idealRemaining))
// 实际燃尽:根据当前日期显示 // 实际燃尽:根据当前日期显示
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)
}
} else {
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress)) const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
actualBurndown.push(Math.round(actualRemaining)) actualBurndown.push(Math.round(actualRemaining))
} else { } else {
actualBurndown.push(null) actualBurndown.push(null)
} }
}
} else { } else {
// 收入:积累图(向上走) // 收入:积累图(向上走)
// 理想积累:每月均匀积累 // 理想积累:每月均匀积累
@@ -699,6 +673,14 @@ const updateYearBurndownChart = () => {
idealBurndown.push(Math.round(idealAccumulated)) idealBurndown.push(Math.round(idealAccumulated))
// 实际积累:根据当前日期显示 // 实际积累:根据当前日期显示
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
actualBurndown.push(Math.round(monthValue))
} else {
actualBurndown.push(null)
}
} else {
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress) const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress)
actualBurndown.push(Math.round(actualAccumulated)) actualBurndown.push(Math.round(actualAccumulated))
@@ -707,14 +689,13 @@ const updateYearBurndownChart = () => {
} }
} }
} }
}
const textColor = getCssVar('--van-text-color')
const textColor2 = getCssVar('--van-text-color-2')
const splitLineColor = getCssVar('--chart-split') const splitLineColor = getCssVar('--chart-split')
const axisLabelColor = getCssVar('--chart-text-muted') const axisLabelColor = getCssVar('--chart-text-muted')
const idealSeriesName = isExpense ? '理想燃尽' : '理想积累' const idealSeriesName = isExpense ? '理想支出' : '理想收入'
const actualSeriesName = isExpense ? '实际燃尽' : '实际积累' const actualSeriesName = isExpense ? '实际支出' : '实际收入'
const option = { const option = {
grid: { grid: {
@@ -758,7 +739,7 @@ const updateYearBurndownChart = () => {
formatter: (params) => { formatter: (params) => {
let result = params[0].name + '<br/>' let result = params[0].name + '<br/>'
params.forEach(param => { params.forEach(param => {
if (param.value !== null) { if (param.value !== null && param.value !== undefined) {
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>' result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
} }
}) })
@@ -801,184 +782,13 @@ const updateYearBurndownChart = () => {
yearBurndownChart.setOption(option) 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}<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 }, // 隐藏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.overallStats, () => nextTick(updateCharts), { deep: true })
watch(() => props.budgets, () => { watch(() => props.budgets, () => {
nextTick(() => { 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() updateCharts()
}
}) })
}, { deep: true }) }, { deep: true })
watch(() => props.activeTab, () => { watch(() => props.activeTab, () => {
nextTick(() => { nextTick(() => {
updateCharts() updateCharts()
@@ -986,48 +796,49 @@ watch(() => props.activeTab, () => {
setTimeout(() => { setTimeout(() => {
burndownChart?.resize() burndownChart?.resize()
yearBurndownChart?.resize() yearBurndownChart?.resize()
varianceChart?.resize() monthVarianceChart?.resize()
yearVarianceChart?.resize()
}, 100) }, 100)
}) })
}) })
// 监听展开/折叠状态 // 监听展开/折叠状态
watch(() => monthBarChartExpanded.value, (expanded) => { watch(() => monthVarianceExpanded.value, (expanded) => {
if (expanded) { if (expanded) {
nextTick(() => { nextTick(() => {
if (monthBarChartRef.value) { if (monthVarianceChartRef.value) {
if (!monthBarChart) { if (!monthVarianceChart) {
monthBarChart = echarts.init(monthBarChartRef.value) monthVarianceChart = echarts.init(monthVarianceChartRef.value)
} }
updateBarChart() updateVarianceChart(monthVarianceChart, monthBudgets.value)
monthBarChart.resize() monthVarianceChart.resize()
} }
}) })
} else { } else {
// 收起时销毁图表实例,释放内存 // 收起时销毁图表实例,释放内存
if (monthBarChart) { if (monthVarianceChart) {
monthBarChart.dispose() monthVarianceChart.dispose()
monthBarChart = null monthVarianceChart = null
} }
} }
}) })
watch(() => yearBarChartExpanded.value, (expanded) => { watch(() => yearVarianceExpanded.value, (expanded) => {
if (expanded) { if (expanded) {
nextTick(() => { nextTick(() => {
if (yearBarChartRef.value) { if (yearVarianceChartRef.value) {
if (!yearBarChart) { if (!yearVarianceChart) {
yearBarChart = echarts.init(yearBarChartRef.value) yearVarianceChart = echarts.init(yearVarianceChartRef.value)
} }
updateBarChart() updateVarianceChart(yearVarianceChart, yearBudgets.value)
yearBarChart.resize() yearVarianceChart.resize()
} }
}) })
} else { } else {
// 收起时销毁图表实例,释放内存 // 收起时销毁图表实例,释放内存
if (yearBarChart) { if (yearVarianceChart) {
yearBarChart.dispose() yearVarianceChart.dispose()
yearBarChart = null yearVarianceChart = null
} }
} }
}) })
@@ -1035,11 +846,10 @@ watch(() => yearBarChartExpanded.value, (expanded) => {
const handleResize = () => { const handleResize = () => {
monthGaugeChart?.resize() monthGaugeChart?.resize()
yearGaugeChart?.resize() yearGaugeChart?.resize()
monthBarChart?.resize() monthVarianceChart?.resize()
yearBarChart?.resize() yearVarianceChart?.resize()
burndownChart?.resize() burndownChart?.resize()
yearBurndownChart?.resize() yearBurndownChart?.resize()
varianceChart?.resize()
} }
onMounted(() => { onMounted(() => {
@@ -1049,13 +859,9 @@ onMounted(() => {
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense) yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
// 只在有数据时初始化柱状图 // 只在有数据时初始化柱状图
if (props.budgets.length > 0) { if (props.budgets.length > 0) {
if (monthBarChartRef.value) { // Variance charts are initialized when expanded, or if we want to init them eagerly?
monthBarChart = echarts.init(monthBarChartRef.value) // Based on watch logic, we init when expanded.
} // But updateCharts logic tries to update if exists.
if (yearBarChartRef.value) {
yearBarChart = echarts.init(yearBarChartRef.value)
}
updateBarChart()
// 初始化燃尽图/积累图 // 初始化燃尽图/积累图
if (burndownChartRef.value) { if (burndownChartRef.value) {
@@ -1067,12 +873,6 @@ onMounted(() => {
yearBurndownChart = echarts.init(yearBurndownChartRef.value) yearBurndownChart = echarts.init(yearBurndownChartRef.value)
updateYearBurndownChart() updateYearBurndownChart()
} }
// 初始化偏差图
if (varianceChartRef.value) {
varianceChart = echarts.init(varianceChartRef.value)
updateVarianceChart()
}
} }
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
}) })
@@ -1082,11 +882,10 @@ onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
monthGaugeChart?.dispose() monthGaugeChart?.dispose()
yearGaugeChart?.dispose() yearGaugeChart?.dispose()
monthBarChart?.dispose() monthVarianceChart?.dispose()
yearBarChart?.dispose() yearVarianceChart?.dispose()
burndownChart?.dispose() burndownChart?.dispose()
yearBurndownChart?.dispose() yearBurndownChart?.dispose()
varianceChart?.dispose()
}) })
</script> </script>
@@ -1146,13 +945,12 @@ onUnmounted(() => {
/* 调小高度 */ /* 调小高度 */
} }
.bar-chart { .variance-chart {
min-height: 200px; min-height: 200px;
max-height: 400px;
} }
.burndown-chart { .burndown-chart {
height: 200px; height: 230px;
} }
.gauge-footer { .gauge-footer {

View File

@@ -657,13 +657,15 @@ const fetchCategoryStats = async () => {
rate: data.month?.rate?.toFixed(1) || '0.0', rate: data.month?.rate?.toFixed(1) || '0.0',
current: data.month?.current || 0, current: data.month?.current || 0,
limit: data.month?.limit || 0, limit: data.month?.limit || 0,
count: data.month?.count || 0 count: data.month?.count || 0,
trend: data.month?.trend || []
}, },
year: { year: {
rate: data.year?.rate?.toFixed(1) || '0.0', rate: data.year?.rate?.toFixed(1) || '0.0',
current: data.year?.current || 0, current: data.year?.current || 0,
limit: data.year?.limit || 0, limit: data.year?.limit || 0,
count: data.year?.count || 0 count: data.year?.count || 0,
trend: data.year?.trend || []
} }
} }
} }

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container calendar-container"> <div class="page-container calendar-container">
<van-calendar <van-calendar
title="日历" title="日历"