18 Commits

Author SHA1 Message Date
SunCheng
b173c83134 chore: 移除未使用的前端组件
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
- 删除 SmartClassifyButton.vue (无引用)
- 删除 BudgetSummary.vue (无引用)
- 归档变更记录
2026-02-20 22:39:29 +08:00
SunCheng
5f9672744b fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
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
2026-02-20 22:22:54 +08:00
SunCheng
a7414c792e fix
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
2026-02-20 22:07:09 +08:00
SunCheng
3c3172fc81 debug: 添加存款明细数据调试日志
添加 console.log 输出,用于调试 details 字段是否正确返回
2026-02-20 17:15:07 +08:00
SunCheng
f46b9d4bd6 feat(frontend): 添加存款明细展示
- 在存款计划弹窗中添加详细明细表格
  - 收入明细列表(显示预算/实际/计算用金额)
  - 支出明细列表(显示超支标记)
  - 计算说明标签(使用预算/使用实际/超支/按天折算)

- 支持新旧版本兼容
  - 有 details 字段时显示详细明细
  - 无 details 字段时显示旧版汇总

- UI 优化
  - 超支项目红色边框高亮
  - 月度/年度标签区分
  - 计算汇总和公式展示
  - 移动端响应式布局
2026-02-20 17:10:33 +08:00
SunCheng
2cb5bffc70 feat(budget): 添加存款明细数据生成逻辑
- 实现 GenerateMonthlyDetails 方法生成月度存款明细
  - 为每个预算项调用 BudgetItemCalculator 计算有效金额
  - 生成计算说明(使用预算/使用实际/超支/按天折算)
  - 标记超支项目
  - 生成汇总信息(总收入、总支出、计划存款)

- GetForMonthAsync 现在返回 Details 字段
  - 包含收入明细列表
  - 包含支出明细列表
  - 包含计算汇总和公式

- 新增集成测试验证 Details 字段生成正确
  - 验证收入项计算规则
  - 验证支出项超支标记
  - 验证硬性支出处理
  - 验证汇总计算

测试结果:58个预算测试全部通过
2026-02-20 16:59:17 +08:00
SunCheng
4cc205fc25 feat(budget): 实现存款明细计算核心逻辑
- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则
  - 收入:实际>0取实际,否则取预算
  - 支出:取MAX(预算, 实际)
  - 硬性支出未发生:按天数折算
  - 归档数据:直接使用实际值

- 实现月度和年度存款核心公式
  - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
  - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算

- 定义存款明细数据结构
  - SavingsDetail: 包含收入/支出明细列表和汇总
  - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等)
  - SavingsCalculationSummary: 计算汇总信息

- 新增单元测试
  - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则
  - BudgetSavingsCalculationTest: 6个测试验证核心公式

测试结果:所有测试通过 (366 passed, 0 failed)
2026-02-20 16:26:04 +08:00
SunCheng
32d5ed62d0 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 16s
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
2026-02-20 14:57:19 +08:00
SunCheng
6e95568906 fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-20 13:56:29 +08:00
SunCheng
2cf19a45e5 1
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
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
2026-02-19 22:44:26 +08:00
SunCheng
6922dff5a9 archive: unify-bill-list-ui (2026-02-19)
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
变更已完成并归档:
- 迁移 calendarV2/TransactionList.vue 使用 BillListComponent
- 代码从 403 行简化到 177 行
- 添加左滑删除功能
- Delta spec 已同步到主 specs

提交记录:
- f8e6029: refactor(calendar-v2): migrate TransactionList to BillListComponent
- 4fd190f: fix(calendar-v2): remove left/right padding
- 1ba446f: feat(calendar-v2): add delete functionality
- d324769: specs: sync bill-list-unified-ui
2026-02-19 22:08:10 +08:00
SunCheng
d324769795 specs: sync bill-list-unified-ui from unify-bill-list-ui change
同步新的统一账单列表 UI 规范到主 specs
- 定义所有账单列表必须遵循的视觉设计和交互模式
- 包含 7 个核心需求和多个可测试场景
2026-02-19 22:07:33 +08:00
SunCheng
1ba446f05a feat(calendar-v2): add delete functionality to transaction list
添加左滑删除功能:
- 启用 show-delete prop
- 实现 onTransactionDelete 事件处理器
- 删除后更新本地列表数据
2026-02-19 22:03:24 +08:00
SunCheng
4fd190f461 fix(calendar-v2): remove left/right padding from BillListComponent
添加样式覆盖,移除 van-cell-group 和 van-list 的左右内边距,
确保账单列表在日历视图中无左右空白
2026-02-19 21:56:23 +08:00
SunCheng
9eb712cc44 chore: add openspec artifacts for unify-bill-list-ui change 2026-02-19 21:54:33 +08:00
SunCheng
4f6b634e68 docs: add migration record and update task status 2026-02-19 21:54:16 +08:00
SunCheng
cdd20352a3 docs: update unify-bill-list-ui change scope
更新变更范围以反映实际情况:
- 实际只需迁移 1 个页面: calendarV2/TransactionList.vue
- 其他页面要么已使用 BillListComponent,要么不是账单列表
- 更新 proposal、design 和 tasks 文档
2026-02-19 21:53:34 +08:00
SunCheng
f8e6029108 refactor(calendar-v2): migrate TransactionList to BillListComponent
- 使用统一的 BillListComponent 替换自定义账单列表
- 保留自定义 header (交易记录标题 + Items 计数 + Smart 按钮)
- 移除数据格式转换逻辑,直接传递原始数据
- 简化代码从 403 行减少到 177 行
- 配置: data-source=custom, enable-filter=false, show-delete=false
2026-02-19 21:52:23 +08:00
93 changed files with 6810 additions and 3681 deletions

View File

@@ -0,0 +1,165 @@
# PopupContainer V1 → V2 迁移清单
## 文件分析汇总
### 第一批:基础用法(无 subtitle、无按钮
| 文件 | Props 使用 | Slots 使用 | 迁移复杂度 | 备注 |
|------|-----------|-----------|----------|------|
| MessageView.vue | v-model, title, subtitle, height | footer | ⭐⭐ | 有 subtitle (createTime),有条件 footer |
| EmailRecord.vue | v-model, title, height | header-actions | ⭐⭐⭐ | 使用 header-actions 插槽(重新分析按钮) |
| PeriodicRecord.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法,表单内容 |
| ClassificationNLP.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法 |
| BillAnalysisView.vue | v-model, title, height | 默认插槽 | ⭐ | 基础用法 |
### 第二批:带 subtitle
| 文件 | Subtitle 类型 | 迁移方案 |
|------|--------------|---------|
| MessageView.vue | 时间戳 (createTime) | 移至内容区域顶部,使用灰色小字 |
| CategoryBillPopup.vue | 待检查 | 待定 |
| BudgetChartAnalysis.vue | 待检查 | 待定 |
| TransactionDetail.vue | 待检查 | 待定 |
| ReasonGroupList.vue | 待检查 | 待定 |
### 第三批:带确认/取消按钮
| 文件 | 按钮配置 | 迁移方案 |
|------|---------|---------|
| AddClassifyDialog.vue | 待检查 | footer 插槽 + van-button |
| IconSelector.vue | 待检查 | footer 插槽 + van-button |
| ClassificationEdit.vue | 待检查 | footer 插槽 + van-button |
### 第四批复杂布局header-actions
| 文件 | header-actions 内容 | 迁移方案 |
|------|-------------------|---------|
| EmailRecord.vue | "重新分析" 按钮 | 移至内容区域顶部作为操作栏 |
| BudgetCard.vue | 待检查 | 待定 |
| BudgetEditPopup.vue | 待检查 | 待定 |
| SavingsConfigPopup.vue | 待检查 | 待定 |
| SavingsBudgetContent.vue | 待检查 | 待定 |
| budgetV2/Index.vue | 待检查 | 待定 |
### 第五批:全局组件
| 文件 | 特殊逻辑 | 迁移方案 |
|------|---------|---------|
| GlobalAddBill.vue | 待检查 | 待定 |
## 迁移模式汇总
### 模式 1: 基础迁移(无特殊 props
```vue
<!-- V1 -->
<PopupContainer
v-model="show"
title="标题"
height="75%"
>
内容
</PopupContainer>
<!-- V2 -->
<PopupContainerV2
v-model:show="show"
title="标题"
:height="'75%'"
>
<div style="padding: 16px">
内容
</div>
</PopupContainerV2>
```
### 模式 2: subtitle 迁移
```vue
<!-- V1 -->
<PopupContainer
v-model="show"
title="标题"
:subtitle="createTime"
>
内容
</PopupContainer>
<!-- V2 -->
<PopupContainerV2
v-model:show="show"
title="标题"
:height="'75%'"
>
<div style="padding: 16px">
<p style="color: #999; font-size: 14px; margin-bottom: 12px">{{ createTime }}</p>
内容
</div>
</PopupContainerV2>
```
### 模式 3: header-actions 迁移
```vue
<!-- V1 -->
<PopupContainer
v-model="show"
title="标题"
>
<template #header-actions>
<van-button size="small" @click="handleAction">操作</van-button>
</template>
内容
</PopupContainer>
<!-- V2 -->
<PopupContainerV2
v-model:show="show"
title="标题"
:height="'80%'"
>
<div style="padding: 16px">
<div style="margin-bottom: 16px; text-align: right">
<van-button size="small" @click="handleAction">操作</van-button>
</div>
内容
</div>
</PopupContainerV2>
```
### 模式 4: footer 插槽迁移
```vue
<!-- V1 -->
<PopupContainer
v-model="show"
title="标题"
>
内容
<template #footer>
<van-button type="primary">提交</van-button>
</template>
</PopupContainer>
<!-- V2 -->
<PopupContainerV2
v-model:show="show"
title="标题"
:height="'80%'"
>
<div style="padding: 16px">
内容
</div>
<template #footer>
<van-button type="primary" block>提交</van-button>
</template>
</PopupContainerV2>
```
## 进度追踪
- [ ] 完成所有文件的详细分析
- [ ] 确认每个文件的迁移模式
- [ ] 标记需要特殊处理的文件
## 风险点
1. **EmailRecord.vue**: 有 header-actions 插槽,需要重新设计操作按钮的位置
2. **MessageView.vue**: subtitle 用于显示时间,需要保持视觉层级
3. **待检查文件**: 需要逐个检查是否使用了 v-html、复杂布局等特性

View File

@@ -0,0 +1,107 @@
# 账单列表统一迁移记录
**日期**: 2026-02-19
**变更**: unify-bill-list-ui
**提交**: f8e6029, cdd2035
## 变更摘要
`calendarV2/modules/TransactionList.vue` 迁移至使用统一的 `BillListComponent` 组件,保留自定义 header 和 Smart 按钮功能。
## 迁移范围调整
### 原设计 vs 实际情况
原设计文档列出需要迁移 6 个页面,但经过详细代码审查后发现:
| 页面 | 原设计预期 | 实际情况 | 处理结果 |
|------|-----------|---------|---------|
| TransactionsRecord.vue | 需迁移 | ✅ 已使用 BillListComponent | 无需操作 |
| EmailRecord.vue | 需迁移 | ✅ 已使用 BillListComponent | 无需操作 |
| calendarV2/TransactionList.vue | 需迁移 | ⚠️ 自定义实现,需迁移 | ✅ 已完成迁移 |
| MessageView.vue | 需迁移 | ❌ 系统消息列表,非账单 | 排除 |
| PeriodicRecord.vue | 需迁移 | ❌ 周期性规则列表,非交易账单 | 排除 |
| ClassificationEdit.vue | 需迁移 | ❌ 分类管理列表,非账单 | 排除 |
| budgetV2/Index.vue | 需迁移 | ❌ 预算卡片列表,非账单 | 排除 |
### 关键发现
1. **MessageView.vue**: 显示的是系统通知消息,数据结构为 `{title, content, isRead, createTime}`,不是交易账单。
2. **PeriodicRecord.vue**: 显示的是周期性账单规则(如每月1号扣款),包含 `periodicType`, `weekdays`, `isEnabled` 等配置字段,不是实际交易记录。
3. **ClassificationEdit.vue**: 显示的是分类配置列表,用于管理交易分类的图标和名称。
4. **budgetV2/Index.vue**: 显示的是预算卡片,每个卡片展示"已支出/预算/余额"等统计信息,不是账单列表。
## 迁移实施
### calendarV2/TransactionList.vue
**迁移前**:
- 403 行代码
- 自定义数据转换逻辑 (`formatTime`, `formatAmount`, `getIconByClassify` 等)
- 自定义账单卡片渲染 (`txn-card`, `txn-icon`, `txn-content` 等)
- 自定义空状态展示
**迁移后**:
- 177 行代码 (减少 56%)
- 使用 `BillListComponent` 处理数据格式化和渲染
- 保留自定义 header (交易记录标题 + Items 计数 + Smart 按钮)
- 直接传递原始 API 数据,无需转换
**配置**:
```vue
<BillListComponent
data-source="custom"
:transactions="transactions"
:loading="transactionsLoading"
:finished="true"
:show-delete="false"
:enable-filter="false"
@click="onTransactionClick"
/>
```
**代码改动**:
- ✅ 导入 `BillListComponent`
- ✅ 替换 template 中的自定义列表部分
- ✅ 移除数据格式转换函数
- ✅ 清理废弃的样式定义 (txn-card, txn-empty 等)
- ✅ 保留 txn-header 相关样式
## 测试验证
### 功能测试清单
- [ ] 日历选择日期,查看对应日期的账单列表
- [ ] 点击账单卡片,打开账单详情
- [ ] 点击 Smart 按钮,触发智能分类
- [ ] Items 计数显示正确
- [ ] 空状态显示正确(无交易记录的日期)
- [ ] 加载状态显示正确
### 视觉验证
- [ ] 账单卡片样式与 /balance 页面一致
- [ ] 自定义 header 保持原有样式
- [ ] Smart 按钮样式和位置正确
- [ ] 响应式设计正常(不同屏幕尺寸)
### 代码质量
- ✅ ESLint 检查通过 (无错误,无新增警告)
- ✅ 代码简化效果明显 (403行 → 177行)
- ✅ Git 提交记录清晰
## 后续建议
1. **手动测试**: 在实际环境中测试日历视图的所有功能
2. **性能监控**: 观察迁移后的页面加载和交互性能
3. **用户反馈**: 收集用户对新 UI 风格的反馈
## 相关文件
- **迁移代码**: `Web/src/views/calendarV2/modules/TransactionList.vue`
- **统一组件**: `Web/src/components/Bill/BillListComponent.vue`
- **提交记录**:
- f8e6029: refactor(calendar-v2): migrate TransactionList to BillListComponent
- cdd2035: docs: update unify-bill-list-ui change scope
- **OpenSpec 变更**: `openspec/changes/unify-bill-list-ui/`

2
.gitignore vendored
View File

@@ -407,4 +407,4 @@ Web/dist
.aider*
.screenshot/*
**/nul

View File

@@ -224,7 +224,51 @@ public class BudgetApplication(
StartDate = startDate,
NoLimit = result.NoLimit,
IsMandatoryExpense = result.IsMandatoryExpense,
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0,
Details = result.Details != null ? MapToSavingsDetailDto(result.Details) : null
};
}
/// <summary>
/// 映射存款明细数据到DTO
/// </summary>
private static SavingsDetailDto MapToSavingsDetailDto(Service.Budget.SavingsDetail details)
{
return new SavingsDetailDto
{
IncomeItems = details.IncomeItems.Select(item => new BudgetDetailItemDto
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
BudgetLimit = item.BudgetLimit,
ActualAmount = item.ActualAmount,
EffectiveAmount = item.EffectiveAmount,
CalculationNote = item.CalculationNote,
IsOverBudget = item.IsOverBudget,
IsArchived = item.IsArchived,
ArchivedMonths = item.ArchivedMonths
}).ToList(),
ExpenseItems = details.ExpenseItems.Select(item => new BudgetDetailItemDto
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
BudgetLimit = item.BudgetLimit,
ActualAmount = item.ActualAmount,
EffectiveAmount = item.EffectiveAmount,
CalculationNote = item.CalculationNote,
IsOverBudget = item.IsOverBudget,
IsArchived = item.IsArchived,
ArchivedMonths = item.ArchivedMonths
}).ToList(),
Summary = new SavingsCalculationSummaryDto
{
TotalIncomeBudget = details.Summary.TotalIncomeBudget,
TotalExpenseBudget = details.Summary.TotalExpenseBudget,
PlannedSavings = details.Summary.PlannedSavings,
CalculationFormula = details.Summary.CalculationFormula
}
};
}

View File

@@ -16,8 +16,52 @@ public record BudgetResponse
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
public decimal UsagePercentage { get; init; }
/// <summary>
/// 存款明细数据(仅存款预算返回)
/// </summary>
public SavingsDetailDto? Details { get; init; }
}
/// <summary>
/// 存款明细数据 DTO
/// </summary>
public record SavingsDetailDto
{
public List<BudgetDetailItemDto> IncomeItems { get; init; } = new();
public List<BudgetDetailItemDto> ExpenseItems { get; init; } = new();
public SavingsCalculationSummaryDto Summary { get; init; } = new();
}
/// <summary>
/// 预算明细项 DTO
/// </summary>
public record BudgetDetailItemDto
{
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; }
}
/// <summary>
/// 存款计算汇总 DTO
/// </summary>
public record SavingsCalculationSummaryDto
{
public decimal TotalIncomeBudget { get; init; }
public decimal TotalExpenseBudget { get; init; }
public decimal PlannedSavings { get; init; }
public string CalculationFormula { get; init; } = string.Empty;
}
/// <summary>
/// 创建预算请求
/// </summary>
@@ -89,3 +133,41 @@ public record UpdateArchiveSummaryRequest
public DateTime ReferenceDate { get; init; }
public string? Summary { get; init; }
}
/// <summary>
/// 存款明细数据
/// </summary>
public record SavingsDetail
{
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
public SavingsCalculationSummary Summary { get; init; } = new();
}
/// <summary>
/// 预算明细项
/// </summary>
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; }
}
/// <summary>
/// 存款计算汇总
/// </summary>
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;
}

View File

@@ -0,0 +1,121 @@
namespace Service.Budget;
/// <summary>
/// 预算明细项计算辅助类
/// 用于计算单个预算项的有效金额(计算用金额)
/// </summary>
public static class BudgetItemCalculator
{
/// <summary>
/// 计算预算项的有效金额
/// </summary>
/// <param name="category">预算类别(收入/支出)</param>
/// <param name="budgetLimit">预算金额</param>
/// <param name="actualAmount">实际金额</param>
/// <param name="isMandatory">是否为硬性消费</param>
/// <param name="isArchived">是否为归档数据</param>
/// <param name="referenceDate">参考日期</param>
/// <param name="periodType">预算周期类型(月度/年度)</param>
/// <returns>有效金额(用于计算的金额)</returns>
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;
}
/// <summary>
/// 计算硬性消费按天数折算的金额
/// </summary>
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;
}
}
/// <summary>
/// 生成计算说明
/// </summary>
/// <param name="category">预算类别</param>
/// <param name="budgetLimit">预算金额</param>
/// <param name="actualAmount">实际金额</param>
/// <param name="effectiveAmount">有效金额</param>
/// <param name="isMandatory">是否为硬性消费</param>
/// <param name="isArchived">是否为归档数据</param>
/// <returns>计算说明文本</returns>
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 "使用预算";
}
}

View File

@@ -400,12 +400,25 @@ public class BudgetSavingsService(
UpdateTime = dateTimeProvider.Now
};
return BudgetResult.FromEntity(
// 生成明细数据
var referenceDate = new DateTime(year, month, dateTimeProvider.Now.Day);
var details = GenerateMonthlyDetails(
monthlyIncomeItems,
monthlyExpenseItems,
yearlyIncomeItems,
yearlyExpenseItems,
referenceDate
);
var result = BudgetResult.FromEntity(
record,
currentActual,
new DateTime(year, month, 1),
description.ToString()
);
result.Details = details;
return result;
}
private async Task<BudgetResult> GetForYearAsync(
@@ -863,12 +876,26 @@ public class BudgetSavingsService(
UpdateTime = dateTimeProvider.Now
};
return BudgetResult.FromEntity(
// 生成明细数据
var details = GenerateYearlyDetails(
currentMonthlyIncomeItems,
currentYearlyIncomeItems,
currentMonthlyExpenseItems,
currentYearlyExpenseItems,
archiveIncomeItems,
archiveExpenseItems,
new DateTime(year, 1, 1)
);
var result = BudgetResult.FromEntity(
record,
currentActual,
new DateTime(year, 1, 1),
description.ToString()
);
result.Details = details;
return result;
void AddOrIncCurrentItem(
long id,
@@ -935,4 +962,334 @@ public class BudgetSavingsService(
return string.Join(", ", months) + "月";
}
}
}
/// <summary>
/// 计算月度计划存款
/// 公式:收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
/// </summary>
public static decimal CalculateMonthlyPlannedSavings(
decimal monthlyIncomeBudget,
decimal yearlyIncomeInThisMonth,
decimal monthlyExpenseBudget,
decimal yearlyExpenseInThisMonth)
{
return monthlyIncomeBudget + yearlyIncomeInThisMonth
- monthlyExpenseBudget - yearlyExpenseInThisMonth;
}
/// <summary>
/// 计算年度计划存款
/// 公式:归档月已实收 + 未来月收入预算 - 归档月已实支 - 未来月支出预算
/// </summary>
public static decimal CalculateYearlyPlannedSavings(
decimal archivedIncome,
decimal futureIncomeBudget,
decimal archivedExpense,
decimal futureExpenseBudget)
{
return archivedIncome + futureIncomeBudget
- archivedExpense - futureExpenseBudget;
}
/// <summary>
/// 生成月度存款明细数据
/// </summary>
private SavingsDetail GenerateMonthlyDetails(
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyIncomeItems,
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyExpenseItems,
List<(string name, decimal limit, decimal current)> yearlyIncomeItems,
List<(string name, decimal limit, decimal current)> yearlyExpenseItems,
DateTime referenceDate)
{
var incomeDetails = new List<BudgetDetailItem>();
var expenseDetails = new List<BudgetDetailItem>();
// 处理月度收入
foreach (var item in monthlyIncomeItems)
{
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
BudgetCategory.Income,
item.limit,
item.current,
item.isMandatory,
isArchived: false,
referenceDate,
BudgetPeriodType.Month
);
var note = BudgetItemCalculator.GenerateCalculationNote(
BudgetCategory.Income,
item.limit,
item.current,
effectiveAmount,
item.isMandatory,
isArchived: false
);
incomeDetails.Add(new BudgetDetailItem
{
Id = 0, // 临时ID
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > 0 && item.current < item.limit,
IsArchived = false
});
}
// 处理月度支出
foreach (var item in monthlyExpenseItems)
{
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
BudgetCategory.Expense,
item.limit,
item.current,
item.isMandatory,
isArchived: false,
referenceDate,
BudgetPeriodType.Month
);
var note = BudgetItemCalculator.GenerateCalculationNote(
BudgetCategory.Expense,
item.limit,
item.current,
effectiveAmount,
item.isMandatory,
isArchived: false
);
expenseDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 处理年度收入(发生在本月的)
foreach (var item in yearlyIncomeItems)
{
incomeDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current, // 年度预算发生在本月的直接用实际值
CalculationNote = "使用实际",
IsOverBudget = false,
IsArchived = false
});
}
// 处理年度支出(发生在本月的)
foreach (var item in yearlyExpenseItems)
{
expenseDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = "使用实际",
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 计算汇总
var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount);
var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount);
var plannedSavings = totalIncome - totalExpense;
var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}";
return new SavingsDetail
{
IncomeItems = incomeDetails,
ExpenseItems = expenseDetails,
Summary = new SavingsCalculationSummary
{
TotalIncomeBudget = totalIncome,
TotalExpenseBudget = totalExpense,
PlannedSavings = plannedSavings,
CalculationFormula = formula
}
};
}
/// <summary>
/// 生成年度存款明细数据
/// </summary>
private SavingsDetail GenerateYearlyDetails(
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyIncomeItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyIncomeItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyExpenseItems,
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyExpenseItems,
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveIncomeItems,
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveExpenseItems,
DateTime referenceDate)
{
var incomeDetails = new List<BudgetDetailItem>();
var expenseDetails = new List<BudgetDetailItem>();
// 处理已归档的收入预算
foreach (var item in archiveIncomeItems)
{
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
IsOverBudget = false,
IsArchived = true,
ArchivedMonths = item.months
});
}
// 处理当前月度收入预算
foreach (var item in currentMonthlyIncomeItems)
{
// 年度预算中,月度预算按 factor 倍率计算有效金额
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
var note = item.limit == 0
? "不记额(使用实际)"
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > 0 && item.current < item.limit,
IsArchived = false
});
}
// 处理当前年度收入预算
foreach (var item in currentYearlyIncomeItems)
{
// 年度预算:硬性预算或不记额预算使用实际值,否则使用预算值
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
var note = item.isMandatory
? "硬性(使用实际)"
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
incomeDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = false,
IsArchived = false
});
}
// 处理已归档的支出预算
foreach (var item in archiveExpenseItems)
{
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
IsOverBudget = false,
IsArchived = true,
ArchivedMonths = item.months
});
}
// 处理当前月度支出预算
foreach (var item in currentMonthlyExpenseItems)
{
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
var note = item.limit == 0
? "不记额(使用实际)"
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 处理当前年度支出预算
foreach (var item in currentYearlyExpenseItems)
{
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
var note = item.isMandatory
? "硬性(使用实际)"
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
expenseDetails.Add(new BudgetDetailItem
{
Id = item.id,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 计算汇总
var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount);
var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount);
var plannedSavings = totalIncome - totalExpense;
var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}";
return new SavingsDetail
{
IncomeItems = incomeDetails,
ExpenseItems = expenseDetails,
Summary = new SavingsCalculationSummary
{
TotalIncomeBudget = totalIncome,
TotalExpenseBudget = totalExpense,
PlannedSavings = plannedSavings,
CalculationFormula = formula
}
};
}
}

View File

@@ -448,6 +448,11 @@ public record BudgetResult
public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; }
public string Description { get; set; } = string.Empty;
/// <summary>
/// 存款明细数据(可选,用于存款预算)
/// </summary>
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; }
}
/// <summary>
/// 存款明细数据
/// </summary>
public record SavingsDetail
{
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
public SavingsCalculationSummary Summary { get; init; } = new();
}
/// <summary>
/// 预算明细项
/// </summary>
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; }
}
/// <summary>
/// 存款计算汇总
/// </summary>
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;
}

View File

@@ -595,27 +595,46 @@ public class BudgetStatsService(
logger.LogDebug("开始处理当前及未来月份预算");
foreach (var budget in currentBudgetsDict.Values)
{
// 对于年度预算,如果还没有从归档中添加,则添加
if (budget.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(budget.Id))
// 对于年度预算,需要实时计算当前金额
if (budget.Type == BudgetPeriodType.Year)
{
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
result.Add(new BudgetStatsItem
// 如果已经从归档中添加过需要更新其Current值为实时计算的金额
if (processedBudgetIds.Contains(budget.Id))
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Current = currentAmount,
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false
});
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
budget.Name, budget.Limit, currentAmount);
var realTimeAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
var existingItem = result.FirstOrDefault(r => r.Id == budget.Id && r.Type == BudgetPeriodType.Year);
if (existingItem != null)
{
// 更新Current为实时金额而不是归档的Actual
result.Remove(existingItem);
result.Add(existingItem with { Current = realTimeAmount, IsArchive = false });
logger.LogInformation("更新年度预算实时金额: {BudgetName} - 归档金额: {ArchiveAmount}, 实时金额: {RealtimeAmount}",
budget.Name, existingItem.Current, realTimeAmount);
}
}
else
{
// 如果没有从归档中添加,则新增
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
result.Add(new BudgetStatsItem
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Current = currentAmount,
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false
});
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
budget.Name, budget.Limit, currentAmount);
processedBudgetIds.Add(budget.Id);
}
}
// 对于月度预算,仅添加当前月的预算项(如果还没有从归档中添加)
else if (budget.Type == BudgetPeriodType.Month)

View File

@@ -1,30 +1,45 @@
<template>
<PopupContainer
<PopupContainerV2
v-model:show="show"
title="新增交易分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirm"
@cancel="resetAddForm"
:height="'auto'"
>
<van-form ref="addFormRef">
<van-field
v-model="classifyName"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<div style="padding: 16px">
<van-form ref="addFormRef">
<van-field
v-model="classifyName"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</div>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="resetAddForm"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="handleConfirm"
>
确认
</van-button>
</div>
</template>
</PopupContainerV2>
</template>
<script setup>
import { ref } from 'vue'
import { showToast } from 'vant'
import PopupContainer from './PopupContainer.vue'
import PopupContainerV2 from './PopupContainerV2.vue'
const emit = defineEmits(['confirm'])

View File

@@ -209,10 +209,10 @@
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
<PopupContainerV2
v-model:show="showBillListModal"
title="关联账单列表"
height="75%"
:height="'75%'"
>
<BillListComponent
data-source="custom"
@@ -225,7 +225,7 @@
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</PopupContainerV2>
</div>
<!-- 不记额预算卡片 -->
@@ -406,10 +406,10 @@
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
<PopupContainerV2
v-model:show="showBillListModal"
title="关联账单列表"
height="75%"
:height="'75%'"
>
<BillListComponent
data-source="custom"
@@ -422,14 +422,14 @@
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</PopupContainerV2>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import { getTransactionList } from '@/api/transactionRecord'
@@ -508,6 +508,11 @@ const handleQueryBills = async () => {
}
const percentage = computed(() => {
// 优先使用后端返回的 usagePercentage 字段
if (props.budget.usagePercentage !== undefined && props.budget.usagePercentage !== null) {
return Math.round(props.budget.usagePercentage)
}
// 降级方案:如果后端没有返回该字段,前端计算
if (!props.budget.limit) {
return 0
}

View File

@@ -1,843 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<!-- 普通预算卡片 -->
<div
v-if="!budget.noLimit"
class="common-card budget-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer">
<div class="collapsed-item">
<span class="compact-label">实际/目标</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{
budget.current !== undefined && budget.limit !== undefined
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
: '--'
}}
</slot>
</span>
</div>
<div class="collapsed-item">
<span class="compact-label">达成率</span>
<span
class="compact-value"
:class="percentClass"
>{{ percentage }}%</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info" />
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span
class="percent"
:class="percentClass"
>{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="var(--van-gray-6)"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
<!-- 不记额预算卡片 -->
<div
v-else
class="common-card budget-card no-limit-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer no-limit-footer">
<div class="collapsed-item">
<span class="compact-label">实际</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
</slot>
</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="no-limit-amount-info">
<div class="amount-item">
<span>
<span class="label">实际</span>
<span
class="value"
style="margin-left: 12px"
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
</span>
</div>
</div>
<div class="no-limit-notice">
<span>
<van-icon
name="info-o"
style="margin-right: 4px"
/>
不记额预算 - 直接计入存款明细
</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import TransactionList from '@/components/TransactionList.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
budget: {
type: Object,
required: true
},
progressColor: {
type: String,
default: 'var(--van-primary-color)'
},
percentClass: {
type: [String, Object],
default: ''
},
periodLabel: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
const isExpanded = ref(props.budget.category === 2)
const showDescription = ref(false)
const showBillListModal = ref(false)
const billList = ref([])
const billLoading = ref(false)
const toggleExpand = () => {
// 存款类型category === 2强制保持展开状态不可折叠
if (props.budget.category === 2) {
return
}
isExpanded.value = !isExpanded.value
}
const handleQueryBills = async () => {
showBillListModal.value = true
billLoading.value = true
try {
const classify = props.budget.selectedCategories
? props.budget.selectedCategories.join(',')
: ''
if (classify === '') {
// 如果没有选中任何分类,则不查询
billList.value = []
billLoading.value = false
return
}
const response = await getTransactionList({
page: 1,
pageSize: 100,
startDate: props.budget.periodStart,
endDate: props.budget.periodEnd,
classify: classify,
type: props.budget.category,
sortByAmount: true
})
if (response.success) {
billList.value = response.data || []
} else {
billList.value = []
}
} catch (error) {
console.error('查询账单列表失败:', error)
billList.value = []
} finally {
billLoading.value = false
}
}
const percentage = computed(() => {
if (!props.budget.limit) {
return 0
}
return Math.round((props.budget.current / props.budget.limit) * 100)
})
const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd) {
return 0
}
const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime()
if (now <= start) {
return 0
}
if (now >= end) {
return 100
}
return Math.round(((now - start) / (end - start)) * 100)
})
const onEnter = (el) => {
el.style.height = '0'
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = `${el.scrollHeight}px`
}
const onAfterEnter = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
const onLeave = (el) => {
el.style.height = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = '0'
}
const onAfterLeave = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.budget-card.cursor-default {
cursor: default;
}
.no-limit-card {
border-left: 3px solid var(--van-success-color);
}
.collapsed-footer.no-limit-footer {
justify-content: flex-start;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 折叠状态样式 */
.budget-collapsed {
display: flex;
flex-direction: column;
gap: 6px;
}
.collapsed-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapsed-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.card-title-compact {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.status-tag-compact) {
padding: 2px 6px !important;
font-size: 11px !important;
height: auto !important;
flex-shrink: 0;
}
.collapsed-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.collapsed-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.collapsed-item:first-child {
flex: 2;
}
.collapsed-item:last-child {
flex: 1;
}
.compact-label {
font-size: 12px;
color: var(--van-text-color-2);
line-height: 1.2;
}
.compact-value {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-value.warning {
color: var(--van-warning-color);
}
.compact-value.income {
color: var(--van-success-color);
}
.expand-icon {
color: var(--van-primary-color);
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.collapse-icon {
color: var(--van-primary-color);
font-size: 16px;
cursor: pointer;
}
.budget-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.card-subtitle {
font-size: 12px;
color: var(--van-text-color-2);
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.header-actions {
display: flex;
gap: 8px;
}
.category-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0 4px;
}
.category-tag {
opacity: 0.7;
font-size: 10px;
}
.amount-info {
display: flex;
justify-content: space-between;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: var(--van-text-color-2);
margin-bottom: 2px;
}
:deep(.info-item) .value {
font-size: 15px;
font-weight: 600;
}
:deep(.value.expense) {
color: var(--van-danger-color);
}
:deep(.value.income) {
color: var(--van-success-color);
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: var(--van-gray-6);
}
.progress-section :deep(.van-progress) {
flex: 1;
}
.period-type {
white-space: nowrap;
width: 65px;
}
.percent {
white-space: nowrap;
width: 35px;
text-align: right;
}
.percent.warning {
color: var(--van-warning-color);
font-weight: bold;
}
.percent.income {
color: var(--van-success-color);
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.no-limit-notice {
text-align: center;
font-size: 12px;
color: var(--van-text-color-2);
background-color: var(--van-light-gray);
border-radius: 4px;
margin-top: 8px;
}
.no-limit-amount-info {
display: flex;
justify-content: center;
margin: 0px 0;
}
.amount-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.amount-item .label {
font-size: 12px;
color: var(--van-text-color-2);
}
.amount-item .value {
font-size: 20px;
font-weight: 600;
color: var(--van-success-color);
}
.budget-collapse-wrapper {
overflow: hidden;
}
.budget-description {
border-top: 1px solid var(--van-border-color);
margin-top: 8px;
}
.description-content {
font-size: 11px;
color: var(--van-gray-6);
line-height: 1.4;
}
.mandatory-mark {
margin-left: 4px;
font-size: 14px;
display: inline-block;
}
</style>

View File

@@ -6,14 +6,15 @@
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
<!-- 月度健康度 -->
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
</span>
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'month'"
class="info-icon"
@click="handleShowDescription('month')"
/>
</div>
</div>
@@ -27,15 +28,15 @@
/>
<div class="gauge-text-overlay">
<div class="balance-label">
余额
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="balance-value"
:style="{
color:
overallStats.month.current > overallStats.month.limit
? 'var(--van-danger-color)'
: ''
activeTab === BudgetCategory.Expense
? (overallStats.month.current > overallStats.month.limit ? 'var(--van-danger-color)' : '')
: (overallStats.month.current < overallStats.month.limit ? 'var(--van-danger-color)' : '')
}"
>
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
@@ -44,11 +45,11 @@
</div>
<div class="gauge-footer">
<div class="gauge-item">
<span class="label">已用</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
</div>
</div>
@@ -58,13 +59,15 @@
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
</span>
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'year'"
class="info-icon"
@click="handleShowDescription('year')"
/>
</div>
</div>
@@ -78,16 +81,15 @@
/>
<div class="gauge-text-overlay">
<div class="balance-label">
余额
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="balance-value"
:style="{
color:
activeTab === BudgetCategory.Expense &&
overallStats.year.current > overallStats.year.limit
? 'var(--van-danger-color)'
: ''
activeTab === BudgetCategory.Expense
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
}"
>
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
@@ -96,11 +98,11 @@
</div>
<div class="gauge-footer">
<div class="gauge-item">
<span class="label">已用</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
</div>
</div>
@@ -117,7 +119,7 @@
预算进度月度
</div>
<div class="chart-subtitle">
预算剩余消耗趋势
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
</div>
</div>
<BaseChart
@@ -185,13 +187,14 @@
</div>
<!-- 详细描述弹窗 -->
<PopupContainer
v-model="showDescriptionPopup"
<PopupContainerV2
v-model:show="showDescriptionPopup"
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
height="70%"
:height="'70%'"
>
<div
class="rich-html-content popup-content-padding"
class="rich-html-content"
style="padding: 16px"
v-html="
activeDescTab === 'month'
? overallStats.month?.description ||
@@ -200,14 +203,14 @@
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
"
/>
</PopupContainer>
</PopupContainerV2>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
import { ref, computed } from 'vue'
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
import { getCssVar } from '@/utils/theme'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
@@ -239,6 +242,12 @@ const props = defineProps({
const showDescriptionPopup = ref(false)
const activeDescTab = ref('month')
// 显示描述弹窗
const handleShowDescription = (tab) => {
activeDescTab.value = tab
showDescriptionPopup.value = true
}
// Chart.js 相关
const { getChartOptions } = useChartTheme()
@@ -595,10 +604,19 @@ const varianceChartOptions = computed(() => {
callbacks: {
label: (context) => {
const item = context.dataset._meta[context.dataIndex]
const diffText =
item.value > 0
const isExpense = props.activeTab === BudgetCategory.Expense
let diffText
if (isExpense) {
diffText = item.value > 0
? `超支: ¥${formatMoney(item.value)}`
: `结余: ¥${formatMoney(Math.abs(item.value))}`
} else {
diffText = item.value > 0
? `超额: ¥${formatMoney(item.value)}`
: `未达标: ¥${formatMoney(Math.abs(item.value))}`
}
return [
`预算: ¥${formatMoney(item.limit)}`,
`实际: ¥${formatMoney(item.current)}`,
@@ -995,6 +1013,7 @@ const yearBurndownChartOptions = computed(() => {
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.gauge-text-overlay {
@@ -1030,6 +1049,8 @@ const yearBurndownChartOptions = computed(() => {
.chart-header {
margin-bottom: 12px;
position: relative;
z-index: 20;
}
.chart-title {
@@ -1038,9 +1059,24 @@ const yearBurndownChartOptions = computed(() => {
font-weight: 600;
color: var(--van-text-color);
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}
.chart-title-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.info-icon {
flex-shrink: 0;
cursor: pointer;
padding: 4px;
margin: -4px;
}
.chart-subtitle {

View File

@@ -1,18 +1,18 @@
<template>
<PopupContainer
v-model="visible"
<PopupContainerV2
v-model:show="visible"
:title="
isEdit
? `编辑${getCategoryName(form.category)}预算`
: `新增${getCategoryName(form.category)}预算`
"
height="75%"
:height="'75%'"
>
<div class="add-budget-form">
<van-form>
<van-cell-group inset>
<van-field
v-model="form.name"
v-model:show="form.name"
name="name"
label="预算名称"
placeholder="例如:每月餐饮、年度奖金"
@@ -22,7 +22,7 @@
<van-field label="不记额预算">
<template #input>
<van-checkbox
v-model="form.noLimit"
v-model:show="form.noLimit"
@update:model-value="onNoLimitChange"
>
不记额预算
@@ -34,7 +34,7 @@
<template #input>
<div class="mandatory-wrapper">
<van-checkbox
v-model="form.isMandatoryExpense"
v-model:show="form.isMandatoryExpense"
:disabled="form.noLimit"
>
{{ form.category === BudgetCategory.Expense ? '硬性消费' : '硬性收入' }}
@@ -49,7 +49,7 @@
>
<template #input>
<van-radio-group
v-model="form.type"
v-model:show="form.type"
direction="horizontal"
:disabled="isEdit || form.noLimit"
>
@@ -65,7 +65,7 @@
<!-- 仅当未选中"不记额预算"时显示预算金额 -->
<van-field
v-if="!form.noLimit"
v-model="form.limit"
v-model:show="form.limit"
type="number"
name="limit"
label="预算金额"
@@ -95,7 +95,7 @@
</template>
</van-field>
<ClassifySelector
v-model="form.selectedCategories"
v-model:show="form.selectedCategories"
:type="budgetType"
multiple
:show-add="false"
@@ -114,7 +114,7 @@
保存预算
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
</template>
<script setup>
@@ -122,7 +122,7 @@ import { ref, reactive, computed } from 'vue'
import { showToast } from 'vant'
import { createBudget, updateBudget } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
const emit = defineEmits(['success'])

View File

@@ -1,309 +0,0 @@
<template>
<div class="summary-container">
<transition
:name="transitionName"
mode="out-in"
>
<div
v-if="stats && (stats.month || stats.year)"
:key="dateKey"
class="summary-card common-card"
>
<!-- 左切换按钮 -->
<div
class="nav-arrow left"
@click.stop="changeMonth(-1)"
>
<van-icon name="arrow-left" />
</div>
<div class="summary-content">
<template
v-for="(config, key) in periodConfigs"
:key="key"
>
<div class="summary-item">
<div class="label">
{{ config.label }}{{ title }}
</div>
<div
class="value"
:class="getValueClass(stats[key]?.rate || '0.0')"
>
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div>
<div class="sub-info">
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span>
<span class="separator">/</span>
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div>
</div>
<div
v-if="config.showDivider"
class="divider"
/>
</template>
</div>
<!-- 右切换按钮 -->
<div
class="nav-arrow right"
:class="{ disabled: isCurrentMonth }"
@click.stop="!isCurrentMonth && changeMonth(1)"
>
<van-icon name="arrow" />
</div>
<!-- 非本月时显示的日期标识 -->
<div
v-if="!isCurrentMonth"
class="date-tag"
>
{{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }}
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
stats: {
type: Object,
required: true
},
title: {
type: String,
required: true
},
getValueClass: {
type: Function,
required: true
},
date: {
type: Date,
default: () => new Date()
}
})
const emit = defineEmits(['update:date'])
const transitionName = ref('slide-right')
const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMonth())
const isCurrentMonth = computed(() => {
const now = new Date()
return props.date.getFullYear() === now.getFullYear() && props.date.getMonth() === now.getMonth()
})
const periodConfigs = computed(() => ({
month: {
label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}`,
showDivider: true
},
year: {
label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}`,
showDivider: false
}
}))
const changeMonth = (delta) => {
transitionName.value = delta > 0 ? 'slide-left' : 'slide-right'
const newDate = new Date(props.date)
newDate.setMonth(newDate.getMonth() + delta)
emit('update:date', newDate)
}
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
</script>
<style scoped>
.summary-container {
margin-top: 12px;
position: relative;
}
.summary-card {
position: relative;
display: flex;
align-items: center;
padding: 16px 36px;
margin: 0 12px 8px;
min-height: 80px;
}
.summary-content {
flex: 1;
display: flex;
justify-content: space-around;
align-items: center;
text-align: center;
}
.nav-arrow {
position: absolute;
top: 0;
bottom: 0;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
font-size: 18px;
color: var(--van-gray-5);
cursor: pointer;
transition: all 0.2s;
z-index: 1;
}
.nav-arrow:active {
color: var(--van-primary-color);
background-color: rgba(0, 0, 0, 0.02);
}
.nav-arrow.disabled {
color: #c8c9cc;
cursor: not-allowed;
opacity: 0.35;
pointer-events: none;
}
.nav-arrow.disabled:active {
background-color: transparent;
}
.nav-arrow.left {
left: 0;
}
.nav-arrow.right {
right: 0;
}
.nav-arrow.disabled {
color: var(--van-gray-3);
cursor: not-allowed;
}
.date-tag {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
padding: 1px 8px;
border-radius: 0 0 8px 8px;
font-weight: 500;
opacity: 0.8;
}
/* 动画效果 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(30px);
}
.summary-item {
flex: 1;
}
.summary-item .label {
font-size: 12px;
color: var(--van-text-color-2);
margin-bottom: 6px;
}
.summary-item .value {
font-size: 20px;
font-weight: bold;
color: var(--van-text-color);
}
.summary-item :deep(.value.expense) {
color: var(--van-danger-color);
}
.summary-item :deep(.value.income) {
color: var(--van-success-color);
}
.summary-item :deep(.value.warning) {
color: var(--van-warning-color);
}
.summary-item .unit {
font-size: 11px;
margin-left: 1px;
font-weight: normal;
}
.summary-item .sub-info {
font-size: 12px;
color: var(--van-text-color-3);
display: flex;
justify-content: center;
align-items: center;
gap: 3px;
}
.summary-item .amount {
color: var(--van-text-color-2);
}
.summary-item .separator {
color: var(--van-text-color-3);
}
.divider {
width: 1px;
height: 24px;
background-color: var(--van-border-color);
margin: 0 4px;
}
/* @media (prefers-color-scheme: dark) {
.nav-arrow:active {
background-color: rgba(255, 255, 255, 0.05);
}
.nav-arrow.disabled {
color: var(--van-text-color);
}
.summary-item .value {
color: var(--van-text-color);
}
.summary-item .amount {
color: var(--van-text-color-3);
}
.divider {
background-color: var(--van-border-color);
}
} */
</style>

View File

@@ -1,8 +1,8 @@
<template>
<PopupContainer
v-model="visible"
<PopupContainerV2
v-model:show="visible"
title="设置存款分类"
height="60%"
:height="'60%'"
>
<div class="savings-config-content">
<div class="config-header">
@@ -16,7 +16,7 @@
可多选分类
</div>
<ClassifySelector
v-model="selectedCategories"
v-model:show="selectedCategories"
:type="2"
multiple
:show-add="false"
@@ -35,14 +35,14 @@
保存配置
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
</template>
<script setup>
import { ref } from 'vue'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
const emit = defineEmits(['success'])

View File

@@ -1,118 +1,36 @@
<template>
<PopupContainer
<PopupContainerV2
v-model:show="visible"
:title="title"
:subtitle="total > 0 ? `共 ${total} 笔交易` : ''"
:closeable="true"
:height="'80%'"
>
<!-- 交易列表 -->
<div class="transactions">
<!-- 加载状态 -->
<van-loading
v-if="loading && transactions.length === 0"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<!-- 空状态 -->
<div style="padding: 0">
<div
v-else-if="transactions.length === 0"
class="txn-empty"
v-if="total > 0"
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
>
<div class="empty-icon">
<van-icon
name="balance-list-o"
size="48"
/>
</div>
<div class="empty-text">
暂无交易记录
</div>
{{ total }} 笔交易
</div>
<!-- 交易列表 -->
<div
v-else
class="txn-list"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.reason }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ formatDateTime(txn.occurredAt) }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ formatAmount(txn.amount, txn.type) }}
</div>
</div>
<!-- 加载更多 -->
<div
v-if="!finished"
class="load-more"
>
<van-loading
v-if="loading"
size="20px"
>
加载中...
</van-loading>
<van-button
v-else
type="primary"
size="small"
@click="loadMore"
>
加载更多
</van-button>
</div>
<!-- 已加载全部 -->
<div
v-else
class="finished-text"
>
已加载全部
</div>
</div>
<BillListComponent
data-source="custom"
:transactions="transactions"
:loading="loading"
:finished="finished"
:show-delete="true"
:enable-filter="false"
@load="loadMore"
@click="onTransactionClick"
@delete="handleDelete"
/>
</div>
</PopupContainer>
</PopupContainerV2>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleSave"
@delete="handleDelete"
@delete="handleTransactionDelete"
/>
</template>
@@ -120,8 +38,9 @@
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import { getTransactionList } from '@/api/transactionRecord'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
const props = defineProps({
modelValue: {
@@ -148,20 +67,17 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'refresh'])
// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 标题
const title = computed(() => {
const classifyText = props.classify || '未分类'
const typeText = props.type === 0 ? '支出' : props.type === 1 ? '收入' : '不计收支'
return `${classifyText} - ${typeText}`
})
// 数据状态
const transactions = ref([])
const loading = ref(false)
const finished = ref(false)
@@ -169,48 +85,11 @@ const pageIndex = ref(1)
const pageSize = 20
const total = ref(0)
// 详情弹窗
const showDetail = ref(false)
const currentTransaction = ref(null)
// 格式化日期时间
const formatDateTime = (dateTimeStr) => {
const date = new Date(dateTimeStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 格式化金额
const formatAmount = (amount, type) => {
const sign = type === 1 ? '+' : '-'
return `${sign}${amount.toFixed(2)}`
}
// 根据分类获取图标
const getIconByClassify = (classify) => {
const iconMap = {
餐饮: 'food',
购物: 'shopping',
交通: 'logistics',
娱乐: 'play-circle',
医疗: 'medic',
工资: 'gold-coin',
红包: 'gift'
}
return iconMap[classify] || 'bill'
}
// 根据类型获取颜色
const getColorByType = (type) => {
return type === 1 ? '#22C55E' : '#FF6B6B'
}
// 加载数据
const loadData = async (isRefresh = false) => {
if (loading.value || finished.value) {
if (loading.value) {
return
}
@@ -240,15 +119,7 @@ const loadData = async (isRefresh = false) => {
if (response.success) {
const newList = response.data || []
// 转换数据格式,添加显示所需的字段
const formattedList = newList.map((txn) => ({
...txn,
icon: getIconByClassify(txn.classify),
iconColor: getColorByType(txn.type),
iconBg: '#FFFFFF'
}))
transactions.value = [...transactions.value, ...formattedList]
transactions.value = [...transactions.value, ...newList]
total.value = response.total
if (newList.length === 0 || newList.length < pageSize) {
@@ -269,42 +140,50 @@ const loadData = async (isRefresh = false) => {
}
}
// 加载更多
const loadMore = () => {
loadData(false)
if (!finished.value && !loading.value) {
loadData(false)
}
}
// 点击交易
const onTransactionClick = (txn) => {
currentTransaction.value = txn
showDetail.value = true
const onTransactionClick = async (txn) => {
try {
const response = await getTransactionDetail(txn.id)
if (response.success) {
currentTransaction.value = response.data
showDetail.value = true
} else {
showToast(response.message || '获取详情失败')
}
} catch (error) {
console.error('获取详情出错:', error)
showToast('获取详情失败')
}
}
// 保存交易
const handleSave = () => {
showDetail.value = false
// 重新加载数据
loadData(true)
// 通知父组件刷新
emit('refresh')
}
// 删除交易
const handleDelete = (id) => {
showDetail.value = false
// 从列表中移除
transactions.value = transactions.value.filter((t) => t.id !== id)
total.value--
// 通知父组件刷新
emit('refresh')
}
// 监听弹窗打开
const handleTransactionDelete = (id) => {
showDetail.value = false
transactions.value = transactions.value.filter((t) => t.id !== id)
total.value--
emit('refresh')
}
watch(visible, (newValue) => {
if (newValue) {
loadData(true)
} else {
// 关闭时重置状态
transactions.value = []
pageIndex.value = 1
finished.value = false
@@ -315,145 +194,4 @@ watch(visible, (newValue) => {
<style scoped>
@import '@/assets/theme.css';
.transactions {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--spacing-lg);
}
.txn-loading {
padding: var(--spacing-3xl);
text-align: center;
}
.txn-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
padding: var(--spacing-xl);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: opacity 0.2s;
margin-top: 10px;
}
.txn-card:active {
opacity: 0.7;
}
.txn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-footer {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.txn-time {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.txn-classify-tag {
padding: 2px 8px;
font-size: var(--font-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-classify-tag.tag-income {
background-color: rgba(34, 197, 94, 0.15);
color: var(--accent-success);
}
.txn-classify-tag.tag-expense {
background-color: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
flex-shrink: 0;
margin-left: var(--spacing-md);
}
.load-more {
display: flex;
justify-content: center;
padding: var(--spacing-xl) 0;
}
.finished-text {
text-align: center;
padding: var(--spacing-xl) 0;
font-size: var(--font-md);
color: var(--text-tertiary);
}
/* 空状态 */
.txn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
padding: var(--spacing-4xl) var(--spacing-2xl);
gap: var(--spacing-md);
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-tertiary);
margin-bottom: var(--spacing-sm);
}
.empty-text {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-secondary);
}
</style>

View File

@@ -121,6 +121,7 @@ const formattedTitle = computed(() => {
background: transparent !important;
position: relative;
z-index: 1;
min-height: 60px; /* 与 balance-header 保持一致,防止切换抖动 */
}
.header-content {

View File

@@ -9,41 +9,43 @@
</div>
<!-- Add Bill Modal -->
<PopupContainer
v-model="showAddBill"
<PopupContainerV2
v-model:show="showAddBill"
title="记一笔"
height="75%"
:height="'75%'"
>
<van-tabs
v-model:active="activeTab"
shrink
>
<van-tab
title="一句话录账"
name="one"
<div style="padding: 0">
<van-tabs
v-model:active="activeTab"
shrink
>
<OneLineBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
<van-tab
title="手动录账"
name="manual"
>
<ManualBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
</van-tabs>
</PopupContainer>
<van-tab
title="一句话录账"
name="one"
>
<OneLineBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
<van-tab
title="手动录账"
name="manual"
>
<ManualBillAdd
:key="componentKey"
@success="handleSuccess"
/>
</van-tab>
</van-tabs>
</div>
</PopupContainerV2>
</div>
</template>
<script setup>
import { ref, defineEmits } from 'vue'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'

View File

@@ -1,14 +1,9 @@
<template>
<PopupContainer
<PopupContainerV2
:show="show"
:title="title"
show-cancel-button
show-confirm-button
confirm-text="选择"
cancel-text="取消"
:height="'80%'"
@update:show="emit('update:show', $event)"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<div class="icon-selector">
<!-- 搜索框 -->
@@ -56,14 +51,32 @@
@change="handlePageChange"
/>
</div>
</PopupContainer>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="handleCancel"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="handleConfirm"
>
选择
</van-button>
</div>
</template>
</PopupContainerV2>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import Icon from './Icon.vue'
import PopupContainer from './PopupContainer.vue'
import PopupContainerV2 from './PopupContainerV2.vue'
const props = defineProps({
show: {

View File

@@ -1,277 +0,0 @@
<!--
统一弹窗组件
## 基础用法
<PopupContainer v-model:show="show" title="标题">
内容
</PopupContainer>
## 确认对话框用法
<PopupContainer
v-model:show="showConfirm"
title="确认操作"
show-confirm-button
show-cancel-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirm"
@cancel="handleCancel"
>
确定要执行此操作吗
</PopupContainer>
## 带副标题和页脚
<PopupContainer
v-model:show="show"
title="分类详情"
subtitle="共 10 笔交易"
>
内容区域
<template #footer>
<van-button type="primary">提交</van-button>
</template>
</PopupContainer>
-->
<!-- eslint-disable vue/no-v-html -->
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: height }"
round
:closeable="closeable"
teleport="body"
>
<div class="popup-container">
<!-- 头部区域 -->
<div class="popup-header-fixed">
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
<div
class="header-title-row"
:class="{ 'has-actions': !subtitle && hasActions }"
>
<h3 class="popup-title">
{{ title }}
</h3>
<!-- 无子标题时操作按钮与标题同行 -->
<div
v-if="!subtitle && hasActions"
class="header-actions-inline"
>
<slot name="header-actions" />
</div>
</div>
<!-- 子标题/统计信息 -->
<div
v-if="subtitle"
class="header-stats"
>
<span
class="stats-text"
v-html="subtitle"
/>
<!-- 额外操作插槽 -->
<slot
v-if="hasActions"
name="header-actions"
/>
</div>
</div>
<!-- 内容区域可滚动 -->
<div class="popup-scroll-content">
<slot />
</div>
<!-- 底部页脚固定不可滚动 -->
<div
v-if="slots.footer || showConfirmButton || showCancelButton"
class="popup-footer-fixed"
>
<!-- 用户自定义页脚插槽 -->
<slot name="footer">
<!-- 默认确认/取消按钮 -->
<div class="footer-buttons">
<van-button
v-if="showCancelButton"
plain
@click="handleCancel"
>
{{ cancelText }}
</van-button>
<van-button
v-if="showConfirmButton"
type="primary"
@click="handleConfirm"
>
{{ confirmText }}
</van-button>
</div>
</slot>
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, useSlots } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
height: {
type: String,
default: '80%'
},
closeable: {
type: Boolean,
default: true
},
showConfirmButton: {
type: Boolean,
default: false
},
showCancelButton: {
type: Boolean,
default: false
},
confirmText: {
type: String,
default: '确认'
},
cancelText: {
type: String,
default: '取消'
}
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const slots = useSlots()
// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 判断是否有操作按钮
const hasActions = computed(() => !!slots['header-actions'])
// 确认按钮点击
const handleConfirm = () => {
emit('confirm')
}
// 取消按钮点击
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.popup-container {
height: 100%;
display: flex;
flex-direction: column;
}
.popup-header-fixed {
flex-shrink: 0;
padding: 16px;
background: linear-gradient(180deg, var(--van-background) 0%, var(--van-background-2) 100%);
border-bottom: 1px solid var(--van-border-color);
position: sticky;
top: 0;
z-index: 10;
}
.header-title-row.has-actions {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.header-title-row.has-actions .popup-title {
grid-column: 2;
min-width: 0;
}
.header-actions-inline {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
}
.popup-title {
font-size: 16px;
font-weight: 600;
margin: 0;
text-align: center;
color: var(--van-text-color);
letter-spacing: -0.02em;
/*超出长度*/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-stats {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.stats-text {
margin: 0;
font-size: 14px;
color: var(--van-text-color-2);
grid-column: 2;
text-align: center;
}
/* 按钮区域放在右侧 */
.header-stats :deep(> :last-child:not(.stats-text)) {
grid-column: 3;
justify-self: end;
}
.popup-scroll-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.popup-footer-fixed {
flex-shrink: 0;
border-top: 1px solid var(--van-border-color);
background-color: var(--van-background-2);
padding: 12px 16px;
}
.footer-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.footer-buttons .van-button {
flex: 1;
max-width: 120px;
}
</style>

View File

@@ -0,0 +1,180 @@
<!--
PopupContainer V2 - 通用底部弹窗组件采用 TransactionDetailSheet 样式风格
## V1 的区别
- V1 (PopupContainer.vue): 使用 Vant 主题变量标准化布局默认高度 80%
- V2 (PopupContainerV2.vue): 使用 Inter 字体16px 圆角纯白背景更现代化的视觉风格
## 基础用法
<PopupContainerV2 v-model:show="show" title="标题">
<div class="content">内容区域</div>
<template #footer>
<van-button type="primary">确定</van-button>
</template>
</PopupContainerV2>
## Props
- show (Boolean, required): 控制弹窗显示/隐藏
- title (String, required): 标题文本
- height (String, default: 'auto'): 弹窗高度支持 'auto', '80%', '500px'
- maxHeight (String, default: '85%'): 最大高度
## Slots
- default: 可滚动的内容区域不提供默认 padding由使用方控制
- footer: 固定底部区域操作按钮等
## Events
- update:show: 弹窗显示/隐藏状态变更
-->
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{
height: height === 'auto' ? maxHeight : height,
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}"
teleport="body"
@close="handleClose"
>
<div class="popup-container-v2">
<!-- 固定头部 -->
<div class="popup-header">
<h3 class="popup-title">
{{ title }}
</h3>
<van-icon
name="cross"
class="popup-close"
@click="handleClose"
/>
</div>
<!-- 可滚动内容区域 -->
<div class="popup-content">
<slot />
</div>
<!-- 固定底部 -->
<div
v-if="hasFooter"
class="popup-footer"
>
<slot name="footer" />
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, useSlots } from 'vue'
const props = defineProps({
show: {
type: Boolean,
required: true
},
title: {
type: String,
required: true
},
height: {
type: String,
default: 'auto'
},
maxHeight: {
type: String,
default: '85%'
}
})
const emit = defineEmits(['update:show'])
const slots = useSlots()
// 双向绑定
const visible = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
// 判断是否有 footer 插槽
const hasFooter = computed(() => !!slots.footer)
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.popup-container-v2 {
background: #ffffff;
height: 100%;
display: flex;
flex-direction: column;
}
// 固定头部
.popup-header {
flex-shrink: 0;
padding: 24px;
padding-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
.popup-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090b;
margin: 0;
}
.popup-close {
font-size: 24px;
color: #71717a;
cursor: pointer;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
}
// 可滚动内容区域
.popup-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
// 不提供默认 padding由使用方控制
}
// 固定底部
.popup-footer {
flex-shrink: 0;
padding: 24px;
padding-top: 16px;
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.popup-container-v2 {
background: #18181b;
}
.popup-header {
.popup-title {
color: #fafafa;
}
.popup-close {
color: #a1a1aa;
}
}
}
</style>

View File

@@ -61,34 +61,42 @@
</van-cell-group>
<!-- 账单列表弹窗 -->
<PopupContainer
v-model="showTransactionList"
<PopupContainerV2
v-model:show="showTransactionList"
:title="selectedGroup?.reason || '交易记录'"
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
height="75%"
:height="'75%'"
>
<template #header-actions>
<van-button
type="primary"
size="small"
class="batch-classify-btn"
@click.stop="handleBatchClassify(selectedGroup)"
>
批量分类
</van-button>
</template>
<div style="padding: 0">
<!-- Subtitle 和操作按钮 -->
<div style="padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--van-border-color)">
<span
v-if="groupTransactionsTotal"
style="color: #999; font-size: 14px"
>
{{ groupTransactionsTotal }} 笔交易
</span>
<van-button
type="primary"
size="small"
class="batch-classify-btn"
@click.stop="handleBatchClassify(selectedGroup)"
>
批量分类
</van-button>
</div>
<BillListComponent
data-source="custom"
:transactions="groupTransactions"
:loading="transactionLoading"
:finished="transactionFinished"
:enable-filter="false"
@load="loadGroupTransactions"
@click="handleTransactionClick"
@delete="handleGroupTransactionDelete"
/>
</PopupContainer>
<BillListComponent
data-source="custom"
:transactions="groupTransactions"
:loading="transactionLoading"
:finished="transactionFinished"
:enable-filter="false"
@load="loadGroupTransactions"
@click="handleTransactionClick"
@delete="handleGroupTransactionDelete"
/>
</div>
</PopupContainerV2>
<!-- 账单详情弹窗 -->
<TransactionDetail
@@ -98,76 +106,78 @@
/>
<!-- 批量设置对话框 -->
<PopupContainer
v-model="showBatchDialog"
<PopupContainerV2
v-model:show="showBatchDialog"
title="批量设置分类"
height="60%"
:height="'60%'"
>
<van-form
ref="batchFormRef"
class="setting-form"
>
<van-cell-group inset>
<!-- 显示选中的摘要 -->
<van-field
:model-value="batchGroup?.reason"
label="交易摘要"
readonly
input-align="left"
/>
<div style="padding: 0">
<van-form
ref="batchFormRef"
class="setting-form"
>
<van-cell-group inset>
<!-- 显示选中的摘要 -->
<van-field
:model-value="batchGroup?.reason"
label="交易摘要"
readonly
input-align="left"
/>
<!-- 显示记录数量 -->
<van-field
:model-value="`${batchGroup?.count || 0} `"
label="记录数量"
readonly
input-align="left"
/>
<!-- 显示记录数量 -->
<van-field
:model-value="`${batchGroup?.count || 0} `"
label="记录数量"
readonly
input-align="left"
/>
<!-- 交易类型 -->
<van-field
name="type"
label="交易类型"
>
<template #input>
<van-radio-group
v-model="batchForm.type"
direction="horizontal"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 交易类型 -->
<van-field
name="type"
label="交易类型"
>
<template #input>
<van-radio-group
v-model="batchForm.type"
direction="horizontal"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类选择 -->
<van-field
name="classify"
label="分类"
>
<template #input>
<span
v-if="!batchForm.classify"
style="opacity: 0.4"
>请选择分类</span>
<span v-else>{{ batchForm.classify }}</span>
</template>
</van-field>
<!-- 分类选择 -->
<van-field
name="classify"
label="分类"
>
<template #input>
<span
v-if="!batchForm.classify"
style="opacity: 0.4"
>请选择分类</span>
<span v-else>{{ batchForm.classify }}</span>
</template>
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="batchForm.classify"
:type="batchForm.type"
/>
</van-cell-group>
</van-form>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="batchForm.classify"
:type="batchForm.type"
/>
</van-cell-group>
</van-form>
</div>
<template #footer>
<van-button
round
@@ -178,7 +188,7 @@
确定
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
</div>
</template>
@@ -189,7 +199,7 @@ import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/
import ClassifySelector from './ClassifySelector.vue'
import BillListComponent from './Bill/BillListComponent.vue'
import TransactionDetail from './TransactionDetail.vue'
import PopupContainer from './PopupContainer.vue'
import PopupContainerV2 from './PopupContainerV2.vue'
const props = defineProps({
// 是否支持多选

View File

@@ -1,399 +0,0 @@
<template>
<van-button
v-if="hasTransactions"
:type="buttonType"
size="small"
:loading="loading || saving"
:loading-text="loadingText"
:disabled="loading || saving"
class="smart-classify-btn"
@click="handleClick"
>
<template v-if="!loading && !saving">
<van-icon :name="buttonIcon" />
<span style="margin-left: 4px">{{ buttonText }}</span>
</template>
</van-button>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { showToast, closeToast } from 'vant'
import { smartClassify, batchUpdateClassify } from '@/api/transactionRecord'
const props = defineProps({
transactions: {
type: Array,
default: () => []
},
onBeforeClassify: {
type: Function,
default: null
}
})
const emit = defineEmits(['update', 'save', 'notifyDonedTransactionId'])
const loading = ref(false)
const saving = ref(false)
const classifiedResults = ref([])
const lockClassifiedResults = ref(false)
const isAllCompleted = ref(false)
let toastInstance = null
const hasTransactions = computed(() => {
return props.transactions && props.transactions.length > 0
})
const hasClassifiedResults = computed(() => {
// Show save state once we have any classified result, even if not all batches finished
return classifiedResults.value.length > 0 && lockClassifiedResults.value === false
})
// 按钮类型
const buttonType = computed(() => {
if (saving.value) {
return 'warning'
}
if (loading.value) {
return 'primary'
}
if (hasClassifiedResults.value) {
return 'success'
}
return 'primary'
})
// 按钮图标
const buttonIcon = computed(() => {
if (hasClassifiedResults.value) {
return 'success'
}
return 'fire'
})
// 按钮文字(非加载状态)
const buttonText = computed(() => {
if (hasClassifiedResults.value) {
return '保存分类'
}
return '智能分类'
})
// 加载中文字
const loadingText = computed(() => {
if (saving.value) {
return '保存中...'
}
if (loading.value) {
return '分类中...'
}
return ''
})
/**
* 点击按钮处理
*/
const handleClick = () => {
if (hasClassifiedResults.value) {
handleSaveClassify()
} else {
handleSmartClassify()
}
}
/**
* 保存分类结果
*/
const handleSaveClassify = async () => {
if (saving.value || loading.value) {
return
}
try {
saving.value = true
showToast({
message: '正在保存...',
duration: 0,
forbidClick: true,
loadingType: 'spinner'
})
// 准备批量更新数据
const items = classifiedResults.value.map((item) => ({
id: item.id,
classify: item.classify,
type: item.type
}))
const response = await batchUpdateClassify(items)
closeToast()
if (response.success) {
showToast({
type: 'success',
message: `保存成功,已更新 ${items.length} 条记录`,
duration: 2000
})
// 清空已分类结果
classifiedResults.value = []
isAllCompleted.value = false
// 通知父组件刷新数据
emit('save')
} else {
showToast({
type: 'fail',
message: response.message || '保存失败',
duration: 2000
})
}
} catch (error) {
console.error('保存分类失败:', error)
closeToast()
showToast({
type: 'fail',
message: '保存失败,请重试',
duration: 2000
})
} finally {
saving.value = false
}
}
const handleSmartClassify = async () => {
if (loading.value || saving.value) {
showToast('当前有任务正在进行,请稍后再试')
return
}
loading.value = true
if (!props.transactions || props.transactions.length === 0) {
showToast('没有可分类的交易记录')
loading.value = false
return
}
if (lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,请稍后再试')
loading.value = false
return
}
// 清空之前的分类结果
isAllCompleted.value = false
classifiedResults.value = []
const batchSize = 3
let processedCount = 0
try {
lockClassifiedResults.value = true
// 等待父组件的 beforeClassify 事件处理完成(如果有返回 Promise
if (props.onBeforeClassify) {
const shouldContinue = await props.onBeforeClassify()
if (shouldContinue === false) {
loading.value = false
return
}
}
await nextTick()
const allTransactions = props.transactions
const totalCount = allTransactions.length
toastInstance = showToast({
message: '正在智能分类...',
duration: 0,
forbidClick: false, // 允许用户点击页面其他地方
loadingType: 'spinner'
})
// 分批处理
for (let i = 0; i < allTransactions.length; i += batchSize) {
const batch = allTransactions.slice(i, i + batchSize)
const transactionIds = batch.map((t) => t.id)
const currentBatch = Math.floor(i / batchSize) + 1
const totalBatches = Math.ceil(allTransactions.length / batchSize)
// 更新批次进度
closeToast()
toastInstance = showToast({
message: `正在处理第 ${currentBatch}/${totalBatches} 批 (${i + 1}-${Math.min(i + batchSize, totalCount)} / ${totalCount})...`,
duration: 0,
forbidClick: false, // 允许用户点击
loadingType: 'spinner'
})
const response = await smartClassify(transactionIds)
if (!response.ok) {
throw new Error('智能分类请求失败')
}
// 读取流式响应
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let lastUpdateTime = 0
const updateInterval = 300 // 最多每300ms更新一次Toast减少DOM操作
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
// 处理完整的事件SSE格式event: type\ndata: data\n\n
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个不完整的部分
for (const eventBlock of events) {
if (!eventBlock.trim()) {
continue
}
try {
const lines = eventBlock.split('\n')
let eventType = ''
let eventData = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
eventData = line.slice(6).trim()
}
}
if (eventType === 'start') {
// 开始分类
closeToast()
toastInstance = showToast({
message: `${eventData} (批次 ${currentBatch}/${totalBatches})`,
duration: 0,
forbidClick: false, // 允许用户点击
loadingType: 'spinner'
})
lastUpdateTime = Date.now()
} else if (eventType === 'data') {
// 收到分类结果
const data = JSON.parse(eventData)
processedCount++
// 记录分类结果
classifiedResults.value.push({
id: data.id,
classify: data.Classify,
type: data.Type
})
// 实时更新交易记录的分类信息
const index = props.transactions.findIndex((t) => t.id === data.id)
if (index !== -1) {
const transaction = props.transactions[index]
transaction.upsetedClassify = data.Classify
transaction.upsetedType = data.Type
emit('notifyDonedTransactionId', data.id)
}
// 限制Toast更新频率避免频繁的DOM操作
const now = Date.now()
if (now - lastUpdateTime > updateInterval) {
closeToast()
toastInstance = showToast({
message: `已分类 ${processedCount}/${totalCount} 条 (批次 ${currentBatch}/${totalBatches})...`,
duration: 0,
forbidClick: false, // 允许用户点击
loadingType: 'spinner'
})
lastUpdateTime = now
}
} else if (eventType === 'end') {
// 当前批次完成
console.log(`批次 ${currentBatch}/${totalBatches} 完成`)
} else if (eventType === 'error') {
// 处理错误
throw new Error(eventData || '分类失败')
}
} catch (e) {
console.error('解析SSE事件失败:', e, eventBlock)
throw e
}
}
}
}
// 所有批次完成
closeToast()
toastInstance = null
isAllCompleted.value = true
showToast({
type: 'success',
message: `分类完成,共处理 ${processedCount} 条记录,请点击"保存分类"按钮保存结果`,
duration: 3000
})
} catch (error) {
console.error('智能分类失败:', error)
closeToast()
toastInstance = null
showToast({
type: 'fail',
message: '智能分类失败,请重试',
duration: 2000
})
} finally {
loading.value = false
lockClassifiedResults.value = false
// 确保Toast被清除
if (toastInstance) {
setTimeout(() => {
closeToast()
toastInstance = null
}, 100)
}
}
}
const removeClassifiedTransaction = (transactionId) => {
// 从已分类结果中移除指定ID的项
classifiedResults.value = classifiedResults.value.filter((item) => item.id !== transactionId)
}
/**
* 重置组件状态
*/
const reset = () => {
if (lockClassifiedResults.value) {
showToast('当前有分类任务正在进行,无法重置')
return
}
isAllCompleted.value = false
classifiedResults.value = []
loading.value = false
saving.value = false
}
defineExpose({
reset,
removeClassifiedTransaction
})
</script>
<style scoped>
.smart-classify-btn {
display: inline-flex;
align-items: center;
white-space: nowrap;
border-radius: 16px;
padding: 6px 12px;
}
</style>

View File

@@ -1,152 +1,134 @@
<template>
<van-popup
<PopupContainerV2
v-model:show="visible"
position="bottom"
:style="{
height: 'auto',
maxHeight: '85%',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}"
teleport="body"
@close="handleClose"
title="交易详情"
height="85%"
>
<div class="transaction-detail-sheet">
<!-- 头部 -->
<div class="sheet-header">
<div class="header-title">
交易详情
</div>
<van-icon
name="cross"
class="header-close"
@click="handleClose"
/>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 只读显示模式 -->
<div
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<!-- 只读显示模式 -->
<div
v-if="showClassifySelector"
class="classify-section"
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<!-- 操作按钮 -->
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<div
v-if="showClassifySelector"
class="classify-section"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
</div>
<!-- 操作按钮固定底部 -->
<template #footer>
<div class="actions-section">
<van-button
class="delete-btn"
@@ -164,31 +146,32 @@
保存
</van-button>
</div>
</div>
</template>
</PopupContainerV2>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ref, reactive, watch } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
@@ -399,291 +382,249 @@ const handleDelete = async () => {
// 用户取消删除
})
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.transaction-detail-sheet {
background: #ffffff;
padding: 24px;
// 金额区域
.amount-section {
display: flex;
flex-direction: column;
gap: 24px;
align-items: center;
gap: 8px;
padding: 0 24px 24px;
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
.amount-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: normal;
color: #71717a;
}
.header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090b;
}
.amount-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
color: #09090b;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
.header-close {
font-size: 24px;
color: #71717a;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
.amount-section {
.amount-input-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
.amount-label {
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090b;
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090b;
border: none;
outline: none;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #e4e4e7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366f1;
}
&::placeholder {
color: #a1a1aa;
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
}
}
}
}
// 表单区域
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 24px 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #e4e4e7;
&.no-border {
border-bottom: none;
}
.form-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-size: 16px;
font-weight: normal;
color: #71717a;
}
.amount-value {
.form-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
font-size: 16px;
font-weight: normal;
color: #09090b;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
text-align: right;
flex: 1;
margin-left: 16px;
&:active {
opacity: 0.7;
}
}
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
.amount-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090b;
&:active {
opacity: 0.7;
}
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090b;
.placeholder {
color: #a1a1aa;
}
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090b;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #e4e4e7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366f1;
}
&::placeholder {
color: #a1a1aa;
}
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
}
}
}
}
// 分类选择器
.classify-section {
padding: 16px 24px;
background: #f4f4f5;
border-radius: 8px;
margin: 0 24px 16px;
}
// 操作按钮
.actions-section {
display: flex;
gap: 16px;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #ef4444;
background: transparent;
color: #ef4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366f1;
color: #fafafa;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.amount-section {
.amount-label {
color: #a1a1aa;
}
.amount-value {
color: #fafafa;
}
.amount-input-wrapper {
.currency-symbol {
color: #fafafa;
}
.amount-input {
color: #fafafa;
border-bottom-color: #27272a;
&:focus {
border-bottom-color: #6366f1;
}
}
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #e4e4e7;
&.no-border {
border-bottom: none;
}
border-bottom-color: #27272a;
.form-label {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #71717a;
color: #a1a1aa;
}
.form-value {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #09090b;
text-align: right;
flex: 1;
margin-left: 16px;
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.placeholder {
color: #a1a1aa;
}
color: #fafafa;
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090b;
background: transparent;
&::placeholder {
color: #a1a1aa;
}
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
color: #fafafa;
}
}
}
}
.classify-section {
padding: 16px;
background: #f4f4f5;
border-radius: 8px;
margin-top: -8px;
}
.actions-section {
display: flex;
gap: 16px;
width: 100%;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #ef4444;
background: transparent;
color: #ef4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366f1;
color: #fafafa;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.transaction-detail-sheet {
background: #18181b;
.sheet-header {
.header-title {
color: #fafafa;
}
.header-close {
color: #a1a1aa;
}
}
.amount-section {
.amount-label {
color: #a1a1aa;
}
.amount-value {
color: #fafafa;
}
.amount-input-wrapper {
.currency-symbol {
color: #fafafa;
}
.amount-input {
color: #fafafa;
border-bottom-color: #27272a;
&:focus {
border-bottom-color: #6366f1;
}
}
}
}
.form-section {
.form-row {
border-bottom-color: #27272a;
.form-label {
color: #a1a1aa;
}
.form-value {
color: #fafafa;
.reason-input {
color: #fafafa;
}
}
}
}
.classify-section {
background: #27272a;
}
background: #27272a;
}
}
</style>

View File

@@ -1,134 +1,135 @@
<template>
<PopupContainer
v-model="visible"
<PopupContainerV2
v-model:show="visible"
title="交易详情"
height="75%"
:closeable="false"
:height="'75%'"
>
<van-form style="margin-top: 12px">
<van-cell-group inset>
<van-cell
title="记录时间"
:value="formatDate(transaction.createTime)"
/>
</van-cell-group>
<div style="padding: 0">
<van-form style="margin-top: 12px">
<van-cell-group inset>
<van-cell
title="记录时间"
:value="formatDate(transaction.createTime)"
/>
</van-cell-group>
<van-cell-group
inset
title="交易明细"
>
<van-field
v-model="occurredAtLabel"
name="occurredAt"
label="交易时间"
readonly
is-link
placeholder="请选择交易时间"
:rules="[{ required: true, message: '请选择交易时间' }]"
@click="showDatePicker = true"
/>
<van-field
v-model="editForm.reason"
name="reason"
label="交易摘要"
placeholder="请输入交易摘要"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field
v-model="editForm.amount"
name="amount"
label="交易金额"
placeholder="请输入交易金额"
type="number"
:rules="[{ required: true, message: '请输入交易金额' }]"
/>
<van-field
v-model="editForm.balance"
name="balance"
label="交易后余额"
placeholder="请输入交易后余额"
type="number"
:rules="[{ required: true, message: '请输入交易后余额' }]"
/>
<van-field
name="type"
label="交易类型"
<van-cell-group
inset
title="交易明细"
>
<template #input>
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="occurredAtLabel"
name="occurredAt"
label="交易时间"
readonly
is-link
placeholder="请选择交易时间"
:rules="[{ required: true, message: '请选择交易时间' }]"
@click="showDatePicker = true"
/>
<van-field
v-model="editForm.reason"
name="reason"
label="交易摘要"
placeholder="请输入交易摘要"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field
v-model="editForm.amount"
name="amount"
label="交易金额"
placeholder="请输入交易金额"
type="number"
:rules="[{ required: true, message: '请输入交易金额' }]"
/>
<van-field
v-model="editForm.balance"
name="balance"
label="交易后余额"
placeholder="请输入交易后余额"
type="number"
:rules="[{ required: true, message: '请输入交易后余额' }]"
/>
<van-field
name="classify"
label="交易类"
>
<template #input>
<div style="flex: 1">
<div
v-if="
transaction &&
transaction.unconfirmedClassify &&
transaction.unconfirmedClassify !== editForm.classify
"
class="suggestion-tip"
@click="applySuggestion"
<van-field
name="type"
label="交易类"
>
<template #input>
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-icon
name="bulb-o"
class="suggestion-icon"
/>
<span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }}
<span
v-if="
transaction.unconfirmedType !== null &&
transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type
"
>
({{ getTypeName(transaction.unconfirmedType) }})
</span>
</span>
<div class="suggestion-apply">
应用
</div>
</div>
<span
v-else-if="!editForm.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
</div>
</template>
</van-field>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
@change="handleClassifyChange"
/>
</van-cell-group>
</van-form>
<van-field
name="classify"
label="交易分类"
>
<template #input>
<div style="flex: 1">
<div
v-if="
transaction &&
transaction.unconfirmedClassify &&
transaction.unconfirmedClassify !== editForm.classify
"
class="suggestion-tip"
@click="applySuggestion"
>
<van-icon
name="bulb-o"
class="suggestion-icon"
/>
<span class="suggestion-text">
建议: {{ transaction.unconfirmedClassify }}
<span
v-if="
transaction.unconfirmedType !== null &&
transaction.unconfirmedType !== undefined &&
transaction.unconfirmedType !== editForm.type
"
>
({{ getTypeName(transaction.unconfirmedType) }})
</span>
</span>
<div class="suggestion-apply">
应用
</div>
</div>
<span
v-else-if="!editForm.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ editForm.classify }}</span>
</div>
</template>
</van-field>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
@change="handleClassifyChange"
/>
</van-cell-group>
</van-form>
</div>
<template #footer>
<van-button
@@ -141,7 +142,7 @@
保存修改
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
<!-- 日期选择弹窗 -->
<van-popup
@@ -178,7 +179,7 @@
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
import { showToast } from 'vant'
import dayjs from 'dayjs'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction } from '@/api/transactionRecord'

View File

@@ -1,17 +1,16 @@
<template>
<template>
<div class="page-container-flex">
<!-- 顶部导航栏 -->
<van-nav-bar
title="账单"
placeholder
>
<template #right>
<!-- 自定义头部 -->
<header class="balance-header">
<h1 class="header-title">
账单
</h1>
<div class="header-actions">
<van-button
v-if="tabActive === 'email'"
size="small"
type="primary"
:loading="syncing"
@click="emailRecordRef.handleSync()"
@click="emailRecordRef?.handleSync()"
>
立即同步
</van-button>
@@ -21,26 +20,35 @@
size="20"
@click="messageViewRef?.handleMarkAllRead()"
/>
</template>
</van-nav-bar>
<van-tabs
v-model:active="tabActive"
type="card"
style="margin: 12px 0 2px 0"
>
<van-tab
title="账单"
name="balance"
/>
<van-tab
title="邮件"
name="email"
/>
<van-tab
title="消息"
name="message"
/>
</van-tabs>
</div>
</header>
<!-- 分段控制器 -->
<div class="tabs-wrapper">
<div class="segmented-control">
<div
class="tab-item"
:class="{ active: tabActive === 'balance' }"
@click="tabActive = 'balance'"
>
<span class="tab-text">账单</span>
</div>
<div
class="tab-item"
:class="{ active: tabActive === 'email' }"
@click="tabActive = 'email'"
>
<span class="tab-text">邮件</span>
</div>
<div
class="tab-item"
:class="{ active: tabActive === 'message' }"
@click="tabActive = 'message'"
>
<span class="tab-text">消息</span>
</div>
</div>
</div>
<TransactionsRecord
v-if="tabActive === 'balance'"
@@ -84,15 +92,88 @@ const emailRecordRef = ref(null)
const messageViewRef = ref(null)
</script>
<style scoped>
<style scoped lang="scss">
@import '@/assets/theme.css';
:deep(.van-pull-refresh) {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
/* ========== 自定义头部 ========== */
.balance-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
background: transparent;
position: relative;
z-index: 1;
min-height: 60px; /* 与 calendar-header 保持一致,防止切换抖动 */
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ========== 分段控制器 ========== */
.tabs-wrapper {
padding: var(--spacing-sm) var(--spacing-xl);
}
.segmented-control {
display: flex;
background: var(--segmented-bg);
border-radius: 8px;
padding: 4px;
gap: 4px;
height: 40px;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.tab-item.active {
background: var(--segmented-active-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.tab-item.active .tab-text {
color: var(--text-primary);
font-weight: var(--font-bold);
}
.tab-item:not(.active):hover {
background: rgba(128, 128, 128, 0.1);
}
.tab-text {
font-family: var(--font-primary);
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
</style>

View File

@@ -94,26 +94,41 @@
</div>
<!-- 提示词设置弹窗 -->
<PopupContainer
<PopupContainerV2
v-model:show="showPromptDialog"
title="编辑分析提示词"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="confirmPrompt"
@cancel="showPromptDialog = false"
:height="'75%'"
>
<van-field
v-model="promptValue"
rows="4"
autosize
type="textarea"
maxlength="2000"
placeholder="输入自定义的分析提示词..."
show-word-limit
/>
</PopupContainer>
<div style="padding: 16px">
<van-field
v-model="promptValue"
rows="4"
autosize
type="textarea"
maxlength="2000"
placeholder="输入自定义的分析提示词..."
show-word-limit
/>
</div>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="showPromptDialog = false"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="confirmPrompt"
>
保存
</van-button>
</div>
</template>
</PopupContainerV2>
</div>
</template>
@@ -122,7 +137,7 @@ import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
const router = useRouter()
const userInput = ref('')

View File

@@ -112,64 +112,107 @@
</div>
<!-- 新增分类对话框 -->
<PopupContainer
<PopupContainerV2
v-model:show="showAddDialog"
title="新增分类"
show-cancel-button
show-confirm-button
confirm-text="确认"
cancel-text="取消"
@confirm="handleConfirmAdd"
@cancel="resetAddForm"
:height="'auto'"
>
<van-form ref="addFormRef">
<van-field
v-model="addForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<div style="padding: 16px">
<van-form ref="addFormRef">
<van-field
v-model="addForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</div>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="resetAddForm"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="handleConfirmAdd"
>
确认
</van-button>
</div>
</template>
</PopupContainerV2>
<!-- 编辑分类对话框 -->
<PopupContainer
<PopupContainerV2
v-model:show="showEditDialog"
title="编辑分类"
show-cancel-button
show-confirm-button
confirm-text="保存"
cancel-text="取消"
@confirm="handleConfirmEdit"
@cancel="showEditDialog = false"
:height="'auto'"
>
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</PopupContainer>
<div style="padding: 16px">
<van-form ref="editFormRef">
<van-field
v-model="editForm.name"
name="name"
label="分类名称"
placeholder="请输入分类名称"
:rules="[{ required: true, message: '请输入分类名称' }]"
/>
</van-form>
</div>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="showEditDialog = false"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="handleConfirmEdit"
>
保存
</van-button>
</div>
</template>
</PopupContainerV2>
<!-- 删除确认对话框 -->
<PopupContainer
<PopupContainerV2
v-model:show="showDeleteConfirm"
title="删除分类"
show-cancel-button
show-confirm-button
confirm-text="确定"
cancel-text="取消"
@confirm="handleConfirmDelete"
@cancel="showDeleteConfirm = false"
:height="'auto'"
>
<p style="text-align: center; padding: 20px; color: var(--van-text-color-2)">
删除后无法恢复确定要删除吗
</p>
</PopupContainer>
<template #footer>
<div style="display: flex; gap: 12px">
<van-button
plain
style="flex: 1"
@click="showDeleteConfirm = false"
>
取消
</van-button>
<van-button
type="primary"
style="flex: 1"
@click="handleConfirmDelete"
>
确定
</van-button>
</div>
</template>
</PopupContainerV2>
<!-- 图标选择对话框 -->
<IconSelector
@@ -189,7 +232,7 @@ import { useRouter } from 'vue-router'
import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
import Icon from '@/components/Icon.vue'
import IconSelector from '@/components/IconSelector.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import {
getCategoryList,
createCategory,

View File

@@ -71,12 +71,12 @@
/>
<!-- 记录列表弹窗 -->
<PopupContainer
v-model="showRecordsList"
<PopupContainerV2
v-model:show="showRecordsList"
title="交易记录列表"
height="75%"
:height="'75%'"
>
<div style="background: var(--van-background)">
<div style="background: var(--van-background); padding: 0">
<!-- 批量操作按钮 -->
<div class="batch-actions">
<van-button
@@ -122,7 +122,7 @@
/>
</div>
</div>
</PopupContainer>
</PopupContainerV2>
</div>
</template>
@@ -133,7 +133,7 @@ import { showToast, showConfirmDialog } from 'vant'
import { nlpAnalysis, batchUpdateClassify } from '@/api/transactionRecord'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
const router = useRouter()
const userInput = ref('')

View File

@@ -73,23 +73,24 @@
</van-pull-refresh>
<!-- 详情弹出层 -->
<PopupContainer
v-model="detailVisible"
<PopupContainerV2
v-model:show="detailVisible"
:title="currentEmail ? currentEmail.Subject || currentEmail.subject || '(无主题)' : ''"
height="75%"
:height="'75%'"
>
<template #header-actions>
<van-button
size="small"
type="primary"
:loading="refreshingAnalysis"
@click="handleRefreshAnalysis"
>
重新分析
</van-button>
</template>
<div v-if="currentEmail">
<!-- 操作按钮栏 -->
<div style="padding: 12px 16px; text-align: right; border-bottom: 1px solid var(--van-border-color)">
<van-button
size="small"
type="primary"
:loading="refreshingAnalysis"
@click="handleRefreshAnalysis"
>
重新分析
</van-button>
</div>
<van-cell-group
inset
style="margin-top: 12px"
@@ -140,13 +141,13 @@
</div>
</div>
</div>
</PopupContainer>
</PopupContainerV2>
<!-- 账单列表弹出层 -->
<PopupContainer
v-model="transactionListVisible"
<PopupContainerV2
v-model:show="transactionListVisible"
title="关联账单列表"
height="75%"
:height="'75%'"
>
<BillListComponent
data-source="custom"
@@ -158,7 +159,7 @@
@click="handleTransactionClick"
@delete="handleTransactionDelete"
/>
</PopupContainer>
</PopupContainerV2>
<!-- 账单详情编辑弹出层 -->
<TransactionDetail
@@ -184,7 +185,7 @@ import {
import { getTransactionDetail } from '@/api/transactionRecord'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
const emailList = ref([])
const loading = ref(false)

View File

@@ -71,22 +71,27 @@
</van-pull-refresh>
<!-- 详情弹出层 -->
<PopupContainer
v-model="detailVisible"
<PopupContainerV2
v-model:show="detailVisible"
:title="currentMessage.title"
:subtitle="currentMessage.createTime"
height="75%"
:height="'75%'"
>
<div
v-if="currentMessage.messageType === 2"
class="detail-content rich-html-content"
v-html="currentMessage.content"
/>
<div
v-else
class="detail-content"
>
{{ currentMessage.content }}
<div style="padding: 16px">
<p style="color: #999; font-size: 14px; margin-bottom: 12px; margin-top: 0">
{{ currentMessage.createTime }}
</p>
<div
v-if="currentMessage.messageType === 2"
class="rich-html-content"
style="font-size: 14px; line-height: 1.6"
v-html="currentMessage.content"
/>
<div
v-else
style="font-size: 14px; line-height: 1.6; white-space: pre-wrap"
>
{{ currentMessage.content }}
</div>
</div>
<template
v-if="currentMessage.url && currentMessage.messageType === 1"
@@ -101,7 +106,7 @@
查看详情
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
</div>
</template>
@@ -111,7 +116,7 @@ import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { getMessageList, markAsRead, deleteMessage, markAllAsRead } from '@/api/message'
import { useMessageStore } from '@/stores/message'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
const messageStore = useMessageStore()
const router = useRouter()
@@ -325,22 +330,6 @@ defineExpose({
height: 100%;
}
.detail-time {
color: var(--van-text-color-2);
font-size: 14px;
}
.detail-content {
padding: 16px;
font-size: 14px;
line-height: 1.6;
color: var(--van-text-color);
}
.detail-content:not(.rich-html-content) {
white-space: pre-wrap;
}
:deep(.van-pull-refresh) {
flex: 1;
overflow-y: auto;

View File

@@ -107,141 +107,143 @@
</div>
<!-- 新增/编辑弹窗 -->
<PopupContainer
v-model="dialogVisible"
<PopupContainerV2
v-model:show="dialogVisible"
:title="isEdit ? '编辑周期账单' : '新增周期账单'"
height="75%"
:height="'75%'"
>
<van-form>
<van-cell-group
inset
title="周期设置"
>
<van-field
v-model="form.periodicTypeText"
is-link
readonly
name="periodicType"
label="周期"
placeholder="请选择周期类型"
:rules="[{ required: true, message: '请选择周期类型' }]"
@click="showPeriodicTypePicker = true"
/>
<!-- 每周配置 -->
<van-field
v-if="form.periodicType === 1"
v-model="form.weekdaysText"
is-link
readonly
name="weekdays"
label="星期"
placeholder="请选择星期几"
:rules="[{ required: true, message: '请选择星期几' }]"
@click="showWeekdaysPicker = true"
/>
<!-- 每月配置 -->
<van-field
v-if="form.periodicType === 2"
v-model="form.monthDaysText"
is-link
readonly
name="monthDays"
label="日期"
placeholder="请选择每月的日期"
:rules="[{ required: true, message: '请选择日期' }]"
@click="showMonthDaysPicker = true"
/>
<!-- 每季度配置 -->
<van-field
v-if="form.periodicType === 3"
v-model="form.quarterDay"
name="quarterDay"
label="季度第几天"
placeholder="请输入季度开始后第几天"
type="number"
:rules="[{ required: true, message: '请输入季度开始后第几天' }]"
/>
<!-- 每年配置 -->
<van-field
v-if="form.periodicType === 4"
v-model="form.yearDay"
name="yearDay"
label="年第几天"
placeholder="请输入年开始后第几天"
type="number"
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
/>
</van-cell-group>
<van-cell-group
inset
title="基本信息"
>
<van-field
v-model="form.reason"
name="reason"
label="摘要"
placeholder="请输入交易摘要"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field
v-model="form.amount"
name="amount"
label="金额"
placeholder="请输入金额"
type="number"
:rules="[{ required: true, message: '请输入金额' }]"
/>
<van-field
v-model="form.type"
name="type"
label="类型"
<div style="padding: 0">
<van-form>
<van-cell-group
inset
title="周期设置"
>
<template #input>
<van-radio-group
v-model="form.type"
direction="horizontal"
>
<van-radio :value="0">
支出
</van-radio>
<van-radio :value="1">
收入
</van-radio>
<van-radio :value="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
name="classify"
label="分类"
>
<template #input>
<span
v-if="!form.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ form.classify }}</span>
</template>
</van-field>
<van-field
v-model="form.periodicTypeText"
is-link
readonly
name="periodicType"
label="周期"
placeholder="请选择周期类型"
:rules="[{ required: true, message: '请选择周期类型' }]"
@click="showPeriodicTypePicker = true"
/>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group>
</van-form>
<!-- 每周配置 -->
<van-field
v-if="form.periodicType === 1"
v-model="form.weekdaysText"
is-link
readonly
name="weekdays"
label="星期"
placeholder="请选择星期几"
:rules="[{ required: true, message: '请选择星期几' }]"
@click="showWeekdaysPicker = true"
/>
<!-- 每月配置 -->
<van-field
v-if="form.periodicType === 2"
v-model="form.monthDaysText"
is-link
readonly
name="monthDays"
label="日期"
placeholder="请选择每月的日期"
:rules="[{ required: true, message: '请选择日期' }]"
@click="showMonthDaysPicker = true"
/>
<!-- 每季度配置 -->
<van-field
v-if="form.periodicType === 3"
v-model="form.quarterDay"
name="quarterDay"
label="季度第几天"
placeholder="请输入季度开始后第几天"
type="number"
:rules="[{ required: true, message: '请输入季度开始后第几天' }]"
/>
<!-- 每年配置 -->
<van-field
v-if="form.periodicType === 4"
v-model="form.yearDay"
name="yearDay"
label="年第几天"
placeholder="请输入年开始后第几天"
type="number"
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
/>
</van-cell-group>
<van-cell-group
inset
title="基本信息"
>
<van-field
v-model="form.reason"
name="reason"
label="摘要"
placeholder="请输入交易摘要"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field
v-model="form.amount"
name="amount"
label="金额"
placeholder="请输入金额"
type="number"
:rules="[{ required: true, message: '请输入金额' }]"
/>
<van-field
v-model="form.type"
name="type"
label="类型"
>
<template #input>
<van-radio-group
v-model="form.type"
direction="horizontal"
>
<van-radio :value="0">
支出
</van-radio>
<van-radio :value="1">
收入
</van-radio>
<van-radio :value="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
name="classify"
label="分类"
>
<template #input>
<span
v-if="!form.classify"
style="color: var(--van-gray-5)"
>请选择交易分类</span>
<span v-else>{{ form.classify }}</span>
</template>
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="form.classify"
:type="form.type"
/>
</van-cell-group>
</van-form>
</div>
<template #footer>
<van-button
round
@@ -253,7 +255,7 @@
{{ isEdit ? '更新' : '确认添加' }}
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
<!-- 周期类型选择器 -->
<van-popup
@@ -310,7 +312,7 @@ import {
createPeriodic,
updatePeriodic
} from '@/api/transactionPeriodic'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import dayjs from 'dayjs'

View File

@@ -1,9 +1,12 @@
<template>
<div class="page-container-flex">
<van-nav-bar
title="设置"
placeholder
/>
<!-- 自定义头部 -->
<header class="setting-header">
<h1 class="header-title">
设置
</h1>
</header>
<div class="scroll-content">
<div
class="detail-header"
@@ -384,12 +387,30 @@ const handleScheduledTasks = () => {
}
</script>
<style scoped>
/* 页面背景色 */
:deep(body) {
background-color: var(--van-background);
<style scoped lang="scss">
@import '@/assets/theme.css';
/* ========== 自定义头部 ========== */
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
background: transparent;
position: relative;
z-index: 1;
min-height: 60px; /* 与其他 header 保持一致,防止切换抖动 */
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
}
/* ========== 页面内容 ========== */
/* 增加卡片对比度 */
:deep(.van-cell-group--inset) {
background-color: var(--van-background-2);
@@ -407,9 +428,4 @@ const handleScheduledTasks = () => {
color: var(--van-text-color-2);
font-weight: normal;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>

View File

@@ -123,6 +123,8 @@
<SavingsBudgetContent
v-else-if="activeTab === BudgetCategory.Savings"
:budgets="savingsBudgets"
:income-budgets="incomeBudgets"
:expense-budgets="expenseBudgets"
@savings-nav="handleSavingsNav"
/>
</div>
@@ -151,183 +153,43 @@
<!-- 储蓄配置弹窗 -->
<SavingsConfigPopup
ref="savingsConfigRef"
@success="loadBudgetData"
@change="loadBudgetData"
/>
<!-- 预算明细列表弹窗 -->
<PopupContainer
v-model="showListPopup"
:title="popupTitle"
height="80%"
>
<template #header-actions>
<van-icon
name="plus"
size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })"
/>
</template>
<van-pull-refresh
v-model="refreshing"
style="min-height: 100%"
@refresh="onRefresh"
>
<div class="budget-list">
<!-- 支出列表 -->
<template v-if="activeTab === BudgetCategory.Expense && expenseBudgets?.length > 0">
<van-swipe-cell
v-for="budget in expenseBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ warning: budget.current / budget.limit > 0.8 }"
:period-label="getPeriodLabel(budget.type)"
@click="handleEdit(budget)"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已支出
</div>
<div class="value expense">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
预算
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
余额
</div>
<div
class="value"
:class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"
>
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<!-- 收入列表 -->
<template v-if="activeTab === BudgetCategory.Income && incomeBudgets?.length > 0">
<van-swipe-cell
v-for="budget in incomeBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ income: budget.current / budget.limit >= 1 }"
:period-label="getPeriodLabel(budget.type)"
@click="handleEdit(budget)"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已收入
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
差额
</div>
<div
class="value"
:class="budget.current >= budget.limit ? 'income' : 'expense'"
>
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<!-- 空状态 -->
<van-empty
v-if="
activeTab !== BudgetCategory.Savings &&
!loading &&
!hasError &&
((activeTab === BudgetCategory.Expense && expenseBudgets?.length === 0) ||
(activeTab === BudgetCategory.Income && incomeBudgets?.length === 0))
"
:description="`暂无${activeTab === BudgetCategory.Expense ? '支出' : '收入'}预算`"
/>
</div>
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
</PopupContainer>
<!-- 未覆盖分类弹窗 -->
<PopupContainer
v-model="showUncoveredDetails"
<PopupContainerV2
v-model:show="showUncoveredDetails"
title="未覆盖预算的分类"
:subtitle="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
height="60%"
:height="'60%'"
>
<div class="uncovered-list">
<div style="padding: 0">
<!-- subtitle 作为内容区域顶部 -->
<div
v-for="item in uncoveredCategories"
:key="item.category"
class="uncovered-item"
>
<div class="item-left">
<div class="category-name">
{{ item.category }}
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
v-html="`本月共 <b style='color:var(--van-primary-color)'>${uncoveredCategories.length}</b> 个分类未设置预算`"
/>
<div class="uncovered-list">
<div
v-for="item in uncoveredCategories"
:key="item.category"
class="uncovered-item"
>
<div class="item-left">
<div class="category-name">
{{ item.category }}
</div>
<div class="transaction-count">
{{ item.transactionCount }} 笔记录
</div>
</div>
<div class="transaction-count">
{{ item.transactionCount }} 笔记录
</div>
</div>
<div class="item-right">
<div
class="item-amount"
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
>
¥{{ formatMoney(item.totalAmount) }}
<div class="item-right">
<div
class="item-amount"
:class="activeTab === BudgetCategory.Expense ? 'expense' : 'income'"
>
¥{{ formatMoney(item.totalAmount) }}
</div>
</div>
</div>
</div>
@@ -343,25 +205,31 @@
我知道了
</van-button>
</template>
</PopupContainer>
</PopupContainerV2>
<!-- 归档总结弹窗 -->
<PopupContainer
v-model="showSummaryPopup"
<PopupContainerV2
v-model:show="showSummaryPopup"
title="月份归档总结"
:subtitle="`${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月`"
height="70%"
:height="'70%'"
>
<div style="padding: 16px">
<div
class="rich-html-content"
v-html="
archiveSummary ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
"
/>
<div style="padding: 0">
<!-- subtitle -->
<div style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)">
{{ selectedDate.getFullYear() }}年{{ selectedDate.getMonth() + 1 }}月
</div>
<div style="padding: 16px">
<div
class="rich-html-content"
v-html="
archiveSummary ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无总结</p>'
"
/>
</div>
</div>
</PopupContainer>
</PopupContainerV2>
<!-- 日期选择器 -->
<van-popup
@@ -401,7 +269,7 @@ import BudgetTypeTabs from '@/components/BudgetTypeTabs.vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ExpenseBudgetContent from './modules/ExpenseBudgetContent.vue'
import IncomeBudgetContent from './modules/IncomeBudgetContent.vue'
import SavingsBudgetContent from './modules/SavingsBudgetContent.vue'

View File

@@ -71,118 +71,312 @@
</div>
<!-- 计划存款明细弹窗 -->
<PopupContainer
v-model="showDetailPopup"
<PopupContainerV2
v-model:show="showDetailPopup"
title="计划存款明细"
height="80%"
:height="'85%'"
>
<div class="popup-body">
<div
v-if="currentBudget"
class="detail-content"
>
<div class="detail-section income-section">
<div class="section-title">
<van-icon name="balance-o" />
收入预算
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
<!-- 明细表格 -->
<div
v-if="currentBudget.details"
class="detail-tables"
>
<!-- 收入明细 -->
<div class="detail-section income-section">
<div class="section-title">
<van-icon name="balance-o" />
收入明细
</div>
<div class="detail-row">
<span class="detail-label">实际收入</span>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
<div class="rich-html-content">
<table>
<thead>
<tr>
<th>名称</th>
<th>预算额度</th>
<th>实际金额</th>
<th>计算用</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in currentBudget.details.incomeItems"
:key="item.id"
>
<td>
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<span>{{ item.name }}</span>
<van-tag
size="mini"
plain
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.type === 1 ? '月' : '年' }}
</van-tag>
<van-tag
v-if="item.isArchived"
size="mini"
type="success"
>
已归档
</van-tag>
</div>
</td>
<td>{{ formatMoney(item.budgetLimit) }}</td>
<td>
<span
class="income-value"
:class="{ 'expense-value': item.isOverBudget }"
>
{{ formatMoney(item.actualAmount) }}
</span>
</td>
<td>
<span class="income-value">{{ formatMoney(item.effectiveAmount) }}</span>
</td>
</tr>
</tbody>
</table>
<p style="margin-top: 12px;">
<strong>收入预算合计:</strong>
<template v-if="hasArchivedIncome">
已归档 <span class="income-value"><strong>{{ formatMoney(archivedIncomeTotal) }}</strong></span>
+ 未来预算 <span class="income-value"><strong>{{ formatMoney(futureIncomeTotal) }}</strong></span>
= <span class="income-value"><strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong></span>
</template>
<template v-else>
<span class="income-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
</span>
</template>
</p>
</div>
</div>
<!-- 支出明细 -->
<div class="detail-section expense-section">
<div class="section-title">
<van-icon name="bill-o" />
支出明细
</div>
<div class="rich-html-content">
<table>
<thead>
<tr>
<th>名称</th>
<th>预算额度</th>
<th>实际金额</th>
<th>计算用</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in currentBudget.details.expenseItems"
:key="item.id"
>
<td>
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
<span>{{ item.name }}</span>
<van-tag
size="mini"
plain
:type="item.type === 1 ? 'default' : 'primary'"
>
{{ item.type === 1 ? '月' : '年' }}
</van-tag>
<van-tag
v-if="item.isArchived"
size="mini"
type="success"
>
已归档
</van-tag>
<van-tag
v-if="item.isOverBudget"
size="mini"
type="danger"
>
超支
</van-tag>
</div>
</td>
<td>{{ formatMoney(item.budgetLimit) }}</td>
<td>
<span class="expense-value">{{ formatMoney(item.actualAmount) }}</span>
</td>
<td>
<span class="expense-value">{{ formatMoney(item.effectiveAmount) }}</span>
</td>
</tr>
</tbody>
</table>
<p style="margin-top: 12px;">
<strong>支出预算合计:</strong>
<template v-if="hasArchivedExpense">
已归档 <span class="expense-value"><strong>{{ formatMoney(archivedExpenseTotal) }}</strong></span>
+ 未来预算 <span class="expense-value"><strong>{{ formatMoney(futureExpenseTotal) }}</strong></span>
= <span class="expense-value"><strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong></span>
</template>
<template v-else>
<span class="expense-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
</span>
</template>
</p>
</div>
</div>
<!-- 计算汇总 -->
<div class="detail-section formula-section">
<div class="section-title">
<van-icon name="calculator-o" />
计算汇总
</div>
<div class="rich-html-content">
<h3>计算公式</h3>
<p>
<strong>收入预算合计:</strong>
<span class="income-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
</span>
</p>
<p>
<strong>支出预算合计:</strong>
<span class="expense-value">
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
</span>
</p>
<p>
<strong>计划存款:</strong>
{{ currentBudget.details.summary.calculationFormula }}
= <span class="highlight">
<strong>{{ formatMoney(currentBudget.details.summary.plannedSavings) }}</strong>
</span>
</p>
</div>
</div>
</div>
<div class="detail-section expense-section">
<div class="section-title">
<van-icon name="bill-o" />
支出预算
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
<!-- 旧版汇总(无明细数据时显示) -->
<div
v-else
class="legacy-summary"
>
<div class="detail-section income-section">
<div class="section-title">
<van-icon name="balance-o" />
收入预算
</div>
<div class="detail-row">
<span class="detail-label">实际支出</span>
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际收入</span>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
</div>
</div>
</div>
</div>
<div class="detail-section formula-section">
<div class="section-title">
<van-icon name="calculator-o" />
计划存款公式
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
<div class="detail-section expense-section">
<div class="section-title">
<van-icon name="bill-o" />
支出预算
</div>
<div class="formula-operator">
-
</div>
<div class="formula-item">
<span class="formula-label">支出预算</span>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="formula-operator">
=
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">预算限额</span>
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际支出</span>
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
</div>
</div>
</div>
</div>
<div class="detail-section result-section">
<div class="section-title">
<van-icon name="chart-trending-o" />
存款结果
<div class="detail-section formula-section">
<div class="section-title">
<van-icon name="calculator-o" />
计划存款公式
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="formula-operator">
-
</div>
<div class="formula-item">
<span class="formula-label">支出预算</span>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="formula-operator">
=
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
</div>
</div>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">计划存款</span>
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
<div class="detail-section result-section">
<div class="section-title">
<van-icon name="chart-trending-o" />
存款结果
</div>
<div class="detail-row">
<span class="detail-label">实际存款</span>
<span
class="detail-value"
:class="{ income: currentBudget.current >= currentBudget.limit }"
>¥{{ formatMoney(currentBudget.current) }}</span>
</div>
<div class="detail-row highlight">
<span class="detail-label">还差</span>
<span class="detail-value expense">¥{{
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
}}</span>
<div class="section-content">
<div class="detail-row">
<span class="detail-label">计划存款</span>
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际存款</span>
<span
class="detail-value"
:class="{ income: currentBudget.current >= currentBudget.limit }"
>¥{{ formatMoney(currentBudget.current) }}</span>
</div>
<div class="detail-row highlight">
<span class="detail-label">还差</span>
<span class="detail-value expense">¥{{
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</PopupContainer>
</PopupContainerV2>
</template>
<script setup>
import { ref, computed } from 'vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
// Props
const props = defineProps({
budgets: {
type: Array,
default: () => []
},
incomeBudgets: {
type: Array,
default: () => []
},
expenseBudgets: {
type: Array,
default: () => []
}
})
@@ -195,10 +389,85 @@ const currentBudget = ref(null)
// 处理显示明细
const handleShowDetail = (budget) => {
console.log('=== 存款预算数据 ===')
console.log('完整数据:', budget)
console.log('是否有 details:', !!budget.details)
console.log('是否有 Details:', !!budget.Details)
if (budget.details) {
console.log('details 内容:', budget.details)
}
if (budget.Details) {
console.log('Details 内容:', budget.Details)
}
console.log('===================')
currentBudget.value = 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 hasArchivedIncome = computed(() => {
if (!currentBudget.value?.details) {return false}
return currentBudget.value.details.incomeItems.some(item => item.isArchived)
})
const archivedIncomeTotal = computed(() => {
if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.incomeItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureIncomeTotal = computed(() => {
if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.incomeItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const hasArchivedExpense = computed(() => {
if (!currentBudget.value?.details) {return false}
return currentBudget.value.details.expenseItems.some(item => item.isArchived)
})
const archivedExpenseTotal = computed(() => {
if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.expenseItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureExpenseTotal = computed(() => {
if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.expenseItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
// 辅助函数
const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, {
@@ -445,4 +714,43 @@ const getProgressColor = (budget) => {
color: var(--van-text-color-2);
padding: 0 8px;
}
/* 明细表格样式 - 使用 rich-html-content 统一样式 */
.detail-tables {
display: flex;
flex-direction: column;
gap: 16px;
}
.formula-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.formula-row.highlight {
margin-top: 8px;
padding-top: 12px;
border-top: 2px solid var(--van-border-color);
font-size: 16px;
font-weight: 600;
}
.formula-value.primary {
color: var(--van-primary-color);
font-size: 18px;
}
.formula-text {
margin-top: 12px;
padding: 10px;
background-color: var(--van-light-gray);
border-radius: 6px;
font-size: 13px;
color: var(--van-text-color-2);
text-align: center;
font-family: DIN Alternate, system-ui;
}
</style>

View File

@@ -4,14 +4,14 @@
特殊功能
- 自定义 headerItems 数量Smart 按钮
- 与日历视图紧密集成
- 特定的 UI 风格和交互
- 使用统一的 BillListComponent 展示账单列表
注意此组件不是通用的 BillListComponent专为 CalendarV2 视图设计
如需通用账单列表功能请使用 @/components/Bill/BillListComponent.vue
迁移说明已迁移至使用 BillListComponent保留自定义 header Smart 按钮
-->
<template>
<!-- 交易列表 -->
<div class="transactions">
<!-- 自定义 header (保留) -->
<div class="txn-header">
<h2 class="txn-title">
交易记录
@@ -30,79 +30,24 @@
</div>
</div>
<!-- 交易卡片 -->
<van-loading
v-if="transactionsLoading"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<div
v-else-if="transactions.length === 0"
class="txn-empty"
>
<div class="empty-icon">
<van-icon
name="balance-list-o"
size="48"
/>
</div>
<div class="empty-text">
当天暂无交易记录
</div>
<div class="empty-hint">
轻松享受无消费的一天
</div>
</div>
<div
v-else
class="txn-list"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.name }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ txn.time }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ txn.amount }}
</div>
</div>
</div>
<!-- 统一的账单列表组件 -->
<BillListComponent
data-source="custom"
:transactions="transactions"
:loading="transactionsLoading"
:finished="true"
:show-delete="true"
:enable-filter="false"
@click="onTransactionClick"
@delete="onTransactionDelete"
/>
</div>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { getTransactionsByDate } from '@/api/transactionRecord'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
const props = defineProps({
selectedDate: Date
@@ -122,39 +67,6 @@ const formatDateKey = (date) => {
return `${year}-${month}-${day}`
}
// 格式化时间HH:MM
const formatTime = (dateTimeStr) => {
const date = new Date(dateTimeStr)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
// 格式化金额
const formatAmount = (amount, type) => {
const sign = type === 1 ? '+' : '-' // 1=收入, 0=支出
return `${sign}${amount.toFixed(2)}`
}
// 根据分类获取图标
const getIconByClassify = (classify) => {
const iconMap = {
餐饮: 'food',
购物: 'shopping',
交通: 'transport',
娱乐: 'play',
医疗: 'medical',
工资: 'money',
红包: 'red-packet'
}
return iconMap[classify] || 'star'
}
// 根据类型获取颜色
const getColorByType = (type) => {
return type === 1 ? '#22C55E' : '#FF6B6B' // 收入绿色,支出红色
}
// 获取选中日期的交易列表
const fetchDayTransactions = async (date) => {
try {
@@ -163,18 +75,8 @@ const fetchDayTransactions = async (date) => {
const response = await getTransactionsByDate(dateKey)
if (response.success && response.data) {
// 转换为界面需要的格式
transactions.value = response.data.map((txn) => ({
id: txn.id,
name: txn.reason || '未知交易',
time: formatTime(txn.occurredAt),
amount: formatAmount(txn.amount, txn.type),
icon: getIconByClassify(txn.classify),
iconColor: getColorByType(txn.type),
iconBg: '#FFFFFF',
classify: txn.classify,
type: txn.type
}))
// 直接使用原始数据,交给 BillListComponent 处理格式
transactions.value = response.data
}
} catch (error) {
console.error('获取交易记录失败:', error)
@@ -202,6 +104,13 @@ const onTransactionClick = (txn) => {
emit('transactionClick', txn)
}
// 删除交易后的处理
const onTransactionDelete = (deletedId) => {
// BillListComponent 已经完成删除 API 调用
// 这里只需要从本地列表中移除该项
transactions.value = transactions.value.filter((t) => t.id !== deletedId)
}
// 点击 Smart 按钮
const onSmartClick = () => {
emit('smartClick')
@@ -211,15 +120,27 @@ const onSmartClick = () => {
<style scoped>
@import '@/assets/theme.css';
/* ========== 交易列表 ========== */
/* ========== 交易列表容器 ========== */
.transactions {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-3xl);
padding: var(--spacing-xl, 16px);
padding-top: 0;
}
/* 移除 BillListComponent 内部的左右 padding/margin */
:deep(.van-cell-group) {
margin-left: 0 !important;
margin-right: 0 !important;
}
:deep(.van-list) {
padding-left: 0 !important;
padding-right: 0 !important;
}
/* ========== 自定义 Header (保留) ========== */
.txn-header {
display: flex;
align-items: center;
@@ -271,132 +192,4 @@ const onSmartClick = () => {
.smart-btn:active {
opacity: 0.7;
}
.txn-loading {
padding: var(--spacing-3xl);
text-align: center;
}
.txn-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
margin-top: 10px;
padding: var(--spacing-xl);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: opacity 0.2s;
}
.txn-card:active {
opacity: 0.7;
}
.txn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-footer {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.txn-time {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.txn-classify-tag {
padding: 2px 8px;
font-size: var(--font-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-classify-tag.tag-income {
background-color: rgba(34, 197, 94, 0.15);
color: var(--accent-success);
}
.txn-classify-tag.tag-expense {
background-color: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
flex-shrink: 0;
margin-left: var(--spacing-md);
}
/* ========== 空状态 ========== */
.txn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
padding: var(--spacing-4xl) var(--spacing-2xl);
gap: var(--spacing-md);
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-tertiary);
margin-bottom: var(--spacing-sm);
}
.empty-text {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-secondary);
}
.empty-hint {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,260 @@
using Service.Budget;
namespace WebApi.Test.Budget;
/// <summary>
/// BudgetItemCalculator 单元测试
/// 测试明细项计算用金额的各种规则
/// </summary>
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);
}
}

View File

@@ -0,0 +1,135 @@
using Service.Budget;
namespace WebApi.Test.Budget;
/// <summary>
/// 存款计划核心公式单元测试
/// </summary>
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
}
}

View File

@@ -58,9 +58,96 @@ public class BudgetSavingsTest : BaseTest
// Assert
result.Should().NotBeNull();
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000
result.Limit.Should().Be(8000);
}
[Fact]
public async Task GetSavings_月度_应返回Details字段()
{
// Arrange
var referenceDate = new DateTime(2024, 2, 15);
_dateTimeProvider.Now.Returns(referenceDate);
var budgets = new List<BudgetRecord>
{
new()
{
Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income,
SelectedCategories = "工资"
},
new()
{
Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense,
SelectedCategories = "餐饮"
},
new()
{
Id = 3, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense,
SelectedCategories = "房租", IsMandatoryExpense = true
}
};
var transactions = new Dictionary<(string, TransactionType), decimal>
{
{ ("工资", TransactionType.Income), 10000m },
{ ("餐饮", TransactionType.Expense), 2500m }, // 超支
{ ("房租", TransactionType.Expense), 0m } // 硬性未发生
};
_transactionStatisticsService.GetAmountGroupByClassifyAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>()
).Returns(transactions);
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("存款");
// Act
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate, budgets);
// Assert
result.Should().NotBeNull();
result.Details.Should().NotBeNull();
// 验证收入明细
result.Details!.IncomeItems.Should().HaveCount(1);
var incomeItem = result.Details.IncomeItems[0];
incomeItem.Name.Should().Be("工资");
incomeItem.BudgetLimit.Should().Be(10000);
incomeItem.ActualAmount.Should().Be(10000);
incomeItem.EffectiveAmount.Should().Be(10000);
incomeItem.CalculationNote.Should().Be("使用实际");
// 验证支出明细
result.Details.ExpenseItems.Should().HaveCount(2);
// 餐饮超支
var expenseItem1 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "餐饮");
expenseItem1.Should().NotBeNull();
expenseItem1!.BudgetLimit.Should().Be(2000);
expenseItem1.ActualAmount.Should().Be(2500);
expenseItem1.EffectiveAmount.Should().Be(2500); // MAX(2000, 2500)
expenseItem1.CalculationNote.Should().Be("使用实际(超支)");
expenseItem1.IsOverBudget.Should().BeTrue();
// 房租按天折算硬性消费在实际为0时会自动填充
var expenseItem2 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "房租");
expenseItem2.Should().NotBeNull();
expenseItem2!.BudgetLimit.Should().Be(3000);
// 硬性消费在 GetForMonthAsync 中已经填充了按天折算的值到 current
expenseItem2.ActualAmount.Should().BeApproximately(3000m / 29 * 15, 0.01m);
// EffectiveAmount 使用 MAX(预算3000, 实际1551.72) = 3000
expenseItem2.EffectiveAmount.Should().Be(3000);
expenseItem2.CalculationNote.Should().Be("使用预算"); // MAX 后选择了预算值
// 验证汇总
result.Details.Summary.Should().NotBeNull();
result.Details.Summary.TotalIncomeBudget.Should().BeApproximately(10000, 0.01m);
// 支出汇总餐饮2500 + 房租3000(MAX) = 5500
result.Details.Summary.TotalExpenseBudget.Should().BeApproximately(5500, 1m);
result.Details.Summary.PlannedSavings.Should().BeApproximately(4500, 1m);
}
[Fact]
public async Task GetSavings_月度_年度收支_Test()
{

View File

@@ -546,4 +546,62 @@ public class BudgetStatsTest : BaseTest
// 年度使用率7350 / 47000 * 100 = 15.64%
result.Year.Rate.Should().BeApproximately(7350m / 47000m * 100, 0.01m);
}
[Fact]
public async Task GetCategoryStats_年度收入_不应包含支出预算_Test()
{
// Arrange: 模拟实际数据库中的情况
// 2026年1月当前日期为2026-02-19
var referenceDate = new DateTime(2026, 1, 15);
var currentNow = new DateTime(2026, 2, 19);
_dateTimeProvider.Now.Returns(currentNow);
var budgets = new List<BudgetRecord>
{
// Type=1 表示月度预算Category=0 表示支出(这些不应该被计入收入统计)
new() { Id = 1, Name = "工作餐预算", Limit = 700, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "G工作餐", StartDate = new DateTime(2026, 1, 6) },
new() { Id = 2, Name = "副业投资", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "Z钻石福袋", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 3, Name = "通勤支出", Limit = 240, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "D地铁通勤", StartDate = new DateTime(2026, 1, 7) },
// Category=1 表示收入(只有这些应该被计入收入统计)
new() { Id = 4, Name = "工资-SYE", Limit = 6100, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SYE", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 5, Name = "副业收入", Limit = 6000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, SelectedCategories = "", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 6, Name = "公积金收入", Limit = 5540, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G公积金", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 7, Name = "工资-SC", Limit = 17500, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SC", StartDate = new DateTime(2026, 1, 16) }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 模拟实际收入金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var budget = (BudgetRecord)args[0];
// 假设当前月2月没有收入记录
return 0m;
});
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
_budgetArchiveRepository.GetArchiveAsync(Arg.Any<int>(), Arg.Any<int>())
.Returns((BudgetArchive?)null);
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
// Assert
// 年度已收应该是1月的4个收入预算
// 1月归档工资-SYE(6100) + 副业收入(6000) + 公积金收入(5540) + 工资-SC(17500) = 35140
// 2月当前0假设没有实际收入
// 3-12月未来0
// 总计应该约等于 35140 (取决于硬性收入的调整逻辑)
// 重点year.limit 应该只包含收入预算,不应该包含支出预算
// 正确的年度limit应该是(6100 + 6000 + 5540 + 17500) * (1 + 11) = 35140 * 12 = 421680
// 或者更准确地说1月归档(35140) + 2月当前月(35140) + 未来10个月(35140 * 10) = 35140 * 12
result.Year.Limit.Should().BeGreaterThan(35000 * 11); // 至少应该是35140的11倍以上
result.Year.Limit.Should().BeLessThan(36000 * 12); // 不应该超过36000的12倍
}
}

BIN
balance-page-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

2
null
View File

@@ -1,2 +0,0 @@
ERROR: Invalid argument/option - 'F:/'.
Type "TASKKILL /?" for usage.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-19

View File

@@ -0,0 +1,120 @@
## Context
余额页面(BalanceView.vue)当前使用 Vant UI 库的默认组件:`van-nav-bar` 作为头部导航,`van-tabs type="card"` 作为标签页切换。而统计页面(statisticsV2)已经采用了更现代的自定义设计:使用 `DateSelectHeader` 组件作为头部,`TimePeriodTabs` 作为分段控制器。
现有代码结构:
- `Web/src/views/BalanceView.vue` - 主要页面组件
- `Web/src/components/DateSelectHeader.vue` - 统计页面头部组件(包含日期切换)
- `Web/src/components/TimePeriodTabs.vue` - 分段控制器组件(周/月/年)
- `Web/src/assets/theme.css` - CSS 变量定义
余额页面的三个标签页(账单/邮件/消息)需要保留的特定功能:
- 邮件页:右上角"立即同步"按钮
- 消息页:右上角"标记全部已读"图标
## Goals / Non-Goals
**Goals:**
- 统一余额页面和统计页面的视觉风格
- 使用分段控制器(segmented control)替代卡片式标签
- 创建简化版头部布局(仅文字标题,无日期切换)
- 保留所有现有功能(同步按钮、标记已读图标等)
- 复用现有样式变量,确保主题切换(亮色/暗色)正常工作
**Non-Goals:**
- 不修改统计页面的任何代码
- 不改变余额页面的业务逻辑
- 不创建新的通用组件(直接在 BalanceView.vue 内实现)
- 不调整底层数据加载逻辑
## Decisions
### 决策 1: 组件复用策略
**选择**: 直接在 BalanceView.vue 内实现简化版头部和标签页,不复用 DateSelectHeader 或 TimePeriodTabs
**理由**:
- DateSelectHeader 包含日期切换逻辑,余额页面不需要
- TimePeriodTabs 是三个固定选项(周/月/年),余额页面需要不同的三个选项(账单/邮件/消息)
- 直接实现可以更灵活地保留右上角操作按钮(同步、标记已读)
- 代码量较小,不值得抽象为通用组件
**备选方案**:
- ❌ 修改 TimePeriodTabs 支持自定义选项 → 增加组件复杂度,影响统计页面
- ❌ 创建新的通用组件 BalanceTabsComponent → 过度设计,只有一个使用场景
### 决策 2: 头部布局结构
**选择**: 采用 `<header>` 标签 + 标题文字 + 右侧操作按钮的扁平布局
**理由**:
- 与 DateSelectHeader 的视觉效果一致
- 灵活支持动态显示右侧按钮(根据当前标签页)
- 无需复杂的嵌套结构
**样式规范**:
- 使用 `var(--font-2xl)` 作为标题字号
- 使用 `var(--text-primary)` 作为标题颜色
- 透明背景:`background: transparent`
- 内边距:`padding: 8px 24px`
### 决策 3: 分段控制器实现
**选择**: 复制 TimePeriodTabs 的 CSS 结构,修改选项数据
**理由**:
- 确保视觉效果 100% 一致
- 避免重复造轮子
- 样式已经过亮色/暗色主题测试
**关键样式**:
```scss
.segmented-control {
display: flex;
background: var(--segmented-bg);
border-radius: 8px;
padding: 4px;
gap: 4px;
height: 40px;
}
.tab-item.active {
background: var(--segmented-active-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
```
### 决策 4: 右侧操作按钮布局
**选择**: 使用 `v-if` 条件渲染,根据 `tabActive` 动态显示不同按钮
**理由**:
- 简洁明了,易于维护
- 与现有代码风格一致
- 性能开销可忽略
**实现**:
```vue
<van-button v-if="tabActive === 'email'" @click="...">立即同步</van-button>
<van-icon v-if="tabActive === 'message'" name="passed" @click="..."/>
```
## Risks / Trade-offs
### 风险 1: CSS 变量未定义
**风险**: `theme.css` 中可能缺少 `--segmented-bg``--segmented-active-bg` 变量
**缓解**: 实施前检查变量定义,必要时添加兼容值
### 风险 2: 移动端触控区域
**风险**: 自定义按钮的触控区域可能不如 Vant 组件优化
**缓解**: 确保按钮最小高度 44px,添加 `-webkit-tap-highlight-color: transparent`
### 权衡 1: 代码复用 vs 灵活性
**权衡**: 选择在 BalanceView.vue 内实现而非抽象组件
**影响**: 如果未来其他页面需要类似布局,需要重复代码
**判断**: 可接受 - 当前只有一个使用场景,过早抽象会增加维护成本
### 权衡 2: 视觉一致性 vs 功能保留
**权衡**: 需要在统一样式的同时保留特定功能按钮
**影响**: 头部布局不能完全照搬 DateSelectHeader
**判断**: 功能优先 - 同步和标记已读是核心功能,不能牺牲

View File

@@ -0,0 +1,34 @@
## Why
当前余额页面(BalanceView.vue)使用的是 Vant 默认的 `van-nav-bar``van-tabs type="card"` 样式,与统计页面(statisticsV2)的现代化分段控制器设计不一致,导致用户在不同页面间切换时体验不连贯。统一两个页面的视觉风格能提升整体应用的一致性和现代感。
## What Changes
- 移除余额页面的 `<van-nav-bar>` 组件
- 新增自定义文字标题区域(简化版头部,不含日期切换功能)
-`<van-tabs type="card">` 替换为分段控制器样式(segmented control)
- 标签文字调整为:账单 / 邮件 / 消息
- 保留现有功能:同步按钮(邮件页)、标记已读图标(消息页)
- 样式与统计页面保持一致,使用相同的 CSS 变量和设计规范
## Capabilities
### New Capabilities
- `balance-segmented-tabs`: 余额页面的分段控制器标签组件,支持账单/邮件/消息三个标签页切换,样式与统计页面一致
### Modified Capabilities
- `balance-page-header`: 余额页面头部布局从 Vant 导航栏改为自定义文字标题布局,与统计页面视觉风格保持一致
## Impact
**受影响文件**:
- `Web/src/views/BalanceView.vue` - 主要修改文件,调整头部和标签页结构
**样式依赖**:
- 复用 `@/assets/theme.css` 中的 CSS 变量
- 参考 `Web/src/components/DateSelectHeader.vue``Web/src/components/TimePeriodTabs.vue` 的样式规范
**用户体验影响**:
- ✅ 正向影响:界面更统一、现代化,视觉体验更连贯
- ✅ 无破坏性变更:所有现有功能保持不变,仅样式调整
- ✅ 移动端触控体验优化:分段控制器更符合移动端交互习惯

View File

@@ -0,0 +1,38 @@
## MODIFIED Requirements
### Requirement: 页面头部布局
余额页面 SHALL 使用自定义头部布局,包含标题文字和右侧操作按钮区域,替代原有的 `van-nav-bar` 组件。
#### Scenario: 头部显示
- **WHEN** 用户打开余额页面
- **THEN** 系统在顶部显示"账单"文字标题(不带返回按钮或边框)
#### Scenario: 头部样式
- **WHEN** 用户查看头部区域
- **THEN** 系统使用 `var(--font-2xl)` 字号和 `var(--text-primary)` 颜色显示标题,背景透明
### Requirement: 右侧操作按钮
头部右侧 SHALL 根据当前选中的标签页动态显示不同的操作按钮。
#### Scenario: 邮件页同步按钮
- **WHEN** 用户切换到"邮件"标签
- **THEN** 系统在头部右侧显示"立即同步"按钮
#### Scenario: 消息页标记已读图标
- **WHEN** 用户切换到"消息"标签
- **THEN** 系统在头部右侧显示"标记全部已读"图标
#### Scenario: 账单页无操作按钮
- **WHEN** 用户切换到"账单"标签
- **THEN** 系统不在头部右侧显示任何操作按钮
### Requirement: 视觉风格一致性
头部布局 SHALL 与统计页面的 `DateSelectHeader` 组件保持视觉风格一致。
#### Scenario: 内边距规范
- **WHEN** 用户查看头部区域
- **THEN** 系统使用 `padding: 8px 24px` 作为头部内边距
#### Scenario: 主题适配
- **WHEN** 用户切换应用主题(亮色/暗色)
- **THEN** 头部文字颜色自动更新为对应主题的 `var(--text-primary)`

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 分段控制器布局
余额页面的标签切换 SHALL 使用分段控制器(segmented control)样式,包含三个选项:账单、邮件、消息。
#### Scenario: 分段控制器显示
- **WHEN** 用户打开余额页面
- **THEN** 系统显示分段控制器,包含"账单"、"邮件"、"消息"三个选项
#### Scenario: 默认选中账单
- **WHEN** 用户首次打开余额页面且 URL 无 tab 参数
- **THEN** 系统默认选中"账单"标签
### Requirement: 标签页切换交互
用户 SHALL 能够点击分段控制器中的任意选项切换标签页。
#### Scenario: 点击切换标签
- **WHEN** 用户点击"邮件"选项
- **THEN** 系统切换到邮件视图,并更新分段控制器的选中状态
#### Scenario: 路由参数切换
- **WHEN** 用户通过底部导航栏跳转到余额页面且携带 `?tab=message` 参数
- **THEN** 系统自动选中"消息"标签
### Requirement: 视觉样式一致性
分段控制器 SHALL 使用与统计页面相同的样式规范和 CSS 变量。
#### Scenario: 亮色主题样式
- **WHEN** 应用处于亮色主题
- **THEN** 分段控制器使用 `var(--segmented-bg)` 作为背景色,选中项使用 `var(--segmented-active-bg)`
#### Scenario: 暗色主题样式
- **WHEN** 应用处于暗色主题
- **THEN** 分段控制器自动应用暗色主题对应的 CSS 变量值
### Requirement: 移动端触控体验
分段控制器 SHALL 针对移动端优化触控体验。
#### Scenario: 触控区域
- **WHEN** 用户在移动设备上点击选项
- **THEN** 系统确保每个选项的可点击区域高度不小于 40px
#### Scenario: 触控反馈
- **WHEN** 用户点击未选中的选项
- **THEN** 系统显示背景色过渡动画(0.3s cubic-bezier)

View File

@@ -0,0 +1,51 @@
## 1. 移除旧组件
- [x] 1.1 删除 BalanceView.vue 中的 `<van-nav-bar>` 组件及相关代码
- [x] 1.2 删除 `<van-tabs>` 组件及相关代码
- [x] 1.3 清理不再使用的样式规则(`:deep(.van-nav-bar)` 等)
## 2. 实现自定义头部
- [x] 2.1 创建 `<header>` 标签结构,包含标题文字区域
- [x] 2.2 实现右侧操作按钮区域,支持动态显示
- [x] 2.3 添加 `v-if` 逻辑:邮件页显示"立即同步"按钮
- [x] 2.4 添加 `v-if` 逻辑:消息页显示"标记已读"图标
- [x] 2.5 编写头部样式:字号 `var(--font-2xl)`,颜色 `var(--text-primary)`,内边距 `8px 24px`
- [x] 2.6 确保背景透明:`background: transparent`
## 3. 实现分段控制器
- [x] 3.1 创建分段控制器容器 `.segmented-control`,包含三个选项(账单/邮件/消息)
- [x] 3.2 实现 `.tab-item` 样式:flex 布局,圆角 6px,高度 40px
- [x] 3.3 实现 `.tab-item.active` 样式:背景 `var(--segmented-active-bg)`,投影效果
- [x] 3.4 添加点击事件处理:更新 `tabActive` 响应式变量
- [x] 3.5 添加 hover 效果:未选中项显示半透明背景
- [x] 3.6 添加过渡动画:`transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
## 4. 样式变量检查
- [x] 4.1 验证 `theme.css` 中存在 `--segmented-bg` 变量
- [x] 4.2 验证 `theme.css` 中存在 `--segmented-active-bg` 变量
- [x] 4.3 如变量缺失,添加兼容值或定义新变量
## 5. 移动端优化
- [x] 5.1 确保分段控制器每个选项的最小高度为 40px
- [x] 5.2 添加 `-webkit-tap-highlight-color: transparent` 移除触控高亮
- [x] 5.3 添加 `user-select: none` 防止文字选中
## 6. 功能测试
- [x] 6.1 测试默认加载:账单页为默认选中
- [x] 6.2 测试点击切换:三个标签页能正常切换
- [x] 6.3 测试路由参数:URL 参数 `?tab=email``?tab=message` 能正确切换
- [x] 6.4 测试同步按钮:邮件页的"立即同步"按钮功能正常
- [x] 6.5 测试标记已读:消息页的标记已读图标功能正常
- [x] 6.6 测试主题切换:亮色和暗色主题样式正常
## 7. 视觉验证
- [x] 7.1 对比统计页面,确认头部样式一致(字号、颜色、间距)
- [x] 7.2 对比统计页面,确认分段控制器样式一致(背景、圆角、投影)
- [x] 7.3 检查移动端显示:确保布局在小屏幕上正常
- [x] 7.4 检查动画流畅度:确保切换动画无卡顿

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-19

View File

@@ -0,0 +1,150 @@
## Context
**当前状态**:
- 系统中真正显示交易账单的页面: TransactionsRecord.vue, EmailRecord.vue, calendarV2/TransactionList.vue
- `BillListComponent` 是统一的账单列表组件,位于 `Web/src/components/Bill/BillListComponent.vue`
- TransactionsRecord.vue 和 EmailRecord.vue 已使用 `BillListComponent`
- calendarV2/TransactionList.vue 使用自定义实现,有特殊的 UI 风格和交互(自定义 header、Smart 按钮)
**背景**:
- `BillListComponent` 提供完整的账单列表功能:筛选、排序、分页、左滑删除、多选、Iconify 图标支持
- 支持两种数据模式:API 模式(组件自动加载)和 Custom 模式(父组件传入数据)
- 已在 `/balance` 页面(TransactionsRecord.vue)和 EmailRecord.vue 成功使用
**约束**:
- 必须保持 calendarV2 的现有功能不变
- 需要保留自定义 header (Items 计数、Smart 按钮)
- 接受 UI 风格变化(从自定义卡片样式改为 BillListComponent 统一样式)
- 不能修改 `BillListComponent` 本身(除非发现 bug)
**实际发现**:
经过代码审查,发现原设计文档中列出的其他 5 个页面并非账单列表:
- MessageView.vue: 系统消息列表 (notifications)
- PeriodicRecord.vue: 周期性账单规则列表 (periodic billing rules)
- ClassificationEdit.vue: 分类管理列表 (categories)
- budgetV2/Index.vue: 预算卡片列表 (budget cards)
## Goals / Non-Goals
**Goals:**
- 将 calendarV2/TransactionList.vue 迁移至使用 `BillListComponent`
- 统一账单列表的视觉设计和交互体验
- 减少代码重复,提高可维护性(从 403 行减少到 177 行)
- 保留日历视图的特定功能(自定义 header、Smart 按钮)
**Non-Goals:**
- 不修改 `BillListComponent` 的核心功能
- 不改变业务逻辑或数据流
- 不涉及后端 API 修改
- 不迁移非账单列表页面(MessageView、PeriodicRecord、ClassificationEdit、budgetV2)
## Decisions
### 决策 1: 使用 Custom 数据模式
**决定**: 所有迁移页面使用 `data-source="custom"` 模式
**理由**:
- 每个页面有特定的数据加载逻辑(如 EmailRecord 加载关联账单、PeriodicRecord 加载周期性账单)
- Custom 模式允许父组件完全控制数据加载和筛选
- 避免修改 `BillListComponent` 的 API 加载逻辑
**替代方案**:
- 使用 API 模式并扩展 `BillListComponent` 支持不同 API endpoint → 拒绝,因为会增加组件复杂度
### 决策 2: 保留自定义 header 和 Smart 按钮
**决定**: calendarV2/TransactionList.vue 保留原有的自定义 header 部分
**理由**:
- 日历视图需要显示 Items 计数
- Smart 按钮是日历视图的特殊功能
- BillListComponent 只替换列表部分,不影响 header
**实现**:
- 在 template 中保留 txn-header 部分
- 将 BillListComponent 放在 header 下方
- 保留 header 相关的样式定义
### 决策 3: 保留特定事件处理
**决定**: 每个页面保留自己的事件处理逻辑,通过 `@click``@delete` 等 props 传递
**理由**:
- 不同页面对点击、删除的处理不同
- `BillListComponent` 已支持事件派发机制
- 保持业务逻辑在父组件中,组件只负责展示
### 决策 4: 禁用不需要的功能
**决定**: 通过 props 控制功能开关(如 `:enable-filter="false"``:show-checkbox="false"`)
**理由**:
- 不是所有页面都需要筛选栏和多选功能
- `BillListComponent` 已提供灵活的配置选项
- 保持页面简洁,符合原设计
## Risks / Trade-offs
### 风险 1: 业务逻辑遗漏
**风险**: 迁移过程中可能遗漏某些特定业务逻辑(如特殊的删除回调、数据刷新机制)
**缓解**:
- 迁移前仔细审查每个页面的现有实现
- 保留所有事件监听器(如 `transaction-deleted` 全局事件)
- 每个页面迁移后进行完整功能测试
### 风险 2: UI 细节差异
**风险**: `BillListComponent` 的样式可能与某些页面的原设计有细微差异
**缓解**:
- 使用 Custom 数据模式,保持数据加载逻辑不变
- 如果发现样式问题,优先通过 CSS 覆盖解决
- 必要时可以考虑为 `BillListComponent` 添加样式 prop(但需谨慎)
### 风险 3: 性能影响
**风险**: `BillListComponent` 可能比某些轻量级自定义实现更重
**缓解**:
- `BillListComponent` 已经过优化,支持虚拟滚动和分页
- Custom 模式下父组件完全控制数据加载,不会有额外请求
- 监控迁移后的页面性能指标
### Trade-off: 灵活性 vs 一致性
**取舍**: 采用统一组件会牺牲一定的定制灵活性
**接受理由**: 一致性和可维护性的收益大于灵活性的损失
**风险控制**: Custom 数据模式提供足够的业务逻辑控制空间
## Migration Plan
### 阶段 1: 准备(已完成)
- ✓ 分析所有账单列表页面
- ✓ 确认 `BillListComponent` 功能完整性
- ✓ 制定迁移顺序
### 阶段 2: 逐页迁移
每个页面遵循以下步骤:
1. **备份**: 保留原实现作为注释(如果需要回滚)
2. **替换**: 用 `BillListComponent` 替换自定义列表
3. **适配**: 调整数据格式和事件处理
4. **测试**: 验证所有功能(点击、删除、筛选、分页)
5. **清理**: 移除废弃代码和样式
### 阶段 3: 验收
- 所有 6 个页面迁移完成
- 功能回归测试通过
- 视觉一致性检查通过
- 代码审查通过
### 回滚策略
- 每个页面独立迁移,可以单独回滚
- Git commit 以页面为单位,便于 revert
- 如果某个页面迁移失败,不影响其他页面
## Open Questions
1. **budgetV2/Index.vue 的双列表问题**: 该页面有 "已使用预算列表" 和 "未分类账单列表" 两个列表,是否都需要迁移?
- **初步决定**: 都迁移,使用两个 `BillListComponent` 实例
2. **calendarV2/modules/TransactionList.vue**: 该组件本身是一个列表组件,是否需要完全替换还是部分复用?
- **初步决定**: 完全替换为 `BillListComponent`,因为注释已建议使用统一组件
3. **图标映射**: 某些页面可能使用特定的分类图标,是否需要特殊处理?
- **初步决定**: `BillListComponent` 已支持 Iconify 和 API 加载的图标映射,应该覆盖大部分场景

View File

@@ -0,0 +1,42 @@
## Why
系统中存在多处账单列表实现,各自使用不同的样式和交互方式。经过代码审查,发现只有 `calendarV2/modules/TransactionList.vue` 需要迁移,其他页面要么已使用 `BillListComponent`,要么不是账单列表。统一账单列表 UI 可以提供一致的用户体验和更好的可维护性。
## What Changes
-`calendarV2/modules/TransactionList.vue` 的自定义账单列表替换为 `BillListComponent` 组件
- 保留日历视图的特殊功能:自定义 header (Items 计数) 和 Smart 按钮
- 移除自定义的数据格式转换逻辑和账单卡片渲染代码
- 简化代码从 403 行减少到 177 行
**备注**: 原设计文档中列出的其他 5 个页面经审查后:
- `TransactionsRecord.vue` (balance页面): ✅ 已使用 `BillListComponent`
- `EmailRecord.vue`: ✅ 已使用 `BillListComponent`
- `MessageView.vue`: ❌ 系统消息列表,不是账单
- `PeriodicRecord.vue`: ❌ 周期性账单规则列表,不是交易账单
- `ClassificationEdit.vue`: ❌ 分类管理列表,不是账单
- `budgetV2/Index.vue`: ❌ 预算卡片列表,不是账单
## Capabilities
### New Capabilities
- `bill-list-unified-ui`: 统一的账单列表 UI 规范,定义所有账单列表页面必须遵循的视觉设计和交互模式
### Modified Capabilities
<!-- 无现有能力需要修改 requirements -->
## Impact
**受影响的代码**:
- `Web/src/views/calendarV2/modules/TransactionList.vue` (已完成迁移)
**依赖组件**:
- `Web/src/components/Bill/BillListComponent.vue` (已存在,无需修改)
**API**:
- 无 API 变更,仅前端 UI 统一
**用户体验影响**:
- 正向影响:日历视图的账单列表与 /balance 页面保持一致
- UI 风格变化:从自定义卡片样式改为统一的 BillListComponent 样式
- 功能保留:自定义 header、Items 计数、Smart 按钮均已保留

View File

@@ -0,0 +1,114 @@
## ADDED Requirements
### Requirement: Unified Bill List Component Usage
所有账单列表页面 SHALL 使用 `BillListComponent` 组件来展示账单列表,而不是自定义实现。
#### Scenario: Component Imported and Used
- **WHEN** 开发者查看任意账单列表页面的代码
- **THEN** 页面 SHALL import `BillListComponent` from `@/components/Bill/BillListComponent.vue`
- **AND** 页面 SHALL 在 template 中使用 `<BillListComponent>` 标签
#### Scenario: No Custom List Rendering
- **WHEN** 开发者查看账单列表页面的代码
- **THEN** 页面 SHALL NOT 包含自定义的 `van-swipe-cell` 列表渲染逻辑
- **AND** 页面 SHALL NOT 包含重复的账单卡片样式定义
### Requirement: Custom Data Source Mode
账单列表页面 SHALL 使用 Custom 数据模式,父组件控制数据加载逻辑。
#### Scenario: Data Source Configuration
- **WHEN** 页面使用 `BillListComponent`
- **THEN** 组件 SHALL 配置 `data-source="custom"` prop
- **AND** 父组件 SHALL 通过 `:transactions` prop 传递账单数据数组
#### Scenario: Parent Controlled Loading
- **WHEN** 页面需要加载账单数据
- **THEN** 父组件 SHALL 负责调用 API 获取数据
- **AND** 父组件 SHALL 负责数据筛选和分页逻辑
- **AND** `BillListComponent` SHALL 仅负责展示传入的数据
### Requirement: Business Logic Preservation
迁移后的页面 SHALL 保持原有的业务逻辑完整,包括事件处理、数据刷新和特定筛选。
#### Scenario: Click Event Handling
- **WHEN** 用户点击账单卡片
- **THEN** `BillListComponent` SHALL emit `@click` 事件
- **AND** 父组件 SHALL 处理该事件,执行原有的业务逻辑(如显示详情弹窗)
#### Scenario: Delete Event Handling
- **WHEN** 用户左滑删除账单
- **THEN** `BillListComponent` SHALL emit `@delete` 事件(内部已完成 API 调用)
- **AND** 父组件 SHALL 更新本地数据列表,移除已删除的账单
#### Scenario: Global Event Listening
- **WHEN** 页面监听全局事件(如 `transaction-deleted`)
- **THEN** 迁移后页面 SHALL 保留所有全局事件监听器
- **AND** 事件处理逻辑 SHALL 保持不变
### Requirement: Feature Configuration
页面 SHALL 通过 props 配置 `BillListComponent` 的功能开关,仅启用所需功能。
#### Scenario: Filter Bar Control
- **WHEN** 页面不需要筛选栏功能
- **THEN** 页面 SHALL 设置 `:enable-filter="false"` prop
- **AND** `BillListComponent` SHALL NOT 显示筛选栏 UI
#### Scenario: Checkbox Control
- **WHEN** 页面不需要多选功能
- **THEN** 页面 SHALL 设置 `:show-checkbox="false"` prop
- **AND** `BillListComponent` SHALL NOT 显示复选框
#### Scenario: Delete Button Control
- **WHEN** 页面需要左滑删除功能
- **THEN** 页面 SHALL 设置 `:show-delete="true"` prop
- **AND** `BillListComponent` SHALL 显示左滑删除按钮
### Requirement: Visual Consistency
所有账单列表页面 SHALL 使用一致的视觉设计和交互模式。
#### Scenario: Card Layout Consistency
- **WHEN** 用户查看任意账单列表页面
- **THEN** 所有账单卡片 SHALL 使用相同的布局:左侧图标、中间内容、右侧金额
- **AND** 卡片样式 SHALL 与 `/balance` 页面一致
#### Scenario: Icon Display Consistency
- **WHEN** 账单有分类信息
- **THEN** 系统 SHALL 显示对应的 Iconify 图标或降级为 Vant 图标
- **AND** 图标颜色和背景 SHALL 根据账单类型(支出/收入/不计入)统一着色
#### Scenario: Amount Formatting Consistency
- **WHEN** 系统显示账单金额
- **THEN** 支出 SHALL 显示为红色 `- ¥XX.XX`
- **AND** 收入 SHALL 显示为绿色 `+ ¥XX.XX`
- **AND** 不计入 SHALL 显示为灰色 `¥XX.XX`
### Requirement: Migration Coverage
系统 SHALL 完成所有指定页面的账单列表迁移。
#### Scenario: All Target Pages Migrated
- **WHEN** 迁移完成
- **THEN** 以下 6 个页面 SHALL 全部使用 `BillListComponent`:
- `MessageView.vue`
- `EmailRecord.vue`
- `PeriodicRecord.vue`
- `ClassificationEdit.vue`
- `calendarV2/modules/TransactionList.vue`
- `budgetV2/Index.vue`
#### Scenario: No Functional Regression
- **WHEN** 迁移完成后用户使用任意账单列表页面
- **THEN** 所有原有功能 SHALL 正常工作(点击、删除、筛选、分页)
- **AND** 用户 SHALL NOT 遇到任何功能缺失或异常
### Requirement: Code Quality
迁移后的代码 SHALL 保持高质量,移除废弃代码。
#### Scenario: No Dead Code
- **WHEN** 开发者审查迁移后的页面代码
- **THEN** 页面 SHALL NOT 包含废弃的自定义列表代码
- **AND** 页面 SHALL NOT 包含未使用的列表样式定义
#### Scenario: Component Import Clarity
- **WHEN** 开发者查看页面导入语句
- **THEN** 页面 SHALL 明确 import `BillListComponent`
- **AND** 页面 SHALL 移除原有的自定义列表组件导入(如果有)

View File

@@ -0,0 +1,123 @@
## 1. 准备和验证
- [x] 1.1 审查 `BillListComponent` 组件,确认支持 Custom 数据模式和所有必需的 props
- [x] 1.2 审查目标页面 - calendarV2/TransactionList.vue (其他页面不是账单列表)
- [x] 1.3 确认 calendarV2/TransactionList.vue 使用的账单数据 API 和数据格式
- [x] 1.4 设计迁移方案(保留自定义header和Smart按钮,接受UI风格变化)
## 2. 迁移 calendarV2/modules/TransactionList.vue
- [x] 2.1 导入 `BillListComponent`
- [x] 2.2 替换自定义列表为 `<BillListComponent>`
- [x] 2.3 配置 `data-source="custom"``:transactions` prop
- [x] 2.4 配置功能开关 props (`:enable-filter="false"`, `:show-delete="false"`)
- [x] 2.5 实现 `@click` 事件处理器,保持原有点击逻辑
- [x] 2.6 移除数据格式转换逻辑(formatTime, formatAmount, getIconByClassify等)
- [x] 2.7 移除废弃的自定义列表代码和样式
- [x] 2.8 运行 lint 检查
- [x] 2.9 提交代码: "refactor(calendar-v2): migrate TransactionList to BillListComponent"
## 3. 代码验证和文档更新
- [ ] 3.1 手动测试日历视图功能流程
- [ ] 3.2 验证视觉效果:对比迁移前后的账单列表样式
- [ ] 3.3 验证交互:确认点击账单、Smart按钮功能正常
- [x] 3.4 更新 `openspec/changes/unify-bill-list-ui/` 文档,记录实际迁移范围
- [x] 3.5 在 `.doc/` 目录下创建迁移记录文档
- [x] 3.6 文档提交: "docs: update unify-bill-list-ui change scope"
## 2. 迁移 MessageView.vue
- [ ] 2.1 在 `MessageView.vue` 中导入 `BillListComponent`
- [ ] 2.2 替换自定义 `van-swipe-cell` 列表为 `<BillListComponent>`
- [ ] 2.3 配置 `data-source="custom"``:transactions` prop
- [ ] 2.4 配置功能开关 props (`:enable-filter="false"`, `:show-delete` 等)
- [ ] 2.5 实现 `@click` 事件处理器,保持原有点击逻辑
- [ ] 2.6 实现 `@delete` 事件处理器,更新本地数据列表
- [ ] 2.7 移除废弃的自定义列表代码和样式
- [ ] 2.8 测试所有功能:点击、删除、数据刷新
- [ ] 2.9 提交代码: "refactor(message): migrate to BillListComponent"
## 3. 迁移 EmailRecord.vue
- [ ] 3.1 在 `EmailRecord.vue` 中导入 `BillListComponent`
- [ ] 3.2 替换关联账单列表的自定义 `van-swipe-cell``<BillListComponent>`
- [ ] 3.3 配置 `data-source="custom"``:transactions` prop
- [ ] 3.4 配置功能开关 props
- [ ] 3.5 实现 `@click` 事件处理器(关联账单详情弹窗逻辑)
- [ ] 3.6 实现 `@delete` 事件处理器
- [ ] 3.7 保留全局事件监听器 (`transaction-deleted`)
- [ ] 3.8 移除废弃的自定义列表代码和样式
- [ ] 3.9 测试:查看关联账单列表、点击账单、删除账单
- [ ] 3.10 提交代码: "refactor(email): migrate bill list to BillListComponent"
## 4. 迁移 PeriodicRecord.vue
- [ ] 4.1 在 `PeriodicRecord.vue` 中导入 `BillListComponent`
- [ ] 4.2 替换周期性账单列表的自定义 `van-swipe-cell``<BillListComponent>`
- [ ] 4.3 配置 `data-source="custom"``:transactions` prop
- [ ] 4.4 适配周期性账单的数据格式(如需要转换为标准 transaction 格式)
- [ ] 4.5 配置功能开关 props
- [ ] 4.6 实现 `@click` 事件处理器(编辑周期性账单逻辑)
- [ ] 4.7 实现 `@delete` 事件处理器(删除周期性账单的特定逻辑)
- [ ] 4.8 移除废弃的自定义列表代码和样式
- [ ] 4.9 测试:查看周期性账单、编辑、删除、暂停/恢复
- [ ] 4.10 提交代码: "refactor(periodic): migrate to BillListComponent"
## 5. 迁移 ClassificationEdit.vue
- [ ] 5.1 在 `ClassificationEdit.vue` 中导入 `BillListComponent`
- [ ] 5.2 替换分类编辑页面的自定义 `van-swipe-cell` 列表为 `<BillListComponent>`
- [ ] 5.3 配置 `data-source="custom"``:transactions` prop
- [ ] 5.4 配置功能开关 props (可能需要禁用筛选栏)
- [ ] 5.5 实现 `@click` 事件处理器(选择账单进行分类编辑)
- [ ] 5.6 实现 `@delete` 事件处理器
- [ ] 5.7 保留分类筛选逻辑(如果有)
- [ ] 5.8 移除废弃的自定义列表代码和样式
- [ ] 5.9 测试:选择账单、修改分类、删除账单
- [ ] 5.10 提交代码: "refactor(classification-edit): migrate to BillListComponent"
## 6. 迁移 calendarV2/modules/TransactionList.vue
- [ ] 6.1 在 `calendarV2/modules/TransactionList.vue` 中导入 `BillListComponent`
- [ ] 6.2 完全替换现有的 TransactionList 组件实现为 `<BillListComponent>`
- [ ] 6.3 配置 `data-source="custom"``:transactions` prop
- [ ] 6.4 配置功能开关 props (根据日历视图需求)
- [ ] 6.5 实现 `@click` 事件处理器(日历视图的账单详情逻辑)
- [ ] 6.6 实现 `@delete` 事件处理器
- [ ] 6.7 确保与日历组件的数据传递正常
- [ ] 6.8 移除废弃的自定义列表代码和样式
- [ ] 6.9 测试:日历选择日期、查看账单列表、点击账单、删除账单
- [ ] 6.10 提交代码: "refactor(calendar-v2): migrate TransactionList to BillListComponent"
## 7. 迁移 budgetV2/Index.vue
- [ ] 7.1 在 `budgetV2/Index.vue` 中导入 `BillListComponent`
- [ ] 7.2 识别页面中的两个账单列表:"已使用预算列表" 和 "未分类账单列表"
- [ ] 7.3 替换 "已使用预算列表" 的自定义 `van-swipe-cell` 为第一个 `<BillListComponent>` 实例
- [ ] 7.4 配置第一个组件: `data-source="custom"`, `:transactions`, `:enable-filter="false"`
- [ ] 7.5 替换 "未分类账单列表" 的自定义 `van-swipe-cell` 为第二个 `<BillListComponent>` 实例
- [ ] 7.6 配置第二个组件: `data-source="custom"`, `:transactions`, `:enable-filter="false"`
- [ ] 7.7 实现两个列表的 `@click` 事件处理器
- [ ] 7.8 实现两个列表的 `@delete` 事件处理器,更新对应的本地数据
- [ ] 7.9 保留预算统计逻辑和全局事件监听器
- [ ] 7.10 移除废弃的自定义列表代码和样式
- [ ] 7.11 测试:预算统计、已使用列表、未分类列表、点击、删除
- [ ] 7.12 提交代码: "refactor(budget-v2): migrate to BillListComponent"
## 8. 代码清理和验证
- [ ] 8.1 审查所有迁移页面,确认没有遗留的自定义列表代码
- [ ] 8.2 审查所有迁移页面,确认没有未使用的列表样式定义
- [ ] 8.3 运行前端 lint: `cd Web && pnpm lint`
- [ ] 8.4 运行前端构建: `cd Web && pnpm build`
- [ ] 8.5 修复任何 lint 错误或构建警告
## 9. 集成测试和文档
- [ ] 9.1 手动测试所有 6 个迁移页面的完整功能流程
- [ ] 9.2 验证视觉一致性:对比每个页面与 `/balance` 页面的账单列表样式
- [ ] 9.3 验证交互一致性:确认点击、左滑删除、分页行为一致
- [ ] 9.4 更新 `Web/src/views/AGENTS.md` (如需要),记录统一使用 BillListComponent
- [ ] 9.5 在 `.doc/` 目录下创建迁移记录文档 (可选)
- [ ] 9.6 最终提交: "refactor: unify all bill lists to BillListComponent"

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,117 @@
## 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 统一管理预算数据
### [权衡] 按周期和类型匹配可能不够健壮
**接受理由:**
- 这是后端设计的数据模型,前端保持一致
- 后端保证同一周期同一类型只有一条预算记录
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)

View File

@@ -0,0 +1,33 @@
## Why
存款明细弹窗显示为空白内容,因为 `SavingsBudgetContent.vue` 组件中引用了未定义的计算属性 `incomeLimit``incomeCurrent``expenseLimit``expenseCurrent`,导致用户无法查看存款计划的详细构成。这影响了用户理解存款目标的计算逻辑和追踪存款进度的能力。
## What Changes
- 修复 `SavingsBudgetContent.vue` 组件中缺失的计算属性
- 添加从父组件获取收入和支出预算数据的逻辑
- 确保存款明细弹窗正确显示收入预算、支出预算、计划存款公式和存款结果
## Capabilities
### New Capabilities
无新增能力。
### Modified Capabilities
- `savings-budget-display`: 修复存款明细弹窗内容显示功能,确保收入预算和支出预算数据正确传递和渲染
## Impact
**受影响文件:**
- `Web/src/views/budgetV2/modules/SavingsBudgetContent.vue` - 添加缺失的计算属性
- `Web/src/views/budgetV2/Index.vue` - 可能需要传递额外的收入/支出预算数据给子组件
**受影响功能:**
- 存款计划明细查看功能
- 用户对存款目标计算逻辑的理解
**依赖:**
- Vue 3 computed API
- 组件间数据传递(props 或 provide/inject)

View File

@@ -0,0 +1,45 @@
## MODIFIED Requirements
### Requirement: Display income and expense budget in savings detail popup
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
#### Scenario: User opens savings detail popup with matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there exist income and expense budgets for the same period and type
- **THEN** the popup SHALL display the income budget limit and current amount
- **AND** the popup SHALL display the expense budget limit and current amount
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
#### Scenario: User opens savings detail popup without matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there are no income or expense budgets for the same period and type
- **THEN** the popup SHALL display 0 for income budget limit and current amount
- **AND** the popup SHALL display 0 for expense budget limit and current amount
- **AND** the popup SHALL still display the savings formula and result with these values
### Requirement: Pass budget data to savings component
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
#### Scenario: Budget data is loaded successfully
- **WHEN** the budget data is loaded from the API
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
### Requirement: Match income and expense budgets to savings plan
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
#### Scenario: Match budgets with same period and type
- **WHEN** displaying savings plan details
- **AND** the component searches for matching budgets
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
- **AND** if multiple matches exist, the component SHALL use the first match
#### Scenario: No matching budgets found
- **WHEN** displaying savings plan details
- **AND** no income budget matches the savings plan's periodStart and type
- **OR** no expense budget matches the savings plan's periodStart and type
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
- **AND** the popup SHALL still render without errors

View File

@@ -0,0 +1,22 @@
## 1. 修改父组件传递数据
- [x] 1.1 在 Index.vue 中修改 SavingsBudgetContent 组件调用,添加 income-budgets 和 expense-budgets props
- [x] 1.2 验证数据传递正确(通过 Vue DevTools 检查 props)
## 2. 修改 SavingsBudgetContent 组件
- [x] 2.1 在 props 定义中添加 incomeBudgets 和 expenseBudgets 数组
- [x] 2.2 添加 matchedIncomeBudget 计算属性(根据 periodStart 和 type 匹配)
- [x] 2.3 添加 matchedExpenseBudget 计算属性(根据 periodStart 和 type 匹配)
- [x] 2.4 添加 incomeLimit 计算属性(从 matchedIncomeBudget 获取或默认 0)
- [x] 2.5 添加 incomeCurrent 计算属性(从 matchedIncomeBudget 获取或默认 0)
- [x] 2.6 添加 expenseLimit 计算属性(从 matchedExpenseBudget 获取或默认 0)
- [x] 2.7 添加 expenseCurrent 计算属性(从 matchedExpenseBudget 获取或默认 0)
## 3. 测试验证
- [ ] 3.1 测试有对应收入和支出预算的存款计划,打开明细弹窗验证数据显示正确
- [ ] 3.2 测试没有对应收入或支出预算的存款计划,验证弹窗显示 0 且不报错
- [ ] 3.3 验证计划存款公式计算正确(收入预算 - 支出预算 = 计划存款)
- [ ] 3.4 测试月度和年度两种类型的存款计划明细
- [ ] 3.5 使用不同月份的存款计划测试,验证匹配逻辑正确

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,107 @@
## Context
项目中已有 `PopupContainer.vue` 通用弹窗组件(位于 `Web/src/components/PopupContainer.vue`),但其样式设计与 `TransactionDetailSheet.vue` 不同:
- PopupContainer: 使用 Vant 主题变量,默认高度 80%,标准化的布局
- TransactionDetailSheet: 使用 Inter 字体16px 圆角,纯白背景(#ffffff / #18181b),更现代化的视觉风格
`TransactionDetailSheet.vue` 当前实现(位于 `Web/src/components/Transaction/TransactionDetailSheet.vue`
- 直接使用 `van-popup` + 自定义样式
- 头部:自定义标题 + 关闭按钮(`.sheet-header`
- 内容区域:金额、表单字段、分类选择器(无固定滚动容器)
- 底部操作按钮:删除、保存(`.actions-section`
- 样式特点:`borderTopLeftRadius: 16px`, Inter 字体, `#ffffff` 背景, `gap: 24px`
当前问题:头部和底部未固定,内容较多时滚动体验不佳。
## Goals / Non-Goals
**Goals:**
- 创建 `PopupContainerV2.vue` 通用组件,采用 TransactionDetailSheet 的样式风格
- 提供固定头部(标题 + 可选关闭按钮)、可滚动内容、固定底部的布局能力
- 支持暗色模式(`#18181b` 背景)
- 将 TransactionDetailSheet 重构为使用 PopupContainerV2
- 保持 TransactionDetailSheet 所有现有功能和对外 API 不变
**Non-Goals:**
- 不修改现有的 `PopupContainer.vue`v1 版本保持不变)
- 不强制项目中其他组件迁移到 v2自愿迁移
- 不改变 TransactionDetailSheet 的业务逻辑
- 不引入新的 UI 库或依赖
## Decisions
### 决策 1创建新组件 PopupContainerV2 而不是修改 PopupContainer
**选择**: 创建新的 `PopupContainerV2.vue` 组件
**理由**:
- PopupContainer 已在项目中广泛使用CategoryBillPopup 等),修改可能影响现有组件
- TransactionDetailSheet 的样式风格更现代,适合作为新版本
- v1 和 v2 可以并存,逐步迁移,降低风险
- **替代方案**: 直接修改 PopupContainer → 不采用,破坏性太大,需要验证所有使用方
### 决策 2PopupContainerV2 的样式来源
**选择**: 完全采用 TransactionDetailSheet 的样式风格
**理由**:
- TransactionDetailSheet 的样式已经过验证,用户体验良好
- Inter 字体、16px 圆角、纯白背景是现代化设计趋势
- 保持样式一致性,避免混合不同的设计语言
- **替代方案**: 混合 PopupContainer 和 TransactionDetailSheet 的样式 → 不采用,会导致样式不统一
### 决策 3PopupContainerV2 的 API 设计
**选择**: 提供 title prop、default 插槽、footer 插槽,关闭按钮默认显示
**理由**:
- title prop 简化使用,覆盖 80% 场景
- default 插槽放可滚动内容,最大灵活性
- footer 插槽放固定底部操作按钮
- 关闭按钮默认显示,符合 TransactionDetailSheet 的行为
- **替代方案**: 提供 header 插槽替代 title prop → 不采用,增加使用复杂度,大多数场景只需简单标题
### 决策 4内容区域的 padding 处理
**选择**: PopupContainerV2 的内容插槽不提供默认 padding由使用方控制
**理由**:
- TransactionDetailSheet 的不同区域有不同的 padding 需求(金额区域 16px 16px 24px表单区域 0 16px 16px
- 组件不应预设 padding保持灵活性
- 使用方可以根据内容自行调整间距
- **替代方案**: 提供统一的 padding → 不采用,会限制布局灵活性
### 决策 5TransactionDetailSheet 的迁移策略
**选择**: 完全移除原有的头部和外层布局代码,使用 PopupContainerV2 的插槽
**理由**:
- 避免代码重复,减少维护成本
- PopupContainerV2 已提供所有需要的布局能力
- 保持 TransactionDetailSheet 的职责单一(业务逻辑)
- **替代方案**: 保留部分原有代码 → 不采用,会造成样式冲突和维护混乱
## Risks / Trade-offs
### 风险 1新增 PopupContainerV2 组件增加项目复杂度
**缓解措施**:
- 在组件文件顶部添加清晰的文档注释,说明 v1 和 v2 的区别
- v2 组件设计简洁API 清晰,易于理解和使用
- 在使用 TransactionDetailSheet 时验证 v2 的稳定性后,再考虑推广到其他组件
### 风险 2样式迁移可能遗漏细节
**缓解措施**:
- 仔细对比 TransactionDetailSheet 的原有样式
- 使用 Chrome DevTools 对比重构前后的渲染效果
- 验证暗色模式的样式一致性
### 风险 3日期选择器van-datetime-picker的嵌套弹窗可能存在 z-index 冲突
**缓解措施**: van-datetime-picker 使用 `teleport="body"`,应与 PopupContainerV2 的弹窗层级独立,测试时重点验证
### Trade-off 1创建 v2 而不是统一到一个组件
**影响**: 项目中会同时存在两个弹窗组件,增加学习成本
**权衡**: 保护现有代码稳定性的收益大于维护两个组件的成本,且 v2 可以逐步替代 v1
### Trade-off 2PopupContainerV2 不提供默认 padding
**影响**: 使用方需要自行管理内容区域的间距
**权衡**: 灵活性优于便利性,避免样式冲突

View File

@@ -0,0 +1,34 @@
## Why
`TransactionDetailSheet.vue` 组件有良好的样式设计圆角、Inter 字体、清晰的布局层次),但头部标题和底部操作按钮没有固定定位,当内容较多时会随着滚动而移动,影响用户体验。同时,项目中其他弹窗可能也需要类似的固定头部/底部布局。因此需要将 TransactionDetailSheet 的样式和布局模式抽取为通用的 PopupContainer v2 组件,提供固定头部和底部的能力,方便在项目中复用。
## What Changes
- 创建新的 `PopupContainerV2.vue` 通用弹窗组件
- 使用 TransactionDetailSheet 的样式风格(`borderTopLeftRadius: 16px`, Inter 字体, `#ffffff` / `#18181b` 背景)
- 提供固定头部区域(标题 + 可选关闭按钮)
- 提供可滚动内容区域default 插槽)
- 提供固定底部区域footer 插槽)
- 支持暗色模式
- 重构 `TransactionDetailSheet.vue` 使用新的 PopupContainerV2 组件
- 移除原有的头部和布局代码
- 将内容和操作按钮迁移到对应插槽
- 保持所有业务逻辑和功能不变
## Capabilities
### New Capabilities
- `popup-container-v2`: 新的通用弹窗组件,提供固定头部/底部布局,采用 TransactionDetailSheet 的样式风格
### Modified Capabilities
- `transaction-detail-ui`: TransactionDetailSheet 的 UI 布局从直接使用 van-popup 改为使用 PopupContainerV2头部和底部固定内容区域可滚动
## Impact
- **新增文件**: `Web/src/components/PopupContainerV2.vue`
- **受影响文件**: `Web/src/components/Transaction/TransactionDetailSheet.vue`
- **用户影响**: 改善交易详情弹窗的交互体验,头部和操作按钮始终可见
- **可复用性**: 新组件可用于项目中其他需要固定头部/底部的弹窗场景
- **兼容性**: TransactionDetailSheet 对外 APIprops、events保持不变不影响调用方

View File

@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: PopupContainerV2 提供固定布局能力
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格16px 圆角、Inter 字体、纯白背景)。
#### Scenario: PopupContainerV2 基础结构
- **WHEN** 使用 PopupContainerV2 组件
- **THEN** 组件基于 van-popup 实现底部弹窗
- **THEN** 弹窗顶部圆角为 16pxborderTopLeftRadius 和 borderTopRightRadius
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
#### Scenario: PopupContainerV2 固定头部
- **WHEN** 传入 title prop
- **THEN** 头部显示标题文本,字体为 Inter18pxfont-weight 600
- **THEN** 头部右侧显示关闭按钮van-icon cross
- **THEN** 头部固定在顶部,不随内容滚动
#### Scenario: PopupContainerV2 可滚动内容区域
- **WHEN** 在 default 插槽中放置内容
- **THEN** 内容区域可独立滚动
- **THEN** 滚动时头部和底部保持固定
- **THEN** 内容区域不提供默认 padding由使用方控制
#### Scenario: PopupContainerV2 固定底部
- **WHEN** 在 footer 插槽中放置操作按钮
- **THEN** 底部区域固定在底部,不随内容滚动
- **THEN** 底部区域保持与内容区域的视觉分隔
#### Scenario: PopupContainerV2 暗色模式支持
- **WHEN** 系统启用暗色模式prefers-color-scheme: dark
- **THEN** 背景色切换为 #18181b
- **THEN** 标题文本颜色切换为 #fafafa
- **THEN** 关闭按钮颜色切换为 #a1a1aa
## MODIFIED Requirements
### Requirement: TransactionDetailSheet 使用 PopupContainerV2 实现固定布局
TransactionDetailSheet 组件 SHALL 使用 PopupContainerV2 组件实现底部弹窗,并确保头部标题和底部操作按钮固定不随内容滚动。
#### Scenario: 打开交易详情弹窗
- **WHEN** 用户点击交易记录打开详情弹窗
- **THEN** 弹窗从底部弹出,使用 PopupContainerV2 组件
- **THEN** 弹窗顶部圆角为 16px
- **THEN** 头部显示固定标题"交易详情",字体为 Inter 18px
- **THEN** 底部固定显示"删除"和"保存"按钮
- **THEN** 中间内容区域(金额、时间、备注、类型、分类)可独立滚动
#### Scenario: 滚动交易详情内容
- **WHEN** 用户在交易详情弹窗中滚动内容
- **THEN** 头部标题始终固定在顶部可见
- **THEN** 底部操作按钮(删除、保存)始终固定在底部可见
- **THEN** 只有中间内容区域发生滚动
#### Scenario: 展开分类选择器后滚动
- **WHEN** 用户点击"分类"字段展开分类选择器
- **THEN** 分类选择器在内容区域内展开
- **THEN** 如果内容超出可视区域,用户可以滚动查看所有分类选项
- **THEN** 头部标题和底部按钮仍然保持固定
### Requirement: 保持现有功能和交互不变
TransactionDetailSheet 组件在重构后 SHALL 保持所有现有的功能和交互逻辑不变,包括但不限于金额编辑、日期选择、分类选择、保存、删除等操作。
#### Scenario: 金额编辑功能保持不变
- **WHEN** 用户点击金额数值进入编辑模式
- **THEN** 金额输入框自动聚焦并选中当前值
- **THEN** 用户输入新金额后失焦时自动格式化为两位小数
#### Scenario: 日期时间选择功能保持不变
- **WHEN** 用户点击"时间"字段
- **THEN** 弹出 van-datetime-picker 日期时间选择器
- **THEN** 选择器显示在最顶层(不受 PopupContainerV2 层级影响)
- **THEN** 确认后时间更新为选择的值
#### Scenario: 分类选择功能保持不变
- **WHEN** 用户点击"分类"字段
- **THEN** 展开 ClassifySelector 组件
- **THEN** 用户可以选择分类
- **THEN** 选择后自动触发保存
#### Scenario: 保存和删除功能保持不变
- **WHEN** 用户点击"保存"按钮
- **THEN** 验证必填字段(金额、分类、时间)
- **THEN** 调用 updateTransaction API 更新数据
- **THEN** 成功后显示提示并关闭弹窗
- **WHEN** 用户点击"删除"按钮
- **THEN** 显示确认对话框
- **THEN** 确认后调用 deleteTransaction API 删除数据
- **THEN** 成功后显示提示并关闭弹窗
### Requirement: 组件对外 API 兼容
TransactionDetailSheet 组件的 props 和 events SHALL 保持与重构前完全一致,确保零破坏性变更。
#### Scenario: Props 保持不变
- **WHEN** 父组件传递 `show``transaction` props
- **THEN** TransactionDetailSheet 正常接收并响应这些 props
- **THEN** 不需要修改任何调用方代码
#### Scenario: Events 保持不变
- **WHEN** 用户保存或删除交易
- **THEN** TransactionDetailSheet 触发 `update:show`, `save`, `delete` 事件
- **THEN** 事件参数格式与重构前完全一致

View File

@@ -0,0 +1,60 @@
## 1. 创建 PopupContainerV2.vue 组件
- [x] 1.1 创建文件 `Web/src/components/PopupContainerV2.vue`
- [x] 1.2 实现基础结构(基于 van-popup16px 圆角,#ffffff / #18181b 背景)
- [x] 1.3 实现固定头部区域(标题 + 关闭按钮Inter 18px font-weight 600
- [x] 1.4 实现可滚动内容区域default 插槽flex: 1, overflow-y: auto
- [x] 1.5 实现固定底部区域footer 插槽border-top 分隔线)
- [x] 1.6 实现暗色模式支持(@media prefers-color-scheme: dark
- [x] 1.7 添加组件文档注释(说明 API、使用示例、与 v1 的区别)
## 2. 重构 TransactionDetailSheet.vue 使用 PopupContainerV2
- [x] 2.1 引入 PopupContainerV2 组件
- [x] 2.2 将 van-popup 替换为 PopupContainerV2
- [x] 2.3 移除自定义 `.sheet-header` 头部区域(使用 PopupContainerV2 的 title prop
- [x] 2.4 将金额区域、表单区域、分类选择器保留在 default 插槽中
- [x] 2.5 将操作按钮区域(删除、保存)移入 PopupContainerV2 的 `#footer` 插槽
- [x] 2.6 移除 handleClose 方法PopupContainerV2 自动处理)
## 3. 调整 TransactionDetailSheet 样式
- [x] 3.1 移除 `.transaction-detail-sheet` 外层容器样式
- [x] 3.2 调整各内容区域的 padding金额区域、表单区域、分类选择器
- [x] 3.3 移除 `.sheet-header` 相关样式
- [x] 3.4 调整暗色模式样式(移除已由 PopupContainerV2 处理的部分)
## 4. 功能验证
- [x] 4.1 测试弹窗打开/关闭功能
- [x] 4.2 测试金额编辑功能(点击进入编辑模式、输入、失焦格式化)
- [x] 4.3 测试日期时间选择功能(弹出选择器、确认更新)
- [x] 4.4 测试类型切换功能(支出/收入/不计)
- [x] 4.5 测试分类选择功能(展开选择器、选择分类、自动保存)
- [x] 4.6 测试备注输入功能
- [x] 4.7 测试保存功能验证、API 调用、成功提示)
- [x] 4.8 测试删除功能确认对话框、API 调用、成功提示)
## 5. 布局验证
- [x] 5.1 验证头部标题固定不滚动
- [x] 5.2 验证底部操作按钮固定不滚动
- [x] 5.3 验证内容区域可独立滚动
- [x] 5.4 验证分类选择器展开后的滚动体验
- [x] 5.5 验证不同内容长度下的布局表现
- [x] 5.6 验证弹窗圆角为 16px
- [x] 5.7 验证标题字体为 Inter 18px font-weight 600
## 6. 兼容性验证
- [x] 6.1 验证 Props 接口不变show, transaction
- [x] 6.2 验证 Events 接口不变update:show, save, delete
- [x] 6.3 验证调用方无需修改代码
- [x] 6.4 测试日期选择器弹窗的 z-index 层级(确保在 PopupContainerV2 之上)
- [x] 6.5 验证暗色模式下的样式表现
## 7. 清理和文档
- [x] 7.1 移除不再使用的代码和注释
- [x] 7.2 确保代码符合项目 ESLint 规范(`pnpm lint`
- [x] 7.3 在本地运行前端项目验证无报错(`pnpm build`

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,216 @@
## Context
当前项目中存在两个弹窗组件:
- **PopupContainer.vue (V1)**: 使用 Vant 主题变量,支持 subtitle、header-actions 插槽、确认/取消按钮等丰富功能,默认高度 80%,被 18 个组件使用
- **PopupContainerV2.vue (V2)**: 采用 Inter 字体和现代化设计风格纯色背景、16px 圆角API 更简洁(只提供 title、footer 插槽),默认高度 auto最大 85%),已被 TransactionDetailSheet 使用
V1 和 V2 的 API 差异较大V1 提供了更多开箱即用的功能如内置按钮、subtitle而 V2 追求灵活性和视觉一致性。此次迁移需要在保持功能不变的前提下,统一使用 V2 的现代化设计。
**约束条件**:
- 不能改变现有页面的交互逻辑和用户体验
- 需要保持暗色模式的正确支持
- 必须通过 ESLint 和现有的代码规范
## Goals / Non-Goals
**Goals:**
- 统一弹窗组件为 PopupContainerV2删除 V1 版本
- 迁移 18 个使用 V1 的组件,保持功能等价性
- 适配 API 差异props → 插槽、样式调整)
- 确保迁移后视觉效果和交互逻辑一致
**Non-Goals:**
- 不重新设计弹窗的交互流程或视觉风格(完全按照 V2 现有设计)
- 不优化或重构业务逻辑(仅做组件替换和 API 适配)
- 不处理与弹窗无关的代码问题
## Decisions
### Decision 1: 迁移策略 - 逐文件手动迁移 vs 自动化脚本
**选择**: 手动逐文件迁移
**理由**:
- V1 和 V2 的 API 差异大props → 插槽),无法通过简单的查找替换完成
- 每个组件对 subtitle、header-actions、确认/取消按钮的使用方式不同,需要根据业务语义定制迁移方案
- 自动化脚本的开发成本高于手动迁移 18 个文件的时间成本
- 手动迁移可以确保每个文件的视觉和逻辑正确性
**备选方案**:
- AST 转换工具(如 jscodeshift复杂度高难以处理插槽和样式的语义转换
### Decision 2: subtitle 功能的迁移方式
**选择**: 根据业务语义分类处理
- **统计信息类 subtitle**(如 "共 10 笔交易")→ 移至默认插槽顶部,使用自定义样式组件
- **纯文本副标题** → 移至默认插槽,或合并到 title 中(如 "分类详情 - 餐饮"
**理由**:
- V2 没有 subtitle prop必须通过插槽实现
- 统计信息通常有特定的业务含义,应作为内容区域的一部分而非标题的附属
- 纯文本副标题可以简化为一级标题的扩展
**备选方案**:
- 扩展 V2 组件增加 subtitle prop违背 V2 简化 API 的设计原则,不采纳
### Decision 3: 确认/取消按钮的迁移方式
**选择**: 转换为 footer 插槽 + 手动事件绑定
**实现模式**:
```vue
<!-- V1 -->
<PopupContainer
show-confirm-button
show-cancel-button
confirm-text="确定"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
<!-- V2 -->
<PopupContainerV2>
<template #footer>
<div class="footer-buttons">
<van-button plain @click="handleCancel">取消</van-button>
<van-button type="primary" @click="handleConfirm">确定</van-button>
</div>
</template>
</PopupContainerV2>
<style scoped>
.footer-buttons {
display: flex;
gap: 12px;
}
.footer-buttons .van-button {
flex: 1;
}
</style>
```
**理由**:
- V2 的 footer 插槽提供了足够的灵活性
- Vant Button 的样式与 V1 内置按钮一致,迁移成本低
- 手动绑定事件可以保持原有的业务逻辑不变
### Decision 4: header-actions 插槽的处理
**选择**: 移至默认插槽顶部或改用自定义布局
**理由**:
- V2 没有 header-actions 插槽,标题区域只有标题文本和关闭按钮
- 根据现有代码(如 BudgetCard.vue、SavingsBudgetContent.vueheader-actions 通常是"编辑"、"删除"等操作按钮
- 这些按钮更适合放在内容区域顶部或 footer 中,符合 V2 的极简标题设计
**实现模式**:
```vue
<!-- V2 -->
<PopupContainerV2>
<div class="content-with-actions">
<div class="action-bar">
<van-button size="small" @click="handleEdit">编辑</van-button>
</div>
<!-- 原内容区域 -->
</div>
</PopupContainerV2>
```
### Decision 5: 高度属性的处理
**选择**: 显式指定 `:height="'80%'"` 保持视觉一致性
**理由**:
- V1 默认 `height="80%"`V2 默认 `height="auto"`(最大 85%
- 直接使用 V2 的 auto 可能导致内容过少时弹窗过小,影响用户体验
- 显式设置 80% 可以确保迁移前后视觉效果一致
- 如果某些组件的内容确实很少,可以在迁移时根据实际情况调整为 auto
### Decision 6: 样式和暗色模式的适配
**选择**: 信任 V2 的内置暗色模式支持,不额外修改
**理由**:
- V2 已在组件内部通过 `@media (prefers-color-scheme: dark)` 实现暗色模式
- V1 使用 Vant 的 CSS 变量V2 使用硬编码颜色,但两者在暗色模式下都能正确切换
- 业务组件只需确保内容区域的样式兼容暗色模式即可
**风险**: 如果业务组件的内容区域使用了与 V2 不兼容的颜色,需要单独调整(通过人工检查)
## Risks / Trade-offs
### Risk 1: 迁移后视觉效果差异
**风险**: V1 和 V2 的字体、颜色、圆角不同,可能导致用户感知到不一致
**缓解措施**:
- 在开发环境逐个测试迁移后的页面
- 重点检查弹窗的标题、内容、按钮的对齐和间距
- 如果某个页面的差异过大,考虑调整 V2 的样式或在该页面单独处理
### Risk 2: subtitle 和 header-actions 迁移语义变化
**风险**: 将 subtitle 移至内容区域可能改变信息层级header-actions 移至内容顶部可能影响交互流畅性
**缓解措施**:
- 迁移时保持原有的语义和视觉层级(如 subtitle 仍然显示在顶部且样式相似)
- 通过 CSS 模拟 V1 的 Grid 布局,确保按钮位置不变
### Risk 3: 高度变化导致滚动问题
**风险**: V1 的 80% 固定高度和 V2 的 auto 可能导致滚动行为不同(如内容过多时 V2 可能超出最大高度)
**缓解措施**:
- 统一使用 `:height="'80%'"` 作为默认值
- 对于内容特别少的弹窗(如确认对话框),可以单独设置为 auto
### Risk 4: 事件处理器遗漏
**风险**: 手动迁移确认/取消按钮时,可能遗漏原有的 `@confirm``@cancel` 事件逻辑
**缓解措施**:
- 迁移前通过搜索确认每个组件是否使用了这些事件
- 迁移后通过功能测试验证按钮点击是否正确触发
### Risk 5: ESLint 和代码规范问题
**风险**: 手动创建的 footer 插槽可能不符合项目的 ESLint 规则(如缩进、引号)
**缓解措施**:
- 迁移完成后运行 `pnpm lint` 并修复所有错误
- 参考现有 V2 的使用示例TransactionDetailSheet.vue保持风格一致
## Migration Plan
### Phase 1: 准备阶段
1. 审查 18 个待迁移文件,分析每个文件使用的 V1 功能subtitle、buttons、header-actions
2. 为每个文件制定迁移清单(需要修改的部分)
### Phase 2: 迁移阶段
逐文件迁移,按以下步骤:
1. 更新 import 路径和组件名
2. 替换基础 props保留 v-model:show、title显式设置 height
3. 迁移 subtitle根据语义选择方案
4. 迁移 header-actions移至内容区域或 footer
5. 迁移确认/取消按钮(创建 footer 插槽)
6. 调整内容区域的 paddingV2 无默认 padding
7. 测试功能和视觉效果
### Phase 3: 验证阶段
1. 运行 `pnpm lint` 确保代码规范
2. 手动测试每个迁移的页面,验证弹窗的打开/关闭、内容展示、按钮交互
3. 检查暗色模式下的显示效果
### Phase 4: 清理阶段
1. 确认所有迁移完成且测试通过
2. 删除 `Web/src/components/PopupContainer.vue`
3. 全局搜索 `PopupContainer` 确认无残留引用
### Rollback 策略
- 如果迁移后发现重大问题(如性能下降、功能缺失),可以通过 Git 回滚到迁移前的版本
- V1 和 V2 是独立文件,迁移失败不会影响现有功能(除非删除了 V1
- 建议在完成所有迁移并验证通过后再删除 V1 文件
## Open Questions
1. **是否需要对 V2 组件进行增强?**
- 例如增加 subtitle prop 简化迁移
- **暂定答案**: 不修改 V2保持其简洁性通过插槽实现所有功能
2. **是否需要统一 footer 按钮的样式?**
- 目前每个文件需要手动创建 `.footer-buttons` 样式
- **暂定答案**: 可以提取为全局样式或在 V2 中提供默认样式(后续优化项)
3. **是否需要通知用户 UI 风格变化?**
- V1 到 V2 的视觉差异可能被用户感知
- **暂定答案**: 作为内部优化,不需要用户通知

View File

@@ -0,0 +1,45 @@
## Why
项目中存在两个版本的弹窗组件(`PopupContainer.vue``PopupContainerV2.vue`造成代码冗余和维护成本增加。V2 版本采用更现代化的视觉风格Inter 字体、16px 圆角、纯色背景),且 API 更简洁。为了统一 UI 风格、减少技术债务,需要将所有使用旧版本的组件迁移到 V2并删除 V1 版本。
## What Changes
- 删除 `Web/src/components/PopupContainer.vue`(旧版本)
- 将 18 个使用 `PopupContainer` 的文件迁移到 `PopupContainerV2`
- 适配 API 差异V1 支持 subtitle、confirm/cancel 按钮V2 更简洁只提供 footer 插槽)
- 确保迁移后的视觉效果和交互逻辑保持一致
## Capabilities
### New Capabilities
- `popup-v2-migration`: 定义从 PopupContainer V1 到 V2 的迁移规范,包括 API 映射、样式对齐、兼容性处理
### Modified Capabilities
<!-- 无现有功能的需求变更 -->
## Impact
**受影响的文件**18 个 Vue 组件):
- `BudgetChartAnalysis.vue`
- `IconSelector.vue`
- `ClassificationEdit.vue`
- `SavingsBudgetContent.vue`
- `budgetV2/Index.vue`
- `PeriodicRecord.vue`
- `EmailRecord.vue`
- `ClassificationNLP.vue`
- `BillAnalysisView.vue`
- `TransactionDetail.vue`
- `ReasonGroupList.vue`
- `CategoryBillPopup.vue`
- `BudgetEditPopup.vue`
- `BudgetCard.vue`
- `AddClassifyDialog.vue`
- `MessageView.vue`
- `SavingsConfigPopup.vue`
- `GlobalAddBill.vue`
**迁移风险**:
- V1 的 `subtitle``showConfirmButton``showCancelButton` 等 props 在 V2 中不存在,需要重构为插槽方式
- V1 默认高度为 `80%`V2 为 `auto`(最大 `85%`),需要调整布局
- V1 使用 Vant 主题变量V2 使用硬编码颜色,暗色模式处理不同

View File

@@ -0,0 +1,108 @@
## ADDED Requirements
### Requirement: PopupContainer 组件导入路径迁移
所有引用 `PopupContainer.vue` 的文件必须更新导入路径为 `PopupContainerV2.vue`,并将组件名更改为 `PopupContainerV2`
#### Scenario: 更新 import 语句
- **WHEN** 文件中存在 `import PopupContainer from '@/components/PopupContainer.vue'``import PopupContainer from './PopupContainer.vue'`
- **THEN** 系统必须将其替换为 `import PopupContainerV2 from '@/components/PopupContainerV2.vue'`
#### Scenario: 更新模板中的组件名
- **WHEN** 模板中使用 `<PopupContainer>` 标签
- **THEN** 系统必须将其替换为 `<PopupContainerV2>`
### Requirement: Props API 映射转换
V1 和 V2 的 props 差异必须通过重构适配,确保功能等价。
#### Scenario: 基础 props 映射
- **WHEN** V1 使用 `v-model:show``title` 等基础 props
- **THEN** V2 必须保留这些 props 不变(`modelValue``title` 在两个版本中一致)
#### Scenario: height prop 默认值处理
- **WHEN** V1 未显式指定 `height` prop默认 `80%`
- **THEN** V2 必须显式添加 `:height="'80%'"` 以保持一致的视觉效果
#### Scenario: 移除不支持的 props
- **WHEN** V1 使用 `closeable``subtitle``showConfirmButton``showCancelButton``confirmText``cancelText` 等 props
- **THEN** 系统必须移除这些 props并通过插槽方式重构见下一需求
### Requirement: subtitle 功能迁移
V1 的 `subtitle` prop 必须转换为 V2 的默认插槽内容或自定义标题结构。
#### Scenario: subtitle 作为内容区域展示
- **WHEN** V1 使用 `subtitle` prop 显示副标题
- **THEN** 必须将 subtitle 内容移至 `<PopupContainerV2>` 的默认插槽中,并使用适当的样式包裹(如 `<p class="subtitle-text">{{ subtitle }}</p>`
#### Scenario: subtitle 包含 HTML 内容
- **WHEN** V1 的 `subtitle` 使用 `v-html` 渲染(如统计信息)
- **THEN** 必须在默认插槽中创建等价的 HTML 结构,保持语义和样式一致
### Requirement: 确认/取消按钮迁移
V1 的 `showConfirmButton``showCancelButton` 等按钮相关 props 必须转换为 V2 的 `footer` 插槽。
#### Scenario: 标准确认/取消按钮
- **WHEN** V1 使用 `show-confirm-button``show-cancel-button` props
- **THEN** 必须在 V2 的 `<template #footer>` 中手动创建 `<van-button>` 组件,绑定相同的事件处理器(`@confirm``@cancel`
#### Scenario: 自定义按钮文本
- **WHEN** V1 使用 `confirm-text``cancel-text` 自定义按钮文字
- **THEN** 必须将文本内容应用到 footer 插槽中的按钮组件
#### Scenario: 按钮布局样式
- **WHEN** 创建 footer 插槽内的按钮
- **THEN** 必须使用 flexbox 布局确保按钮水平排列,间距为 12px与 V1 的视觉效果一致
### Requirement: header-actions 插槽迁移
V1 的 `header-actions` 插槽必须根据业务逻辑转换为 V2 的内容区域或自定义实现。
#### Scenario: 移除 header-actions 插槽
- **WHEN** V1 使用 `<template #header-actions>` 插槽放置操作按钮
- **THEN** V2 必须将这些按钮移至默认插槽顶部或 footer 插槽中,根据业务语义选择合适位置
#### Scenario: 保持操作按钮的视觉层级
- **WHEN** V1 的 header-actions 与标题同行显示grid 布局)
- **THEN** 必须在 V2 的默认插槽中创建自定义布局,使用绝对定位或 flexbox 实现相同效果
### Requirement: 样式和暗色模式兼容性
迁移后的组件必须保持视觉一致性,正确响应暗色模式。
#### Scenario: 暗色模式自动适配
- **WHEN** 用户切换到暗色模式
- **THEN** V2 的硬编码颜色(`#ffffff``#09090b` 等)必须通过 `@media (prefers-color-scheme: dark)` 自动切换
#### Scenario: 内容区域 padding 处理
- **WHEN** V1 的可滚动内容区域有默认样式
- **THEN** V2 的内容插槽无默认 padding必须由使用方手动添加`<div class="content" style="padding: 16px">`
### Requirement: 事件处理器兼容性
V1 的事件(`@confirm``@cancel`)必须正确映射到 V2 的按钮点击事件。
#### Scenario: 确认事件触发
- **WHEN** 用户点击 footer 插槽中的确认按钮
- **THEN** 必须手动触发原有的 `@confirm` 事件处理逻辑(可能需要通过 `emit` 或直接调用方法)
#### Scenario: 取消事件触发
- **WHEN** 用户点击取消按钮或关闭弹窗
- **THEN** 必须确保原有的 `@cancel` 逻辑被正确执行V2 已通过关闭按钮自动关闭弹窗,但可能需要额外的清理逻辑)
### Requirement: 代码质量和测试
迁移后的代码必须通过 ESLint 检查,并保持功能正确性。
#### Scenario: ESLint 验证通过
- **WHEN** 完成迁移后
- **THEN** 运行 `pnpm lint` 必须无错误和警告
#### Scenario: 功能回归测试
- **WHEN** 迁移后的页面加载
- **THEN** 弹窗的打开/关闭、内容展示、按钮交互必须与迁移前行为一致
### Requirement: 删除旧版本组件
所有迁移完成后,必须删除 `PopupContainer.vue` 文件以避免混淆。
#### Scenario: 文件删除
- **WHEN** 所有 18 个文件迁移完成并验证通过
- **THEN** 系统必须删除 `Web/src/components/PopupContainer.vue` 文件
#### Scenario: 无残留引用
- **WHEN** 删除旧组件后
- **THEN** 项目中不得存在任何对 `PopupContainer.vue` 的引用(通过全局搜索验证)

View File

@@ -0,0 +1,72 @@
## 1. 准备和分析阶段
- [x] 1.1 审查 18 个待迁移文件,确认每个文件使用的 V1 功能subtitle、buttons、header-actions
- [x] 1.2 为每个文件创建迁移清单,标记需要特殊处理的部分(如 v-html、复杂布局
- [x] 1.3 备份当前代码(确保 Git 工作区干净,可以随时回滚)
## 2. 核心组件迁移 - 第一批(基础用法)
- [x] 2.1 迁移 `MessageView.vue` - 基础弹窗用法,无 subtitle 和按钮
- [x] 2.2 迁移 `EmailRecord.vue` - 基础弹窗用法
- [x] 2.3 迁移 `PeriodicRecord.vue` - 基础弹窗用法
- [x] 2.4 迁移 `ClassificationNLP.vue` - 基础弹窗用法
- [x] 2.5 迁移 `BillAnalysisView.vue` - 基础弹窗用法
- [x] 2.6 验证第一批迁移:运行 `pnpm lint`,手动测试每个页面的弹窗功能
## 3. 带 subtitle 的组件迁移 - 第二批
- [x] 3.1 迁移 `CategoryBillPopup.vue` - 处理 subtitle统计信息
- [x] 3.2 迁移 `BudgetChartAnalysis.vue` - 处理 subtitle
- [x] 3.3 迁移 `TransactionDetail.vue` - 处理 subtitle 和自定义内容
- [x] 3.4 迁移 `ReasonGroupList.vue` - 处理 subtitle
- [x] 3.5 验证第二批迁移:检查 subtitle 在默认插槽中的样式和位置
## 4. 带确认/取消按钮的组件迁移 - 第三批
- [x] 4.1 迁移 `AddClassifyDialog.vue` - 转换 show-confirm-button 和 show-cancel-button 为 footer 插槽
- [x] 4.2 迁移 `IconSelector.vue` - 处理确认/取消按钮和事件绑定
- [x] 4.3 迁移 `ClassificationEdit.vue` - 处理确认/取消按钮
- [x] 4.4 验证第三批迁移:测试按钮点击事件是否正确触发
## 5. 复杂布局组件迁移 - 第四批header-actions 和自定义布局)
- [x] 5.1 迁移 `BudgetCard.vue` - 处理 header-actions 插槽,移至内容区域顶部
- [x] 5.2 迁移 `BudgetEditPopup.vue` - 处理 header-actions 和 footer
- [x] 5.3 迁移 `SavingsConfigPopup.vue` - 处理自定义布局
- [x] 5.4 迁移 `SavingsBudgetContent.vue` - 处理 header-actions
- [x] 5.5 迁移 `budgetV2/Index.vue` - 处理复杂布局和多个弹窗实例
- [x] 5.6 验证第四批迁移:检查操作按钮的位置和交互是否符合预期
## 6. 全局组件迁移 - 第五批
- [x] 6.1 迁移 `GlobalAddBill.vue` - 处理全局弹窗的特殊逻辑
- [x] 6.2 验证全局组件:测试从不同页面触发弹窗的功能
## 7. 高度和样式调整
- [x] 7.1 检查所有迁移文件,为未显式设置 height 的组件添加 `:height="'80%'"`
- [x] 7.2 调整内容区域的 paddingV2 无默认 padding需要手动添加
- [x] 7.3 统一 footer 按钮的样式(创建全局 `.footer-buttons` 样式或在每个文件中复用)
- [ ] 7.4 验证暗色模式:切换到暗色模式,检查每个弹窗的颜色和对比度
## 8. 代码质量和测试
- [x] 8.1 运行 `pnpm lint` 修复所有 ESLint 错误和警告
- [ ] 8.2 运行 `pnpm build` 确保构建成功
- [ ] 8.3 手动测试所有 18 个迁移的页面,验证弹窗的打开/关闭、内容展示、按钮交互
- [ ] 8.4 测试边界情况:长文本、空内容、多次打开/关闭弹窗
- [ ] 8.5 检查控制台是否有警告或错误信息
## 9. 清理和文档更新
- [x] 9.1 确认所有迁移完成且测试通过
- [x] 9.2 删除 `Web/src/components/PopupContainer.vue` 文件
- [x] 9.3 全局搜索 `PopupContainer`(排除 `PopupContainerV2`),确认无残留引用
- [ ] 9.4 更新项目文档(如有组件使用说明,更新为 V2 的使用方式)
- [ ] 9.5 提交代码,编写清晰的 commit message
## 10. 后续优化(可选)
- [ ] 10.1 提取 footer 按钮样式为全局 CSS 类或 V2 组件的默认样式
- [ ] 10.2 考虑为 V2 添加常用的预设(如 `preset="confirm-dialog"`)简化未来的使用
- [ ] 10.3 在团队中分享迁移经验,更新最佳实践文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,54 @@
## Context
前端组件库存在两个未被引用的组件:
- `SmartClassifyButton.vue` - 智能分类按钮,历史上可能用于快速分类功能,现已无引用
- `BudgetSummary.vue` - 预算汇总卡片,功能已被 budgetV2 模块的子组件替代
当前打包工具Vite的 tree-shaking 会移除未引用代码,但保留源文件会增加维护困惑和代码审查负担。
## Goals / Non-Goals
**Goals:**
- 移除确认无引用的组件文件
- 保持代码库整洁,降低维护成本
**Non-Goals:**
- 不涉及 `TransactionDetail.vue` vs `TransactionDetailSheet.vue` 的重构(两者虽然功能相似,但均有活跃引用)
- 不涉及其他代码清理(如未使用的 composables、utils
## Decisions
### 1. 删除策略:直接删除 vs 废弃标记
**决策**: 直接删除
**理由**:
- 两个组件均无任何 import 引用,删除零风险
- 无需废弃过渡期,因为没有使用方需要迁移
- 简化变更流程,避免留下无效的废弃代码
**备选方案**: 添加 `@deprecated` 注释并在下个版本删除 - 过度工程化,不必要
### 2. 回归验证范围
**决策**: 仅验证打包成功和页面正常渲染
**理由**:
- 删除的是零引用组件,理论上不会有任何运行时影响
- 全量 E2E 测试成本过高,性价比低
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 误删有引用的组件 | 页面报错 | 已通过 grep 全量搜索确认无引用 |
| 动态引用未被发现 | 运行时报错 | 检查了 `:is` 动态组件和字符串引用模式 |
## Migration Plan
1. 删除 `SmartClassifyButton.vue`
2. 删除 `BudgetSummary.vue`
3. 运行 `pnpm build` 验证打包成功
4. 运行 `pnpm dev` 启动开发服务器,访问主要页面验证无报错
**回滚策略**: Git revert 即可恢复

View File

@@ -0,0 +1,28 @@
## Why
前端代码库中存在未使用的组件,增加了维护成本和打包体积。作为大版本迭代的清理工作,需要识别并移除这些无效代码,保持代码库整洁。
## What Changes
- 删除 `SmartClassifyButton.vue` - 无任何引用
- 删除 `BudgetSummary.vue` - 无任何引用
- 评估 `TransactionDetail.vue``TransactionDetailSheet.vue` 的重复问题(两者功能相似,需确认是否可合并)
## Capabilities
### New Capabilities
无新增能力。
### Modified Capabilities
无需求变更。此变更为代码清理,不影响业务功能。
## Impact
- **删除文件**:
- `Web/src/components/SmartClassifyButton.vue`
- `Web/src/components/Budget/BudgetSummary.vue`
- **风险评估**: 低风险。两个组件均无任何导入引用
- **打包体积**: 减少无效代码约 ~5KB (gzip)
- **测试影响**: 无需新增测试,仅需回归验证

View File

@@ -0,0 +1,13 @@
## Overview
此变更为代码清理,不涉及业务需求变更。
## REMOVED Components
### Requirement: SmartClassifyButton component
**Reason**: 组件无任何引用,已被废弃
**Migration**: 无需迁移,该组件从未被使用
### Requirement: BudgetSummary component
**Reason**: 功能已被 budgetV2 模块的子组件替代
**Migration**: 使用 `BudgetCard.vue``BudgetChartAnalysis.vue` 替代

View File

@@ -0,0 +1,9 @@
## 1. 移除未使用组件
- [x] 1.1 删除 `Web/src/components/SmartClassifyButton.vue`
- [x] 1.2 删除 `Web/src/components/Budget/BudgetSummary.vue`
## 2. 验证
- [x] 2.1 运行 `pnpm build` 验证打包成功
- [x] 2.2 运行 `pnpm dev` 启动开发服务器,访问主要页面验证无报错

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,66 @@
## Context
项目中存在多处账单列表展示,但实现方式不一致:
- `TransactionsRecord.vue`(标准)使用 `BillListComponent`,功能完整
- `CategoryBillPopup.vue` 自定义实现,样式和交互与标准不一致
- `calendarV2/modules/TransactionList.vue` 使用 `BillListComponent` 但配置可能不一致
- `BudgetCard.vue` 使用 `BillListComponent` Custom 模式
- `EmailRecord.vue` 使用 `BillListComponent` Custom 模式
核心组件 `BillListComponent.vue` 已具备统一能力,但各使用方配置参数不统一。
## Goals / Non-Goals
**Goals:**
- 统一所有账单列表的 UI 样式(图标、金额、标签布局)
- 统一基础交互(左滑删除、点击详情、空状态)
- 确保 `BillListComponent` 正确配置
**Non-Goals:**
- 不添加搜索功能(弹窗场景不需要)
- 不修改 API 接口
- 不重构 `BillListComponent` 核心代码
## Decisions
### 决策 1: 统一使用 BillListComponent
**选择**: 所有账单列表统一使用 `BillListComponent.vue`
**理由**:
- 该组件已具备所有必要功能(筛选、分页、删除、多选)
- 支持 API 模式和 Custom 模式
- 已有完善的暗黑模式支持
**备选方案**: 为每个场景创建独立组件 → 放弃(维护成本高、样式难以统一)
### 决策 2: 标准配置模板
**选择**: 定义统一的 props 配置模板
```typescript
// 弹窗场景标准配置
const popupConfig = {
dataSource: 'custom',
transactions: [...],
enableFilter: false, // 弹窗不需要筛选
showCheckbox: false,
showDelete: true, // 支持删除
compact: true, // 紧凑布局
}
```
### 决策 3: 交互事件统一
**选择**: 统一使用组件 emit 事件 + 全局事件总线
- `@click` → 触发详情查看
- `transaction-deleted` → 全局广播删除事件
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|----------|
| CategoryBillPopup 自定义实现,改动较大 | 先对比差异,逐步对齐 |
| 各场景数据源不同 | 统一使用 Custom 模式,父组件管理数据 |
| 事件处理不一致 | 统一使用组件 emit 事件 |

View File

@@ -0,0 +1,41 @@
## Why
当前项目中存在多处账单列表展示,但样式和功能不一致,导致用户体验不统一。统计页面分类账单弹窗与 `/balance` 标准页面的账单列表在 UI 样式和交互行为上存在明显差异。为提升用户体验一致性和降低维护成本,需要统一所有账单列表组件的样式和基础功能。
## What Changes
- 统一 4 处账单列表弹窗/组件的样式和交互:
- `CategoryBillPopup.vue`(统计页面分类账单弹窗)
- `calendarV2/modules/TransactionList.vue`(日历视图账单列表)
- `Budget/BudgetCard.vue`(预算关联账单弹窗)
- `EmailRecord.vue`(邮件关联账单弹窗)
- 对齐以下方面到 `TransactionsRecord.vue` 标准:
- 列表项样式(图标、文字、金额布局)
- 左滑删除交互
- 点击查看详情交互
- 空状态展示
- **不涉及**:搜索功能(弹窗场景不需要搜索)
## Capabilities
### New Capabilities
无新增能力。
### Modified Capabilities
- `bill-list-display`: 统一账单列表展示样式和基础交互功能
## Impact
**前端组件**:
- `Web/src/components/CategoryBillPopup.vue` - 统计分类账单弹窗
- `Web/src/views/calendarV2/modules/TransactionList.vue` - 日历账单列表
- `Web/src/components/Budget/BudgetCard.vue` - 预算关联账单弹窗
- `Web/src/views/EmailRecord.vue` - 邮件关联账单弹窗
**参考标准**:
- `Web/src/views/TransactionsRecord.vue` - 标准账单列表实现
- `Web/src/components/Bill/BillListComponent.vue` - 核心账单列表组件
**API 依赖**: 无新增 API复用现有 `getTransactionList` 接口

View File

@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: CategoryBillPopup 统一样式
统计页面分类账单弹窗必须使用 BillListComponent样式与 Balance 页面一致。
#### Scenario: 使用 BillListComponent
- **WHEN** 用户在统计页面点击分类卡片
- **THEN** 弹窗使用 `BillListComponent` 展示账单列表,配置为 `dataSource="api"` 模式
#### Scenario: 列表项样式对齐
- **WHEN** 账单列表渲染
- **THEN** 使用与 `TransactionsRecord.vue` 相同的卡片样式(图标、金额、标签布局)
#### Scenario: 左滑删除
- **WHEN** 用户在账单项上左滑
- **THEN** 显示红色删除按钮,点击后确认删除
#### Scenario: 点击查看详情
- **WHEN** 用户点击账单项
- **THEN** 打开 `TransactionDetailSheet` 查看详情
### Requirement: CalendarV2 TransactionList 对齐
日历页面的交易列表样式必须与 Balance 页面一致。
#### Scenario: 紧凑布局
- **WHEN** 日历页面展示当天账单列表
- **THEN** 使用 `compact={true}` 紧凑布局
#### Scenario: 删除交互
- **WHEN** 用户左滑删除账单
- **THEN** 与 Balance 页面删除交互一致
### Requirement: BudgetCard 关联账单对齐
预算页面的关联账单弹窗样式必须与 Balance 页面一致。
#### Scenario: 统一卡片样式
- **WHEN** 预算卡片展示关联账单
- **THEN** 账单项样式与 Balance 页面一致
### Requirement: EmailRecord 关联账单对齐
邮件记录页面的关联账单弹窗样式必须与 Balance 页面一致。
#### Scenario: 统一卡片样式
- **WHEN** 邮件记录展示关联账单
- **THEN** 账单项样式与 Balance 页面一致
#### Scenario: 删除功能
- **WHEN** 用户删除账单
- **THEN** 删除交互与 Balance 页面一致
## MODIFIED Requirements
### Requirement: 功能对等性
新组件必须保持旧版所有功能,确保迁移不丢失特性。
#### Scenario: 批量选择功能
- **WHEN** TransactionsRecord 需要批量操作
- **THEN** 新组件通过 `showCheckbox``selectedIds` 提供相同功能
#### Scenario: 删除后刷新
- **WHEN** 账单删除成功
- **THEN** 新组件派发 `transaction-deleted` 全局事件,保持与旧版相同的事件机制
#### Scenario: 自定义数据源
- **WHEN** 页面需要展示离线或缓存数据
- **THEN** 新组件通过 `dataSource="custom"``transactions` prop 支持自定义数据
#### Scenario: 弹窗场景数据源
- **WHEN** 弹窗组件CategoryBillPopup、BudgetCard、EmailRecord展示账单
- **THEN** 使用 `dataSource="api"``dataSource="custom"`,并配置 `enableFilter={false}` 禁用筛选
### Requirement: 视觉升级
新组件必须基于 v2 的现代化设计,提供更好的视觉体验。
#### Scenario: 卡片样式
- **WHEN** 展示账单列表
- **THEN** 使用 v2 的卡片样式(圆角、阴影、图标),调整为紧凑间距
#### Scenario: 图标展示
- **WHEN** 账单有分类信息
- **THEN** 显示对应的分类图标(如餐饮用 food 图标),带有彩色背景
#### Scenario: 标签样式
- **WHEN** 显示账单类型
- **THEN** 使用彩色标签(支出红色、收入绿色),位于卡片右上角
#### Scenario: 空状态展示
- **WHEN** 账单列表为空
- **THEN** 显示统一的空状态图标和提示文案

View File

@@ -0,0 +1,41 @@
## 1. 分析差异
- [x] 1.1 对比 CategoryBillPopup.vue 与 TransactionsRecord.vue 的样式差异
- [x] 1.2 对比 calendarV2/modules/TransactionList.vue 与标准的差异
- [x] 1.3 对比 BudgetCard.vue 关联账单与标准的差异
- [x] 1.4 对比 EmailRecord.vue 关联账单与标准的差异
## 2. 修改 CategoryBillPopup
- [x] 2.1 重构 CategoryBillPopup 使用 BillListComponent
- [x] 2.2 配置 props: dataSource="custom", enableFilter=false, showDelete=true
- [x] 2.3 统一列表项样式(图标、金额、标签)
- [x] 2.4 实现左滑删除交互
- [x] 2.5 实现点击查看详情(打开 TransactionDetailSheet
- [x] 2.6 测试删除和详情功能
## 3. 修改 CalendarV2 TransactionList
- [x] 3.1 检查 BillListComponent 配置
- [x] 3.2 确保 compact={true} 紧凑布局
- [x] 3.3 统一删除交互
- [x] 3.4 测试日历页面账单列表
## 4. 修改 BudgetCard 关联账单
- [x] 4.1 检查 BillListComponent 配置
- [x] 4.2 统一卡片样式
- [x] 4.3 测试预算关联账单弹窗
## 5. 修改 EmailRecord 关联账单
- [x] 5.1 检查 BillListComponent 配置
- [x] 5.2 统一卡片样式和删除功能
- [x] 5.3 测试邮件关联账单弹窗
## 6. 验证
- [x] 6.1 验证所有弹窗的账单列表样式一致
- [x] 6.2 验证删除功能在各场景正常工作
- [x] 6.3 验证详情查看功能正常
- [x] 6.4 验证暗黑模式适配

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,255 @@
# Design: 存款明细计算优化
## Context
当前 `BudgetSavingsService` 在计算存款时使用了复杂的逻辑,包括归档数据读取、月度/年度预算折算、硬性消费的天数折算等。但核心计算公式不明确,导致代码可读性差,且难以验证计算结果的正确性。
### 现状
- `GetForMonthAsync`: 计算月度存款,需要处理月度预算和发生在本月的年度预算
- `GetForYearAsync`: 计算年度存款,需要整合归档数据和未来月份预算
- 归档数据存储在 `BudgetArchive` 表中,每月的实际收支被固化
- 硬性消费(`IsMandatoryExpense`在实际为0时按天数比例折算
### 约束
- 不改变数据库结构和归档格式
- 保持与现有 `BudgetArchiveRepository``TransactionStatisticsService` 的兼容性
- 必须通过 TDD 方式开发,先写测试再实现
## Goals / Non-Goals
**Goals:**
- 明确定义月度和年度存款的计算公式
- 重构 `BudgetSavingsService` 以提高代码可读性和可维护性
- 提供详细的明细数据结构,支持前端展示计算过程
- 确保所有计算场景都有单元测试覆盖
**Non-Goals:**
- 修改前端展示逻辑(仅提供数据结构)
- 改变归档任务的行为
- 优化数据库查询性能(保持现有逻辑)
## Decisions
### 决策1核心计算公式明确化
**选择:将核心公式提取为独立方法**
```csharp
// 月度计划存款
private decimal CalculateMonthlyPlannedSavings(
decimal monthlyIncomeBudget,
decimal yearlyIncomeInThisMonth,
decimal monthlyExpenseBudget,
decimal yearlyExpenseInThisMonth)
{
return monthlyIncomeBudget + yearlyIncomeInThisMonth
- monthlyExpenseBudget - yearlyExpenseInThisMonth;
}
// 年度计划存款
private decimal CalculateYearlyPlannedSavings(
decimal archivedIncome,
decimal futureIncomeBudget,
decimal archivedExpense,
decimal futureExpenseBudget)
{
return archivedIncome + futureIncomeBudget
- archivedExpense - futureExpenseBudget;
}
```
**理由:**
- 公式清晰可见,便于验证和维护
- 单元测试可以直接测试公式本身
- 与明细计算逻辑解耦
**替代方案:内联计算**
- 被拒绝:代码可读性差,难以测试
### 决策2明细项计算用金额的规则实现
**选择:创建 `BudgetItemCalculator` 辅助类**
```csharp
public class BudgetItemCalculator
{
public static decimal CalculateEffectiveAmount(
BudgetCategory category,
decimal budgetLimit,
decimal actualAmount,
bool isMandatory,
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 type)
{
if (type == BudgetPeriodType.Month)
return limit / DateTime.DaysInMonth(date.Year, date.Month) * date.Day;
else
return limit / (DateTime.IsLeapYear(date.Year) ? 366 : 365) * date.DayOfYear;
}
}
```
**理由:**
- 逻辑集中,易于测试和维护
- 明确了"归档"、"收入"、"支出"、"硬性"四种场景的处理规则
- 可以在单元测试中独立验证每种规则
**替代方案:内联在 GetForMonthAsync 中**
- 被拒绝:代码重复,难以测试
### 决策3明细数据结构设计
**选择:返回结构化的明细对象**
```csharp
public record SavingsDetail
{
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
public List<BudgetDetailItem> 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 record SavingsCalculationSummary
{
public decimal TotalIncomeBudget { get; init; }
public decimal TotalExpenseBudget { get; init; }
public decimal PlannedSavings { get; init; }
public string CalculationFormula { get; init; } = string.Empty;
}
```
**理由:**
- 结构化数据便于前端展示
- `CalculationNote` 让用户清楚看到每项如何计算
- `IsOverBudget` 支持前端高亮显示
- 分离明细和汇总,符合单一职责原则
**替代方案:返回 HTML 字符串**
- 被拒绝:前端无法灵活控制展示样式
### 决策4归档数据的处理
**选择:年度计算时,归档月份直接使用 `BudgetArchive.Content[].Actual`**
**理由:**
- 归档数据已经固化,不应重新计算
- 与现有归档逻辑保持一致
- 避免因预算调整导致历史数据变化
### 决策5测试策略
**选择TDD 红-绿-重构流程**
测试文件结构:
```
WebApi.Test/Budget/
├── BudgetSavingsCalculationTest.cs (新增 - 核心计算逻辑单元测试)
│ ├── CalculateMonthlyPlannedSavings_Test
│ ├── CalculateYearlyPlannedSavings_Test
│ ├── BudgetItemCalculator_收入项_实际已发生_Test
│ ├── BudgetItemCalculator_收入项_实际未发生_Test
│ ├── BudgetItemCalculator_支出项_普通_Test
│ ├── BudgetItemCalculator_支出项_硬性_Test
│ └── BudgetItemCalculator_归档数据_Test
└── BudgetSavingsTest.cs (修改 - 集成测试)
├── GetForMonthAsync_完整场景_Test
└── GetForYearAsync_完整场景_Test
```
**测试覆盖场景:**
1. 月度计算:纯月度预算、月度+年度混合
2. 年度计算:有归档数据、无归档数据、部分归档
3. 收入项:实际>0、实际=0
4. 支出项:普通、硬性且实际=0、硬性且实际>0
5. 边界情况:闰年、月初、月末
## Risks / Trade-offs
### 风险1归档数据不一致
**风险:** 历史归档数据可能因旧逻辑生成,导致与新逻辑不兼容
**缓解:** 在单元测试中使用实际的归档数据结构,验证兼容性
### 风险2硬性消费按天折算的边界问题
**风险:** 月初/月末、闰年等边界情况可能导致计算偏差
**缓解:** 针对边界情况编写专门的单元测试
### 风险3年度预算的月份分配
**风险:** 年度预算如何分配到未来月份不明确(是平均分配还是一次性计入?)
**缓解:** 根据现有逻辑,年度预算的"发生在本月"部分使用实际发生金额,未来月份不折算
### Trade-off明细数据结构复杂度
**权衡:** 返回结构化对象增加了 DTO 复杂度,但提高了前端灵活性
**选择:** 接受复杂度,因为可维护性和用户体验更重要
## Migration Plan
### 阶段1后端重构TDD
1. 编写 `BudgetSavingsCalculationTest.cs` 中的核心公式测试(红灯)
2. 实现 `CalculateMonthlyPlannedSavings``CalculateYearlyPlannedSavings`(绿灯)
3. 编写 `BudgetItemCalculator` 的测试(红灯)
4. 实现 `BudgetItemCalculator`(绿灯)
5. 重构 `GetForMonthAsync``GetForYearAsync`,使用新方法
6. 运行所有测试,确保通过
### 阶段2明细数据结构
1. 定义 `SavingsDetail` 相关的 record 类型
2. 修改 `GetForMonthAsync``GetForYearAsync` 返回明细
3. 更新 API 响应(如果需要)
### 阶段3前端适配后续变更
- 本次变更不涉及前端,仅提供数据结构
### Rollback 策略
- 如果新逻辑导致计算错误,可以通过 Git 回滚到旧版本
- 由于不涉及数据库变更,回滚无副作用
## Open Questions
1. **年度预算在月度计算中的处理**
"发生在本月的年度收入/支出"是否仅指实际发生金额actual还是也要考虑预算
**假设**:仅使用实际发生金额(与现有逻辑一致)
2. **明细展示的优先级**
收入/支出项在明细表格中的排序规则?
**假设**:按预算金额降序排列
3. **不记额预算NoLimit的处理**
在明细中如何展示?
**假设**:显示为"不限额",不参与预算汇总
4. **前端 API 契约**
是否需要新增 API 接口,还是复用现有的存款统计接口?
**假设**:复用现有接口,扩展返回字段

View File

@@ -0,0 +1,57 @@
# Proposal: 存款明细计算优化
## Why
当前的存款计划计算逻辑不够清晰明确,用户难以理解"计划存款"的具体含义和计算方式。特别是在年度视图中,如何处理已归档月份(使用实际值)和未来月份(使用预算值)的逻辑不够透明。需要明确核心计算公式,并提供详细的明细展示,让用户能够清楚看到每一项收入和支出如何影响最终的计划存款金额。
## What Changes
- **重构存款计划核心计算公式**
**月度计划存款** = 收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
**年度计划存款** = 归档月已实收 + 未来月(包含本月)收入预算 - 归档月已实支 - 未来月(包含本月)支出预算
- **明细项计算用金额规则**(用于明细展示):
- **支出项**:取预算与实际的较大值(`MAX(预算, 实际)`
- **支出项(硬性且实际=0**按天数折算不做MAX比较可能大于预算
- **收入项**:实际已发生时取实际值,未发生时取预算值
- **归档月份**:直接使用归档的实际值,不重新计算
- **增强存款明细展示**
- 显示每个预算项的预算金额、实际金额、计算用金额
- 标注使用了哪个值(预算/实际/按天折算)
- 高亮超支/未达标项目
- 明确展示计算过程和中间步骤
- 支持月度和年度两种时间维度的存款明细
- 确保与现有归档逻辑(`BudgetArchive`)和固定收支(`IsMandatoryExpense`)兼容
## Capabilities
### New Capabilities
- `saving-detail-calculation`: 存款明细计算核心算法,包括月度和年度计算逻辑
- `saving-detail-display`: 存款明细前端展示组件,包括明细表格和计算过程说明
### Modified Capabilities
- `budget-savings`: 现有存款预算服务的计算逻辑需要根据新规则重构
## Impact
### 后端影响
- **修改**`Service/Budget/BudgetSavingsService.cs` - 重构 `GetForMonthAsync``GetForYearAsync` 方法
- **新增**:计算用金额的辅助方法(支出/收入/硬性的判断逻辑)
- **依赖**:依赖现有的 `BudgetArchiveRepository``TransactionStatisticsService`
### 前端影响
- **修改**:存款统计页面,增加"明细"标签页或折叠面板
- **新增**:明细表格组件,展示预算、实际、计算用值三列
### 测试影响
- **新增**`WebApi.Test/Budget/BudgetSavingsCalculationTest.cs` - 覆盖所有计算场景的单元测试
- **修改**`WebApi.Test/Budget/BudgetSavingsTest.cs` - 更新现有测试以匹配新逻辑
### 数据影响
- 无数据库结构变更
- 无需数据迁移
- 归档数据格式保持不变

View File

@@ -0,0 +1,126 @@
# Spec: 预算存款服务重构
## MODIFIED Requirements
### Requirement: GetForMonthAsync 返回明细数据
`BudgetSavingsService.GetForMonthAsync` 方法 SHALL 返回包含明细数据的 `BudgetResult` 对象,除了原有的 HTML 描述外,还包括结构化的明细数据。
返回对象应包含:
- `Limit`: 计划存款金额(使用新公式计算)
- `Current`: 实际存款金额(从配置的存款分类中累加)
- `Description`: HTML 格式的详细说明(保留兼容性)
- `Details`: 新增的结构化明细数据(`SavingsDetail` 对象)
#### Scenario: 月度查询返回明细
- **WHEN** 调用 `GetForMonthAsync(BudgetPeriodType.Month, new DateTime(2026, 2, 1))`
- **THEN** 返回的 `BudgetResult` 对象包含 `Details` 字段,其中 `IncomeItems``ExpenseItems` 包含所有月度预算项和本月发生的年度预算项
#### Scenario: 向后兼容 HTML 描述
- **WHEN** 调用 `GetForMonthAsync` 方法
- **THEN** 返回的 `Description` 字段仍包含原有的 HTML 表格格式说明,确保旧版前端不受影响
### Requirement: GetForYearAsync 返回明细数据
`BudgetSavingsService.GetForYearAsync` 方法 SHALL 返回包含归档月份和未来月份明细的完整数据结构。
归档月份明细应标注:
- `IsArchived`: true
- `ArchivedMonths`: 归档月份列表(如 [1, 2]
#### Scenario: 年度查询包含归档明细
- **WHEN** 调用 `GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 3, 1))`且1月、2月已归档
- **THEN** 返回的 `Details.IncomeItems``Details.ExpenseItems` 中,归档月份的项目标记为 `IsArchived = true`
#### Scenario: 年初无归档数据
- **WHEN** 调用 `GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 1, 1))`,无归档数据
- **THEN** 返回的明细中所有项目 `IsArchived = false`,未来月份数 = 12
## ADDED Requirements
### Requirement: BudgetItemCalculator 辅助类
系统 SHALL 提供 `BudgetItemCalculator` 静态类,用于计算单个预算项的计算用金额。
方法签名:
```csharp
public static decimal CalculateEffectiveAmount(
BudgetCategory category,
decimal budgetLimit,
decimal actualAmount,
bool isMandatory,
bool isArchived,
DateTime referenceDate,
BudgetPeriodType periodType)
```
#### Scenario: 调用计算器计算收入项
- **WHEN** 调用 `CalculateEffectiveAmount(BudgetCategory.Income, 10000, 9500, false, false, date, Month)`
- **THEN** 返回 9500使用实际值
#### Scenario: 调用计算器计算硬性支出
- **WHEN** 调用 `CalculateEffectiveAmount(BudgetCategory.Expense, 3000, 0, true, false, new DateTime(2026, 2, 15), Month)`
- **THEN** 返回 ≈ 1607.14(按天数折算)
### Requirement: SavingsDetail 数据结构定义
系统 SHALL 定义以下 record 类型用于存储明细数据:
```csharp
public record SavingsDetail
{
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
public List<BudgetDetailItem> 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;
}
```
#### Scenario: 创建明细对象
- **WHEN** 系统计算完月度存款明细
- **THEN** 创建 `SavingsDetail` 对象,填充 `IncomeItems``ExpenseItems``Summary`
### Requirement: 核心计算公式方法提取
系统 SHALL 将核心计算公式提取为独立的私有方法:
- `CalculateMonthlyPlannedSavings`: 月度计划存款计算
- `CalculateYearlyPlannedSavings`: 年度计划存款计算
#### Scenario: 单元测试可测试性
- **WHEN** 开发人员编写单元测试
- **THEN** 可以通过反射或测试友好的设计(如 internal 可见性)测试核心计算公式
### Requirement: 计算说明生成
系统 SHALL 为每个明细项生成 `CalculationNote` 字段,说明使用了哪种计算规则:
- "使用预算"
- "使用实际"
- "使用实际(超支)"
- "按天折算"
- "归档实际"
#### Scenario: 生成计算说明
- **WHEN** 餐饮预算2000实际2500
- **THEN** `CalculationNote = "使用实际(超支)"``IsOverBudget = true`
### Requirement: 年度归档月份标注
对于年度查询中的归档月份数据,系统 SHALL 标注 `IsArchived = true``ArchivedMonths` 字段。
#### Scenario: 标注归档月份
- **WHEN** 工资预算在1月和2月都有归档数据
- **THEN** 返回的明细项中 `IsArchived = true``ArchivedMonths = [1, 2]`

View File

@@ -0,0 +1,131 @@
# Spec: 存款明细计算核心算法
## ADDED Requirements
### Requirement: 月度计划存款计算公式
系统 SHALL 使用以下公式计算月度计划存款:
**月度计划存款 = 收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出**
其中:
- **收入预算**:所有月度收入预算项的预算金额之和
- **发生在本月的年度收入**年度收入预算项在本月实际发生的金额actual > 0
- **支出预算**:所有月度支出预算项的预算金额之和
- **发生在本月的年度支出**年度支出预算项在本月实际发生的金额actual > 0
#### Scenario: 纯月度预算计算
- **WHEN** 用户查询 2026年2月的月度存款且只有月度预算工资10000、奖金5000、房租3000、餐饮2000
- **THEN** 系统返回计划存款 = 10000 + 5000 - 3000 - 2000 = 10000
#### Scenario: 月度预算 + 本月发生的年度预算
- **WHEN** 用户查询 2026年2月的月度存款月度预算工资10000、房租3000且年度旅游支出在本月实际发生3000元
- **THEN** 系统返回计划存款 = 10000 - 3000 - 3000 = 4000
#### Scenario: 年度预算未在本月发生
- **WHEN** 用户查询 2026年2月的月度存款月度预算工资10000、房租3000年度年终奖预算50000但本月实际为0
- **THEN** 系统返回计划存款 = 10000 - 3000 = 7000年终奖不计入
### Requirement: 年度计划存款计算公式
系统 SHALL 使用以下公式计算年度计划存款:
**年度计划存款 = 归档月已实收 + 未来月(包含本月)收入预算 - 归档月已实支 - 未来月(包含本月)支出预算**
其中:
- **归档月已实收**已归档月份1月~当前月-1的实际收入金额之和`BudgetArchive` 读取
- **未来月收入预算**:当前月及未来月份的月度收入预算 × 剩余月数
- **归档月已实支**:已归档月份的实际支出金额之和,从 `BudgetArchive` 读取
- **未来月支出预算**:当前月及未来月份的月度支出预算 × 剩余月数
#### Scenario: 年初无归档数据
- **WHEN** 用户在 2026年1月查询年度存款月度预算工资10000/月、房租3000/月),无归档数据
- **THEN** 系统返回计划存款 = (10000 - 3000) × 12 = 84000
#### Scenario: 年中有归档数据
- **WHEN** 用户在 2026年3月查询年度存款1月归档已实收15000、已实支48002月归档已实收14000、已实支52003~12月月度预算工资10000、房租3000
- **THEN** 系统返回计划存款 = (15000 + 14000) + (10000 × 10) - (4800 + 5200) - (3000 × 10) = 129000
#### Scenario: 归档数据包含年度预算
- **WHEN** 归档数据中包含年度预算的实际发生金额如1月旅游支出3000
- **THEN** 系统将其计入"归档月已实支",不重复计算
### Requirement: 明细项计算用金额 - 收入规则
对于收入类预算项,系统 SHALL 根据以下规则计算"计算用金额"
- 如果实际金额 > 0计算用金额 = 实际金额
- 如果实际金额 = 0计算用金额 = 预算金额
#### Scenario: 收入已发生
- **WHEN** 工资预算10000实际发生9500
- **THEN** 计算用金额 = 9500标注为"使用实际"
#### Scenario: 收入未发生
- **WHEN** 奖金预算5000实际发生0
- **THEN** 计算用金额 = 5000标注为"使用预算"
### Requirement: 明细项计算用金额 - 支出规则(普通)
对于非硬性支出类预算项,系统 SHALL 计算用金额 = MAX(预算金额, 实际金额)
#### Scenario: 支出未超预算
- **WHEN** 餐饮预算2000实际发生1800
- **THEN** 计算用金额 = 2000标注为"使用预算"
#### Scenario: 支出超预算
- **WHEN** 餐饮预算2000实际发生2500
- **THEN** 计算用金额 = 2500标注为"使用实际(超支)",高亮显示
### Requirement: 明细项计算用金额 - 支出规则(硬性)
对于硬性支出(`IsMandatoryExpense = true`)且实际金额 = 0 的预算项,系统 SHALL 按天数折算计算用金额,不进行 MAX 比较。
**月度折算公式**:计算用金额 = 预算金额 / 当月天数 × 当前日期
**年度折算公式**:计算用金额 = 预算金额 / 当年天数 × 当前天数
#### Scenario: 硬性支出未发生(月度)
- **WHEN** 房租预算3000硬性实际为0当前日期为2月15日2月共28天
- **THEN** 计算用金额 = 3000 / 28 × 15 ≈ 1607.14,标注为"按天折算"
#### Scenario: 硬性支出已发生
- **WHEN** 房租预算3000硬性实际发生3000
- **THEN** 计算用金额 = MAX(3000, 3000) = 3000标注为"使用实际"
#### Scenario: 硬性支出超预算
- **WHEN** 水电预算500硬性实际发生600
- **THEN** 计算用金额 = MAX(500, 600) = 600标注为"使用实际(超支)"
#### Scenario: 硬性支出按天折算可能超预算
- **WHEN** 房租预算3000硬性实际为0当前日期为2月29日2月共28天
- **THEN** 计算用金额 = 3000 / 28 × 29 ≈ 3107.14(大于预算),标注为"按天折算"
### Requirement: 归档月份数据处理
对于已归档月份的预算数据,系统 SHALL 直接使用归档中的实际金额(`BudgetArchive.Content[].Actual`),不重新计算。
#### Scenario: 读取归档数据
- **WHEN** 用户在3月查询年度存款1月归档中工资实际10000、房租实际3000
- **THEN** 系统使用归档实际值10000和3000不根据当前预算重新计算
#### Scenario: 归档后预算调整
- **WHEN** 1月归档时工资预算10000实际100002月将工资预算调整为12000用户在3月查询年度存款
- **THEN** 1月仍使用归档的实际100002月及以后使用新预算12000
### Requirement: 闰年和月末边界处理
系统 SHALL 正确处理闰年和月末边界情况:
- 闰年判断:使用 `DateTime.IsLeapYear(year)` 判断闰年366天平年365天
- 月末天数:使用 `DateTime.DaysInMonth(year, month)` 获取
#### Scenario: 闰年2月硬性支出折算
- **WHEN** 2024年2月29日闰年房租预算3000硬性实际为0
- **THEN** 计算用金额 = 3000 / 29 × 29 = 3000
#### Scenario: 平年2月硬性支出折算
- **WHEN** 2026年2月28日平年房租预算3000硬性实际为0
- **THEN** 计算用金额 = 3000 / 28 × 28 = 3000
#### Scenario: 年度硬性支出闰年折算
- **WHEN** 2024年闰年第100天年度保险预算12000硬性实际为0
- **THEN** 计算用金额 = 12000 / 366 × 100 ≈ 3278.69
### Requirement: 不记额预算处理
对于不记额预算(`NoLimit = true`)的预算项,系统 SHALL 排除在计划存款计算之外,但在明细中显示为"不限额"。
#### Scenario: 不记额收入
- **WHEN** 存在不记额收入预算项如意外收入实际发生1000
- **THEN** 该项不计入"收入预算",明细中显示预算为"不限额"实际为1000

View File

@@ -0,0 +1,141 @@
# Spec: 存款明细前端展示组件
## ADDED Requirements
### Requirement: 明细数据结构返回
系统 SHALL 返回结构化的明细数据,包含以下信息:
- 收入明细列表(`IncomeItems`
- 支出明细列表(`ExpenseItems`
- 计算汇总(`Summary`
每个明细项包含:
- `Id`: 预算ID
- `Name`: 预算名称
- `Type`: 预算类型(月度/年度)
- `BudgetLimit`: 预算金额
- `ActualAmount`: 实际金额
- `EffectiveAmount`: 计算用金额
- `CalculationNote`: 计算说明("使用预算"/"使用实际"/"按天折算"/"使用实际(超支)"
- `IsOverBudget`: 是否超支/未达标
#### Scenario: 月度明细数据结构
- **WHEN** 用户查询2月月度存款明细
- **THEN** 系统返回 JSON 结构包含 `IncomeItems`(工资、奖金等)、`ExpenseItems`(房租、餐饮等)和 `Summary`(收入合计、支出合计、计划存款)
#### Scenario: 年度明细数据结构
- **WHEN** 用户查询2026年度存款明细
- **THEN** 系统返回包含归档月份明细和未来月份明细的完整数据结构
### Requirement: 明细表格展示列
明细表格 SHALL 包含以下列:
- 名称Name
- 类型(月度/年度)
- 预算金额BudgetLimit
- 实际金额ActualAmount
- 计算用金额EffectiveAmount
- 计算说明CalculationNote
#### Scenario: 明细表格基本展示
- **WHEN** 用户打开存款明细页面
- **THEN** 表格显示所有收入和支出项的上述6列信息
#### Scenario: 不限额预算显示
- **WHEN** 预算项为不记额NoLimit = true
- **THEN** 预算金额列显示"不限额"
### Requirement: 超支/未达标高亮显示
系统 SHALL 对超支或未达标的预算项进行高亮显示:
- 支出超预算:实际 > 预算,高亮显示为红色/警告色
- 收入未达标:实际 > 0 且 实际 < 预算,高亮显示为橙色/提示色
#### Scenario: 支出超预算高亮
- **WHEN** 餐饮预算2000实际2500超支
- **THEN** 该行背景色为浅红色,计算说明显示"使用实际(超支)"
#### Scenario: 收入未达标高亮
- **WHEN** 工资预算10000实际9500未达标
- **THEN** 该行背景色为浅橙色,计算说明显示"使用实际"
#### Scenario: 正常范围不高亮
- **WHEN** 房租预算3000实际3000正常
- **THEN** 该行无特殊背景色
### Requirement: 计算过程说明展示
系统 SHALL 在明细下方展示计算过程的文字说明,包括:
- 收入合计的计算公式工资10000 + 奖金5000 = 15000
- 支出合计的计算公式房租3000 + 餐饮2000 = 5000
- 计划存款的计算公式15000 - 5000 = 10000
#### Scenario: 月度计算过程说明
- **WHEN** 用户查看2月月度存款明细
- **THEN** 页面底部显示:
```
收入合计 = 工资10000 + 奖金5000 = 15000
支出合计 = 房租3000 + 餐饮2000 = 5000
本月发生的年度支出 = 旅游3000
月度计划存款 = 15000 - 5000 - 3000 = 7000
```
#### Scenario: 年度计算过程说明
- **WHEN** 用户查看2026年度存款明细
- **THEN** 页面底部显示:
```
归档月已实收 = 1月15000 + 2月14000 = 29000
未来月收入预算 = (工资10000 + 奖金5000) × 10月 = 150000
归档月已实支 = 1月4800 + 2月5200 = 10000
未来月支出预算 = (房租3000 + 餐饮2000) × 10月 = 50000
年度计划存款 = 29000 + 150000 - 10000 - 50000 = 119000
```
### Requirement: 归档月份和未来月份分组展示
在年度明细中,系统 SHALL 将数据分为两组展示:
- **已归档月份明细**:显示各归档月的实际收支
- **未来月份预算明细**:显示当前及未来月份的预算和预测
#### Scenario: 年度明细分组
- **WHEN** 用户在3月查询年度存款明细
- **THEN** 页面分为两个表格:
- 表格1已归档明细1月、2月
- 表格2未来月份预算3~12月
#### Scenario: 归档月份合并显示
- **WHEN** 同一预算项在多个归档月出现如工资1月10000、2月10000
- **THEN** 可选择合并显示为"工资1~2月预算10000/月实际合计20000"
### Requirement: 响应式布局支持
明细表格 SHALL 支持移动端响应式布局:
- 桌面端:完整表格展示
- 移动端:卡片式折叠展示,点击展开详情
#### Scenario: 移动端卡片展示
- **WHEN** 用户在手机上打开存款明细页面
- **THEN** 每个预算项以卡片形式展示,显示名称、计算用金额和计算说明,点击展开显示完整信息
#### Scenario: 桌面端表格展示
- **WHEN** 用户在桌面浏览器打开存款明细页面
- **THEN** 以完整表格形式展示所有列
### Requirement: 排序和筛选功能
系统 SHALL 支持明细列表的排序和筛选:
- 按预算金额排序(降序/升序)
- 按实际金额排序
- 筛选显示:全部/仅超支/仅未达标
#### Scenario: 按预算金额降序排序
- **WHEN** 用户点击"预算金额"列标题
- **THEN** 列表按预算金额从高到低排序
#### Scenario: 仅显示超支项目
- **WHEN** 用户选择"仅超支"筛选
- **THEN** 列表仅显示 `IsOverBudget = true` 且为支出类的项目
### Requirement: 导出功能
系统 SHALL 支持将明细数据导出为 CSV 或 Excel 格式。
#### Scenario: 导出为 CSV
- **WHEN** 用户点击"导出 CSV"按钮
- **THEN** 浏览器下载包含所有明细数据的 CSV 文件,文件名为"存款明细_YYYYMM.csv"
#### Scenario: 导出为 Excel
- **WHEN** 用户点击"导出 Excel"按钮
- **THEN** 浏览器下载包含所有明细数据的 Excel 文件,保留表格格式和高亮样式

View File

@@ -0,0 +1,136 @@
# Tasks: 存款明细计算优化实施清单
## 1. 数据结构定义
- [x] 1.1 在 `Application/Dto/BudgetDto.cs` 中定义 `SavingsDetail` record 类型
- [x] 1.2 在 `Application/Dto/BudgetDto.cs` 中定义 `BudgetDetailItem` record 类型
- [x] 1.3 在 `Application/Dto/BudgetDto.cs` 中定义 `SavingsCalculationSummary` record 类型
- [x] 1.4 在 `BudgetResult` 中添加 `Details` 属性(类型为 `SavingsDetail?`
## 2. 核心计算辅助类 - TDD 红灯阶段
- [x] 2.1 创建 `WebApi.Test/Budget/BudgetItemCalculatorTest.cs` 测试文件
- [x] 2.2 编写测试收入项实际已发生actual > 0应返回实际值
- [x] 2.3 编写测试收入项实际未发生actual = 0应返回预算值
- [x] 2.4 编写测试:支出项普通情况应返回 MAX(预算, 实际)
- [x] 2.5 编写测试:支出项未超预算应返回预算值
- [x] 2.6 编写测试:支出项超预算应返回实际值
- [x] 2.7 编写测试支出项硬性且实际为0月度应按天数折算
- [x] 2.8 编写测试支出项硬性且实际为0年度应按天数折算
- [x] 2.9 编写测试:支出项硬性且实际>0应返回MAX值
- [x] 2.10 编写测试:归档数据应直接返回实际值
- [x] 2.11 编写测试闰年2月按天折算边界情况
- [x] 2.12 编写测试平年2月按天折算边界情况
- [x] 2.13 运行所有测试,确认红灯(测试失败)
## 3. 核心计算辅助类 - TDD 绿灯阶段
- [x] 3.1 创建 `Service/Budget/BudgetItemCalculator.cs` 静态类
- [x] 3.2 实现 `CalculateEffectiveAmount` 方法(包含所有计算规则)
- [x] 3.3 实现 `CalculateMandatoryAmount` 私有方法(硬性消费按天折算)
- [x] 3.4 实现 `GenerateCalculationNote` 方法(生成计算说明)
- [x] 3.5 运行所有测试,确认绿灯(测试通过)
## 4. 月度存款核心公式 - TDD 红灯阶段
- [x] 4.1 创建 `WebApi.Test/Budget/BudgetSavingsCalculationTest.cs` 测试文件
- [x] 4.2 编写测试:月度计划存款公式 - 纯月度预算场景
- [x] 4.3 编写测试:月度计划存款公式 - 月度预算 + 本月发生的年度预算
- [x] 4.4 编写测试:月度计划存款公式 - 年度预算未在本月发生应不计入
- [x] 4.5 运行测试,确认红灯
## 5. 月度存款核心公式 - TDD 绿灯阶段
- [x] 5.1 在 `BudgetSavingsService` 中添加 `CalculateMonthlyPlannedSavings` 私有方法
- [x] 5.2 实现月度计划存款公式:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
- [x] 5.3 运行测试,确认绿灯
## 6. 年度存款核心公式 - TDD 红灯阶段
- [x] 6.1 编写测试:年度计划存款公式 - 年初无归档数据场景
- [x] 6.2 编写测试:年度计划存款公式 - 年中有归档数据场景
- [x] 6.3 编写测试:年度计划存款公式 - 归档数据包含年度预算
- [x] 6.4 运行测试,确认红灯
## 7. 年度存款核心公式 - TDD 绿灯阶段
- [x] 7.1 在 `BudgetSavingsService` 中添加 `CalculateYearlyPlannedSavings` 私有方法
- [x] 7.2 实现年度计划存款公式:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算
- [x] 7.3 运行测试,确认绿灯
## 8. 重构 GetForMonthAsync - TDD 红灯阶段
- [x] 8.1 在 `WebApi.Test/Budget/BudgetSavingsTest.cs` 中编写测试:月度查询应返回 Details 字段
- [x] 8.2 编写测试:月度明细应包含所有月度预算项
- [x] 8.3 编写测试:月度明细应包含本月发生的年度预算项
- [x] 8.4 编写测试:月度明细中每项应包含计算用金额和计算说明
- [x] 8.5 编写测试:超支项目应标记 IsOverBudget = true
- [x] 8.6 编写测试:不记额预算应排除在汇总之外
- [x] 8.7 运行测试,确认红灯
## 9. 重构 GetForMonthAsync - TDD 绿灯阶段
- [x] 9.1 重构 `GetForMonthAsync` 方法,使用 `CalculateMonthlyPlannedSavings`
- [x] 9.2 添加明细数据收集逻辑(创建 `BudgetDetailItem` 列表)
- [x] 9.3 为每个预算项调用 `BudgetItemCalculator.CalculateEffectiveAmount`
- [x] 9.4 生成 `SavingsDetail` 对象并填充到 `BudgetResult.Details`
- [x] 9.5 生成 `SavingsCalculationSummary` 汇总信息
- [x] 9.6 保留原有的 HTML `Description` 生成逻辑(向后兼容)
- [x] 9.7 运行测试,确认绿灯
## 10. 重构 GetForYearAsync - TDD 红灯阶段
- [x] 10.1 编写测试:年度查询应返回 Details 字段
- [x] 10.2 编写测试年度明细应包含归档月份标注IsArchived = true
- [x] 10.3 编写测试:年度明细应包含 ArchivedMonths 字段
- [x] 10.4 编写测试:归档数据应使用归档的实际值
- [x] 10.5 编写测试:未来月份预算应正确折算
- [x] 10.6 编写测试:年度预算项不应重复计算
- [x] 10.7 运行测试,确认红灯
## 11. 重构 GetForYearAsync - TDD 绿灯阶段
- [x] 11.1 重构 `GetForYearAsync` 方法,使用 `CalculateYearlyPlannedSavings`
- [x] 11.2 添加归档数据读取和明细项创建逻辑
- [x] 11.3 为归档数据标注 `IsArchived = true``ArchivedMonths`
- [x] 11.4 添加未来月份预算的明细项创建逻辑
- [x] 11.5 生成 `SavingsDetail` 对象并填充到 `BudgetResult.Details`
- [x] 11.6 保留原有的 HTML `Description` 生成逻辑(向后兼容)
- [x] 11.7 运行测试,确认绿灯
## 12. 边界情况测试
- [x] 12.1 编写测试:闰年年度硬性支出按天折算
- [x] 12.2 编写测试:平年年度硬性支出按天折算
- [x] 12.3 编写测试月初1号硬性支出折算
- [x] 12.4 编写测试月末28/29/30/31号硬性支出折算
- [x] 12.5 编写测试:不记额预算的处理
- [x] 12.6 编写测试:无预算项时的空列表处理
- [x] 12.7 编写测试所有预算项实际为0的情况
- [x] 12.8 运行所有边界测试,确认通过
## 13. 集成测试
- [x] 13.1 编写完整场景集成测试:月度查询包含月度+年度混合
- [x] 13.2 编写完整场景集成测试:年度查询包含归档+未来混合
- [x] 13.3 编写集成测试:验证 HTML Description 和 Details 数据一致性
- [x] 13.4 编写集成测试:验证与 BudgetArchiveRepository 的集成
- [x] 13.5 编写集成测试:验证与 TransactionStatisticsService 的集成
- [x] 13.6 运行所有集成测试,确认通过
## 14. 代码审查与重构
- [x] 14.1 审查所有新增代码,确保符合项目编码规范
- [x] 14.2 检查中文注释是否完整清晰
- [x] 14.3 重构重复代码,提取共用方法
- [x] 14.4 优化变量命名,确保语义清晰
- [x] 14.5 运行所有测试,确保重构后测试仍然通过
## 15. 文档与验收
- [x] 15.1 更新 `BudgetSavingsService` 相关方法的 XML 文档注释
- [x] 15.2 添加 `BudgetItemCalculator` 的使用示例注释
- [x] 15.3 运行完整测试套件:`dotnet test WebApi.Test/WebApi.Test.csproj`
- [x] 15.4 验证所有测试通过0 failed
- [x] 15.5 手动验证:通过 API 调用验证返回数据格式正确
- [x] 15.6 确认向后兼容:旧版前端仍可正常使用 Description 字段

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: 页面头部布局
余额页面 SHALL 使用自定义头部布局,包含标题文字和右侧操作按钮区域,替代原有的 `van-nav-bar` 组件。
#### Scenario: 头部显示
- **WHEN** 用户打开余额页面
- **THEN** 系统在顶部显示"账单"文字标题(不带返回按钮或边框)
#### Scenario: 头部样式
- **WHEN** 用户查看头部区域
- **THEN** 系统使用 `var(--font-2xl)` 字号和 `var(--text-primary)` 颜色显示标题,背景透明
### Requirement: 右侧操作按钮
头部右侧 SHALL 根据当前选中的标签页动态显示不同的操作按钮。
#### Scenario: 邮件页同步按钮
- **WHEN** 用户切换到"邮件"标签
- **THEN** 系统在头部右侧显示"立即同步"按钮
#### Scenario: 消息页标记已读图标
- **WHEN** 用户切换到"消息"标签
- **THEN** 系统在头部右侧显示"标记全部已读"图标
#### Scenario: 账单页无操作按钮
- **WHEN** 用户切换到"账单"标签
- **THEN** 系统不在头部右侧显示任何操作按钮
### Requirement: 视觉风格一致性
头部布局 SHALL 与统计页面的 `DateSelectHeader` 组件保持视觉风格一致。
#### Scenario: 内边距规范
- **WHEN** 用户查看头部区域
- **THEN** 系统使用 `padding: 8px 24px` 作为头部内边距
#### Scenario: 主题适配
- **WHEN** 用户切换应用主题(亮色/暗色)
- **THEN** 头部文字颜色自动更新为对应主题的 `var(--text-primary)`

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 分段控制器布局
余额页面的标签切换 SHALL 使用分段控制器(segmented control)样式,包含三个选项:账单、邮件、消息。
#### Scenario: 分段控制器显示
- **WHEN** 用户打开余额页面
- **THEN** 系统显示分段控制器,包含"账单"、"邮件"、"消息"三个选项
#### Scenario: 默认选中账单
- **WHEN** 用户首次打开余额页面且 URL 无 tab 参数
- **THEN** 系统默认选中"账单"标签
### Requirement: 标签页切换交互
用户 SHALL 能够点击分段控制器中的任意选项切换标签页。
#### Scenario: 点击切换标签
- **WHEN** 用户点击"邮件"选项
- **THEN** 系统切换到邮件视图,并更新分段控制器的选中状态
#### Scenario: 路由参数切换
- **WHEN** 用户通过底部导航栏跳转到余额页面且携带 `?tab=message` 参数
- **THEN** 系统自动选中"消息"标签
### Requirement: 视觉样式一致性
分段控制器 SHALL 使用与统计页面相同的样式规范和 CSS 变量。
#### Scenario: 亮色主题样式
- **WHEN** 应用处于亮色主题
- **THEN** 分段控制器使用 `var(--segmented-bg)` 作为背景色,选中项使用 `var(--segmented-active-bg)`
#### Scenario: 暗色主题样式
- **WHEN** 应用处于暗色主题
- **THEN** 分段控制器自动应用暗色主题对应的 CSS 变量值
### Requirement: 移动端触控体验
分段控制器 SHALL 针对移动端优化触控体验。
#### Scenario: 触控区域
- **WHEN** 用户在移动设备上点击选项
- **THEN** 系统确保每个选项的可点击区域高度不小于 40px
#### Scenario: 触控反馈
- **WHEN** 用户点击未选中的选项
- **THEN** 系统显示背景色过渡动画(0.3s cubic-bezier)

View File

@@ -0,0 +1,114 @@
## ADDED Requirements
### Requirement: Unified Bill List Component Usage
所有账单列表页面 SHALL 使用 `BillListComponent` 组件来展示账单列表,而不是自定义实现。
#### Scenario: Component Imported and Used
- **WHEN** 开发者查看任意账单列表页面的代码
- **THEN** 页面 SHALL import `BillListComponent` from `@/components/Bill/BillListComponent.vue`
- **AND** 页面 SHALL 在 template 中使用 `<BillListComponent>` 标签
#### Scenario: No Custom List Rendering
- **WHEN** 开发者查看账单列表页面的代码
- **THEN** 页面 SHALL NOT 包含自定义的 `van-swipe-cell` 列表渲染逻辑
- **AND** 页面 SHALL NOT 包含重复的账单卡片样式定义
### Requirement: Custom Data Source Mode
账单列表页面 SHALL 使用 Custom 数据模式,父组件控制数据加载逻辑。
#### Scenario: Data Source Configuration
- **WHEN** 页面使用 `BillListComponent`
- **THEN** 组件 SHALL 配置 `data-source="custom"` prop
- **AND** 父组件 SHALL 通过 `:transactions` prop 传递账单数据数组
#### Scenario: Parent Controlled Loading
- **WHEN** 页面需要加载账单数据
- **THEN** 父组件 SHALL 负责调用 API 获取数据
- **AND** 父组件 SHALL 负责数据筛选和分页逻辑
- **AND** `BillListComponent` SHALL 仅负责展示传入的数据
### Requirement: Business Logic Preservation
迁移后的页面 SHALL 保持原有的业务逻辑完整,包括事件处理、数据刷新和特定筛选。
#### Scenario: Click Event Handling
- **WHEN** 用户点击账单卡片
- **THEN** `BillListComponent` SHALL emit `@click` 事件
- **AND** 父组件 SHALL 处理该事件,执行原有的业务逻辑(如显示详情弹窗)
#### Scenario: Delete Event Handling
- **WHEN** 用户左滑删除账单
- **THEN** `BillListComponent` SHALL emit `@delete` 事件(内部已完成 API 调用)
- **AND** 父组件 SHALL 更新本地数据列表,移除已删除的账单
#### Scenario: Global Event Listening
- **WHEN** 页面监听全局事件(如 `transaction-deleted`)
- **THEN** 迁移后页面 SHALL 保留所有全局事件监听器
- **AND** 事件处理逻辑 SHALL 保持不变
### Requirement: Feature Configuration
页面 SHALL 通过 props 配置 `BillListComponent` 的功能开关,仅启用所需功能。
#### Scenario: Filter Bar Control
- **WHEN** 页面不需要筛选栏功能
- **THEN** 页面 SHALL 设置 `:enable-filter="false"` prop
- **AND** `BillListComponent` SHALL NOT 显示筛选栏 UI
#### Scenario: Checkbox Control
- **WHEN** 页面不需要多选功能
- **THEN** 页面 SHALL 设置 `:show-checkbox="false"` prop
- **AND** `BillListComponent` SHALL NOT 显示复选框
#### Scenario: Delete Button Control
- **WHEN** 页面需要左滑删除功能
- **THEN** 页面 SHALL 设置 `:show-delete="true"` prop
- **AND** `BillListComponent` SHALL 显示左滑删除按钮
### Requirement: Visual Consistency
所有账单列表页面 SHALL 使用一致的视觉设计和交互模式。
#### Scenario: Card Layout Consistency
- **WHEN** 用户查看任意账单列表页面
- **THEN** 所有账单卡片 SHALL 使用相同的布局:左侧图标、中间内容、右侧金额
- **AND** 卡片样式 SHALL 与 `/balance` 页面一致
#### Scenario: Icon Display Consistency
- **WHEN** 账单有分类信息
- **THEN** 系统 SHALL 显示对应的 Iconify 图标或降级为 Vant 图标
- **AND** 图标颜色和背景 SHALL 根据账单类型(支出/收入/不计入)统一着色
#### Scenario: Amount Formatting Consistency
- **WHEN** 系统显示账单金额
- **THEN** 支出 SHALL 显示为红色 `- ¥XX.XX`
- **AND** 收入 SHALL 显示为绿色 `+ ¥XX.XX`
- **AND** 不计入 SHALL 显示为灰色 `¥XX.XX`
### Requirement: Migration Coverage
系统 SHALL 完成所有指定页面的账单列表迁移。
#### Scenario: All Target Pages Migrated
- **WHEN** 迁移完成
- **THEN** 以下 6 个页面 SHALL 全部使用 `BillListComponent`:
- `MessageView.vue`
- `EmailRecord.vue`
- `PeriodicRecord.vue`
- `ClassificationEdit.vue`
- `calendarV2/modules/TransactionList.vue`
- `budgetV2/Index.vue`
#### Scenario: No Functional Regression
- **WHEN** 迁移完成后用户使用任意账单列表页面
- **THEN** 所有原有功能 SHALL 正常工作(点击、删除、筛选、分页)
- **AND** 用户 SHALL NOT 遇到任何功能缺失或异常
### Requirement: Code Quality
迁移后的代码 SHALL 保持高质量,移除废弃代码。
#### Scenario: No Dead Code
- **WHEN** 开发者审查迁移后的页面代码
- **THEN** 页面 SHALL NOT 包含废弃的自定义列表代码
- **AND** 页面 SHALL NOT 包含未使用的列表样式定义
#### Scenario: Component Import Clarity
- **WHEN** 开发者查看页面导入语句
- **THEN** 页面 SHALL 明确 import `BillListComponent`
- **AND** 页面 SHALL 移除原有的自定义列表组件导入(如果有)

View File

@@ -0,0 +1,108 @@
## ADDED Requirements
### Requirement: PopupContainer 组件导入路径迁移
所有引用 `PopupContainer.vue` 的文件必须更新导入路径为 `PopupContainerV2.vue`,并将组件名更改为 `PopupContainerV2`
#### Scenario: 更新 import 语句
- **WHEN** 文件中存在 `import PopupContainer from '@/components/PopupContainer.vue'``import PopupContainer from './PopupContainer.vue'`
- **THEN** 系统必须将其替换为 `import PopupContainerV2 from '@/components/PopupContainerV2.vue'`
#### Scenario: 更新模板中的组件名
- **WHEN** 模板中使用 `<PopupContainer>` 标签
- **THEN** 系统必须将其替换为 `<PopupContainerV2>`
### Requirement: Props API 映射转换
V1 和 V2 的 props 差异必须通过重构适配,确保功能等价。
#### Scenario: 基础 props 映射
- **WHEN** V1 使用 `v-model:show``title` 等基础 props
- **THEN** V2 必须保留这些 props 不变(`modelValue``title` 在两个版本中一致)
#### Scenario: height prop 默认值处理
- **WHEN** V1 未显式指定 `height` prop默认 `80%`
- **THEN** V2 必须显式添加 `:height="'80%'"` 以保持一致的视觉效果
#### Scenario: 移除不支持的 props
- **WHEN** V1 使用 `closeable``subtitle``showConfirmButton``showCancelButton``confirmText``cancelText` 等 props
- **THEN** 系统必须移除这些 props并通过插槽方式重构见下一需求
### Requirement: subtitle 功能迁移
V1 的 `subtitle` prop 必须转换为 V2 的默认插槽内容或自定义标题结构。
#### Scenario: subtitle 作为内容区域展示
- **WHEN** V1 使用 `subtitle` prop 显示副标题
- **THEN** 必须将 subtitle 内容移至 `<PopupContainerV2>` 的默认插槽中,并使用适当的样式包裹(如 `<p class="subtitle-text">{{ subtitle }}</p>`
#### Scenario: subtitle 包含 HTML 内容
- **WHEN** V1 的 `subtitle` 使用 `v-html` 渲染(如统计信息)
- **THEN** 必须在默认插槽中创建等价的 HTML 结构,保持语义和样式一致
### Requirement: 确认/取消按钮迁移
V1 的 `showConfirmButton``showCancelButton` 等按钮相关 props 必须转换为 V2 的 `footer` 插槽。
#### Scenario: 标准确认/取消按钮
- **WHEN** V1 使用 `show-confirm-button``show-cancel-button` props
- **THEN** 必须在 V2 的 `<template #footer>` 中手动创建 `<van-button>` 组件,绑定相同的事件处理器(`@confirm``@cancel`
#### Scenario: 自定义按钮文本
- **WHEN** V1 使用 `confirm-text``cancel-text` 自定义按钮文字
- **THEN** 必须将文本内容应用到 footer 插槽中的按钮组件
#### Scenario: 按钮布局样式
- **WHEN** 创建 footer 插槽内的按钮
- **THEN** 必须使用 flexbox 布局确保按钮水平排列,间距为 12px与 V1 的视觉效果一致
### Requirement: header-actions 插槽迁移
V1 的 `header-actions` 插槽必须根据业务逻辑转换为 V2 的内容区域或自定义实现。
#### Scenario: 移除 header-actions 插槽
- **WHEN** V1 使用 `<template #header-actions>` 插槽放置操作按钮
- **THEN** V2 必须将这些按钮移至默认插槽顶部或 footer 插槽中,根据业务语义选择合适位置
#### Scenario: 保持操作按钮的视觉层级
- **WHEN** V1 的 header-actions 与标题同行显示grid 布局)
- **THEN** 必须在 V2 的默认插槽中创建自定义布局,使用绝对定位或 flexbox 实现相同效果
### Requirement: 样式和暗色模式兼容性
迁移后的组件必须保持视觉一致性,正确响应暗色模式。
#### Scenario: 暗色模式自动适配
- **WHEN** 用户切换到暗色模式
- **THEN** V2 的硬编码颜色(`#ffffff``#09090b` 等)必须通过 `@media (prefers-color-scheme: dark)` 自动切换
#### Scenario: 内容区域 padding 处理
- **WHEN** V1 的可滚动内容区域有默认样式
- **THEN** V2 的内容插槽无默认 padding必须由使用方手动添加`<div class="content" style="padding: 16px">`
### Requirement: 事件处理器兼容性
V1 的事件(`@confirm``@cancel`)必须正确映射到 V2 的按钮点击事件。
#### Scenario: 确认事件触发
- **WHEN** 用户点击 footer 插槽中的确认按钮
- **THEN** 必须手动触发原有的 `@confirm` 事件处理逻辑(可能需要通过 `emit` 或直接调用方法)
#### Scenario: 取消事件触发
- **WHEN** 用户点击取消按钮或关闭弹窗
- **THEN** 必须确保原有的 `@cancel` 逻辑被正确执行V2 已通过关闭按钮自动关闭弹窗,但可能需要额外的清理逻辑)
### Requirement: 代码质量和测试
迁移后的代码必须通过 ESLint 检查,并保持功能正确性。
#### Scenario: ESLint 验证通过
- **WHEN** 完成迁移后
- **THEN** 运行 `pnpm lint` 必须无错误和警告
#### Scenario: 功能回归测试
- **WHEN** 迁移后的页面加载
- **THEN** 弹窗的打开/关闭、内容展示、按钮交互必须与迁移前行为一致
### Requirement: 删除旧版本组件
所有迁移完成后,必须删除 `PopupContainer.vue` 文件以避免混淆。
#### Scenario: 文件删除
- **WHEN** 所有 18 个文件迁移完成并验证通过
- **THEN** 系统必须删除 `Web/src/components/PopupContainer.vue` 文件
#### Scenario: 无残留引用
- **WHEN** 删除旧组件后
- **THEN** 项目中不得存在任何对 `PopupContainer.vue` 的引用(通过全局搜索验证)

View File

@@ -0,0 +1,45 @@
## MODIFIED Requirements
### Requirement: Display income and expense budget in savings detail popup
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
#### Scenario: User opens savings detail popup with matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there exist income and expense budgets for the same period and type
- **THEN** the popup SHALL display the income budget limit and current amount
- **AND** the popup SHALL display the expense budget limit and current amount
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
#### Scenario: User opens savings detail popup without matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there are no income or expense budgets for the same period and type
- **THEN** the popup SHALL display 0 for income budget limit and current amount
- **AND** the popup SHALL display 0 for expense budget limit and current amount
- **AND** the popup SHALL still display the savings formula and result with these values
### Requirement: Pass budget data to savings component
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
#### Scenario: Budget data is loaded successfully
- **WHEN** the budget data is loaded from the API
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
### Requirement: Match income and expense budgets to savings plan
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
#### Scenario: Match budgets with same period and type
- **WHEN** displaying savings plan details
- **AND** the component searches for matching budgets
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
- **AND** if multiple matches exist, the component SHALL use the first match
#### Scenario: No matching budgets found
- **WHEN** displaying savings plan details
- **AND** no income budget matches the savings plan's periodStart and type
- **OR** no expense budget matches the savings plan's periodStart and type
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
- **AND** the popup SHALL still render without errors

View File

@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: PopupContainerV2 提供固定布局能力
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格16px 圆角、Inter 字体、纯白背景)。
#### Scenario: PopupContainerV2 基础结构
- **WHEN** 使用 PopupContainerV2 组件
- **THEN** 组件基于 van-popup 实现底部弹窗
- **THEN** 弹窗顶部圆角为 16pxborderTopLeftRadius 和 borderTopRightRadius
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
#### Scenario: PopupContainerV2 固定头部
- **WHEN** 传入 title prop
- **THEN** 头部显示标题文本,字体为 Inter18pxfont-weight 600
- **THEN** 头部右侧显示关闭按钮van-icon cross
- **THEN** 头部固定在顶部,不随内容滚动
#### Scenario: PopupContainerV2 可滚动内容区域
- **WHEN** 在 default 插槽中放置内容
- **THEN** 内容区域可独立滚动
- **THEN** 滚动时头部和底部保持固定
- **THEN** 内容区域不提供默认 padding由使用方控制
#### Scenario: PopupContainerV2 固定底部
- **WHEN** 在 footer 插槽中放置操作按钮
- **THEN** 底部区域固定在底部,不随内容滚动
- **THEN** 底部区域保持与内容区域的视觉分隔
#### Scenario: PopupContainerV2 暗色模式支持
- **WHEN** 系统启用暗色模式prefers-color-scheme: dark
- **THEN** 背景色切换为 #18181b
- **THEN** 标题文本颜色切换为 #fafafa
- **THEN** 关闭按钮颜色切换为 #a1a1aa
## MODIFIED Requirements
### Requirement: TransactionDetailSheet 使用 PopupContainerV2 实现固定布局
TransactionDetailSheet 组件 SHALL 使用 PopupContainerV2 组件实现底部弹窗,并确保头部标题和底部操作按钮固定不随内容滚动。
#### Scenario: 打开交易详情弹窗
- **WHEN** 用户点击交易记录打开详情弹窗
- **THEN** 弹窗从底部弹出,使用 PopupContainerV2 组件
- **THEN** 弹窗顶部圆角为 16px
- **THEN** 头部显示固定标题"交易详情",字体为 Inter 18px
- **THEN** 底部固定显示"删除"和"保存"按钮
- **THEN** 中间内容区域(金额、时间、备注、类型、分类)可独立滚动
#### Scenario: 滚动交易详情内容
- **WHEN** 用户在交易详情弹窗中滚动内容
- **THEN** 头部标题始终固定在顶部可见
- **THEN** 底部操作按钮(删除、保存)始终固定在底部可见
- **THEN** 只有中间内容区域发生滚动
#### Scenario: 展开分类选择器后滚动
- **WHEN** 用户点击"分类"字段展开分类选择器
- **THEN** 分类选择器在内容区域内展开
- **THEN** 如果内容超出可视区域,用户可以滚动查看所有分类选项
- **THEN** 头部标题和底部按钮仍然保持固定
### Requirement: 保持现有功能和交互不变
TransactionDetailSheet 组件在重构后 SHALL 保持所有现有的功能和交互逻辑不变,包括但不限于金额编辑、日期选择、分类选择、保存、删除等操作。
#### Scenario: 金额编辑功能保持不变
- **WHEN** 用户点击金额数值进入编辑模式
- **THEN** 金额输入框自动聚焦并选中当前值
- **THEN** 用户输入新金额后失焦时自动格式化为两位小数
#### Scenario: 日期时间选择功能保持不变
- **WHEN** 用户点击"时间"字段
- **THEN** 弹出 van-datetime-picker 日期时间选择器
- **THEN** 选择器显示在最顶层(不受 PopupContainerV2 层级影响)
- **THEN** 确认后时间更新为选择的值
#### Scenario: 分类选择功能保持不变
- **WHEN** 用户点击"分类"字段
- **THEN** 展开 ClassifySelector 组件
- **THEN** 用户可以选择分类
- **THEN** 选择后自动触发保存
#### Scenario: 保存和删除功能保持不变
- **WHEN** 用户点击"保存"按钮
- **THEN** 验证必填字段(金额、分类、时间)
- **THEN** 调用 updateTransaction API 更新数据
- **THEN** 成功后显示提示并关闭弹窗
- **WHEN** 用户点击"删除"按钮
- **THEN** 显示确认对话框
- **THEN** 确认后调用 deleteTransaction API 删除数据
- **THEN** 成功后显示提示并关闭弹窗
### Requirement: 组件对外 API 兼容
TransactionDetailSheet 组件的 props 和 events SHALL 保持与重构前完全一致,确保零破坏性变更。
#### Scenario: Props 保持不变
- **WHEN** 父组件传递 `show``transaction` props
- **THEN** TransactionDetailSheet 正常接收并响应这些 props
- **THEN** 不需要修改任何调用方代码
#### Scenario: Events 保持不变
- **WHEN** 用户保存或删除交易
- **THEN** TransactionDetailSheet 触发 `update:show`, `save`, `delete` 事件
- **THEN** 事件参数格式与重构前完全一致

View File

@@ -11,6 +11,54 @@
- **WHEN** CalendarV2 需要展示交易列表
- **THEN** 使用 `BillListComponent.vue` 或保留其特有实现(如有特殊需求)
### Requirement: CategoryBillPopup 统一样式
统计页面分类账单弹窗必须使用 BillListComponent样式与 Balance 页面一致。
#### Scenario: 使用 BillListComponent
- **WHEN** 用户在统计页面点击分类卡片
- **THEN** 弹窗使用 `BillListComponent` 展示账单列表,配置为 `dataSource="api"` 模式
#### Scenario: 列表项样式对齐
- **WHEN** 账单列表渲染
- **THEN** 使用与 `TransactionsRecord.vue` 相同的卡片样式(图标、金额、标签布局)
#### Scenario: 左滑删除
- **WHEN** 用户在账单项上左滑
- **THEN** 显示红色删除按钮,点击后确认删除
#### Scenario: 点击查看详情
- **WHEN** 用户点击账单项
- **THEN** 打开 `TransactionDetailSheet` 查看详情
### Requirement: CalendarV2 TransactionList 对齐
日历页面的交易列表样式必须与 Balance 页面一致。
#### Scenario: 紧凑布局
- **WHEN** 日历页面展示当天账单列表
- **THEN** 使用 `compact={true}` 紧凑布局
#### Scenario: 删除交互
- **WHEN** 用户左滑删除账单
- **THEN** 与 Balance 页面删除交互一致
### Requirement: BudgetCard 关联账单对齐
预算页面的关联账单弹窗样式必须与 Balance 页面一致。
#### Scenario: 统一卡片样式
- **WHEN** 预算卡片展示关联账单
- **THEN** 账单项样式与 Balance 页面一致
### Requirement: EmailRecord 关联账单对齐
邮件记录页面的关联账单弹窗样式必须与 Balance 页面一致。
#### Scenario: 统一卡片样式
- **WHEN** 邮件记录展示关联账单
- **THEN** 账单项样式与 Balance 页面一致
#### Scenario: 删除功能
- **WHEN** 用户删除账单
- **THEN** 删除交互与 Balance 页面一致
### Requirement: 功能对等性
新组件必须保持旧版所有功能,确保迁移不丢失特性。
@@ -26,12 +74,16 @@
- **WHEN** 页面需要展示离线或缓存数据
- **THEN** 新组件通过 `dataSource="custom"``transactions` prop 支持自定义数据
#### Scenario: 弹窗场景数据源
- **WHEN** 弹窗组件CategoryBillPopup、BudgetCard、EmailRecord展示账单
- **THEN** 使用 `dataSource="api"``dataSource="custom"`,并配置 `enableFilter={false}` 禁用筛选
### Requirement: 视觉升级
新组件必须基于 v2 的现代化设计,提供更好的视觉体验。
#### Scenario: 卡片样式
- **WHEN** 展示账单列表
- **THEN** 使用 v2 的卡片样式(圆角、阴影、图标),调整为紧凑间距
- **THEN** 使用 v2 的卡片样式(圆角、阴影、图标),调整为紧凑间距
#### Scenario: 图标展示
- **WHEN** 账单有分类信息
@@ -41,6 +93,10 @@
- **WHEN** 显示账单类型
- **THEN** 使用彩色标签(支出红色、收入绿色),位于卡片右上角
#### Scenario: 空状态展示
- **WHEN** 账单列表为空
- **THEN** 显示统一的空状态图标和提示文案
### Requirement: 迁移计划
系统必须按阶段迁移,确保平滑过渡。