From 64aa24c07bc96b7c34e909e1753412b027e53d88 Mon Sep 17 00:00:00 2001 From: SunCheng Date: Wed, 21 Jan 2026 18:52:31 +0800 Subject: [PATCH] fix --- Service/Budget/BudgetService.cs | 29 +-- .../components/Budget/BudgetChartAnalysis.vue | 194 +++++++++++------- Web/src/views/BudgetView.vue | 43 ++-- WebApi.Test/{ => Budget}/BudgetSavingsTest.cs | 12 +- WebApi.Test/Budget/BudgetTest.cs | 172 ++++++++++++++++ WebApi.Test/GlobalUsings.cs | 3 +- 6 files changed, 326 insertions(+), 127 deletions(-) rename WebApi.Test/{ => Budget}/BudgetSavingsTest.cs (97%) create mode 100644 WebApi.Test/Budget/BudgetTest.cs diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs index 2d727d0..18ba65f 100644 --- a/Service/Budget/BudgetService.cs +++ b/Service/Budget/BudgetService.cs @@ -32,7 +32,8 @@ public class BudgetService( IOpenAiService openAiService, IMessageService messageService, ILogger logger, - IBudgetSavingsService budgetSavingsService + IBudgetSavingsService budgetSavingsService, + IDateTimeProvider dateTimeProvider ) : IBudgetService { public async Task> GetListAsync(DateTime referenceDate) @@ -40,8 +41,8 @@ public class BudgetService( var year = referenceDate.Year; var month = referenceDate.Month; - var isArchive = year < DateTime.Now.Year - || (year == DateTime.Now.Year && month < DateTime.Now.Month); + var isArchive = year < dateTimeProvider.Now.Year + || (year == dateTimeProvider.Now.Year && month < dateTimeProvider.Now.Month); if (isArchive) { @@ -49,7 +50,7 @@ public class BudgetService( if (archive != null) { - var (start, end) = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate); + var (start, end) = GetPeriodRange(dateTimeProvider.Now, BudgetPeriodType.Month, referenceDate); return [.. archive.Content.Select(c => new BudgetResult { Id = c.Id, @@ -123,7 +124,7 @@ public class BudgetService( public async Task> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null) { - var date = referenceDate ?? DateTime.Now; + var date = referenceDate ?? dateTimeProvider.Now; var transactionType = category switch { BudgetCategory.Expense => TransactionType.Expense, @@ -282,7 +283,7 @@ public class BudgetService( groupByMonth); decimal accumulated = 0; - var now = DateTime.Now; + var now = dateTimeProvider.Now; if (statType == BudgetPeriodType.Month) { @@ -360,7 +361,7 @@ public class BudgetService( if (archive != null) { archive.Content = content; - archive.ArchiveDate = DateTime.Now; + archive.ArchiveDate = dateTimeProvider.Now; archive.ExpenseSurplus = expenseSurplus; archive.IncomeSurplus = incomeSurplus; if (!await budgetArchiveRepository.UpdateAsync(archive)) @@ -375,7 +376,7 @@ public class BudgetService( Year = year, Month = month, Content = content, - ArchiveDate = DateTime.Now, + ArchiveDate = dateTimeProvider.Now, ExpenseSurplus = expenseSurplus, IncomeSurplus = incomeSurplus }; @@ -503,7 +504,7 @@ public class BudgetService( 11. 不要使用 div 包裹大段内容 【系统信息】 - 当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss} + 当前时间:{dateTimeProvider.Now:yyyy-MM-dd HH:mm:ss} 预算归档周期:{year}年{month}月 直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。 @@ -532,7 +533,7 @@ public class BudgetService( private async Task CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null) { - var referenceDate = now ?? DateTime.Now; + var referenceDate = now ?? dateTimeProvider.Now; var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate); var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate); @@ -541,7 +542,7 @@ public class BudgetService( if (actualAmount == 0 && budget.IsMandatoryExpense && referenceDate.Year == startDate.Year - && referenceDate.Month == startDate.Month) + && (budget.Type == BudgetPeriodType.Year || referenceDate.Month == startDate.Month)) { if (budget.Type == BudgetPeriodType.Month) { @@ -616,11 +617,11 @@ public record BudgetResult public static BudgetResult FromEntity( BudgetRecord entity, - decimal currentAmount = 0, - DateTime? referenceDate = null, + decimal currentAmount, + DateTime referenceDate, string description = "") { - var date = referenceDate ?? DateTime.Now; + var date = referenceDate; var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date); return new BudgetResult diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue index b22fb11..5626da4 100644 --- a/Web/src/components/Budget/BudgetChartAnalysis.vue +++ b/Web/src/components/Budget/BudgetChartAnalysis.vue @@ -181,6 +181,10 @@ const props = defineProps({ activeTab: { type: [Number, String], default: BudgetCategory.Expense + }, + selectedDate: { + type: Date, + default: () => new Date() } }) @@ -294,33 +298,81 @@ const updateSingleGauge = (chart, data, isExpense) => { ] } - chart.setOption(option) + chart.setOption(option, true) +} + +const disposeBudgetCharts = () => { + varianceChart?.dispose() + varianceChart = null + burndownChart?.dispose() + burndownChart = null + yearBurndownChart?.dispose() + yearBurndownChart = null } const updateCharts = () => { const isExpense = props.activeTab === BudgetCategory.Expense - updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense) - updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense) + + // 仪表盘总是存在的 + 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) + } if (props.budgets.length > 0) { - // Update Variance Chart - if (!varianceChart && varianceChartRef.value) { - varianceChart = echarts.init(varianceChartRef.value) - } - if (varianceChart) { - updateVarianceChart(varianceChart, props.budgets) - } + // 等待 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() + } - // 更新燃尽图/积累图 - if (!burndownChart && burndownChartRef.value) { - burndownChart = echarts.init(burndownChartRef.value) - } - updateBurndownChart() + // 月度燃尽图 + if (burndownChartRef.value) { + const existing = echarts.getInstanceByDom(burndownChartRef.value) + if (existing) { + burndownChart = existing + } else { + burndownChart?.dispose() + burndownChart = echarts.init(burndownChartRef.value) + } + updateBurndownChart() + burndownChart.resize() + } - if (!yearBurndownChart && yearBurndownChartRef.value) { - yearBurndownChart = echarts.init(yearBurndownChartRef.value) - } - updateYearBurndownChart() + // 年度燃尽图 + 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() } } @@ -447,7 +499,7 @@ const updateVarianceChart = (chart, budgets) => { ] } - chart.setOption(option) + chart.setOption(option, true) } const calculateChartHeight = (budgets) => { @@ -462,12 +514,18 @@ const calculateChartHeight = (budgets) => { const updateBurndownChart = () => { if (!burndownChart) { return } - // 获取当前月份的日期 - const today = new Date() - const year = today.getFullYear() - const month = today.getMonth() + // 使用传入的所选日期作为参考日期 + const refDate = props.selectedDate + const year = refDate.getFullYear() + const month = refDate.getMonth() const daysInMonth = new Date(year, month + 1, 0).getDate() - const currentDay = today.getDate() + + 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 const isExpense = props.activeTab === BudgetCategory.Expense // 生成日期和理想燃尽线/积累线 @@ -490,6 +548,7 @@ const updateBurndownChart = () => { // 实际燃尽:根据当前日期显示 if (trend.length > 0) { + // 后端返回了趋势数据 const dayValue = trend[i - 1] if (dayValue !== undefined && dayValue !== null) { const actualRemaining = Math.max(0, totalBudget - dayValue) @@ -498,6 +557,7 @@ const updateBurndownChart = () => { actualBurndown.push(null) } } else { + // 后端没有趋势数据, fallback 到线性估算 if (i <= currentDay && totalBudget > 0) { const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay)) actualBurndown.push(Math.round(actualRemaining)) @@ -620,18 +680,22 @@ const updateBurndownChart = () => { ] } - burndownChart.setOption(option) + burndownChart.setOption(option, true) } 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 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() const isExpense = props.activeTab === BudgetCategory.Expense // 生成月份和理想燃尽线/积累线 @@ -654,21 +718,19 @@ const updateYearBurndownChart = () => { daysInYear += new Date(year, j + 1, 0).getDate() } - if (i < currentMonth) { - // 之前的月份都已完成 + if (year < currentYear || (year === currentYear && i < currentMonth)) { + // 以前的年/月 daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate() - } else if (i === currentMonth) { - // 当前月份 + } else if (year === currentYear && i === currentMonth) { + // 当前月 daysPassedInYear = daysInYear + currentDay - daysInYear += daysInCurrentMonth } else { - // 未来的月份 - daysInYear += new Date(year, i + 1, 0).getDate() + // 未来的年/月 + daysPassedInYear = 0 } - // 全年总天数(365或366) - const daysInYearTotal = new Date(year, 12, 0).getDate() === 29 ? 366 : 365 - const yearProgress = i === 11 ? 1 : daysPassedInYear / daysInYearTotal + const daysInYearTotal = (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) ? 366 : 365 + const yearProgress = daysPassedInYear / daysInYearTotal if (isExpense) { // 支出:燃尽图(向下走) @@ -676,7 +738,7 @@ const updateYearBurndownChart = () => { const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12)) idealBurndown.push(Math.round(idealRemaining)) - // 实际燃尽:根据当前日期显示 + // 实际燃尽:根据日期显示 if (trend.length > 0) { const monthValue = trend[i] if (monthValue !== undefined && monthValue !== null) { @@ -686,7 +748,9 @@ const updateYearBurndownChart = () => { actualBurndown.push(null) } } else { - if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { + // Fallback: 如果是今年且月份未开始,或者去年,做线性统计 + const isFuture = year > currentYear || (year === currentYear && i > currentMonth) + if (!isFuture && totalBudget > 0) { const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress)) actualBurndown.push(Math.round(actualRemaining)) } else { @@ -699,7 +763,7 @@ const updateYearBurndownChart = () => { const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12)) idealBurndown.push(Math.round(idealAccumulated)) - // 实际积累:根据当前日期显示 + // 实际积累:根据参数显示 if (trend.length > 0) { const monthValue = trend[i] if (monthValue !== undefined && monthValue !== null) { @@ -708,7 +772,8 @@ const updateYearBurndownChart = () => { actualBurndown.push(null) } } else { - if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { + 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 { @@ -806,7 +871,7 @@ const updateYearBurndownChart = () => { ] } - yearBurndownChart.setOption(option) + yearBurndownChart.setOption(option, true) } watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true }) @@ -816,6 +881,12 @@ watch(() => props.budgets, () => { }) }, { deep: true }) +watch(() => props.selectedDate, () => { + nextTick(() => { + updateCharts() + }) +}) + watch(() => props.activeTab, () => { nextTick(() => { updateCharts() @@ -838,28 +909,7 @@ const handleResize = () => { onMounted(() => { nextTick(() => { - const isExpense = props.activeTab === BudgetCategory.Expense - monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense) - yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense) - // 只在有数据时初始化柱状图 - if (props.budgets.length > 0) { - // 初始化偏差图 - if (varianceChartRef.value) { - varianceChart = echarts.init(varianceChartRef.value) - updateVarianceChart(varianceChart, props.budgets) - } - - // 初始化燃尽图/积累图 - if (burndownChartRef.value) { - burndownChart = echarts.init(burndownChartRef.value) - updateBurndownChart() - } - - if (yearBurndownChartRef.value) { - yearBurndownChart = echarts.init(yearBurndownChartRef.value) - updateYearBurndownChart() - } - } + updateCharts() window.addEventListener('resize', handleResize) }) }) @@ -867,10 +917,10 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener('resize', handleResize) monthGaugeChart?.dispose() + monthGaugeChart = null yearGaugeChart?.dispose() - varianceChart?.dispose() - burndownChart?.dispose() - yearBurndownChart?.dispose() + yearGaugeChart = null + disposeBudgetCharts() }) diff --git a/Web/src/views/BudgetView.vue b/Web/src/views/BudgetView.vue index d3f2b1b..e4fd4a7 100644 --- a/Web/src/views/BudgetView.vue +++ b/Web/src/views/BudgetView.vue @@ -59,6 +59,7 @@ :overall-stats="overallStats" :budgets="expenseBudgets" :active-tab="activeTab" + :selected-date="selectedDate" /> @@ -72,6 +73,7 @@ :overall-stats="overallStats" :budgets="incomeBudgets" :active-tab="activeTab" + :selected-date="selectedDate" /> @@ -501,6 +503,7 @@