Files
EmailBill/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/design.md
SunCheng a7414c792e
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-20 22:07:09 +08:00

4.3 KiB

Context

SavingsBudgetContent.vue 是一个显示存款计划列表和明细的组件。每个存款计划卡片可以点击查看详细信息,弹窗会显示:

  1. 收入预算(预算限额和实际收入)
  2. 支出预算(预算限额和实际支出)
  3. 计划存款公式(收入预算 - 支出预算 = 计划存款)
  4. 存款结果(计划存款、实际存款、还差)

问题在于弹窗模板引用了 incomeLimitincomeCurrentexpenseLimitexpenseCurrent 这些计算属性,但在 <script setup> 中并未定义,导致弹窗内容为空。

父组件 Index.vue 维护了三个独立的预算数组:

  • expenseBudgets - 支出预算列表
  • incomeBudgets - 收入预算列表
  • savingsBudgets - 存款计划列表

当前 SavingsBudgetContent.vue 只接收 savingsBudgets 数组,无法访问收入和支出预算数据。

Goals / Non-Goals

Goals:

  • 修复存款明细弹窗显示为空的问题
  • 正确显示收入预算和支出预算的限额及实际值
  • 确保计算逻辑与后端逻辑一致
  • 保持组件的单一职责,不引入不必要的依赖

Non-Goals:

  • 不修改存款计划的计算逻辑(后端已有)
  • 不改变预算数据的加载方式
  • 不重构整个预算页面的架构

Decisions

决策 1: 数据传递方式

选择: 通过 props 传递收入和支出预算数据

理由:

  • 为什么不用 provide/inject? 数据流更清晰,易于追踪和调试
  • 为什么不在子组件中调用 API? 违反单一数据源原则,会导致重复加载和不一致
  • 为什么不用 Pinia store? 这是页面级别的临时数据,不需要全局状态管理

实现:Index.vue 中传递 incomeBudgetsexpenseBudgetsSavingsBudgetContent.vue:

<SavingsBudgetContent
  :budgets="savingsBudgets"
  :income-budgets="incomeBudgets"
  :expense-budgets="expenseBudgets"
  @savings-nav="handleSavingsNav"
/>

决策 2: 匹配收入/支出预算的逻辑

选择: 根据 periodStarttype 进行匹配

理由:

  • 存款计划、收入预算、支出预算都有相同的 periodStart(周期开始时间)和 type(月度/年度)字段
  • 同一周期、同一类型的预算应该对应同一个存款计划
  • 后端逻辑中存款 = 收入预算限额 - 支出预算限额

实现:SavingsBudgetContent.vue 中添加计算属性:

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)

决策 3: 处理数据缺失情况

选择: 使用默认值 0,不显示错误提示

理由:

  • 如果找不到对应的收入或支出预算,说明用户可能还未设置
  • 显示 0 比显示错误信息更友好,符合"计划存款 = 收入 - 支出"的语义
  • 用户可以在主界面看到预算设置情况,弹窗只是详情展示

Risks / Trade-offs

[风险] 如果收入/支出预算数据未加载完成

缓解措施:

  • 父组件 Index.vue 已经统一加载所有预算数据
  • 弹窗在用户点击时打开,此时数据应已加载完成
  • 使用可选链和默认值避免运行时错误

[权衡] Props 增加导致组件耦合度提高

接受理由:

  • 父组件本身就维护了所有预算数据,传递给子组件是合理的
  • 子组件不负责数据加载,只负责展示,职责依然清晰
  • 如果未来需要重构,可以考虑引入 Pinia store 统一管理预算数据

[权衡] 按周期和类型匹配可能不够健壮

接受理由:

  • 这是后端设计的数据模型,前端保持一致
  • 后端保证同一周期同一类型只有一条预算记录
  • 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)