diff --git a/Entity/BudgetArchive.cs b/Entity/BudgetArchive.cs index af405ac..b8fd7df 100644 --- a/Entity/BudgetArchive.cs +++ b/Entity/BudgetArchive.cs @@ -73,6 +73,11 @@ public record BudgetArchiveContent /// public bool NoLimit { get; set; } = false; + /// + /// 硬性消费 + /// + public bool IsMandatoryExpense { get; set; } = false; + /// /// 描述说明 /// diff --git a/Entity/BudgetRecord.cs b/Entity/BudgetRecord.cs index 2c62510..73696c5 100644 --- a/Entity/BudgetRecord.cs +++ b/Entity/BudgetRecord.cs @@ -39,6 +39,11 @@ public class BudgetRecord : BaseEntity /// 不记额预算(选中后该预算没有预算金额,发生的收入或支出直接在存款中加减) /// public bool NoLimit { get; set; } = false; + + /// + /// 硬性消费(固定消费,如房租、水电等。当是当前年月且为硬性消费时,会根据经过的天数累加Current) + /// + public bool IsMandatoryExpense { get; set; } = false; } public enum BudgetPeriodType diff --git a/Service/BudgetService.cs b/Service/BudgetService.cs index 8e809d2..e3a6824 100644 --- a/Service/BudgetService.cs +++ b/Service/BudgetService.cs @@ -58,6 +58,7 @@ public class BudgetService( Category = c.Category, SelectedCategories = c.SelectedCategories, NoLimit = c.NoLimit, + IsMandatoryExpense = c.IsMandatoryExpense, Description = c.Description, PeriodStart = periodRange.start, PeriodEnd = periodRange.end, @@ -206,7 +207,8 @@ public class BudgetService( Limit = budget.Limit, Category = budget.Category, SelectedCategories = selectedCategories, - StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1) + StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1), + IsMandatoryExpense = budget.IsMandatoryExpense }, referenceDate); if (budget.Type == statType) { @@ -254,6 +256,7 @@ public class BudgetService( Category = b.Category, SelectedCategories = b.SelectedCategories, NoLimit = b.NoLimit, + IsMandatoryExpense = b.IsMandatoryExpense, Description = b.Description }).ToArray(); @@ -437,7 +440,40 @@ public class BudgetService( var referenceDate = now ?? DateTime.Now; var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate); - return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate); + var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate); + + // 如果是硬性消费,且是当前年当前月,则根据经过的天数累加 + if (actualAmount == 0 + && budget.IsMandatoryExpense + && referenceDate.Year == startDate.Year + && referenceDate.Month == startDate.Month) + { + if (budget.Type == BudgetPeriodType.Month) + { + // 计算本月的天数 + var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month); + // 计算当前已经过的天数(包括今天) + var daysElapsed = referenceDate.Day; + // 根据预算金额和经过天数计算应累加的金额 + var mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth; + // 返回实际消费和硬性消费累加中的较大值 + return mandatoryAccumulation; + } + else if (budget.Type == BudgetPeriodType.Year) + { + // 计算本年的天数(考虑闰年) + var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; + // 计算当前已经过的天数(包括今天) + var daysElapsed = referenceDate.DayOfYear; + // 根据预算金额和经过天数计算应累加的金额 + var mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear; + // 返回实际消费和硬性消费累加中的较大值 + return mandatoryAccumulation; + } + + } + + return actualAmount; } internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate) @@ -793,6 +829,7 @@ public record BudgetResult public DateTime? PeriodStart { get; set; } public DateTime? PeriodEnd { get; set; } public bool NoLimit { get; set; } = false; + public bool IsMandatoryExpense { get; set; } = false; public string Description { get; set; } = string.Empty; public static BudgetResult FromEntity( @@ -825,6 +862,7 @@ public record BudgetResult PeriodStart = start, PeriodEnd = end, NoLimit = entity.NoLimit, + IsMandatoryExpense = entity.IsMandatoryExpense, Description = description }; } diff --git a/Web/src/components/Budget/BudgetCard.vue b/Web/src/components/Budget/BudgetCard.vue index 5f87766..0d2bcec 100644 --- a/Web/src/components/Budget/BudgetCard.vue +++ b/Web/src/components/Budget/BudgetCard.vue @@ -14,6 +14,7 @@ class="status-tag" > {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} + 📌

@@ -58,6 +59,7 @@ class="status-tag" > {{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }} + 📌

@@ -672,6 +674,12 @@ const timePercentage = computed(() => { line-height: 1.4; } +.mandatory-mark { + margin-left: 4px; + font-size: 14px; + display: inline-block; +} + /* @media (prefers-color-scheme: dark) { .budget-description { background-color: var(--van-background-2); diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue index da15f59..fd4c0d6 100644 --- a/Web/src/components/Budget/BudgetChartAnalysis.vue +++ b/Web/src/components/Budget/BudgetChartAnalysis.vue @@ -74,7 +74,6 @@
@@ -115,7 +114,6 @@
@@ -315,40 +313,21 @@ const updateCharts = () => { 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() + // 更新燃尽图/积累图 + 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() } } @@ -517,8 +496,9 @@ const updateBurndownChart = () => { const month = today.getMonth() const daysInMonth = new Date(year, month + 1, 0).getDate() const currentDay = today.getDate() + const isExpense = props.activeTab === BudgetCategory.Expense - // 生成日期和理想燃尽线 + // 生成日期和理想燃尽线/积累线 const dates = [] const idealBurndown = [] const actualBurndown = [] @@ -528,16 +508,33 @@ const updateBurndownChart = () => { 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 (isExpense) { + // 支出:燃尽图(向下走) + // 理想燃尽:每天均匀消耗 + 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)) + // 实际燃尽:根据当前日期显示 + if (i <= currentDay && totalBudget > 0) { + const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay)) + actualBurndown.push(Math.round(actualRemaining)) + } else { + actualBurndown.push(null) + } } else { - actualBurndown.push(null) + // 收入:积累图(向上走) + // 理想积累:每天均匀积累 + const idealAccumulated = Math.min(totalBudget, totalBudget * (i / daysInMonth)) + idealBurndown.push(Math.round(idealAccumulated)) + + // 实际积累:根据当前日期显示 + if (i <= currentDay && totalBudget > 0) { + const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay) + actualBurndown.push(Math.round(actualAccumulated)) + } else { + actualBurndown.push(null) + } } } @@ -546,6 +543,9 @@ const updateBurndownChart = () => { const splitLineColor = getCssVar('--chart-split') const axisLabelColor = getCssVar('--chart-text-muted') + const idealSeriesName = isExpense ? '理想燃尽' : '理想积累' + const actualSeriesName = isExpense ? '实际燃尽' : '实际积累' + const option = { grid: { left: '3%', @@ -599,7 +599,7 @@ const updateBurndownChart = () => { }, series: [ { - name: '理想燃尽', + name: idealSeriesName, type: 'line', data: idealBurndown, smooth: false, @@ -614,7 +614,7 @@ const updateBurndownChart = () => { z: 1 }, { - name: '实际燃尽', + name: actualSeriesName, type: 'line', data: actualBurndown, smooth: false, @@ -642,8 +642,9 @@ const updateYearBurndownChart = () => { const currentMonth = today.getMonth() const currentDay = today.getDate() const daysInCurrentMonth = new Date(year, currentMonth + 1, 0).getDate() + const isExpense = props.activeTab === BudgetCategory.Expense - // 生成月份和理想燃尽线 + // 生成月份和理想燃尽线/积累线 const months = [] const idealBurndown = [] const actualBurndown = [] @@ -678,16 +679,32 @@ const updateYearBurndownChart = () => { 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 (isExpense) { + // 支出:燃尽图(向下走) + // 理想燃尽:每月均匀消耗 + 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)) + // 实际燃尽:根据当前日期显示 + 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) + } } else { - actualBurndown.push(null) + // 收入:积累图(向上走) + // 理想积累:每月均匀积累 + const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12)) + idealBurndown.push(Math.round(idealAccumulated)) + + // 实际积累:根据当前日期显示 + if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) { + const actualAccumulated = Math.min(totalBudget, currentExpense * yearProgress) + actualBurndown.push(Math.round(actualAccumulated)) + } else { + actualBurndown.push(null) + } } } @@ -696,6 +713,9 @@ const updateYearBurndownChart = () => { const splitLineColor = getCssVar('--chart-split') const axisLabelColor = getCssVar('--chart-text-muted') + const idealSeriesName = isExpense ? '理想燃尽' : '理想积累' + const actualSeriesName = isExpense ? '实际燃尽' : '实际积累' + const option = { grid: { left: '3%', @@ -747,7 +767,7 @@ const updateYearBurndownChart = () => { }, series: [ { - name: '理想燃尽', + name: idealSeriesName, type: 'line', data: idealBurndown, smooth: false, @@ -762,7 +782,7 @@ const updateYearBurndownChart = () => { z: 1 }, { - name: '实际燃尽', + name: actualSeriesName, type: 'line', data: actualBurndown, smooth: false, @@ -1037,13 +1057,13 @@ onMounted(() => { } updateBarChart() - // 仅支出时初始化燃尽图 - if (isExpense && burndownChartRef.value) { + // 初始化燃尽图/积累图 + if (burndownChartRef.value) { burndownChart = echarts.init(burndownChartRef.value) updateBurndownChart() } - if (isExpense && yearBurndownChartRef.value) { + if (yearBurndownChartRef.value) { yearBurndownChart = echarts.init(yearBurndownChartRef.value) updateYearBurndownChart() } diff --git a/Web/src/components/Budget/BudgetEditPopup.vue b/Web/src/components/Budget/BudgetEditPopup.vue index 8ac7a3d..4363edc 100644 --- a/Web/src/components/Budget/BudgetEditPopup.vue +++ b/Web/src/components/Budget/BudgetEditPopup.vue @@ -21,20 +21,46 @@ - + + + + + @@ -60,7 +86,10 @@ > 可多选分类
-
+
{{ form.selectedCategories.join('、') }} @@ -78,7 +107,14 @@
@@ -103,7 +139,8 @@ const form = reactive({ category: BudgetCategory.Expense, limit: '', selectedCategories: [], - noLimit: false // 新增字段 + noLimit: false, // 新增字段 + isMandatoryExpense: false // 新增:硬性消费 }) const open = ({ data, isEditFlag, category }) => { @@ -121,7 +158,8 @@ const open = ({ data, isEditFlag, category }) => { category: category, limit: data.limit, selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [], - noLimit: data.noLimit || false // 新增 + noLimit: data.noLimit || false, // 新增 + isMandatoryExpense: data.isMandatoryExpense || false // 新增:硬性消费 }) } else { Object.assign(form, { @@ -131,7 +169,8 @@ const open = ({ data, isEditFlag, category }) => { category: category, limit: '', selectedCategories: [], - noLimit: false // 新增 + noLimit: false, // 新增 + isMandatoryExpense: false // 新增:硬性消费 }) } visible.value = true @@ -155,7 +194,8 @@ const onSubmit = async () => { ...form, limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0 selectedCategories: form.selectedCategories, - noLimit: form.noLimit // 新增 + noLimit: form.noLimit, // 新增 + isMandatoryExpense: form.isMandatoryExpense // 新增:硬性消费 } const res = form.id ? await updateBudget(data) : await createBudget(data) @@ -187,6 +227,8 @@ const onNoLimitChange = (value) => { if (value) { // 选中不记额时,自动设为年度预算 form.type = BudgetPeriodType.Year + // 选中不记额时,清除硬性消费选择 + form.isMandatoryExpense = false } } @@ -218,4 +260,16 @@ const onNoLimitChange = (value) => { color: var(--van-text-color-2); padding: 8px 16px; } + +.mandatory-wrapper { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mandatory-tip { + font-size: 11px; + color: var(--van-text-color-3); + margin-left: 6px; +} diff --git a/WebApi/Controllers/BudgetController.cs b/WebApi/Controllers/BudgetController.cs index 8204260..89bab85 100644 --- a/WebApi/Controllers/BudgetController.cs +++ b/WebApi/Controllers/BudgetController.cs @@ -139,7 +139,8 @@ public class BudgetController( Category = dto.Category, SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty, StartDate = dto.StartDate ?? DateTime.Now, - NoLimit = dto.NoLimit + NoLimit = dto.NoLimit, + IsMandatoryExpense = dto.IsMandatoryExpense }; var varidationError = await ValidateBudgetSelectedCategoriesAsync(budget); @@ -182,6 +183,7 @@ public class BudgetController( budget.Category = dto.Category; budget.SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty; budget.NoLimit = dto.NoLimit; + budget.IsMandatoryExpense = dto.IsMandatoryExpense; if (dto.StartDate.HasValue) { budget.StartDate = dto.StartDate.Value; diff --git a/WebApi/Controllers/Dto/BudgetDto.cs b/WebApi/Controllers/Dto/BudgetDto.cs index 114e91d..281686d 100644 --- a/WebApi/Controllers/Dto/BudgetDto.cs +++ b/WebApi/Controllers/Dto/BudgetDto.cs @@ -9,6 +9,7 @@ public class CreateBudgetDto public string[] SelectedCategories { get; set; } = Array.Empty(); public DateTime? StartDate { get; set; } public bool NoLimit { get; set; } = false; + public bool IsMandatoryExpense { get; set; } = false; } public class UpdateBudgetDto : CreateBudgetDto