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

118 lines
4.3 KiB
Markdown

## Context
`SavingsBudgetContent.vue` 是一个显示存款计划列表和明细的组件。每个存款计划卡片可以点击查看详细信息,弹窗会显示:
1. 收入预算(预算限额和实际收入)
2. 支出预算(预算限额和实际支出)
3. 计划存款公式(收入预算 - 支出预算 = 计划存款)
4. 存款结果(计划存款、实际存款、还差)
问题在于弹窗模板引用了 `incomeLimit``incomeCurrent``expenseLimit``expenseCurrent` 这些计算属性,但在 `<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` 中传递 `incomeBudgets``expenseBudgets``SavingsBudgetContent.vue`:
```vue
<SavingsBudgetContent
:budgets="savingsBudgets"
:income-budgets="incomeBudgets"
:expense-budgets="expenseBudgets"
@savings-nav="handleSavingsNav"
/>
```
### 决策 2: 匹配收入/支出预算的逻辑
**选择:** 根据 `periodStart``type` 进行匹配
**理由:**
- 存款计划、收入预算、支出预算都有相同的 `periodStart`(周期开始时间)和 `type`(月度/年度)字段
- 同一周期、同一类型的预算应该对应同一个存款计划
- 后端逻辑中存款 = 收入预算限额 - 支出预算限额
**实现:**
`SavingsBudgetContent.vue` 中添加计算属性:
```javascript
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 统一管理预算数据
### [权衡] 按周期和类型匹配可能不够健壮
**接受理由:**
- 这是后端设计的数据模型,前端保持一致
- 后端保证同一周期同一类型只有一条预算记录
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)