13 Commits

Author SHA1 Message Date
SunCheng
3d0fde5eee 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 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-21 22:09:05 +08:00
SunCheng
9dce12c61b 归档
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 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-21 21:58:55 +08:00
SunCheng
c2751c79cf fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 14s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-21 12:15:08 +08:00
SunCheng
749624f290 feat: 统一交易详情组件,替换为 TransactionDetailSheet,增强功能并删除旧组件
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
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-21 12:11:50 +08:00
SunCheng
5f5c15ffb5 fix: 修复 ReasonGroupList 组件引用路径
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 2s
2026-02-21 10:57:09 +08:00
SunCheng
045158730f refactor: 整理组件目录结构
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 4m47s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
- TransactionDetail, CategoryBillPopup 移入 Transaction/
- BudgetTypeTabs 移入 Budget/
- GlassBottomNav, ModernEmpty 移入 Global/
- Icon, IconSelector, ClassifySelector 等 8 个通用组件移入 Common/
- 更新所有相关引用路径
2026-02-21 10:10:16 +08:00
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
126 changed files with 3891 additions and 1850 deletions

View File

@@ -0,0 +1,105 @@
---
title: 前端整体交互测试报告
author: AI Assistant
date: 2026-02-21
status: draft
category: 测试
---
# 前端整体交互测试报告
## 一、测试目标
第一步:对 `http://localhost:5173/` 前端页面进行逐页面、按钮与输入控件的基础交互测试。
第二步:检查是否存在明显样式崩坏、按钮不可点击或无响应的情况。
第三步:记录控制台错误与异常行为,形成后续修复依据。
## 二、测试范围
- 页面范围:底部导航 5 个页面(日历、统计、账单、预算、设置)
- 控件范围:可见按钮、链接、开关、输入/文本域/下拉框(不含上传功能)
- 非目标:不做业务流程深度验证、不做上传功能测试、不做截图对比
## 三、测试环境与前置条件
- 地址:`http://localhost:5173/`
- 工具Playwright自动化点击与输入
- 说明:已跳过文件上传输入控件(`input[type=file]`
## 四、测试方法
第一步:通过底部导航依次进入 5 个页面。
第二步:对每个页面所有 `button``a``.van-switch` 进行点击。
第三步:对可编辑输入控件执行填入与清空。
第四步:记录页面路由变化与控制台异常。
## 五、测试结果概览
- 样式检查:未发现明显崩坏(基于 DOM 与可见结构的自动化判断)
- 点击响应:大多数按钮可触发交互或弹窗
- 控制台异常:存在路由错误与 Vue 警告(详见第七节)
## 六、逐页面结果
### 1) 日历页 `/calendar-v2`
| 项目 | 结果 | 备注 |
| --- | --- | --- |
| 按钮点击 | 通过 | 存在未命名按钮,点击后跳转至 `/balance?tab=message` |
| 链接点击 | 无 | 未检测到 `a` 标签 |
| 输入控件 | 无 | 未检测到输入控件 |
| 样式 | 正常 | 未见崩坏 |
### 2) 统计页 `/statistics-v2`
| 项目 | 结果 | 备注 |
| --- | --- | --- |
| 按钮点击 | 通过 | 包含“取消/确认”按钮 |
| 链接点击 | 无 | 未检测到 `a` 标签 |
| 输入控件 | 无 | 未检测到输入控件 |
| 样式 | 正常 | 未见崩坏 |
### 3) 账单页 `/balance`
| 项目 | 结果 | 备注 |
| --- | --- | --- |
| 按钮点击 | 通过 | 存在大量“删除”按钮,点击可触发确认弹窗 |
| 链接点击 | 无 | 未检测到 `a` 标签 |
| 输入控件 | 无 | 未检测到输入控件 |
| 样式 | 正常 | 未见崩坏 |
### 4) 预算页 `/budget-v2`
| 项目 | 结果 | 备注 |
| --- | --- | --- |
| 按钮点击 | 通过 | 包含“取消/确认”按钮 |
| 链接点击 | 无 | 未检测到 `a` 标签 |
| 输入控件 | 无 | 未检测到输入控件 |
| 样式 | 正常 | 未见崩坏 |
### 5) 设置页 `/setting`
| 项目 | 结果 | 备注 |
| --- | --- | --- |
| 按钮点击 | 通过 | 包含“取消/确认”按钮 |
| 开关点击 | 通过 | `.van-switch` 可点击切换 |
| 输入控件 | 跳过 | `input[type=file]` 按要求跳过 |
| 样式 | 正常 | 未见崩坏 |
## 七、控制台异常记录
- Vue 警告Invalid prop type check failed
- Vue 警告Unhandled error during execution出现多次
- 路由错误No match for {"name":"statistics","params":...}
## 八、结论与建议
- 结论:基础可见交互大多可点击,页面样式未见明显崩坏,但存在控制台错误与警告,需优先排查。
- 建议:
1. 修复路由名称与参数不匹配问题(统计页相关)。
2. 排查触发弹窗/确认按钮时的异常栈Vue Unhandled error
3. 如需“功能正常且符合逻辑”的强结论,建议补充关键业务流程的人工验证。
## 更新日志
- 2026-02-21首次生成前端整体交互测试报告自动化点击/输入)。

View File

@@ -19,7 +19,7 @@
| MessageView.vue | 时间戳 (createTime) | 移至内容区域顶部,使用灰色小字 |
| CategoryBillPopup.vue | 待检查 | 待定 |
| BudgetChartAnalysis.vue | 待检查 | 待定 |
| TransactionDetail.vue | 待检查 | 待定 |
| TransactionDetail.vue | 已删除 | 已被 TransactionDetailSheet.vue 替代 |
| ReasonGroupList.vue | 待检查 | 待定 |
### 第三批:带确认/取消按钮

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

@@ -449,6 +449,11 @@ public record BudgetResult
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,
decimal currentAmount,
@@ -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

@@ -45,7 +45,7 @@ import { RouterView, useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useMessageStore } from '@/stores/message'
import GlobalAddBill from '@/components/Global/GlobalAddBill.vue'
import GlassBottomNav from '@/components/GlassBottomNav.vue'
import GlassBottomNav from '@/components/Global/GlassBottomNav.vue'
import '@/styles/common.css'
import { needRefresh, updateServiceWorker } from './registerServiceWorker'
@@ -150,7 +150,8 @@ const isShowAddBill = computed(() => {
route.path === '/' ||
route.path === '/balance' ||
route.path === '/message' ||
route.path === '/calendar-v2'
route.path === '/calendar-v2' ||
route.path === '/statistics-v2'
)
})

View File

@@ -132,7 +132,7 @@
import { ref, onMounted, watch, nextTick } from 'vue'
import { showToast } from 'vant'
import dayjs from 'dayjs'
import ClassifySelector from '@/components/ClassifySelector.vue'
import ClassifySelector from '@/components/Common/ClassifySelector.vue'
const props = defineProps({
initialData: {

View File

@@ -232,7 +232,7 @@ import { ref, computed, watch, onMounted } from 'vue'
import { showConfirmDialog, showToast } from 'vant'
import { getTransactionList, deleteTransaction } from '@/api/transactionRecord'
import { getCategoryList } from '@/api/transactionCategory'
import Icon from '@/components/Icon.vue'
import Icon from '@/components/Common/Icon.vue'
/**
* @typedef {Object} Transaction

View File

@@ -429,7 +429,7 @@
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/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

@@ -210,7 +210,7 @@
import { ref, computed } from 'vue'
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
import { getCssVar } from '@/utils/theme'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
@@ -459,24 +459,26 @@ const calculateChartHeight = (budgets) => {
const varianceLabelPlugin = {
id: 'variance-label-plugin',
afterDatasetsDraw: (chart) => {
afterDraw: (chart) => {
const dataset = chart.data?.datasets?.[0]
const metaData = dataset?._meta
if (!dataset || !metaData) {
if (!dataset || !metaData || !chart.chartArea) {
return
}
const meta = chart.getDatasetMeta(0)
if (!meta?.data) {
if (!meta?.data || meta.data.length === 0) {
return
}
const { ctx, chartArea } = chart
const fontFamily = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
ctx.save()
ctx.font = `12px ${fontFamily}`
ctx.font = `bold 11px ${fontFamily}`
ctx.textBaseline = 'middle'
const padding = 6
meta.data.forEach((bar, index) => {
const item = metaData[index]
if (!item || item.value === 0) {
@@ -485,26 +487,22 @@ const varianceLabelPlugin = {
const label = formatVarianceLabelValue(item.value)
const textWidth = ctx.measureText(label).width
const position = bar.tooltipPosition ? bar.tooltipPosition() : { x: bar.x, y: bar.y }
const offset = 8
const isPositive = item.value > 0
const y = bar.y
let x
if (isPositive) {
x = Math.max(bar.x, bar.base) + padding
ctx.textAlign = 'left'
if (x + textWidth > chartArea.right - 4) {
x = chartArea.right - textWidth - 4
}
} else {
x = Math.max(bar.base, chartArea.left) + padding
ctx.textAlign = 'left'
}
ctx.fillStyle = getVarianceLabelColor(item.value)
let x = position.x + (isPositive ? offset : -offset)
const y = position.y
if (chartArea) {
const rightLimit = chartArea.right - 4
const leftLimit = chartArea.left + 4
if (isPositive && x + textWidth > rightLimit) {
x = rightLimit - textWidth
}
if (!isPositive && x - textWidth < leftLimit) {
x = leftLimit + textWidth
}
}
ctx.textAlign = isPositive ? 'left' : 'right'
ctx.fillText(label, x, y)
})
@@ -1013,6 +1011,7 @@ const yearBurndownChartOptions = computed(() => {
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.gauge-text-overlay {
@@ -1048,6 +1047,8 @@ const yearBurndownChartOptions = computed(() => {
.chart-header {
margin-bottom: 12px;
position: relative;
z-index: 20;
}
.chart-title {

View File

@@ -122,8 +122,8 @@ import { ref, reactive, computed } from 'vue'
import { showToast } from 'vant'
import { createBudget, updateBudget } from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import ClassifySelector from '@/components/Common/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

@@ -42,8 +42,8 @@
import { ref } from 'vue'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getConfig, setConfig } from '@/api/config'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import ClassifySelector from '@/components/Common/ClassifySelector.vue'
const emit = defineEmits(['success'])

View File

@@ -1,468 +0,0 @@
<template>
<PopupContainerV2
v-model:show="visible"
:title="title"
:height="'80%'"
>
<!-- 交易列表 -->
<div style="padding: 0">
<!-- Subtitle 作为内容区域顶部 -->
<div
v-if="total > 0"
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
>
{{ total }} 笔交易
</div>
<div class="transactions">
<!-- 加载状态 -->
<van-loading
v-if="loading && transactions.length === 0"
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>
<!-- 交易列表 -->
<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>
</div>
</div>
</PopupContainerV2>
<!-- 交易详情弹窗 -->
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleSave"
@delete="handleDelete"
/>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
classify: {
type: String,
default: ''
},
type: {
type: Number,
default: 0
},
year: {
type: Number,
required: true
},
month: {
type: Number,
required: true
}
})
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)
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) {
return
}
if (isRefresh) {
pageIndex.value = 1
transactions.value = []
finished.value = false
}
loading.value = true
try {
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize,
type: props.type,
year: props.year,
month: props.month || 0,
sortByAmount: true
}
if (props.classify) {
params.classify = props.classify
}
const response = await getTransactionList(params)
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]
total.value = response.total
if (newList.length === 0 || newList.length < pageSize) {
finished.value = true
} else {
pageIndex.value++
}
} else {
showToast(response.message || '加载账单失败')
finished.value = true
}
} catch (error) {
console.error('加载分类账单失败:', error)
showToast('加载账单失败')
finished.value = true
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = () => {
loadData(false)
}
// 点击交易
const onTransactionClick = (txn) => {
currentTransaction.value = txn
showDetail.value = true
}
// 保存交易
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')
}
// 监听弹窗打开
watch(visible, (newValue) => {
if (newValue) {
loadData(true)
} else {
// 关闭时重置状态
transactions.value = []
pageIndex.value = 1
finished.value = false
total.value = 0
}
})
</script>
<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

@@ -99,10 +99,11 @@
</PopupContainerV2>
<!-- 账单详情弹窗 -->
<TransactionDetail
<TransactionDetailSheet
v-model:show="showTransactionDetail"
:transaction="selectedTransaction"
@save="handleTransactionSaved"
@delete="handleGroupTransactionDelete"
/>
<!-- 批量设置对话框 -->
@@ -197,8 +198,8 @@ import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { showToast, showSuccessToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
import ClassifySelector from './ClassifySelector.vue'
import BillListComponent from './Bill/BillListComponent.vue'
import TransactionDetail from './TransactionDetail.vue'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from './PopupContainerV2.vue'
const props = defineProps({

View File

@@ -45,7 +45,7 @@
<script setup>
import { ref, defineEmits } from 'vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'

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

@@ -0,0 +1,197 @@
<template>
<PopupContainerV2
v-model:show="visible"
:title="title"
:height="'80%'"
>
<div style="padding: 0">
<div
v-if="total > 0"
style="padding: 12px 16px; text-align: center; color: #999; font-size: 14px; border-bottom: 1px solid var(--van-border-color)"
>
{{ total }} 笔交易
</div>
<BillListComponent
data-source="custom"
:transactions="transactions"
:loading="loading"
:finished="finished"
:show-delete="true"
:enable-filter="false"
@load="loadMore"
@click="onTransactionClick"
@delete="handleDelete"
/>
</div>
</PopupContainerV2>
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleSave"
@delete="handleTransactionDelete"
/>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
classify: {
type: String,
default: ''
},
type: {
type: Number,
default: 0
},
year: {
type: Number,
required: true
},
month: {
type: Number,
required: true
}
})
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)
const pageIndex = ref(1)
const pageSize = 20
const total = ref(0)
const showDetail = ref(false)
const currentTransaction = ref(null)
const loadData = async (isRefresh = false) => {
if (loading.value) {
return
}
if (isRefresh) {
pageIndex.value = 1
transactions.value = []
finished.value = false
}
loading.value = true
try {
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize,
type: props.type,
year: props.year,
month: props.month || 0,
sortByAmount: true
}
if (props.classify) {
params.classify = props.classify
}
const response = await getTransactionList(params)
if (response.success) {
const newList = response.data || []
transactions.value = [...transactions.value, ...newList]
total.value = response.total
if (newList.length === 0 || newList.length < pageSize) {
finished.value = true
} else {
pageIndex.value++
}
} else {
showToast(response.message || '加载账单失败')
finished.value = true
}
} catch (error) {
console.error('加载分类账单失败:', error)
showToast('加载账单失败')
finished.value = true
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value && !loading.value) {
loadData(false)
}
}
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) => {
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
total.value = 0
}
})
</script>
<style scoped>
@import '@/assets/theme.css';
</style>

View File

@@ -99,8 +99,38 @@
<div class="form-label">
分类
</div>
<div class="form-value">
<div style="flex: 1">
<!-- 建议分类提示 -->
<div
class="form-value clickable"
v-if="showSuggestionTip"
class="suggestion-tip"
>
<div
class="suggestion-content"
@click="showClassifySelector = !showClassifySelector"
>
<van-icon
name="bulb-o"
class="suggestion-icon"
/>
<span class="suggestion-text">
建议: {{ props.transaction?.unconfirmedClassify }}
<span v-if="showSuggestedType">
({{ getTypeName(props.transaction?.unconfirmedType) }})
</span>
</span>
</div>
<div
class="suggestion-apply"
@click.stop="applySuggestion"
>
应用
</div>
</div>
<div
v-else
class="classify-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
@@ -111,6 +141,8 @@
</div>
</div>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<div
@@ -149,30 +181,43 @@
</template>
</PopupContainerV2>
<!-- 日期时间选择器 -->
<!-- 日期选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
<van-date-picker
v-model="currentDate"
title="选择日期"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@confirm="onDateConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 时间选择器 -->
<van-popup
v-model:show="showTimePicker"
position="bottom"
round
>
<van-time-picker
v-model="currentTime"
title="选择时间"
@confirm="onTimeConfirm"
@cancel="showTimePicker = false"
/>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ref, reactive, watch, computed } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import ClassifySelector from '@/components/Common/ClassifySelector.vue'
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
const props = defineProps({
@@ -192,16 +237,18 @@ const visible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const showDatePicker = ref(false)
const showTimePicker = ref(false)
const showClassifySelector = ref(false)
const isEditingAmount = ref(false)
// 金额输入框引用
const amountInputRef = ref(null)
// 日期时间选择器配置
// 日期选择器配置
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31)
const currentDateTime = ref(new Date())
const currentDate = ref(['2024', '01', '01'])
const currentTime = ref(['00', '00'])
// 编辑表单
const editForm = reactive({
@@ -213,6 +260,45 @@ const editForm = reactive({
reason: ''
})
// 建议分类提示相关
const showSuggestionTip = computed(() => {
const txn = props.transaction
return (
txn &&
txn.unconfirmedClassify &&
txn.unconfirmedClassify !== editForm.classify
)
})
const showSuggestedType = computed(() => {
const txn = props.transaction
return (
txn &&
txn.unconfirmedType !== null &&
txn.unconfirmedType !== undefined &&
txn.unconfirmedType !== editForm.type
)
})
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计'
}
return typeMap[type] || '未知'
}
const applySuggestion = () => {
const txn = props.transaction
if (txn?.unconfirmedClassify) {
editForm.classify = txn.unconfirmedClassify
if (txn.unconfirmedType !== null && txn.unconfirmedType !== undefined) {
editForm.type = txn.unconfirmedType
}
}
}
// 监听 props 变化
watch(
() => props.show,
@@ -233,9 +319,15 @@ watch(
editForm.occurredAt = newVal.occurredAt
editForm.reason = newVal.reason || ''
// 初始化日期时间
// 初始化日期时间选择器
if (newVal.occurredAt) {
currentDateTime.value = new Date(newVal.occurredAt)
const dt = dayjs(newVal.occurredAt)
currentDate.value = [dt.format('YYYY'), dt.format('MM'), dt.format('DD')]
currentTime.value = [dt.format('HH'), dt.format('mm')]
} else {
const now = dayjs()
currentDate.value = [now.format('YYYY'), now.format('MM'), now.format('DD')]
currentTime.value = [now.format('HH'), now.format('mm')]
}
// 收起分类选择器
@@ -298,11 +390,21 @@ const handleClassifyChange = async () => {
}
}
// 日期时间确认
const handleDateTimeConfirm = (value) => {
editForm.occurredAt = dayjs(value).format('YYYY-MM-DDTHH:mm:ss')
currentDateTime.value = value
// 日期确认
const onDateConfirm = ({ selectedValues }) => {
currentDate.value = selectedValues
showDatePicker.value = false
// 接着选择时间
showTimePicker.value = true
}
// 时间确认
const onTimeConfirm = ({ selectedValues }) => {
currentTime.value = selectedValues
const [year, month, day] = currentDate.value
const [hour, minute] = selectedValues
editForm.occurredAt = `${year}-${month}-${day}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`
showTimePicker.value = false
}
// 保存修改
@@ -549,6 +651,58 @@ const handleDelete = async () => {
margin: 0 24px 16px;
}
// 建议分类提示
.suggestion-tip {
display: flex;
align-items: center;
background: var(--van-active-color);
color: var(--van-primary-color);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--van-primary-color);
}
.suggestion-content {
flex: 1;
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: opacity 0.2s;
&:active {
opacity: 0.8;
}
}
.suggestion-icon {
margin-right: 6px;
font-size: 16px;
}
.suggestion-text {
font-size: 14px;
font-weight: 500;
}
.suggestion-apply {
padding: 10px 12px;
background: var(--van-primary-color);
color: #fff;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
&:active {
opacity: 0.8;
}
}
.classify-value {
text-align: right;
}
// 操作按钮
.actions-section {
display: flex;
@@ -626,5 +780,9 @@ const handleDelete = async () => {
.classify-section {
background: #27272a;
}
.suggestion-tip {
background: rgba(99, 102, 241, 0.1);
}
}
</style>

View File

@@ -1,407 +0,0 @@
<template>
<PopupContainerV2
v-model:show="visible"
title="交易详情"
:height="'75%'"
>
<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="交易类型"
>
<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
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
round
block
type="primary"
:loading="submitting"
@click="onSubmit"
>
保存修改
</van-button>
</template>
</PopupContainerV2>
<!-- 日期选择弹窗 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
teleport="body"
>
<van-date-picker
v-model="currentDate"
title="选择日期"
@confirm="onConfirmDate"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 时间选择弹窗 -->
<van-popup
v-model:show="showTimePicker"
position="bottom"
round
teleport="body"
>
<van-time-picker
v-model="currentTime"
title="选择时间"
@confirm="onConfirmTime"
@cancel="showTimePicker = false"
/>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, defineProps, defineEmits, computed, nextTick } from 'vue'
import { showToast } from 'vant'
import dayjs from 'dayjs'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction } from '@/api/transactionRecord'
const props = defineProps({
show: {
type: Boolean,
default: false
},
transaction: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:show', 'save'])
const visible = ref(false)
const submitting = ref(false)
const isSyncing = ref(false)
// 日期选择相关
const showDatePicker = ref(false)
const showTimePicker = ref(false)
const currentDate = ref([])
const currentTime = ref([])
// 编辑表单
const editForm = reactive({
id: 0,
reason: '',
amount: '',
balance: '',
type: 0,
classify: '',
occurredAt: ''
})
// 显示用的日期格式化
const occurredAtLabel = computed(() => {
return formatDate(editForm.occurredAt)
})
// 监听props变化
watch(
() => props.show,
(newVal) => {
visible.value = newVal
}
)
watch(
() => props.transaction,
(newVal) => {
if (newVal) {
isSyncing.value = true
// 填充编辑表单
editForm.id = newVal.id
editForm.reason = newVal.reason || ''
editForm.amount = String(newVal.amount)
editForm.balance = String(newVal.balance)
editForm.type = newVal.type
editForm.classify = newVal.classify || ''
// 初始化日期时间
if (newVal.occurredAt) {
editForm.occurredAt = newVal.occurredAt
const dt = dayjs(newVal.occurredAt)
currentDate.value = dt.format('YYYY-MM-DD').split('-')
currentTime.value = dt.format('HH:mm').split(':')
}
// 在下一个 tick 结束同步状态,确保 van-radio-group 的 @change 已触发完毕
nextTick(() => {
isSyncing.value = false
})
}
}
)
watch(visible, (newVal) => {
emit('update:show', newVal)
})
// 处理类型切换
const handleTypeChange = () => {
if (!isSyncing.value) {
editForm.classify = ''
}
}
// 处理日期确认
const onConfirmDate = ({ selectedValues }) => {
const dateStr = selectedValues.join('-')
const timeStr = currentTime.value.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showDatePicker.value = false
// 接着选时间
showTimePicker.value = true
}
const onConfirmTime = ({ selectedValues }) => {
currentTime.value = selectedValues
const dateStr = currentDate.value.join('-')
const timeStr = selectedValues.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showTimePicker.value = false
}
const applySuggestion = () => {
if (props.transaction.unconfirmedClassify) {
editForm.classify = props.transaction.unconfirmedClassify
if (
props.transaction.unconfirmedType !== null &&
props.transaction.unconfirmedType !== undefined
) {
editForm.type = props.transaction.unconfirmedType
}
}
}
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计'
}
return typeMap[type] || '未知'
}
// 提交编辑
const onSubmit = async () => {
try {
submitting.value = true
const data = {
id: editForm.id,
reason: editForm.reason,
amount: parseFloat(editForm.amount),
balance: parseFloat(editForm.balance),
type: editForm.type,
classify: editForm.classify,
occurredAt: editForm.occurredAt
}
const response = await updateTransaction(data)
if (response.success) {
showToast('保存成功')
visible.value = false
emit('save', data)
} else {
showToast(response.message || '保存失败')
}
} catch (error) {
console.error('保存出错:', error)
showToast('保存失败')
} finally {
submitting.value = false
}
}
// 分类选择变化
const handleClassifyChange = () => {
if (editForm.id > 0 && editForm.type >= 0) {
// 直接保存
onSubmit()
}
}
// 清空分类
const formatDate = (dateString) => {
if (!dateString) {
return ''
}
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.suggestion-tip {
font-size: 12px;
display: flex;
align-items: center;
padding: 6px 10px;
background: var(--van-active-color);
color: var(--van-primary-color);
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s;
border: 1px solid var(--van-primary-color);
width: fit-content;
opacity: 0.1;
}
.suggestion-tip:active {
opacity: 0.2;
}
.suggestion-icon {
margin-right: 4px;
font-size: 14px;
}
.suggestion-text {
font-weight: 500;
}
.suggestion-apply {
margin-left: 8px;
padding: 0 6px;
background: var(--van-primary-color);
color: var(--van-white);
border-radius: 4px;
font-size: 10px;
height: 18px;
line-height: 18px;
font-weight: bold;
}
</style>

View File

@@ -137,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 PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
const router = useRouter()
const userInput = ref('')

View File

@@ -42,7 +42,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getUnclassifiedCount } from '@/api/transactionRecord'
import ReasonGroupList from '@/components/ReasonGroupList.vue'
import ReasonGroupList from '@/components/Common/ReasonGroupList.vue'
const router = useRouter()
const groupListRef = ref(null)

View File

@@ -230,9 +230,9 @@
import { ref, computed } from 'vue'
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 PopupContainerV2 from '@/components/PopupContainerV2.vue'
import Icon from '@/components/Common/Icon.vue'
import IconSelector from '@/components/Common/IconSelector.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import {
getCategoryList,
createCategory,

View File

@@ -64,10 +64,11 @@
</div>
<!-- 交易详情弹窗 -->
<TransactionDetail
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleDetailSave"
@delete="handleDetailDelete"
/>
<!-- 记录列表弹窗 -->
@@ -132,8 +133,8 @@ import { useRouter } from 'vue-router'
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 PopupContainerV2 from '@/components/PopupContainerV2.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
const router = useRouter()
const userInput = ref('')
@@ -262,11 +263,15 @@ const handleRecordClick = (transaction) => {
// 详情保存后
const handleDetailSave = () => {
// 详情中的修改已经保存到服务器
// 这里可以选择重新分析或者只更新本地显示
showToast('修改已保存')
}
// 详情删除后
const handleDetailDelete = () => {
showDetail.value = false
showToast('删除成功')
}
// 提交分类
const handleSubmit = async () => {
if (selectedIds.value.size === 0) {

View File

@@ -60,7 +60,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import { getUnclassifiedCount, smartClassify, batchUpdateClassify } from '@/api/transactionRecord'
import ReasonGroupList from '@/components/ReasonGroupList.vue'
import ReasonGroupList from '@/components/Common/ReasonGroupList.vue'
const router = useRouter()
const groupListRef = ref(null)

View File

@@ -162,11 +162,12 @@
</PopupContainerV2>
<!-- 账单详情编辑弹出层 -->
<TransactionDetail
<TransactionDetailSheet
:show="transactionDetailVisible"
:transaction="currentTransaction"
@update:show="transactionDetailVisible = $event"
@save="handleTransactionSave"
@delete="handleTransactionDelete"
/>
</div>
</template>
@@ -184,8 +185,8 @@ import {
} from '@/api/emailRecord'
import { getTransactionDetail } from '@/api/transactionRecord'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
const emailList = ref([])
const loading = ref(false)

View File

@@ -116,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 PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
const messageStore = useMessageStore()
const router = useRouter()

View File

@@ -312,8 +312,8 @@ import {
createPeriodic,
updatePeriodic
} from '@/api/transactionPeriodic'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import ClassifySelector from '@/components/Common/ClassifySelector.vue'
import dayjs from 'dayjs'
const router = useRouter()

View File

@@ -43,10 +43,11 @@
</van-pull-refresh>
<!-- 详情/编辑弹出层 -->
<TransactionDetail
<TransactionDetailSheet
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onDetailSave"
@delete="handleDelete"
/>
</div>
</template>
@@ -56,7 +57,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { showToast } from 'vant'
import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
const transactionList = ref([])
const loading = ref(false)

View File

@@ -91,10 +91,11 @@
</div>
<!-- 交易详情弹窗 -->
<TransactionDetail
<TransactionDetailSheet
v-model:show="showDetail"
:transaction="currentTransaction"
@save="handleDetailSave"
@delete="handleDetailDelete"
/>
</div>
</template>
@@ -104,7 +105,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import { getUnconfirmedTransactionList, confirmAllUnconfirmed } from '@/api/transactionRecord'
import TransactionDetail from '@/components/TransactionDetail.vue'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import BillListComponent from '@/components/Bill/BillListComponent.vue'
const router = useRouter()
@@ -260,6 +261,10 @@ const handleDetailSave = () => {
loadData()
}
const handleDetailDelete = () => {
loadData()
}
onMounted(() => {
loadData()
})

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>
@@ -262,12 +264,12 @@ import {
getSavingsBudget
} from '@/api/budget'
import { BudgetPeriodType, BudgetCategory } from '@/constants/enums'
import DateSelectHeader from '@/components/DateSelectHeader.vue'
import BudgetTypeTabs from '@/components/BudgetTypeTabs.vue'
import DateSelectHeader from '@/components/Common/DateSelectHeader.vue'
import BudgetTypeTabs from '@/components/Budget/BudgetTypeTabs.vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/PopupContainerV2.vue'
import ExpenseBudgetContent from './modules/ExpenseBudgetContent.vue'
import IncomeBudgetContent from './modules/IncomeBudgetContent.vue'
import SavingsBudgetContent from './modules/SavingsBudgetContent.vue'

View File

@@ -74,12 +74,197 @@
<PopupContainerV2
v-model:show="showDetailPopup"
title="计划存款明细"
:height="'80%'"
:height="'85%'"
>
<div class="popup-body">
<div
v-if="currentBudget"
class="detail-content"
>
<!-- 明细表格 -->
<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="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
v-else
class="legacy-summary"
>
<div class="detail-section income-section">
<div class="section-title">
@@ -169,6 +354,7 @@
</div>
</div>
</div>
</div>
</PopupContainerV2>
</template>
@@ -176,13 +362,21 @@
import { ref, computed } from 'vue'
import BudgetCard from '@/components/Budget/BudgetCard.vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import PopupContainerV2 from '@/components/Common/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

@@ -75,7 +75,7 @@
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import CalendarHeader from '@/components/DateSelectHeader.vue'
import CalendarHeader from '@/components/Common/DateSelectHeader.vue'
import CalendarModule from './modules/Calendar.vue'
import StatsModule from './modules/Stats.vue'
import TransactionListModule from './modules/TransactionList.vue'

View File

@@ -34,6 +34,7 @@
:aria-label="getAriaLabel(day)"
@click="onDayClick(day)"
@touchstart="onTouchStartHoliday($event, day)"
@touchmove="onTouchEndHoliday"
@touchend="onTouchEndHoliday"
>
<div
@@ -73,9 +74,12 @@
>
<div
class="holiday-tooltip-wrapper"
@click="closeHolidayTooltip"
>
<div
class="holiday-tooltip"
@click.stop
>
<div class="holiday-tooltip">
<div class="tooltip-title">
{{ currentHolidayName }}
</div>
@@ -615,7 +619,7 @@ const onTouchEnd = () => {
}
.holiday-tooltip {
background: white;
background: var(--bg-primary);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);

View File

@@ -124,12 +124,12 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import CalendarHeader from '@/components/DateSelectHeader.vue'
import TimePeriodTabs from '@/components/TimePeriodTabs.vue'
import CalendarHeader from '@/components/Common/DateSelectHeader.vue'
import TimePeriodTabs from '@/components/Common/TimePeriodTabs.vue'
import MonthlyExpenseCard from './modules/MonthlyExpenseCard.vue'
import ExpenseCategoryCard from './modules/ExpenseCategoryCard.vue'
import IncomeNoneCategoryCard from './modules/IncomeNoneCategoryCard.vue'
import CategoryBillPopup from '@/components/CategoryBillPopup.vue'
import CategoryBillPopup from '@/components/Transaction/CategoryBillPopup.vue'
import {
// 新统一接口
getDailyStatisticsByRange,

View File

@@ -85,7 +85,7 @@
<script setup>
import { ref, computed } from 'vue'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
import ModernEmpty from '@/components/Global/ModernEmpty.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { pieCenterTextPlugin } from '@/plugins/chartjs-pie-center-plugin'

View File

@@ -99,7 +99,7 @@
<script setup>
import { computed } from 'vue'
import ModernEmpty from '@/components/ModernEmpty.vue'
import ModernEmpty from '@/components/Global/ModernEmpty.vue'
const props = defineProps({
incomeCategories: {

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()
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

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,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

Some files were not shown because too many files have changed in this diff Show More