Compare commits
13 Commits
32d5ed62d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0fde5eee | ||
|
|
9dce12c61b | ||
|
|
c2751c79cf | ||
|
|
749624f290 | ||
|
|
5f5c15ffb5 | ||
|
|
045158730f | ||
|
|
b173c83134 | ||
|
|
5f9672744b | ||
|
|
a7414c792e | ||
|
|
3c3172fc81 | ||
|
|
f46b9d4bd6 | ||
|
|
2cb5bffc70 | ||
|
|
4cc205fc25 |
105
.doc/frontend-interactive-test-report.md
Normal file
105
.doc/frontend-interactive-test-report.md
Normal 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:首次生成前端整体交互测试报告(自动化点击/输入)。
|
||||
@@ -19,7 +19,7 @@
|
||||
| MessageView.vue | 时间戳 (createTime) | 移至内容区域顶部,使用灰色小字 |
|
||||
| CategoryBillPopup.vue | 待检查 | 待定 |
|
||||
| BudgetChartAnalysis.vue | 待检查 | 待定 |
|
||||
| TransactionDetail.vue | 待检查 | 待定 |
|
||||
| TransactionDetail.vue | 已删除 | 已被 TransactionDetailSheet.vue 替代 |
|
||||
| ReasonGroupList.vue | 待检查 | 待定 |
|
||||
|
||||
### 第三批:带确认/取消按钮
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
121
Service/Budget/BudgetItemCalculator.cs
Normal file
121
Service/Budget/BudgetItemCalculator.cs
Normal 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 "使用预算";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +448,11 @@ public record BudgetResult
|
||||
public bool NoLimit { get; set; }
|
||||
public bool IsMandatoryExpense { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据(可选,用于存款预算)
|
||||
/// </summary>
|
||||
public SavingsDetail? Details { get; set; }
|
||||
|
||||
public static BudgetResult FromEntity(
|
||||
BudgetRecord entity,
|
||||
@@ -547,3 +552,41 @@ public class UncoveredCategoryDetail
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据
|
||||
/// </summary>
|
||||
public record SavingsDetail
|
||||
{
|
||||
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummary Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预算明细项
|
||||
/// </summary>
|
||||
public record BudgetDetailItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; }
|
||||
public string CalculationNote { get; init; } = string.Empty;
|
||||
public bool IsOverBudget { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public int[]? ArchivedMonths { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款计算汇总
|
||||
/// </summary>
|
||||
public record SavingsCalculationSummary
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
ctx.fillStyle = getVarianceLabelColor(item.value)
|
||||
let x = position.x + (isPositive ? offset : -offset)
|
||||
const y = position.y
|
||||
const y = bar.y
|
||||
let x
|
||||
|
||||
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
|
||||
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.textAlign = isPositive ? 'left' : 'right'
|
||||
|
||||
ctx.fillStyle = getVarianceLabelColor(item.value)
|
||||
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 {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
197
Web/src/components/Transaction/CategoryBillPopup.vue
Normal file
197
Web/src/components/Transaction/CategoryBillPopup.vue
Normal 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>
|
||||
@@ -99,15 +99,47 @@
|
||||
<div class="form-label">
|
||||
分类
|
||||
</div>
|
||||
<div
|
||||
class="form-value clickable"
|
||||
@click="showClassifySelector = !showClassifySelector"
|
||||
>
|
||||
<span v-if="editForm.classify">{{ editForm.classify }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="placeholder"
|
||||
>请选择分类</span>
|
||||
<div class="form-value">
|
||||
<div style="flex: 1">
|
||||
<!-- 建议分类提示 -->
|
||||
<div
|
||||
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>
|
||||
<span
|
||||
v-else
|
||||
class="placeholder"
|
||||
>请选择分类</span>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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('')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -74,96 +74,282 @@
|
||||
<PopupContainerV2
|
||||
v-model:show="showDetailPopup"
|
||||
title="计划存款明细"
|
||||
:height="'80%'"
|
||||
:height="'85%'"
|
||||
>
|
||||
<div class="popup-body">
|
||||
<div
|
||||
v-if="currentBudget"
|
||||
class="detail-content"
|
||||
>
|
||||
<div class="detail-section income-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="balance-o" />
|
||||
收入预算
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">预算限额</span>
|
||||
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
|
||||
<!-- 明细表格 -->
|
||||
<div
|
||||
v-if="currentBudget.details"
|
||||
class="detail-tables"
|
||||
>
|
||||
<!-- 收入明细 -->
|
||||
<div class="detail-section income-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="balance-o" />
|
||||
收入明细
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际收入</span>
|
||||
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
|
||||
<div class="rich-html-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>计算用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in currentBudget.details.incomeItems"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<span>{{ item.name }}</span>
|
||||
<van-tag
|
||||
size="mini"
|
||||
plain
|
||||
:type="item.type === 1 ? 'default' : 'primary'"
|
||||
>
|
||||
{{ item.type === 1 ? '月' : '年' }}
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isArchived"
|
||||
size="mini"
|
||||
type="success"
|
||||
>
|
||||
已归档
|
||||
</van-tag>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatMoney(item.budgetLimit) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="income-value"
|
||||
:class="{ 'expense-value': item.isOverBudget }"
|
||||
>
|
||||
{{ formatMoney(item.actualAmount) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="income-value">{{ formatMoney(item.effectiveAmount) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>收入预算合计:</strong>
|
||||
<template v-if="hasArchivedIncome">
|
||||
已归档 <span class="income-value"><strong>{{ formatMoney(archivedIncomeTotal) }}</strong></span>
|
||||
+ 未来预算 <span class="income-value"><strong>{{ formatMoney(futureIncomeTotal) }}</strong></span>
|
||||
= <span class="income-value"><strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="income-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支出明细 -->
|
||||
<div class="detail-section expense-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="bill-o" />
|
||||
支出明细
|
||||
</div>
|
||||
<div class="rich-html-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>计算用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in currentBudget.details.expenseItems"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<span>{{ item.name }}</span>
|
||||
<van-tag
|
||||
size="mini"
|
||||
plain
|
||||
:type="item.type === 1 ? 'default' : 'primary'"
|
||||
>
|
||||
{{ item.type === 1 ? '月' : '年' }}
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isArchived"
|
||||
size="mini"
|
||||
type="success"
|
||||
>
|
||||
已归档
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isOverBudget"
|
||||
size="mini"
|
||||
type="danger"
|
||||
>
|
||||
超支
|
||||
</van-tag>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatMoney(item.budgetLimit) }}</td>
|
||||
<td>
|
||||
<span class="expense-value">{{ formatMoney(item.actualAmount) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="expense-value">{{ formatMoney(item.effectiveAmount) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>支出预算合计:</strong>
|
||||
<template v-if="hasArchivedExpense">
|
||||
已归档 <span class="expense-value"><strong>{{ formatMoney(archivedExpenseTotal) }}</strong></span>
|
||||
+ 未来预算 <span class="expense-value"><strong>{{ formatMoney(futureExpenseTotal) }}</strong></span>
|
||||
= <span class="expense-value"><strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="expense-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计算汇总 -->
|
||||
<div class="detail-section formula-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="calculator-o" />
|
||||
计算汇总
|
||||
</div>
|
||||
<div class="rich-html-content">
|
||||
<h3>计算公式</h3>
|
||||
<p>
|
||||
<strong>收入预算合计:</strong>
|
||||
<span class="income-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>支出预算合计:</strong>
|
||||
<span class="expense-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>计划存款:</strong>
|
||||
{{ currentBudget.details.summary.calculationFormula }}
|
||||
= <span class="highlight">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.plannedSavings) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section expense-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="bill-o" />
|
||||
支出预算
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">预算限额</span>
|
||||
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
|
||||
<!-- 旧版汇总(无明细数据时显示) -->
|
||||
<div
|
||||
v-else
|
||||
class="legacy-summary"
|
||||
>
|
||||
<div class="detail-section income-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="balance-o" />
|
||||
收入预算
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际支出</span>
|
||||
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">预算限额</span>
|
||||
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际收入</span>
|
||||
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section formula-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="calculator-o" />
|
||||
计划存款公式
|
||||
</div>
|
||||
<div class="formula-box">
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">收入预算</span>
|
||||
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
|
||||
<div class="detail-section expense-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="bill-o" />
|
||||
支出预算
|
||||
</div>
|
||||
<div class="formula-operator">
|
||||
-
|
||||
</div>
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">支出预算</span>
|
||||
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
|
||||
</div>
|
||||
<div class="formula-operator">
|
||||
=
|
||||
</div>
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">计划存款</span>
|
||||
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">预算限额</span>
|
||||
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际支出</span>
|
||||
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section result-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="chart-trending-o" />
|
||||
存款结果
|
||||
<div class="detail-section formula-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="calculator-o" />
|
||||
计划存款公式
|
||||
</div>
|
||||
<div class="formula-box">
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">收入预算</span>
|
||||
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
|
||||
</div>
|
||||
<div class="formula-operator">
|
||||
-
|
||||
</div>
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">支出预算</span>
|
||||
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
|
||||
</div>
|
||||
<div class="formula-operator">
|
||||
=
|
||||
</div>
|
||||
<div class="formula-item">
|
||||
<span class="formula-label">计划存款</span>
|
||||
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">计划存款</span>
|
||||
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
|
||||
|
||||
<div class="detail-section result-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="chart-trending-o" />
|
||||
存款结果
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际存款</span>
|
||||
<span
|
||||
class="detail-value"
|
||||
:class="{ income: currentBudget.current >= currentBudget.limit }"
|
||||
>¥{{ formatMoney(currentBudget.current) }}</span>
|
||||
</div>
|
||||
<div class="detail-row highlight">
|
||||
<span class="detail-label">还差</span>
|
||||
<span class="detail-value expense">¥{{
|
||||
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
|
||||
}}</span>
|
||||
<div class="section-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">计划存款</span>
|
||||
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">实际存款</span>
|
||||
<span
|
||||
class="detail-value"
|
||||
:class="{ income: currentBudget.current >= currentBudget.limit }"
|
||||
>¥{{ formatMoney(currentBudget.current) }}</span>
|
||||
</div>
|
||||
<div class="detail-row highlight">
|
||||
<span class="detail-label">还差</span>
|
||||
<span class="detail-value expense">¥{{
|
||||
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.stop
|
||||
@click="closeHolidayTooltip"
|
||||
>
|
||||
<div class="holiday-tooltip">
|
||||
<div
|
||||
class="holiday-tooltip"
|
||||
@click.stop
|
||||
>
|
||||
<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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
260
WebApi.Test/Budget/BudgetItemCalculatorTest.cs
Normal file
260
WebApi.Test/Budget/BudgetItemCalculatorTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
135
WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
Normal file
135
WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 |
BIN
budget-page.png
BIN
budget-page.png
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
@@ -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 统一管理预算数据
|
||||
|
||||
### [权衡] 按周期和类型匹配可能不够健壮
|
||||
|
||||
**接受理由:**
|
||||
- 这是后端设计的数据模型,前端保持一致
|
||||
- 后端保证同一周期同一类型只有一条预算记录
|
||||
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 使用不同月份的存款计划测试,验证匹配逻辑正确
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
@@ -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 即可恢复
|
||||
@@ -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)
|
||||
- **测试影响**: 无需新增测试,仅需回归验证
|
||||
@@ -0,0 +1,13 @@
|
||||
## Overview
|
||||
|
||||
此变更为代码清理,不涉及业务需求变更。
|
||||
|
||||
## REMOVED Components
|
||||
|
||||
### Requirement: SmartClassifyButton component
|
||||
**Reason**: 组件无任何引用,已被废弃
|
||||
**Migration**: 无需迁移,该组件从未被使用
|
||||
|
||||
### Requirement: BudgetSummary component
|
||||
**Reason**: 功能已被 budgetV2 模块的子组件替代
|
||||
**Migration**: 使用 `BudgetCard.vue` 和 `BudgetChartAnalysis.vue` 替代
|
||||
@@ -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` 启动开发服务器,访问主要页面验证无报错
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
@@ -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 事件 |
|
||||
@@ -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` 接口
|
||||
@@ -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** 显示统一的空状态图标和提示文案
|
||||
@@ -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 验证暗黑模式适配
|
||||
@@ -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
Reference in New Issue
Block a user