diff --git a/Application/Dto/BudgetDto.cs b/Application/Dto/BudgetDto.cs
index 9b86c40..002ba83 100644
--- a/Application/Dto/BudgetDto.cs
+++ b/Application/Dto/BudgetDto.cs
@@ -89,3 +89,41 @@ public record UpdateArchiveSummaryRequest
public DateTime ReferenceDate { get; init; }
public string? Summary { get; init; }
}
+
+///
+/// 存款明细数据
+///
+public record SavingsDetail
+{
+ public List IncomeItems { get; init; } = new();
+ public List ExpenseItems { get; init; } = new();
+ public SavingsCalculationSummary Summary { get; init; } = new();
+}
+
+///
+/// 预算明细项
+///
+public record BudgetDetailItem
+{
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public BudgetPeriodType Type { get; init; }
+ public decimal BudgetLimit { get; init; }
+ public decimal ActualAmount { get; init; }
+ public decimal EffectiveAmount { get; init; }
+ public string CalculationNote { get; init; } = string.Empty;
+ public bool IsOverBudget { get; init; }
+ public bool IsArchived { get; init; }
+ public int[]? ArchivedMonths { get; init; }
+}
+
+///
+/// 存款计算汇总
+///
+public record SavingsCalculationSummary
+{
+ public decimal TotalIncomeBudget { get; init; }
+ public decimal TotalExpenseBudget { get; init; }
+ public decimal PlannedSavings { get; init; }
+ public string CalculationFormula { get; init; } = string.Empty;
+}
diff --git a/Service/Budget/BudgetItemCalculator.cs b/Service/Budget/BudgetItemCalculator.cs
new file mode 100644
index 0000000..7ec4746
--- /dev/null
+++ b/Service/Budget/BudgetItemCalculator.cs
@@ -0,0 +1,121 @@
+namespace Service.Budget;
+
+///
+/// 预算明细项计算辅助类
+/// 用于计算单个预算项的有效金额(计算用金额)
+///
+public static class BudgetItemCalculator
+{
+ ///
+ /// 计算预算项的有效金额
+ ///
+ /// 预算类别(收入/支出)
+ /// 预算金额
+ /// 实际金额
+ /// 是否为硬性消费
+ /// 是否为归档数据
+ /// 参考日期
+ /// 预算周期类型(月度/年度)
+ /// 有效金额(用于计算的金额)
+ public static decimal CalculateEffectiveAmount(
+ BudgetCategory category,
+ decimal budgetLimit,
+ decimal actualAmount,
+ bool isMandatory,
+ bool isArchived,
+ DateTime referenceDate,
+ BudgetPeriodType periodType)
+ {
+ // 归档数据直接返回实际值
+ if (isArchived)
+ {
+ return actualAmount;
+ }
+
+ // 收入:实际>0取实际,否则取预算
+ if (category == BudgetCategory.Income)
+ {
+ return actualAmount > 0 ? actualAmount : budgetLimit;
+ }
+
+ // 支出(硬性且实际=0):按天数折算
+ if (category == BudgetCategory.Expense && isMandatory && actualAmount == 0)
+ {
+ return CalculateMandatoryAmount(budgetLimit, referenceDate, periodType);
+ }
+
+ // 支出(普通):取MAX
+ if (category == BudgetCategory.Expense)
+ {
+ return Math.Max(budgetLimit, actualAmount);
+ }
+
+ return budgetLimit;
+ }
+
+ ///
+ /// 计算硬性消费按天数折算的金额
+ ///
+ private static decimal CalculateMandatoryAmount(
+ decimal limit,
+ DateTime date,
+ BudgetPeriodType periodType)
+ {
+ if (periodType == BudgetPeriodType.Month)
+ {
+ var daysInMonth = DateTime.DaysInMonth(date.Year, date.Month);
+ return limit / daysInMonth * date.Day;
+ }
+ else // Year
+ {
+ var daysInYear = DateTime.IsLeapYear(date.Year) ? 366 : 365;
+ return limit / daysInYear * date.DayOfYear;
+ }
+ }
+
+ ///
+ /// 生成计算说明
+ ///
+ /// 预算类别
+ /// 预算金额
+ /// 实际金额
+ /// 有效金额
+ /// 是否为硬性消费
+ /// 是否为归档数据
+ /// 计算说明文本
+ public static string GenerateCalculationNote(
+ BudgetCategory category,
+ decimal budgetLimit,
+ decimal actualAmount,
+ decimal effectiveAmount,
+ bool isMandatory,
+ bool isArchived)
+ {
+ if (isArchived)
+ {
+ return "归档实际";
+ }
+
+ if (category == BudgetCategory.Income)
+ {
+ return actualAmount > 0 ? "使用实际" : "使用预算";
+ }
+
+ if (category == BudgetCategory.Expense)
+ {
+ if (isMandatory && actualAmount == 0)
+ {
+ return "按天折算";
+ }
+
+ if (actualAmount > budgetLimit)
+ {
+ return "使用实际(超支)";
+ }
+
+ return effectiveAmount == actualAmount ? "使用实际" : "使用预算";
+ }
+
+ return "使用预算";
+ }
+}
diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs
index e8d72a6..83c012e 100644
--- a/Service/Budget/BudgetSavingsService.cs
+++ b/Service/Budget/BudgetSavingsService.cs
@@ -935,4 +935,32 @@ public class BudgetSavingsService(
return string.Join(", ", months) + "月";
}
}
-}
\ No newline at end of file
+
+ ///
+ /// 计算月度计划存款
+ /// 公式:收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
+ ///
+ public static decimal CalculateMonthlyPlannedSavings(
+ decimal monthlyIncomeBudget,
+ decimal yearlyIncomeInThisMonth,
+ decimal monthlyExpenseBudget,
+ decimal yearlyExpenseInThisMonth)
+ {
+ return monthlyIncomeBudget + yearlyIncomeInThisMonth
+ - monthlyExpenseBudget - yearlyExpenseInThisMonth;
+ }
+
+ ///
+ /// 计算年度计划存款
+ /// 公式:归档月已实收 + 未来月收入预算 - 归档月已实支 - 未来月支出预算
+ ///
+ public static decimal CalculateYearlyPlannedSavings(
+ decimal archivedIncome,
+ decimal futureIncomeBudget,
+ decimal archivedExpense,
+ decimal futureExpenseBudget)
+ {
+ return archivedIncome + futureIncomeBudget
+ - archivedExpense - futureExpenseBudget;
+ }
+}
diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs
index 553051c..c9d8e2c 100644
--- a/Service/Budget/BudgetService.cs
+++ b/Service/Budget/BudgetService.cs
@@ -448,6 +448,11 @@ public record BudgetResult
public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; }
public string Description { get; set; } = string.Empty;
+
+ ///
+ /// 存款明细数据(可选,用于存款预算)
+ ///
+ public SavingsDetail? Details { get; set; }
public static BudgetResult FromEntity(
BudgetRecord entity,
@@ -547,3 +552,41 @@ public class UncoveredCategoryDetail
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}
+
+///
+/// 存款明细数据
+///
+public record SavingsDetail
+{
+ public List IncomeItems { get; init; } = new();
+ public List ExpenseItems { get; init; } = new();
+ public SavingsCalculationSummary Summary { get; init; } = new();
+}
+
+///
+/// 预算明细项
+///
+public record BudgetDetailItem
+{
+ public long Id { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public BudgetPeriodType Type { get; init; }
+ public decimal BudgetLimit { get; init; }
+ public decimal ActualAmount { get; init; }
+ public decimal EffectiveAmount { get; init; }
+ public string CalculationNote { get; init; } = string.Empty;
+ public bool IsOverBudget { get; init; }
+ public bool IsArchived { get; init; }
+ public int[]? ArchivedMonths { get; init; }
+}
+
+///
+/// 存款计算汇总
+///
+public record SavingsCalculationSummary
+{
+ public decimal TotalIncomeBudget { get; init; }
+ public decimal TotalExpenseBudget { get; init; }
+ public decimal PlannedSavings { get; init; }
+ public string CalculationFormula { get; init; } = string.Empty;
+}
diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue
index 586f77a..4892338 100644
--- a/Web/src/components/Budget/BudgetChartAnalysis.vue
+++ b/Web/src/components/Budget/BudgetChartAnalysis.vue
@@ -1013,6 +1013,7 @@ const yearBurndownChartOptions = computed(() => {
display: flex;
justify-content: center;
align-items: center;
+ z-index: 1;
}
.gauge-text-overlay {
@@ -1048,6 +1049,8 @@ const yearBurndownChartOptions = computed(() => {
.chart-header {
margin-bottom: 12px;
+ position: relative;
+ z-index: 20;
}
.chart-title {
diff --git a/Web/src/views/budgetV2/Index.vue b/Web/src/views/budgetV2/Index.vue
index 261beb4..addc25b 100644
--- a/Web/src/views/budgetV2/Index.vue
+++ b/Web/src/views/budgetV2/Index.vue
@@ -123,6 +123,8 @@
diff --git a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
index a25b46c..15d4f0d 100644
--- a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
+++ b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
@@ -183,6 +183,14 @@ const props = defineProps({
budgets: {
type: Array,
default: () => []
+ },
+ incomeBudgets: {
+ type: Array,
+ default: () => []
+ },
+ expenseBudgets: {
+ type: Array,
+ default: () => []
}
})
@@ -199,6 +207,30 @@ const handleShowDetail = (budget) => {
showDetailPopup.value = true
}
+// 匹配收入预算
+const matchedIncomeBudget = computed(() => {
+ if (!currentBudget.value) {return null}
+ return props.incomeBudgets?.find(
+ b => b.periodStart === currentBudget.value.periodStart && b.type === currentBudget.value.type
+ )
+})
+
+// 匹配支出预算
+const matchedExpenseBudget = computed(() => {
+ if (!currentBudget.value) {return null}
+ return props.expenseBudgets?.find(
+ b => b.periodStart === currentBudget.value.periodStart && b.type === currentBudget.value.type
+ )
+})
+
+// 收入预算数据
+const incomeLimit = computed(() => matchedIncomeBudget.value?.limit || 0)
+const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0)
+
+// 支出预算数据
+const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0)
+const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
+
// 辅助函数
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, {
diff --git a/WebApi.Test/Budget/BudgetItemCalculatorTest.cs b/WebApi.Test/Budget/BudgetItemCalculatorTest.cs
new file mode 100644
index 0000000..b17ae5a
--- /dev/null
+++ b/WebApi.Test/Budget/BudgetItemCalculatorTest.cs
@@ -0,0 +1,260 @@
+using Service.Budget;
+
+namespace WebApi.Test.Budget;
+
+///
+/// BudgetItemCalculator 单元测试
+/// 测试明细项计算用金额的各种规则
+///
+public class BudgetItemCalculatorTest : BaseTest
+{
+ [Fact]
+ public void 收入项实际已发生_应返回实际值()
+ {
+ // Arrange
+ var budgetLimit = 10000m;
+ var actualAmount = 9500m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Income,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(9500m);
+ }
+
+ [Fact]
+ public void 收入项实际未发生_应返回预算值()
+ {
+ // Arrange
+ var budgetLimit = 5000m;
+ var actualAmount = 0m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Income,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(5000m);
+ }
+
+ [Fact]
+ public void 支出项普通情况_应返回MAX预算和实际()
+ {
+ // Arrange
+ var budgetLimit = 2000m;
+ var actualAmount = 2500m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(2500m);
+ }
+
+ [Fact]
+ public void 支出项未超预算_应返回预算值()
+ {
+ // Arrange
+ var budgetLimit = 2000m;
+ var actualAmount = 1800m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(2000m);
+ }
+
+ [Fact]
+ public void 支出项超预算_应返回实际值()
+ {
+ // Arrange
+ var budgetLimit = 2000m;
+ var actualAmount = 2500m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(2500m);
+ }
+
+ [Fact]
+ public void 支出项硬性且实际为0_月度_应按天数折算()
+ {
+ // Arrange
+ var budgetLimit = 3000m;
+ var actualAmount = 0m;
+ var date = new DateTime(2026, 2, 15); // 2月共28天,当前15号
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: true,
+ isArchived: false,
+ date,
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ var expected = 3000m / 28 * 15; // ≈ 1607.14
+ result.Should().BeApproximately(expected, 0.01m);
+ }
+
+ [Fact]
+ public void 支出项硬性且实际为0_年度_应按天数折算()
+ {
+ // Arrange
+ var budgetLimit = 12000m;
+ var actualAmount = 0m;
+ var date = new DateTime(2026, 2, 15); // 2026年第46天(31+15)
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: true,
+ isArchived: false,
+ date,
+ BudgetPeriodType.Year
+ );
+
+ // Assert
+ var expected = 12000m / 365 * 46; // ≈ 1512.33
+ result.Should().BeApproximately(expected, 0.01m);
+ }
+
+ [Fact]
+ public void 支出项硬性且实际大于0_应返回MAX值()
+ {
+ // Arrange
+ var budgetLimit = 3000m;
+ var actualAmount = 3200m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: true,
+ isArchived: false,
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(3200m);
+ }
+
+ [Fact]
+ public void 归档数据_应直接返回实际值()
+ {
+ // Arrange
+ var budgetLimit = 2000m;
+ var actualAmount = 1800m;
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: false,
+ isArchived: true, // 归档数据
+ new DateTime(2026, 2, 15),
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ result.Should().Be(1800m); // 归档数据直接返回实际值,不走MAX逻辑
+ }
+
+ [Fact]
+ public void 闰年2月按天折算边界情况()
+ {
+ // Arrange
+ var budgetLimit = 3000m;
+ var actualAmount = 0m;
+ var date = new DateTime(2024, 2, 29); // 闰年2月29日
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: true,
+ isArchived: false,
+ date,
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ var expected = 3000m / 29 * 29; // = 3000
+ result.Should().Be(expected);
+ }
+
+ [Fact]
+ public void 平年2月按天折算边界情况()
+ {
+ // Arrange
+ var budgetLimit = 3000m;
+ var actualAmount = 0m;
+ var date = new DateTime(2026, 2, 28); // 平年2月28日
+
+ // Act
+ var result = BudgetItemCalculator.CalculateEffectiveAmount(
+ BudgetCategory.Expense,
+ budgetLimit,
+ actualAmount,
+ isMandatory: true,
+ isArchived: false,
+ date,
+ BudgetPeriodType.Month
+ );
+
+ // Assert
+ var expected = 3000m / 28 * 28; // = 3000
+ result.Should().Be(expected);
+ }
+}
diff --git a/WebApi.Test/Budget/BudgetSavingsCalculationTest.cs b/WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
new file mode 100644
index 0000000..0fd4c36
--- /dev/null
+++ b/WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
@@ -0,0 +1,135 @@
+using Service.Budget;
+
+namespace WebApi.Test.Budget;
+
+///
+/// 存款计划核心公式单元测试
+///
+public class BudgetSavingsCalculationTest : BaseTest
+{
+ [Fact]
+ public void 月度计划存款公式_纯月度预算场景()
+ {
+ // Arrange
+ var monthlyIncomeBudget = 15000m; // 工资10000 + 奖金5000
+ var yearlyIncomeInThisMonth = 0m;
+ var monthlyExpenseBudget = 5000m; // 房租3000 + 餐饮2000
+ var yearlyExpenseInThisMonth = 0m;
+
+ // Act
+ var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
+ monthlyIncomeBudget,
+ yearlyIncomeInThisMonth,
+ monthlyExpenseBudget,
+ yearlyExpenseInThisMonth
+ );
+
+ // Assert
+ result.Should().Be(10000m); // 15000 - 5000
+ }
+
+ [Fact]
+ public void 月度计划存款公式_月度预算加本月发生的年度预算()
+ {
+ // Arrange
+ var monthlyIncomeBudget = 10000m; // 工资
+ var yearlyIncomeInThisMonth = 0m;
+ var monthlyExpenseBudget = 3000m; // 房租
+ var yearlyExpenseInThisMonth = 3000m; // 旅游实际发生
+
+ // Act
+ var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
+ monthlyIncomeBudget,
+ yearlyIncomeInThisMonth,
+ monthlyExpenseBudget,
+ yearlyExpenseInThisMonth
+ );
+
+ // Assert
+ result.Should().Be(4000m); // 10000 - 3000 - 3000
+ }
+
+ [Fact]
+ public void 月度计划存款公式_年度预算未在本月发生应不计入()
+ {
+ // Arrange
+ var monthlyIncomeBudget = 10000m;
+ var yearlyIncomeInThisMonth = 0m; // 年终奖未发生
+ var monthlyExpenseBudget = 3000m;
+ var yearlyExpenseInThisMonth = 0m; // 旅游未发生
+
+ // Act
+ var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
+ monthlyIncomeBudget,
+ yearlyIncomeInThisMonth,
+ monthlyExpenseBudget,
+ yearlyExpenseInThisMonth
+ );
+
+ // Assert
+ result.Should().Be(7000m); // 10000 - 3000
+ }
+
+ [Fact]
+ public void 年度计划存款公式_年初无归档数据场景()
+ {
+ // Arrange
+ var archivedIncome = 0m;
+ var futureIncomeBudget = 120000m; // 10000×12
+ var archivedExpense = 0m;
+ var futureExpenseBudget = 36000m; // 3000×12
+
+ // Act
+ var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
+ archivedIncome,
+ futureIncomeBudget,
+ archivedExpense,
+ futureExpenseBudget
+ );
+
+ // Assert
+ result.Should().Be(84000m); // 120000 - 36000
+ }
+
+ [Fact]
+ public void 年度计划存款公式_年中有归档数据场景()
+ {
+ // Arrange
+ var archivedIncome = 29000m; // 1月15000 + 2月14000
+ var futureIncomeBudget = 100000m; // 10000×10月
+ var archivedExpense = 10000m; // 1月4800 + 2月5200
+ var futureExpenseBudget = 30000m; // 3000×10月
+
+ // Act
+ var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
+ archivedIncome,
+ futureIncomeBudget,
+ archivedExpense,
+ futureExpenseBudget
+ );
+
+ // Assert
+ result.Should().Be(89000m); // 29000 + 100000 - 10000 - 30000
+ }
+
+ [Fact]
+ public void 年度计划存款公式_归档数据包含年度预算()
+ {
+ // Arrange
+ var archivedIncome = 15000m;
+ var futureIncomeBudget = 110000m;
+ var archivedExpense = 7800m; // 包含1月旅游3000的年度支出
+ var futureExpenseBudget = 30000m;
+
+ // Act
+ var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
+ archivedIncome,
+ futureIncomeBudget,
+ archivedExpense,
+ futureExpenseBudget
+ );
+
+ // Assert
+ result.Should().Be(87200m); // 15000 + 110000 - 7800 - 30000
+ }
+}
diff --git a/openspec/changes/fix-deposit-detail-empty/.openspec.yaml b/openspec/changes/fix-deposit-detail-empty/.openspec.yaml
new file mode 100644
index 0000000..d0ec88b
--- /dev/null
+++ b/openspec/changes/fix-deposit-detail-empty/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-20
diff --git a/openspec/changes/fix-deposit-detail-empty/design.md b/openspec/changes/fix-deposit-detail-empty/design.md
new file mode 100644
index 0000000..3c2a74f
--- /dev/null
+++ b/openspec/changes/fix-deposit-detail-empty/design.md
@@ -0,0 +1,117 @@
+## Context
+
+`SavingsBudgetContent.vue` 是一个显示存款计划列表和明细的组件。每个存款计划卡片可以点击查看详细信息,弹窗会显示:
+1. 收入预算(预算限额和实际收入)
+2. 支出预算(预算限额和实际支出)
+3. 计划存款公式(收入预算 - 支出预算 = 计划存款)
+4. 存款结果(计划存款、实际存款、还差)
+
+问题在于弹窗模板引用了 `incomeLimit`、`incomeCurrent`、`expenseLimit`、`expenseCurrent` 这些计算属性,但在 `