fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 3s

This commit is contained in:
SunCheng
2026-01-30 10:41:19 +08:00
parent d9703d31ae
commit 704f58b1a1
46 changed files with 6074 additions and 301 deletions

View File

@@ -29,7 +29,7 @@ public static class ServiceExtension
// 注册所有服务实现 // 注册所有服务实现
RegisterServices(services, serviceAssembly); RegisterServices(services, serviceAssembly);
// 注册所有仓储实现 // 注册所有仓储实现
RegisterRepositories(services, repositoryAssembly); RegisterRepositories(services, repositoryAssembly);
@@ -74,7 +74,7 @@ public static class ServiceExtension
foreach (var type in types) foreach (var type in types)
{ {
var interfaces = type.GetInterfaces() var interfaces = type.GetInterfaces()
.Where(i => i.Name.StartsWith("I") .Where(i => i.Name.StartsWith("I")
&& i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T> && i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T>
foreach (var @interface in interfaces) foreach (var @interface in interfaces)

View File

@@ -18,7 +18,7 @@ public class TransactionRecord : BaseEntity
/// <summary> /// <summary>
/// 交易金额 /// 交易金额
/// </summary> /// </summary>
public decimal Amount { get; set; } public decimal Amount { get; set; }
/// <summary> /// <summary>
/// 交易后余额 /// 交易后余额
@@ -60,7 +60,7 @@ public decimal Amount { get; set; }
/// </summary> /// </summary>
public string ImportNo { get; set; } = string.Empty; public string ImportNo { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 导入来源 /// 导入来源
/// </summary> /// </summary>
public string ImportFrom { get; set; } = string.Empty; public string ImportFrom { get; set; } = string.Empty;

View File

@@ -3,7 +3,7 @@
public interface IBudgetRepository : IBaseRepository<BudgetRecord> public interface IBudgetRepository : IBaseRepository<BudgetRecord>
{ {
Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate); Task<decimal> GetCurrentAmountAsync(BudgetRecord budget, DateTime startDate, DateTime endDate);
Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type); Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type);
} }
@@ -35,7 +35,7 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type) public async Task UpdateBudgetCategoryNameAsync(string oldName, string newName, TransactionType type)
{ {
var records = await FreeSql.Select<BudgetRecord>() var records = await FreeSql.Select<BudgetRecord>()
.Where(b => b.SelectedCategories.Contains(oldName) && .Where(b => b.SelectedCategories.Contains(oldName) &&
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) || ((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
(type == TransactionType.Income && b.Category == BudgetCategory.Income))) (type == TransactionType.Income && b.Category == BudgetCategory.Income)))
.ToListAsync(); .ToListAsync();

View File

@@ -3,7 +3,7 @@
public interface IEmailMessageRepository : IBaseRepository<EmailMessage> public interface IEmailMessageRepository : IBaseRepository<EmailMessage>
{ {
Task<EmailMessage?> ExistsAsync(string md5); Task<EmailMessage?> ExistsAsync(string md5);
/// <summary> /// <summary>
/// 分页获取邮件列表(游标分页) /// 分页获取邮件列表(游标分页)
/// </summary> /// </summary>
@@ -12,7 +12,7 @@ public interface IEmailMessageRepository : IBaseRepository<EmailMessage>
/// <param name="pageSize">每页数量</param> /// <param name="pageSize">每页数量</param>
/// <returns>邮件列表、最后接收时间和最后ID</returns> /// <returns>邮件列表、最后接收时间和最后ID</returns>
Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20); Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20);
/// <summary> /// <summary>
/// 获取总数 /// 获取总数
/// </summary> /// </summary>
@@ -31,20 +31,20 @@ public class EmailMessageRepository(IFreeSql freeSql) : BaseRepository<EmailMess
public async Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20) public async Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20)
{ {
var query = FreeSql.Select<EmailMessage>(); var query = FreeSql.Select<EmailMessage>();
// 如果提供了游标,则获取小于游标位置的记录 // 如果提供了游标,则获取小于游标位置的记录
if (lastReceivedDate.HasValue && lastId.HasValue) if (lastReceivedDate.HasValue && lastId.HasValue)
{ {
query = query.Where(e => e.ReceivedDate < lastReceivedDate.Value || query = query.Where(e => e.ReceivedDate < lastReceivedDate.Value ||
(e.ReceivedDate == lastReceivedDate.Value && e.Id < lastId.Value)); (e.ReceivedDate == lastReceivedDate.Value && e.Id < lastId.Value));
} }
var list = await query var list = await query
.OrderByDescending(e => e.ReceivedDate) .OrderByDescending(e => e.ReceivedDate)
.OrderByDescending(e => e.Id) .OrderByDescending(e => e.Id)
.Page(1, pageSize) .Page(1, pageSize)
.ToListAsync(); .ToListAsync();
var lastRecord = list.Count > 0 ? list.Last() : null; var lastRecord = list.Count > 0 ? list.Last() : null;
return (list, lastRecord?.ReceivedDate, lastRecord?.Id ?? 0); return (list, lastRecord?.ReceivedDate, lastRecord?.Id ?? 0);
} }

View File

@@ -15,7 +15,7 @@ public class MessageRecordRepository(IFreeSql freeSql) : BaseRepository<MessageR
.Count(out var total) .Count(out var total)
.Page(pageIndex, pageSize) .Page(pageIndex, pageSize)
.ToListAsync(); .ToListAsync();
return (list, total); return (list, total);
} }

View File

@@ -29,12 +29,12 @@ public interface ITransactionPeriodicRepository : IBaseRepository<TransactionPer
/// <summary> /// <summary>
/// 周期性账单仓储实现 /// 周期性账单仓储实现
/// </summary> /// </summary>
public class TransactionPeriodicRepository(IFreeSql freeSql) public class TransactionPeriodicRepository(IFreeSql freeSql)
: BaseRepository<TransactionPeriodic>(freeSql), ITransactionPeriodicRepository : BaseRepository<TransactionPeriodic>(freeSql), ITransactionPeriodicRepository
{ {
public async Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync( public async Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync(
int pageIndex, int pageIndex,
int pageSize, int pageSize,
string? searchKeyword = null) string? searchKeyword = null)
{ {
var query = FreeSql.Select<TransactionPeriodic>(); var query = FreeSql.Select<TransactionPeriodic>();
@@ -42,8 +42,8 @@ public class TransactionPeriodicRepository(IFreeSql freeSql)
// 搜索关键词 // 搜索关键词
if (!string.IsNullOrWhiteSpace(searchKeyword)) if (!string.IsNullOrWhiteSpace(searchKeyword))
{ {
query = query.Where(x => query = query.Where(x =>
x.Reason.Contains(searchKeyword) || x.Reason.Contains(searchKeyword) ||
x.Classify.Contains(searchKeyword)); x.Classify.Contains(searchKeyword));
} }
@@ -60,8 +60,8 @@ public class TransactionPeriodicRepository(IFreeSql freeSql)
if (!string.IsNullOrWhiteSpace(searchKeyword)) if (!string.IsNullOrWhiteSpace(searchKeyword))
{ {
query = query.Where(x => query = query.Where(x =>
x.Reason.Contains(searchKeyword) || x.Reason.Contains(searchKeyword) ||
x.Classify.Contains(searchKeyword)); x.Classify.Contains(searchKeyword));
} }

View File

@@ -18,8 +18,8 @@ public class OpenAiService(
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt) public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -44,7 +44,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
try try
{ {
using var resp = await http.PostAsync(url, content); using var resp = await http.PostAsync(url, content);
@@ -75,8 +75,8 @@ public class OpenAiService(
public async Task<string?> ChatAsync(string prompt) public async Task<string?> ChatAsync(string prompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -100,7 +100,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
try try
{ {
using var resp = await http.PostAsync(url, content); using var resp = await http.PostAsync(url, content);
@@ -131,8 +131,8 @@ public class OpenAiService(
public async IAsyncEnumerable<string> ChatStreamAsync(string prompt) public async IAsyncEnumerable<string> ChatStreamAsync(string prompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -157,11 +157,11 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content; request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var err = await resp.Content.ReadAsStringAsync(); var err = await resp.Content.ReadAsStringAsync();
@@ -201,8 +201,8 @@ public class OpenAiService(
public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt) public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt)
{ {
var cfg = aiSettings.Value; var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) || if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) || string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model)) string.IsNullOrWhiteSpace(cfg.Model))
{ {
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI"); logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
@@ -228,12 +228,12 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions"; var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var content = new StringContent(json, Encoding.UTF8, "application/json");
// 使用 SendAsync 来支持 HttpCompletionOption // 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content; request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
var err = await resp.Content.ReadAsStringAsync(); var err = await resp.Content.ReadAsStringAsync();

View File

@@ -38,7 +38,7 @@ public class TextSegmentService : ITextSegmentService
_logger = logger; _logger = logger;
_segmenter = new JiebaSegmenter(); _segmenter = new JiebaSegmenter();
_extractor = new TfidfExtractor(); _extractor = new TfidfExtractor();
// 仅添加JiebaNet词典中可能缺失的特定业务词汇 // 仅添加JiebaNet词典中可能缺失的特定业务词汇
AddCustomWords(); AddCustomWords();
} }
@@ -109,7 +109,7 @@ public class TextSegmentService : ITextSegmentService
filteredKeywords.Add(text.Length > 10 ? text.Substring(0, 10) : text); filteredKeywords.Add(text.Length > 10 ? text.Substring(0, 10) : text);
} }
_logger.LogDebug("从文本 '{Text}' 中提取关键词: {Keywords}", _logger.LogDebug("从文本 '{Text}' 中提取关键词: {Keywords}",
text, string.Join(", ", filteredKeywords)); text, string.Join(", ", filteredKeywords));
return filteredKeywords; return filteredKeywords;

View File

@@ -375,14 +375,14 @@ public class BudgetSavingsService(
var currentActual = 0m; var currentActual = 0m;
if (!string.IsNullOrEmpty(savingsCategories)) if (!string.IsNullOrEmpty(savingsCategories))
{ {
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries)); var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach(var kvp in transactionClassify) foreach (var kvp in transactionClassify)
{ {
if (cats.Contains(kvp.Key.Item1)) if (cats.Contains(kvp.Key.Item1))
{ {
currentActual += kvp.Value; currentActual += kvp.Value;
} }
} }
} }
var record = new BudgetRecord var record = new BudgetRecord
@@ -595,16 +595,14 @@ public class BudgetSavingsService(
"""); """);
} }
description.AppendLine("</tbody></table>"); description.AppendLine("</tbody></table>");
description.AppendLine($""" description.AppendLine($"""
<p> <p>
预算收入合计: 预算收入合计:
<span class='expense-value'> <span class='expense-value'>
<strong> <strong>
{ {currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
currentMonthlyIncomeItems.Sum(i => i.limit * i.factor) + currentYearlyIncomeItems.Sum(i => i.limit):N0}
+ currentYearlyIncomeItems.Sum(i => i.limit)
:N0}
</strong> </strong>
</span> </span>
</p> </p>
@@ -644,7 +642,7 @@ public class BudgetSavingsService(
"""); """);
} }
description.AppendLine("</tbody></table>"); description.AppendLine("</tbody></table>");
archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current); archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
description.AppendLine($""" description.AppendLine($"""
<p> <p>
@@ -714,10 +712,8 @@ public class BudgetSavingsService(
支出预算合计: 支出预算合计:
<span class='expense-value'> <span class='expense-value'>
<strong> <strong>
{ {currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
currentMonthlyExpenseItems.Sum(i => i.limit * i.factor) + currentYearlyExpenseItems.Sum(i => i.limit):N0}
+ currentYearlyExpenseItems.Sum(i => i.limit)
:N0}
</strong> </strong>
</span> </span>
</p> </p>
@@ -769,18 +765,18 @@ public class BudgetSavingsService(
#endregion #endregion
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty; var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
var currentActual = 0m; var currentActual = 0m;
if (!string.IsNullOrEmpty(savingsCategories)) if (!string.IsNullOrEmpty(savingsCategories))
{ {
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries)); var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach(var kvp in transactionClassify) foreach (var kvp in transactionClassify)
{ {
if (cats.Contains(kvp.Key.Item1)) if (cats.Contains(kvp.Key.Item1))
{ {
currentActual += kvp.Value; currentActual += kvp.Value;
} }
} }
} }
var record = new BudgetRecord var record = new BudgetRecord

View File

@@ -21,7 +21,7 @@ public class BudgetStatsService(
{ {
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{ {
logger.LogInformation("开始计算分类统计信息: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", logger.LogInformation("开始计算分类统计信息: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, referenceDate); category, referenceDate);
var result = new BudgetCategoryStats(); var result = new BudgetCategoryStats();
@@ -31,20 +31,20 @@ public class BudgetStatsService(
// 获取月度统计 // 获取月度统计
logger.LogDebug("开始计算月度统计"); logger.LogDebug("开始计算月度统计");
result.Month = await CalculateMonthlyCategoryStatsAsync(category, referenceDate); result.Month = await CalculateMonthlyCategoryStatsAsync(category, referenceDate);
logger.LogInformation("月度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%", logger.LogInformation("月度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
result.Month.Count, result.Month.Limit, result.Month.Current, result.Month.Rate); result.Month.Count, result.Month.Limit, result.Month.Current, result.Month.Rate);
// 获取年度统计 // 获取年度统计
logger.LogDebug("开始计算年度统计"); logger.LogDebug("开始计算年度统计");
result.Year = await CalculateYearlyCategoryStatsAsync(category, referenceDate); result.Year = await CalculateYearlyCategoryStatsAsync(category, referenceDate);
logger.LogInformation("年度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%", logger.LogInformation("年度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
result.Year.Count, result.Year.Limit, result.Year.Current, result.Year.Rate); result.Year.Count, result.Year.Limit, result.Year.Current, result.Year.Rate);
logger.LogInformation("分类统计信息计算完成"); logger.LogInformation("分类统计信息计算完成");
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "计算分类统计信息时发生错误: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", logger.LogError(ex, "计算分类统计信息时发生错误: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, referenceDate); category, referenceDate);
throw; throw;
} }
@@ -54,7 +54,7 @@ public class BudgetStatsService(
private async Task<BudgetStatsDto> CalculateMonthlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) private async Task<BudgetStatsDto> CalculateMonthlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{ {
logger.LogDebug("开始计算月度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM}", logger.LogDebug("开始计算月度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM}",
category, referenceDate); category, referenceDate);
var result = new BudgetStatsDto var result = new BudgetStatsDto
@@ -94,7 +94,7 @@ public class BudgetStatsService(
// 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值 // 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值
var totalCurrent = budgets.Sum(b => b.Current); var totalCurrent = budgets.Sum(b => b.Current);
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count); logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count);
var transactionType = category switch var transactionType = category switch
{ {
BudgetCategory.Expense => TransactionType.Expense, BudgetCategory.Expense => TransactionType.Expense,
@@ -129,7 +129,7 @@ public class BudgetStatsService(
decimal accumulated = 0; decimal accumulated = 0;
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month); var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth); logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
for (var i = 1; i <= daysInMonth; i++) for (var i = 1; i <= daysInMonth; i++)
{ {
var currentDate = new DateTime(startDate.Year, startDate.Month, i); var currentDate = new DateTime(startDate.Year, startDate.Month, i);
@@ -143,21 +143,21 @@ public class BudgetStatsService(
if (dailyStats.TryGetValue(currentDate.Date, out var amount)) if (dailyStats.TryGetValue(currentDate.Date, out var amount))
{ {
accumulated += amount; accumulated += amount;
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 金额={Amount}, 累计={Accumulated}", logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 金额={Amount}, 累计={Accumulated}",
currentDate, amount, accumulated); currentDate, amount, accumulated);
} }
else else
{ {
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}", logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}",
currentDate, accumulated); currentDate, accumulated);
} }
// 对每一天的累计值应用硬性预算调整 // 对每一天的累计值应用硬性预算调整
var adjustedAccumulated = accumulated; var adjustedAccumulated = accumulated;
if (transactionType == TransactionType.Expense) if (transactionType == TransactionType.Expense)
{ {
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
// 检查是否为当前月份 // 检查是否为当前月份
var isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month; var isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
if (isCurrentMonth && currentDate.Date <= now.Date) if (isCurrentMonth && currentDate.Date <= now.Date)
@@ -166,31 +166,31 @@ public class BudgetStatsService(
// totalMandatoryVirtual是所有硬性预算的虚拟消耗 // totalMandatoryVirtual是所有硬性预算的虚拟消耗
// 但如果硬性预算有实际交易accumulated中已经包含了会重复 // 但如果硬性预算有实际交易accumulated中已经包含了会重复
// 所以需要accumulated + (totalMandatoryVirtual - 硬性预算的实际交易部分) // 所以需要accumulated + (totalMandatoryVirtual - 硬性预算的实际交易部分)
// 更简单的理解: // 更简单的理解:
// - 如果某个硬性预算本月完全没有交易记录它的虚拟值应该加到accumulated上 // - 如果某个硬性预算本月完全没有交易记录它的虚拟值应该加到accumulated上
// - 如果某个硬性预算有部分交易记录,应该补齐到虚拟值 // - 如果某个硬性预算有部分交易记录,应该补齐到虚拟值
// - 实现:取 max(accumulated, totalMandatoryVirtual) 是不对的 // - 实现:取 max(accumulated, totalMandatoryVirtual) 是不对的
// - 正确accumulated + 硬性预算中没有实际交易的那部分的虚拟值 // - 正确accumulated + 硬性预算中没有实际交易的那部分的虚拟值
// 由于无法精确区分,采用近似方案: // 由于无法精确区分,采用近似方案:
// 计算所有硬性预算的Current总和这个值已经包含了虚拟消耗在CalculateCurrentAmountAsync中处理 // 计算所有硬性预算的Current总和这个值已经包含了虚拟消耗在CalculateCurrentAmountAsync中处理
// 计算非硬性预算的交易累计这部分在accumulated中 // 计算非硬性预算的交易累计这部分在accumulated中
// 但accumulated是所有交易的累计包括硬性预算的实际交易 // 但accumulated是所有交易的累计包括硬性预算的实际交易
// 最终简化方案: // 最终简化方案:
// dailyStats包含所有实际交易包括硬性预算的实际交易 // dailyStats包含所有实际交易包括硬性预算的实际交易
// 对于没有实际交易的硬性预算它们的虚拟消耗没有在dailyStats中 // 对于没有实际交易的硬性预算它们的虚拟消耗没有在dailyStats中
// 所以adjustedAccumulated = accumulated + 没有实际交易的硬性预算的虚拟消耗 // 所以adjustedAccumulated = accumulated + 没有实际交易的硬性预算的虚拟消耗
// 实用方法:每个硬性预算,取 max(它在dailyStats中的累计, 虚拟值) // 实用方法:每个硬性预算,取 max(它在dailyStats中的累计, 虚拟值)
// 但我们无法从dailyStats中提取单个预算的数据 // 但我们无法从dailyStats中提取单个预算的数据
// 终极简化如果硬性预算的Current值等于虚拟值说明没有实际交易 // 终极简化如果硬性预算的Current值等于虚拟值说明没有实际交易
// 这种情况下accumulated中不包含这部分需要加上虚拟值 // 这种情况下accumulated中不包含这部分需要加上虚拟值
// 如果Current值大于虚拟值说明有实际交易accumulated中已包含不需要调整 // 如果Current值大于虚拟值说明有实际交易accumulated中已包含不需要调整
decimal mandatoryAdjustment = 0; decimal mandatoryAdjustment = 0;
foreach (var budget in mandatoryBudgets) foreach (var budget in mandatoryBudgets)
{ {
@@ -205,16 +205,16 @@ public class BudgetStatsService(
var dayOfYear = currentDate.DayOfYear; var dayOfYear = currentDate.DayOfYear;
dailyVirtual = budget.Limit * dayOfYear / daysInYear; dailyVirtual = budget.Limit * dayOfYear / daysInYear;
} }
// 如果budget.Current约等于整月的虚拟值说明没有实际交易 // 如果budget.Current约等于整月的虚拟值说明没有实际交易
// 但Current是整个月的dailyVirtual是到当前天的 // 但Current是整个月的dailyVirtual是到当前天的
// 需要判断该预算是否有实际交易记录 // 需要判断该预算是否有实际交易记录
// 简化假设如果硬性预算的Current等于虚拟值误差<1元就没有实际交易 // 简化假设如果硬性预算的Current等于虚拟值误差<1元就没有实际交易
var monthlyVirtual = budget.Type == BudgetPeriodType.Month var monthlyVirtual = budget.Type == BudgetPeriodType.Month
? budget.Limit * now.Day / daysInMonth ? budget.Limit * now.Day / daysInMonth
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365); : budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
if (Math.Abs(budget.Current - monthlyVirtual) < 1) if (Math.Abs(budget.Current - monthlyVirtual) < 1)
{ {
// 没有实际交易,需要添加虚拟消耗 // 没有实际交易,需要添加虚拟消耗
@@ -223,14 +223,14 @@ public class BudgetStatsService(
currentDate, budget.Name, dailyVirtual); currentDate, budget.Name, dailyVirtual);
} }
} }
adjustedAccumulated += mandatoryAdjustment; adjustedAccumulated += mandatoryAdjustment;
} }
} }
result.Trend.Add(adjustedAccumulated); result.Trend.Add(adjustedAccumulated);
} }
logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)"); logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)");
} }
else else
@@ -265,7 +265,7 @@ public class BudgetStatsService(
logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate);
// 5. 生成预算明细汇总日志 // 5. 生成预算明细汇总日志
var budgetDetails = budgets.Select(b => var budgetDetails = budgets.Select(b =>
{ {
var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate); var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate);
var prefix = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : ""; var prefix = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : "";
@@ -285,7 +285,7 @@ public class BudgetStatsService(
private async Task<BudgetStatsDto> CalculateYearlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) private async Task<BudgetStatsDto> CalculateYearlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{ {
logger.LogDebug("开始计算年度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy}", logger.LogDebug("开始计算年度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy}",
category, referenceDate); category, referenceDate);
var result = new BudgetStatsDto var result = new BudgetStatsDto
@@ -338,7 +338,7 @@ public class BudgetStatsService(
// 计算当前实际值,考虑硬性预算的特殊逻辑 // 计算当前实际值,考虑硬性预算的特殊逻辑
var totalCurrent = budgets.Sum(b => b.Current); var totalCurrent = budgets.Sum(b => b.Current);
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)",
totalCurrent, budgets.Count); totalCurrent, budgets.Count);
var now = dateTimeProvider.Now; var now = dateTimeProvider.Now;
@@ -380,21 +380,21 @@ public class BudgetStatsService(
if (dailyStats.TryGetValue(currentMonthDate, out var amount)) if (dailyStats.TryGetValue(currentMonthDate, out var amount))
{ {
accumulated += amount; accumulated += amount;
logger.LogTrace("月份 {Month:yyyy-MM}: 金额={Amount}, 累计={Accumulated}", logger.LogTrace("月份 {Month:yyyy-MM}: 金额={Amount}, 累计={Accumulated}",
currentMonthDate, amount, accumulated); currentMonthDate, amount, accumulated);
} }
else else
{ {
logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}", logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}",
currentMonthDate, accumulated); currentMonthDate, accumulated);
} }
// 对每个月的累计值应用硬性预算调整 // 对每个月的累计值应用硬性预算调整
var adjustedAccumulated = accumulated; var adjustedAccumulated = accumulated;
if (transactionType == TransactionType.Expense) if (transactionType == TransactionType.Expense)
{ {
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
// 检查是否为当前年份 // 检查是否为当前年份
var isCurrentYear = referenceDate.Year == now.Year; var isCurrentYear = referenceDate.Year == now.Year;
if (isCurrentYear && currentMonthDate <= now) if (isCurrentYear && currentMonthDate <= now)
@@ -403,7 +403,7 @@ public class BudgetStatsService(
foreach (var budget in mandatoryBudgets) foreach (var budget in mandatoryBudgets)
{ {
decimal monthlyVirtual = 0; decimal monthlyVirtual = 0;
if (budget.Type == BudgetPeriodType.Month) if (budget.Type == BudgetPeriodType.Month)
{ {
// 月度硬性预算:如果该月已完成,累加整月;如果是当前月,按天数比例 // 月度硬性预算:如果该月已完成,累加整月;如果是当前月,按天数比例
@@ -424,12 +424,12 @@ public class BudgetStatsService(
var dayOfYear = i < now.Month ? lastDayOfMonth.DayOfYear : now.DayOfYear; var dayOfYear = i < now.Month ? lastDayOfMonth.DayOfYear : now.DayOfYear;
monthlyVirtual = budget.Limit * dayOfYear / daysInYear; monthlyVirtual = budget.Limit * dayOfYear / daysInYear;
} }
// 判断该硬性预算是否有实际交易 // 判断该硬性预算是否有实际交易
var yearlyVirtual = budget.Type == BudgetPeriodType.Month var yearlyVirtual = budget.Type == BudgetPeriodType.Month
? budget.Limit * now.Month + (budget.Limit * now.Day / DateTime.DaysInMonth(now.Year, now.Month)) - budget.Limit ? budget.Limit * now.Month + (budget.Limit * now.Day / DateTime.DaysInMonth(now.Year, now.Month)) - budget.Limit
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365); : budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
if (Math.Abs(budget.Current - yearlyVirtual) < 1) if (Math.Abs(budget.Current - yearlyVirtual) < 1)
{ {
// 没有实际交易,需要添加虚拟消耗 // 没有实际交易,需要添加虚拟消耗
@@ -438,11 +438,11 @@ public class BudgetStatsService(
currentMonthDate, budget.Name, monthlyVirtual); currentMonthDate, budget.Name, monthlyVirtual);
} }
} }
adjustedAccumulated += mandatoryAdjustment; adjustedAccumulated += mandatoryAdjustment;
} }
} }
result.Trend.Add(adjustedAccumulated); result.Trend.Add(adjustedAccumulated);
} }
@@ -480,7 +480,7 @@ public class BudgetStatsService(
logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate);
// 5. 生成预算明细汇总日志 // 5. 生成预算明细汇总日志
var budgetDetails = budgets.Select(b => var budgetDetails = budgets.Select(b =>
{ {
var limit = CalculateBudgetLimit(b, BudgetPeriodType.Year, referenceDate); var limit = CalculateBudgetLimit(b, BudgetPeriodType.Year, referenceDate);
var prefix = b.IsArchive ? "(归档)" : b.RemainingMonths > 0 ? $"(剩余{b.RemainingMonths}月)" : ""; var prefix = b.IsArchive ? "(归档)" : b.RemainingMonths > 0 ? $"(剩余{b.RemainingMonths}月)" : "";
@@ -503,7 +503,7 @@ public class BudgetStatsService(
BudgetPeriodType statType, BudgetPeriodType statType,
DateTime referenceDate) DateTime referenceDate)
{ {
logger.LogDebug("开始获取预算数据: Category={Category}, StatType={StatType}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", logger.LogDebug("开始获取预算数据: Category={Category}, StatType={StatType}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, statType, referenceDate); category, statType, referenceDate);
var result = new List<BudgetStatsItem>(); var result = new List<BudgetStatsItem>();
@@ -516,7 +516,7 @@ public class BudgetStatsService(
if (statType == BudgetPeriodType.Year) if (statType == BudgetPeriodType.Year)
{ {
logger.LogDebug("年度统计:开始获取整年的预算数据"); logger.LogDebug("年度统计:开始获取整年的预算数据");
// 获取当前有效的预算(用于当前月及未来月) // 获取当前有效的预算(用于当前月及未来月)
var currentBudgets = await budgetRepository.GetAllAsync(); var currentBudgets = await budgetRepository.GetAllAsync();
var currentBudgetsDict = currentBudgets var currentBudgetsDict = currentBudgets
@@ -526,7 +526,7 @@ public class BudgetStatsService(
// 用于跟踪已处理的预算ID避免重复 // 用于跟踪已处理的预算ID避免重复
var processedBudgetIds = new HashSet<long>(); var processedBudgetIds = new HashSet<long>();
// 1. 处理历史归档月份1月到当前月-1 // 1. 处理历史归档月份1月到当前月-1
if (referenceDate.Year == now.Year && now.Month > 1) if (referenceDate.Year == now.Year && now.Month > 1)
{ {
@@ -585,7 +585,7 @@ public class BudgetStatsService(
} }
} }
} }
// 2. 处理当前月及未来月(使用当前预算) // 2. 处理当前月及未来月(使用当前预算)
logger.LogDebug("开始处理当前及未来月份预算"); logger.LogDebug("开始处理当前及未来月份预算");
foreach (var budget in currentBudgetsDict.Values) foreach (var budget in currentBudgetsDict.Values)
@@ -675,7 +675,7 @@ public class BudgetStatsService(
// 获取归档数据 // 获取归档数据
logger.LogDebug("开始获取归档数据: Year={Year}, Month={Month}", year, month); logger.LogDebug("开始获取归档数据: Year={Year}, Month={Month}", year, month);
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null) if (archive != null)
{ {
var itemCount = archive.Content.Count(); var itemCount = archive.Content.Count();
@@ -714,7 +714,7 @@ public class BudgetStatsService(
var budgets = await budgetRepository.GetAllAsync(); var budgets = await budgetRepository.GetAllAsync();
var budgetCount = budgets.Count(); var budgetCount = budgets.Count();
logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount); logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount);
foreach (var budget in budgets) foreach (var budget in budgets)
{ {
if (budget.Category == category && ShouldIncludeBudget(budget, statType)) if (budget.Category == category && ShouldIncludeBudget(budget, statType))
@@ -845,7 +845,7 @@ public class BudgetStatsService(
logger.LogInformation("预算 {BudgetName} 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}", logger.LogInformation("预算 {BudgetName} 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}",
budget.Name, budget.Limit, itemLimit, algorithmDescription); budget.Name, budget.Limit, itemLimit, algorithmDescription);
return itemLimit; return itemLimit;
} }
@@ -888,13 +888,13 @@ public class BudgetStatsService(
private decimal ApplyMandatoryBudgetAdjustment(List<BudgetStatsItem> budgets, decimal currentTotal, DateTime referenceDate, BudgetPeriodType statType) private decimal ApplyMandatoryBudgetAdjustment(List<BudgetStatsItem> budgets, decimal currentTotal, DateTime referenceDate, BudgetPeriodType statType)
{ {
logger.LogDebug("开始应用硬性预算调整: 当前总计={CurrentTotal}, 统计类型={StatType}, 参考日期={ReferenceDate:yyyy-MM-dd}", logger.LogDebug("开始应用硬性预算调整: 当前总计={CurrentTotal}, 统计类型={StatType}, 参考日期={ReferenceDate:yyyy-MM-dd}",
currentTotal, statType, referenceDate); currentTotal, statType, referenceDate);
var now = dateTimeProvider.Now; var now = dateTimeProvider.Now;
var adjustedTotal = currentTotal; var adjustedTotal = currentTotal;
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count); logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count);
var mandatoryIndex = 0; var mandatoryIndex = 0;
@@ -906,14 +906,14 @@ public class BudgetStatsService(
if (statType == BudgetPeriodType.Month) if (statType == BudgetPeriodType.Month)
{ {
isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month; isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}", logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}",
mandatoryIndex, mandatoryBudgets.Count, budget.Name, mandatoryIndex, mandatoryBudgets.Count, budget.Name,
referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod); referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod);
} }
else // Year else // Year
{ {
isCurrentPeriod = referenceDate.Year == now.Year; isCurrentPeriod = referenceDate.Year == now.Year;
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}", logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}",
mandatoryIndex, mandatoryBudgets.Count, budget.Name, mandatoryIndex, mandatoryBudgets.Count, budget.Name,
referenceDate.Year, now.Year, isCurrentPeriod); referenceDate.Year, now.Year, isCurrentPeriod);
} }
@@ -923,7 +923,7 @@ public class BudgetStatsService(
// 计算硬性预算的应累加值 // 计算硬性预算的应累加值
decimal mandatoryAccumulation = 0; decimal mandatoryAccumulation = 0;
var accumulationAlgorithm = ""; var accumulationAlgorithm = "";
if (budget.Type == BudgetPeriodType.Month) if (budget.Type == BudgetPeriodType.Month)
{ {
// 月度硬性预算按天数比例累加 // 月度硬性预算按天数比例累加
@@ -931,7 +931,7 @@ public class BudgetStatsService(
var daysElapsed = now.Day; var daysElapsed = now.Day;
mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth; mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
accumulationAlgorithm = $"月度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInMonth} = {mandatoryAccumulation:F2}"; accumulationAlgorithm = $"月度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInMonth} = {mandatoryAccumulation:F2}";
logger.LogDebug("月度硬性预算 {BudgetName}: 限额={Limit}, 本月天数={DaysInMonth}, 已过天数={DaysElapsed}, 应累加值={Accumulation}", logger.LogDebug("月度硬性预算 {BudgetName}: 限额={Limit}, 本月天数={DaysInMonth}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
budget.Name, budget.Limit, daysInMonth, daysElapsed, mandatoryAccumulation); budget.Name, budget.Limit, daysInMonth, daysElapsed, mandatoryAccumulation);
} }
else if (budget.Type == BudgetPeriodType.Year) else if (budget.Type == BudgetPeriodType.Year)
@@ -941,7 +941,7 @@ public class BudgetStatsService(
var daysElapsed = now.DayOfYear; var daysElapsed = now.DayOfYear;
mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear; mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
accumulationAlgorithm = $"年度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInYear} = {mandatoryAccumulation:F2}"; accumulationAlgorithm = $"年度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInYear} = {mandatoryAccumulation:F2}";
logger.LogDebug("年度硬性预算 {BudgetName}: 限额={Limit}, 本年天数={DaysInYear}, 已过天数={DaysElapsed}, 应累加值={Accumulation}", logger.LogDebug("年度硬性预算 {BudgetName}: 限额={Limit}, 本年天数={DaysInYear}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation); budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation);
} }
@@ -958,7 +958,7 @@ public class BudgetStatsService(
} }
else else
{ {
logger.LogDebug("硬性预算 {BudgetName} 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}", logger.LogDebug("硬性预算 {BudgetName} 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}",
budget.Name, adjustedTotal, mandatoryAccumulation); budget.Name, adjustedTotal, mandatoryAccumulation);
} }
} }
@@ -1029,7 +1029,7 @@ public class BudgetStatsService(
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate); var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通"; var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通";
var archiveLabel = budget.IsArchive ? $" ({budget.ArchiveMonth}月归档)" : ""; var archiveLabel = budget.IsArchive ? $" ({budget.ArchiveMonth}月归档)" : "";
description.AppendLine($""" description.AppendLine($"""
<tr> <tr>
<td>{budget.Name}{archiveLabel}</td> <td>{budget.Name}{archiveLabel}</td>
@@ -1138,8 +1138,8 @@ public class BudgetStatsService(
{ {
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
var typeStr = budget.IsCurrentMonth ? "当前月" : "未来月"; var typeStr = budget.IsCurrentMonth ? "当前月" : "未来月";
var calcStr = budget.IsCurrentMonth var calcStr = budget.IsCurrentMonth
? $"1月×{budget.Limit:N0}" ? $"1月×{budget.Limit:N0}"
: $"{budget.RemainingMonths}月×{budget.Limit:N0}"; : $"{budget.RemainingMonths}月×{budget.Limit:N0}";
description.AppendLine($""" description.AppendLine($"""
@@ -1195,7 +1195,7 @@ public class BudgetStatsService(
description.AppendLine($"<h3>计算公式</h3>"); description.AppendLine($"<h3>计算公式</h3>");
description.AppendLine($"<p><strong>年度预算额度合计:</strong>"); description.AppendLine($"<p><strong>年度预算额度合计:</strong>");
var limitParts = new List<string>(); var limitParts = new List<string>();
// 归档月度预算部分 // 归档月度预算部分
foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id)) foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id))
{ {
@@ -1204,7 +1204,7 @@ public class BudgetStatsService(
var groupTotalLimit = first.Limit * count; var groupTotalLimit = first.Limit * count;
limitParts.Add($"{first.Name}(归档{count}月×{first.Limit:N0}={groupTotalLimit:N0})"); limitParts.Add($"{first.Name}(归档{count}月×{first.Limit:N0}={groupTotalLimit:N0})");
} }
// 当前月度预算部分 // 当前月度预算部分
foreach (var budget in currentMonthlyBudgets) foreach (var budget in currentMonthlyBudgets)
{ {
@@ -1218,18 +1218,18 @@ public class BudgetStatsService(
limitParts.Add($"{budget.Name}(剩余{budget.RemainingMonths}月×{budget.Limit:N0}={budgetLimit:N0})"); limitParts.Add($"{budget.Name}(剩余{budget.RemainingMonths}月×{budget.Limit:N0}={budgetLimit:N0})");
} }
} }
// 年度预算部分 // 年度预算部分
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets)) foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
{ {
limitParts.Add($"{budget.Name}({budget.Limit:N0})"); limitParts.Add($"{budget.Name}({budget.Limit:N0})");
} }
description.AppendLine($"{string.Join(" + ", limitParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>"); description.AppendLine($"{string.Join(" + ", limitParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>");
description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>"); description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>");
var currentParts = new List<string>(); var currentParts = new List<string>();
// 归档月度预算的实际值 // 归档月度预算的实际值
foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id)) foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id))
{ {
@@ -1237,13 +1237,13 @@ public class BudgetStatsService(
var groupTotalCurrent = group.Sum(b => b.Current); var groupTotalCurrent = group.Sum(b => b.Current);
currentParts.Add($"{first.Name}(归档{groupTotalCurrent:N1})"); currentParts.Add($"{first.Name}(归档{groupTotalCurrent:N1})");
} }
// 年度预算的实际值 // 年度预算的实际值
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets)) foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
{ {
currentParts.Add($"{budget.Name}({budget.Current:N1})"); currentParts.Add($"{budget.Name}({budget.Current:N1})");
} }
description.AppendLine($"{string.Join(" + ", currentParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>"); description.AppendLine($"{string.Join(" + ", currentParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>");
var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;

View File

@@ -74,13 +74,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
_useSsl = useSsl; _useSsl = useSsl;
_email = email; _email = email;
_password = password; _password = password;
// 如果已连接,先断开 // 如果已连接,先断开
if (_imapClient?.IsConnected == true) if (_imapClient?.IsConnected == true)
{ {
await DisconnectAsync(); await DisconnectAsync();
} }
_imapClient = new ImapClient(); _imapClient = new ImapClient();
if (useSsl) if (useSsl)
@@ -206,7 +206,7 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
// 标记邮件为已读设置Seen标记 // 标记邮件为已读设置Seen标记
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false); await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid); _logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
} }
catch (Exception ex) catch (Exception ex)
@@ -240,13 +240,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
} }
return _imapClient?.IsConnected == true; return _imapClient?.IsConnected == true;
} }
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email)) if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
{ {
_logger.LogWarning("未初始化连接信息,无法自动重连"); _logger.LogWarning("未初始化连接信息,无法自动重连");
return false; return false;
} }
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email); _logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
return await ConnectAsync(_host, _port, _useSsl, _email, _password); return await ConnectAsync(_host, _port, _useSsl, _email, _password);
} }

View File

@@ -179,7 +179,7 @@ public class EmailHandleService(
{ {
var clone = records.ToArray().DeepClone(); var clone = records.ToArray().DeepClone();
if(clone?.Any() != true) if (clone?.Any() != true)
{ {
return; return;
} }

View File

@@ -72,7 +72,7 @@ public class EmailParseForm95555(
var balanceStr = match.Groups["balance"].Value; var balanceStr = match.Groups["balance"].Value;
var typeStr = match.Groups["type"].Value; var typeStr = match.Groups["type"].Value;
var reason = match.Groups["reason"].Value; var reason = match.Groups["reason"].Value;
if(string.IsNullOrEmpty(reason)) if (string.IsNullOrEmpty(reason))
{ {
reason = typeStr; reason = typeStr;
} }

View File

@@ -13,7 +13,7 @@ public partial class EmailParseFormCcsvc(
{ {
[GeneratedRegex("<.*?>")] [GeneratedRegex("<.*?>")]
private static partial Regex HtmlRegex(); private static partial Regex HtmlRegex();
public override bool CanParse(string from, string subject, string body) public override bool CanParse(string from, string subject, string body)
{ {
if (!from.Contains("ccsvc@message.cmbchina.com")) if (!from.Contains("ccsvc@message.cmbchina.com"))
@@ -141,7 +141,7 @@ public partial class EmailParseFormCcsvc(
} }
// 招商信用卡特殊,消费金额为正数,退款为负数 // 招商信用卡特殊,消费金额为正数,退款为负数
if(amount > 0) if (amount > 0)
{ {
type = TransactionType.Expense; type = TransactionType.Expense;
} }

View File

@@ -47,7 +47,7 @@ public abstract class EmailParseServicesBase(
// AI兜底 // AI兜底
result = await ParseByAiAsync(emailContent) ?? []; result = await ParseByAiAsync(emailContent) ?? [];
if(result.Length == 0) if (result.Length == 0)
{ {
logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录"); logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录");
} }
@@ -65,10 +65,10 @@ public abstract class EmailParseServicesBase(
)[]> ParseEmailContentAsync(string emailContent); )[]> ParseEmailContentAsync(string emailContent);
private async Task<( private async Task<(
string card, string card,
string reason, string reason,
decimal amount, decimal amount,
decimal balance, decimal balance,
TransactionType type, TransactionType type,
DateTime? occurredAt DateTime? occurredAt
)[]?> ParseByAiAsync(string body) )[]?> ParseByAiAsync(string body)
@@ -175,7 +175,7 @@ public abstract class EmailParseServicesBase(
} }
var occurredAt = (DateTime?)null; var occurredAt = (DateTime?)null;
if(DateTime.TryParse(occurredAtStr, out var occurredAtValue)) if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
{ {
occurredAt = occurredAtValue; occurredAt = occurredAtValue;
} }

View File

@@ -199,12 +199,12 @@ public class EmailSyncService(
message.TextBody ?? message.HtmlBody ?? string.Empty message.TextBody ?? message.HtmlBody ?? string.Empty
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
{ {
#if DEBUG #if DEBUG
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
#else #else
// 标记邮件为已读 // 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid); await emailFetchService.MarkAsReadAsync(uid);
#endif #endif
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -283,7 +283,7 @@ public class ImportService(
DateTime GetDateTimeValue(IDictionary<string, string> row, string key) DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
{ {
if(!row.ContainsKey(key)) if (!row.ContainsKey(key))
{ {
return DateTime.MinValue; return DateTime.MinValue;
} }

View File

@@ -24,7 +24,7 @@ public class BudgetArchiveJob(
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>(); var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
// 归档月度数据 // 归档月度数据
var result = await budgetService.ArchiveBudgetsAsync(year, month); var result = await budgetService.ArchiveBudgetsAsync(year, month);

View File

@@ -15,7 +15,7 @@ public class DbBackupJob(
try try
{ {
logger.LogInformation("开始执行数据库备份任务"); logger.LogInformation("开始执行数据库备份任务");
// 数据库文件路径 (基于 appsettings.json 中的配置: database/EmailBill.db) // 数据库文件路径 (基于 appsettings.json 中的配置: database/EmailBill.db)
var dbPath = Path.Combine(env.ContentRootPath, "database", "EmailBill.db"); var dbPath = Path.Combine(env.ContentRootPath, "database", "EmailBill.db");
var backupDir = Path.Combine(env.ContentRootPath, "database", "backups"); var backupDir = Path.Combine(env.ContentRootPath, "database", "backups");
@@ -48,7 +48,7 @@ public class DbBackupJob(
var filesToDelete = files.Skip(20); var filesToDelete = files.Skip(20);
foreach (var file in filesToDelete) foreach (var file in filesToDelete)
{ {
try try
{ {
file.Delete(); file.Delete();
logger.LogInformation("删除过期备份: {Name}", file.Name); logger.LogInformation("删除过期备份: {Name}", file.Name);

View File

@@ -144,12 +144,12 @@ public class EmailSyncJob(
message.TextBody ?? message.HtmlBody ?? string.Empty message.TextBody ?? message.HtmlBody ?? string.Empty
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
{ {
#if DEBUG #if DEBUG
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
#else #else
// 标记邮件为已读 // 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid); await emailFetchService.MarkAsReadAsync(uid);
#endif #endif
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -30,9 +30,9 @@ public class MessageService(IMessageRecordRepository messageRepo, INotificationS
} }
public async Task<bool> AddAsync( public async Task<bool> AddAsync(
string title, string title,
string content, string content,
MessageType type = MessageType.Text, MessageType type = MessageType.Text,
string? url = null string? url = null
) )
{ {
@@ -56,7 +56,7 @@ public class MessageService(IMessageRecordRepository messageRepo, INotificationS
{ {
var message = await messageRepo.GetByIdAsync(id); var message = await messageRepo.GetByIdAsync(id);
if (message == null) return false; if (message == null) return false;
message.IsRead = true; message.IsRead = true;
message.UpdateTime = DateTime.Now; message.UpdateTime = DateTime.Now;
return await messageRepo.UpdateAsync(message); return await messageRepo.UpdateAsync(message);

View File

@@ -31,10 +31,10 @@ public class TransactionPeriodicService(
try try
{ {
logger.LogInformation("开始执行周期性账单检查..."); logger.LogInformation("开始执行周期性账单检查...");
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync(); var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
var billsList = pendingBills.ToList(); var billsList = pendingBills.ToList();
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count); logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
foreach (var bill in billsList) foreach (var bill in billsList)
@@ -61,10 +61,10 @@ public class TransactionPeriodicService(
}; };
var success = await transactionRepository.AddAsync(transaction); var success = await transactionRepository.AddAsync(transaction);
if (success) if (success)
{ {
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}", logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
bill.Reason, bill.Amount); bill.Reason, bill.Amount);
// 创建未读消息 // 创建未读消息
@@ -80,8 +80,8 @@ public class TransactionPeriodicService(
var now = DateTime.Now; var now = DateTime.Now;
var nextTime = CalculateNextExecuteTime(bill, now); var nextTime = CalculateNextExecuteTime(bill, now);
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime); await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}", logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"); bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
} }
else else
@@ -114,7 +114,7 @@ public class TransactionPeriodicService(
} }
var today = DateTime.Today; var today = DateTime.Today;
// 如果从未执行过,需要执行 // 如果从未执行过,需要执行
if (bill.LastExecuteTime == null) if (bill.LastExecuteTime == null)
{ {
@@ -236,7 +236,7 @@ public class TransactionPeriodicService(
return null; return null;
var currentDayOfWeek = (int)baseTime.DayOfWeek; var currentDayOfWeek = (int)baseTime.DayOfWeek;
// 找下一个执行日 // 找下一个执行日
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek); var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
if (nextDay > 0) if (nextDay > 0)
@@ -244,7 +244,7 @@ public class TransactionPeriodicService(
var daysToAdd = nextDay - currentDayOfWeek; var daysToAdd = nextDay - currentDayOfWeek;
return baseTime.Date.AddDays(daysToAdd); return baseTime.Date.AddDays(daysToAdd);
} }
// 下周的第一个执行日 // 下周的第一个执行日
var firstDay = executeDays.First(); var firstDay = executeDays.First();
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay; var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
@@ -293,7 +293,7 @@ public class TransactionPeriodicService(
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1; var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
var nextQuarterStartMonth = currentQuarterStartMonth + 3; var nextQuarterStartMonth = currentQuarterStartMonth + 3;
var nextQuarterYear = baseTime.Year; var nextQuarterYear = baseTime.Year;
if (nextQuarterStartMonth > 12) if (nextQuarterStartMonth > 12)
{ {
nextQuarterStartMonth = 1; nextQuarterStartMonth = 1;
@@ -318,7 +318,7 @@ public class TransactionPeriodicService(
// 处理闰年情况 // 处理闰年情况
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365; var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
var actualDay = Math.Min(dayOfYear, daysInYear); var actualDay = Math.Min(dayOfYear, daysInYear);
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1); return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
} }
} }

View File

@@ -82,6 +82,7 @@ export const createTransaction = (data) => {
* @param {number} data.balance - 交易后余额 * @param {number} data.balance - 交易后余额
* @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
* @param {string} data.classify - 交易分类 * @param {string} data.classify - 交易分类
* @param {string} [data.occurredAt] - 交易时间
* @returns {Promise<{success: boolean}>} * @returns {Promise<{success: boolean}>}
*/ */
export const updateTransaction = (data) => { export const updateTransaction = (data) => {

184
Web/src/assets/theme.css Normal file
View File

@@ -0,0 +1,184 @@
/**
* EmailBill 主题系统 - 根据 v2.pen 设计稿
* 用于保持整个应用色彩和布局一致性
*/
:root {
/* ============ 颜色变量 - 浅色主题 ============ */
/* 背景色 */
--bg-primary: #FFFFFF;
--bg-secondary: #F6F7F8;
--bg-tertiary: #F3F4F6;
--bg-button: #F5F5F5;
/* 文字颜色 */
--text-primary: #1A1A1A;
--text-secondary: #6B7280;
--text-tertiary: #9CA3AF;
/* 强调色 */
--accent-primary: #FF6B6B;
--accent-danger: #EF4444;
--accent-warning: #D97706;
--accent-warning-bg: #FFFBEB;
--accent-success: #22C55E;
--accent-success-bg: #F0FDF4;
--accent-info: #6366F1;
--accent-info-bg: #E0E7FF;
/* 图标色 */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
/* ============ 布局变量 ============ */
/* 间距 */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px
--spacing-lg: 12px;
--spacing-xl: 16px;
--spacing-2xl: 20px;
--spacing-3xl: 24px;
/* 圆角 */
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-full: 22px;
/* 字体大小 */
--font-xs: 9px;
--font-sm: 11px;
--font-base: 12px;
--font-md: 13px;
--font-lg: 15px;
--font-xl: 18px;
--font-2xl: 24px;
--font-3xl: 32px;
/* 字体粗细 */
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* 字体 */
--font-primary: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Bricolage Grotesque', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* 阴影 (可选) */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.05);
}
/* ============ 深色主题 ============ */
[data-theme="dark"] {
/* 背景色 */
--bg-primary: #09090B;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
--bg-button: #27272a;
/* 文字颜色 */
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
/* 强调色 (深色主题调整) */
--accent-primary: #FF6B6B;
--accent-danger: #f87171;
--accent-warning: #fbbf24;
--accent-warning-bg: #451a03;
--accent-success: #4ade80;
--accent-success-bg: #064e3b;
--accent-info: #818cf8;
--accent-info-bg: #312e81;
/* 图标色 (深色主题) */
--icon-star: #FF6B6B;
--icon-coffee: #FCD34D;
}
/* ============ 通用工具类 ============ */
/* 文字 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.text-danger {
color: var(--accent-danger);
}
/* 背景 */
.bg-primary {
background-color: var(--bg-primary);
}
.bg-secondary {
background-color: var(--bg-secondary);
}
.bg-tertiary {
background-color: var(--bg-tertiary);
}
/* 布局容器 */
.container-fluid {
width: 100%;
max-width: 402px;
margin: 0 auto;
}
/* Flex 布局 */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
/* 间距 */
.gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); }
.gap-2xl { gap: var(--spacing-2xl); }
.gap-3xl { gap: var(--spacing-3xl); }
/* 内边距 */
.p-sm { padding: var(--spacing-md); }
.p-md { padding: var(--spacing-xl); }
.p-lg { padding: var(--spacing-2xl); }
.p-xl { padding: var(--spacing-3xl); }
/* 圆角 */
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-full { border-radius: var(--radius-full); }

View File

@@ -275,7 +275,7 @@ const handleTypeChange = () => {
const onConfirmDate = ({ selectedValues }) => { const onConfirmDate = ({ selectedValues }) => {
const dateStr = selectedValues.join('-') const dateStr = selectedValues.join('-')
const timeStr = currentTime.value.join(':') const timeStr = currentTime.value.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString() editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showDatePicker.value = false showDatePicker.value = false
// 接着选时间 // 接着选时间
showTimePicker.value = true showTimePicker.value = true
@@ -285,7 +285,7 @@ const onConfirmTime = ({ selectedValues }) => {
currentTime.value = selectedValues currentTime.value = selectedValues
const dateStr = currentDate.value.join('-') const dateStr = currentDate.value.join('-')
const timeStr = selectedValues.join(':') const timeStr = selectedValues.join(':')
editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).toISOString() editForm.occurredAt = dayjs(`${dateStr} ${timeStr}`).format('YYYY-MM-DDTHH:mm:ss')
showTimePicker.value = false showTimePicker.value = false
} }

View File

@@ -1,4 +1,5 @@
import './assets/main.css' import './assets/main.css'
import './assets/theme.css'
import './styles/common.css' import './styles/common.css'
import './styles/rich-content.css' import './styles/rich-content.css'

View File

@@ -0,0 +1,630 @@
<template>
<div
class="calendar-v2"
:data-theme="theme"
>
<!-- 头部 -->
<header class="calendar-header">
<div class="header-content">
<h1 class="header-title">
{{ currentMonth }}
</h1>
</div>
<button
class="notif-btn"
aria-label="通知"
>
<van-icon name="bell" />
</button>
</header>
<!-- 日历容器 -->
<div class="calendar-container">
<!-- 星期标题 -->
<div class="week-days">
<span
v-for="day in weekDays"
:key="day"
class="week-day"
>{{ day }}</span>
</div>
<!-- 日历网格 -->
<div class="calendar-grid">
<div
v-for="(week, weekIndex) in calendarWeeks"
:key="weekIndex"
class="calendar-week"
>
<div
v-for="day in week"
:key="day.date"
class="day-cell"
@click="onDayClick(day)"
>
<div
class="day-number"
:class="{
'day-today': day.isToday,
'day-selected': day.isSelected,
'day-has-data': day.hasData,
'day-over-limit': day.isOverLimit,
'day-other-month': !day.isCurrentMonth
}"
>
{{ day.dayNumber }}
</div>
<div
v-if="day.amount"
class="day-amount"
:class="{ 'amount-over': day.isOverLimit }"
>
{{ day.amount }}
</div>
</div>
</div>
</div>
</div>
<!-- 每日统计 -->
<div class="daily-stats">
<div class="stats-header">
<h2 class="stats-title">
Daily Stats
</h2>
<span class="stats-date">{{ selectedDateFormatted }}</span>
</div>
<div class="stats-card">
<div class="stats-row">
<span class="stats-label">Total Spent</span>
<div class="stats-badge">
Daily Limit: {{ dailyLimit }}
</div>
</div>
<div class="stats-value">
¥ {{ totalSpent }}
</div>
</div>
</div>
<!-- 交易列表 -->
<div class="transactions">
<div class="txn-header">
<h2 class="txn-title">
Transactions
</h2>
<div class="txn-actions">
<div class="txn-badge badge-success">
{{ transactionCount }} Items
</div>
<button class="smart-btn">
<van-icon name="star-o" />
<span>Smart</span>
</button>
</div>
</div>
<!-- 交易卡片 -->
<div class="txn-list">
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.name }}
</div>
<div class="txn-time">
{{ txn.time }}
</div>
</div>
<div class="txn-amount">
{{ txn.amount }}
</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div class="bottom-spacer" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// 当前主题
const theme = ref('light') // 'light' | 'dark'
// 星期标题
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
// 当前日期
const currentDate = ref(new Date())
const selectedDate = ref(new Date())
// 当前月份格式化
const currentMonth = computed(() => {
return currentDate.value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})
})
// 选中日期格式化
const selectedDateFormatted = computed(() => {
return selectedDate.value.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
// 生成日历数据
const calendarWeeks = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
// 获取当月第一天
const firstDay = new Date(year, month, 1)
// 获取当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
const weeks = []
let currentWeek = []
// 填充上月日期
for (let i = 0; i < startDayOfWeek; i++) {
const date = new Date(year, month, -(startDayOfWeek - i - 1))
currentWeek.push(createDayObject(date, false))
}
// 填充当月日期
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(year, month, day)
currentWeek.push(createDayObject(date, true))
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}
// 填充下月日期
if (currentWeek.length > 0) {
const remainingDays = 7 - currentWeek.length
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i)
currentWeek.push(createDayObject(date, false))
}
weeks.push(currentWeek)
}
return weeks
})
// 创建日期对象
const createDayObject = (date, isCurrentMonth) => {
const today = new Date()
const isToday =
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
const isSelected =
date.getDate() === selectedDate.value.getDate() &&
date.getMonth() === selectedDate.value.getMonth() &&
date.getFullYear() === selectedDate.value.getFullYear()
// 模拟数据 - 实际应该从 API 获取
const mockData = getMockDataForDate(date)
return {
date: date.getTime(),
dayNumber: date.getDate(),
isCurrentMonth,
isToday,
isSelected,
hasData: mockData.hasData,
amount: mockData.amount,
isOverLimit: mockData.isOverLimit
}
}
// 模拟数据获取
const getMockDataForDate = (date) => {
const day = date.getDate()
// 模拟一些有数据的日期
if (day >= 4 && day <= 28 && date.getMonth() === currentDate.value.getMonth()) {
const amounts = [128, 45, 230, 12, 88, 223, 15, 34, 120, 56, 442]
const amount = amounts[day % amounts.length]
return {
hasData: true,
amount: amount || '',
isOverLimit: amount > 200 // 超过限额标红
}
}
return { hasData: false, amount: '', isOverLimit: false }
}
// 统计数据
const dailyLimit = ref('2500')
const totalSpent = ref('1,248.50')
const transactionCount = computed(() => transactions.value.length)
// 交易列表数据
const transactions = ref([
{
id: 1,
name: 'Lunch',
time: '12:30 PM',
amount: '-58.00',
icon: 'star',
iconColor: '#FF6B6B',
iconBg: '#FFFFFF'
},
{
id: 2,
name: 'Coffee',
time: '08:15 AM',
amount: '-24.50',
icon: 'coffee-o',
iconColor: '#FCD34D',
iconBg: '#FFFFFF'
}
])
// 点击日期
const onDayClick = (day) => {
if (!day.isCurrentMonth) {return}
selectedDate.value = new Date(day.date)
// TODO: 加载选中日期的数据
console.log('Selected date:', day)
}
// 点击交易
const onTransactionClick = (txn) => {
console.log('Transaction clicked:', txn)
// TODO: 打开交易详情
}
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 暴露切换主题方法供外部调用
defineExpose({
toggleTheme
})
</script>
<style scoped>
@import '@/assets/theme.css';
.calendar-v2 {
min-height: 100vh;
background-color: var(--bg-primary);
font-family: var(--font-primary);
color: var(--text-primary);
}
/* ========== 头部 ========== */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
gap: 4px;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-xl);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin: 0;
}
.notif-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background-color: var(--bg-button);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.notif-btn:active {
opacity: 0.7;
}
/* ========== 日历容器 ========== */
.calendar-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.week-days {
display: flex;
justify-content: space-between;
}
.week-day {
width: 44px;
text-align: center;
font-size: var(--font-base);
font-weight: var(--font-semibold);
color: var(--text-tertiary);
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.calendar-week {
display: flex;
justify-content: space-between;
}
.day-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 44px;
cursor: pointer;
}
.day-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 16px;
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-primary);
transition: all 0.2s;
}
.day-number.day-has-data {
background-color: var(--bg-tertiary);
}
.day-number.day-selected {
background-color: var(--accent-primary);
color: #FFFFFF;
}
.day-number.day-other-month {
opacity: 0.3;
}
.day-amount {
font-size: var(--font-xs);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.day-amount.amount-over {
color: var(--accent-danger);
}
/* ========== 统计卡片 ========== */
.daily-stats {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
.stats-date {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.stats-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
background-color: var(--bg-secondary);
border-radius: var(--radius-lg);
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-label {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.stats-badge {
padding: 6px 10px;
background-color: var(--accent-warning-bg);
color: var(--accent-warning);
font-size: var(--font-sm);
font-weight: var(--font-semibold);
border-radius: var(--radius-sm);
}
.stats-value {
font-family: var(--font-display);
font-size: var(--font-3xl);
font-weight: var(--font-extrabold);
color: var(--text-primary);
}
/* ========== 交易列表 ========== */
.transactions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
}
.txn-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.txn-title {
font-family: var(--font-display);
font-size: var(--font-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
.txn-actions {
display: flex;
align-items: center;
gap: 8px;
}
.txn-badge {
padding: 6px 12px;
font-size: var(--font-base);
font-weight: var(--font-semibold);
border-radius: var(--radius-sm);
}
.badge-success {
background-color: var(--accent-success-bg);
color: var(--accent-success);
}
.smart-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--accent-info-bg);
color: var(--accent-info);
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-base);
font-weight: var(--font-semibold);
cursor: pointer;
transition: opacity 0.2s;
}
.smart-btn:active {
opacity: 0.7;
}
.txn-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: opacity 0.2s;
}
.txn-card:active {
opacity: 0.7;
}
.txn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.txn-time {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
}
/* 底部安全距离 */
.bottom-spacer {
height: calc(60px + env(safe-area-inset-bottom, 0px));
}
</style>

View File

@@ -21,7 +21,7 @@ public class BudgetStatsTest : BaseTest
public BudgetStatsTest() public BudgetStatsTest()
{ {
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15)); _dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
IBudgetStatsService budgetStatsService = new BudgetStatsService( IBudgetStatsService budgetStatsService = new BudgetStatsService(
_budgetRepository, _budgetRepository,
_budgetArchiveRepository, _budgetArchiveRepository,
@@ -29,7 +29,7 @@ public class BudgetStatsTest : BaseTest
_dateTimeProvider, _dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>() Substitute.For<ILogger<BudgetStatsService>>()
); );
_service = new BudgetService( _service = new BudgetService(
_budgetRepository, _budgetRepository,
_budgetArchiveRepository, _budgetArchiveRepository,
@@ -89,7 +89,7 @@ public class BudgetStatsTest : BaseTest
_budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()) _budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(0m); // 实际支出的金额为0 .Returns(0m); // 实际支出的金额为0
_dateTimeProvider.Now.Returns(referenceDate); _dateTimeProvider.Now.Returns(referenceDate);
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>()) _transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>()); .Returns(new Dictionary<DateTime, decimal>());
@@ -117,7 +117,7 @@ public class BudgetStatsTest : BaseTest
}; };
_budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetAllAsync().Returns(budgets);
// 月度统计使用趋势统计数据(只包含月度预算的分类) // 月度统计使用趋势统计数据(只包含月度预算的分类)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
@@ -128,7 +128,7 @@ public class BudgetStatsTest : BaseTest
{ {
{ new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800 { new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800
}); });
// 年度统计使用GetCurrentAmountAsync // 年度统计使用GetCurrentAmountAsync
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()) _budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args => .Returns(args =>
@@ -136,22 +136,22 @@ public class BudgetStatsTest : BaseTest
var b = (BudgetRecord)args[0]; var b = (BudgetRecord)args[0];
var startDate = (DateTime)args[1]; var startDate = (DateTime)args[1];
var endDate = (DateTime)args[2]; var endDate = (DateTime)args[2];
// 月度范围查询 - 月度吃饭1月 // 月度范围查询 - 月度吃饭1月
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 1) if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 1)
{ {
return b.Name == "月度吃饭" ? 800m : 0m; return b.Name == "月度吃饭" ? 800m : 0m;
} }
// 年度范围查询 - 年度旅游 // 年度范围查询 - 年度旅游
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 12) if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 12)
{ {
return b.Name == "年度旅游" ? 2000m : 0m; return b.Name == "年度旅游" ? 2000m : 0m;
} }
return 0m; return 0m;
}); });
// 年度趋势统计(包含所有分类) // 年度趋势统计(包含所有分类)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
@@ -172,7 +172,7 @@ public class BudgetStatsTest : BaseTest
result.Month.Limit.Should().Be(3000); // 月度吃饭3000 result.Month.Limit.Should().Be(3000); // 月度吃饭3000
result.Month.Current.Should().Be(800); // 月度吃饭已用800从GetCurrentAmountAsync获取 result.Month.Current.Should().Be(800); // 月度吃饭已用800从GetCurrentAmountAsync获取
result.Month.Count.Should().Be(1); // 只包含1个月度预算 result.Month.Count.Should().Be(1); // 只包含1个月度预算
// 年度统计中:包含所有预算(月度预算按剩余月份折算) // 年度统计中:包含所有预算(月度预算按剩余月份折算)
// 1月时月度预算分为当前月(1月) + 剩余月份(2-12月共11个月) // 1月时月度预算分为当前月(1月) + 剩余月份(2-12月共11个月)
result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000 result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000
@@ -239,7 +239,7 @@ public class BudgetStatsTest : BaseTest
{ {
// Arrange // Arrange
var referenceDate = new DateTime(2024, 1, 15); var referenceDate = new DateTime(2024, 1, 15);
// 设置预算:包含月度预算和年度预算 // 设置预算:包含月度预算和年度预算
var budgets = new List<BudgetRecord> var budgets = new List<BudgetRecord>
{ {
@@ -257,7 +257,7 @@ public class BudgetStatsTest : BaseTest
}; };
_budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetAllAsync().Returns(budgets);
// 设置月度预算的当前金额 // 设置月度预算的当前金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()) _budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args => .Returns(args =>
@@ -272,7 +272,7 @@ public class BudgetStatsTest : BaseTest
_ => 0m _ => 0m
}; };
}); });
// 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通) // 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通)
// 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖) // 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
@@ -284,7 +284,7 @@ public class BudgetStatsTest : BaseTest
{ {
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300不包含年度旅游2000 { new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300不包含年度旅游2000
}); });
// 设置年度趋势统计数据:包含所有预算相关的分类 // 设置年度趋势统计数据:包含所有预算相关的分类
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
@@ -296,7 +296,7 @@ public class BudgetStatsTest : BaseTest
{ {
{ new DateTime(2024, 1, 1), 3500m } // 1月累计3500吃喝1200+交通300+年度旅游2000 { new DateTime(2024, 1, 1), 3500m } // 1月累计3500吃喝1200+交通300+年度旅游2000
}); });
// 设置收入相关的趋势统计数据 // 设置收入相关的趋势统计数据
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
@@ -304,7 +304,7 @@ public class BudgetStatsTest : BaseTest
TransactionType.Income, TransactionType.Income,
Arg.Any<List<string>>()) Arg.Any<List<string>>())
.Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空 .Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空
_transactionStatisticsService.GetFilteredTrendStatisticsAsync( _transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31), Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
@@ -318,7 +318,7 @@ public class BudgetStatsTest : BaseTest
// Act - 测试支出统计 // Act - 测试支出统计
var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Act - 测试收入统计 // Act - 测试收入统计
var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate); var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
@@ -345,17 +345,17 @@ public class BudgetStatsTest : BaseTest
incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算 incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算
} }
[Fact] [Fact]
public async Task GetCategoryStats_年度_3月_2月预算变更_Test() public async Task GetCategoryStats_年度_3月_2月预算变更_Test()
{ {
// Arrange // Arrange
// 测试场景2024年3月查看年度预算统计其中2月份发生了预算变更吃喝预算从2000增加到2500 // 测试场景2024年3月查看年度预算统计其中2月份发生了预算变更吃喝预算从2000增加到2500
var referenceDate = new DateTime(2024, 3, 15); var referenceDate = new DateTime(2024, 3, 15);
// 设置当前时间确保3月被认为是当前月份 // 设置当前时间确保3月被认为是当前月份
_dateTimeProvider.Now.Returns(new DateTime(2024, 3, 15)); _dateTimeProvider.Now.Returns(new DateTime(2024, 3, 15));
// 当前3月份有效的预算 // 当前3月份有效的预算
var currentBudgets = new List<BudgetRecord> var currentBudgets = new List<BudgetRecord>
{ {
@@ -426,11 +426,11 @@ public class BudgetStatsTest : BaseTest
// 设置仓储响应 // 设置仓储响应
_budgetRepository.GetAllAsync().Returns(currentBudgets); _budgetRepository.GetAllAsync().Returns(currentBudgets);
// 设置归档仓储响应 // 设置归档仓储响应
_budgetArchiveRepository.GetArchiveAsync(2024, 2).Returns(febArchive); _budgetArchiveRepository.GetArchiveAsync(2024, 2).Returns(febArchive);
_budgetArchiveRepository.GetArchiveAsync(2024, 1).Returns(janArchive); _budgetArchiveRepository.GetArchiveAsync(2024, 1).Returns(janArchive);
// 设置月度预算的当前金额查询仅用于3月份 // 设置月度预算的当前金额查询仅用于3月份
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 3), Arg.Is<DateTime>(d => d.Month == 3)) _budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 3), Arg.Is<DateTime>(d => d.Month == 3))
.Returns(args => .Returns(args =>
@@ -443,11 +443,11 @@ public class BudgetStatsTest : BaseTest
_ => 0m _ => 0m
}; };
}); });
// 年度旅游的年度金额查询 // 年度旅游的年度金额查询
_budgetRepository.GetCurrentAmountAsync( _budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3), Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1), Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12)) Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500 .Returns(2500m); // 年度旅游1-3月已花费2500
@@ -477,11 +477,11 @@ public class BudgetStatsTest : BaseTest
// 3月累计月度预算1000 + 年度旅游2500 = 3500 // 3月累计月度预算1000 + 年度旅游2500 = 3500
{ new DateTime(2024, 3, 1), 3500m } { new DateTime(2024, 3, 1), 3500m }
}); });
// 补充年度旅游的GetCurrentAmountAsync调用用于计算Current // 补充年度旅游的GetCurrentAmountAsync调用用于计算Current
_budgetRepository.GetCurrentAmountAsync( _budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3), Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1), Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12)) Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500 .Returns(2500m); // 年度旅游1-3月已花费2500
@@ -494,7 +494,7 @@ public class BudgetStatsTest : BaseTest
_dateTimeProvider, _dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>() Substitute.For<ILogger<BudgetStatsService>>()
); );
var result = await budgetStatsService.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); var result = await budgetStatsService.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert - 月度统计3月份 // Assert - 月度统计3月份
@@ -502,7 +502,7 @@ public class BudgetStatsTest : BaseTest
result.Month.Limit.Should().Be(3000); // 吃喝2500 + 交通500使用变更后的预算 result.Month.Limit.Should().Be(3000); // 吃喝2500 + 交通500使用变更后的预算
result.Month.Current.Should().Be(1000); // 吃喝800 + 交通200 result.Month.Current.Should().Be(1000); // 吃喝800 + 交通200
result.Month.Count.Should().Be(2); // 包含2个月度预算 result.Month.Count.Should().Be(2); // 包含2个月度预算
// Assert - 年度统计(需要考虑预算变更和剩余月份) // Assert - 年度统计(需要考虑预算变更和剩余月份)
// 新逻辑: // 新逻辑:
// 1. 对于归档数据,直接使用归档的限额,不折算 // 1. 对于归档数据,直接使用归档的限额,不折算
@@ -515,7 +515,7 @@ public class BudgetStatsTest : BaseTest
// 年度旅游12000 // 年度旅游12000
// 总计2500 + 2500 + 30000 + 12000 = 47000 // 总计2500 + 2500 + 30000 + 12000 = 47000
result.Year.Limit.Should().Be(47000); result.Year.Limit.Should().Be(47000);
// 预期年度实际金额: // 预期年度实际金额:
// 根据趋势统计数据3月累计: 月度预算1000 + 年度旅游2500 = 3500 // 根据趋势统计数据3月累计: 月度预算1000 + 年度旅游2500 = 3500
// 但业务代码会累加所有预算项的Current值 // 但业务代码会累加所有预算项的Current值
@@ -528,7 +528,7 @@ public class BudgetStatsTest : BaseTest
// - 年度旅游2500 // - 年度旅游2500
// 总计1500+250+1800+300+800+200+2500 = 7350 // 总计1500+250+1800+300+800+200+2500 = 7350
result.Year.Current.Should().Be(7350); result.Year.Current.Should().Be(7350);
// 应该包含: // 应该包含:
// - 1月归档的月度预算吃喝、1个 // - 1月归档的月度预算吃喝、1个
// - 1月归档的月度预算交通、1个 // - 1月归档的月度预算交通、1个
@@ -541,7 +541,7 @@ public class BudgetStatsTest : BaseTest
// - 年度旅游1个 // - 年度旅游1个
// 总计9个 // 总计9个
result.Year.Count.Should().Be(9); result.Year.Count.Should().Be(9);
// 验证使用率计算正确 // 验证使用率计算正确
result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m); result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m);
// 年度使用率7350 / 47000 * 100 = 15.64% // 年度使用率7350 / 47000 * 100 = 15.64%

View File

@@ -60,7 +60,7 @@ public class BudgetRepositoryTest : TransactionTestBase
var b1_updated = all.First(b => b.Name == "B1"); var b1_updated = all.First(b => b.Name == "B1");
b1_updated.SelectedCategories.Should().Contain("美食"); b1_updated.SelectedCategories.Should().Contain("美食");
b1_updated.SelectedCategories.Should().NotContain("餐饮"); b1_updated.SelectedCategories.Should().NotContain("餐饮");
var b2_updated = all.First(b => b.Name == "B2"); var b2_updated = all.First(b => b.Name == "B2");
b2_updated.SelectedCategories.Should().Be("美食"); b2_updated.SelectedCategories.Should().Be("美食");
} }

View File

@@ -13,7 +13,7 @@ public class ConfigRepositoryTest : RepositoryTestBase
public async Task GetByKeyAsync_获取配置_Test() public async Task GetByKeyAsync_获取配置_Test()
{ {
await _repository.AddAsync(new ConfigEntity { Key = "k1", Value = "v1" }); await _repository.AddAsync(new ConfigEntity { Key = "k1", Value = "v1" });
var config = await _repository.GetByKeyAsync("k1"); var config = await _repository.GetByKeyAsync("k1");
config.Should().NotBeNull(); config.Should().NotBeNull();
config.Value.Should().Be("v1"); config.Value.Should().Be("v1");

View File

@@ -13,10 +13,10 @@ public class EmailMessageRepositoryTest : RepositoryTestBase
public async Task ExistsAsync_检查存在_Test() public async Task ExistsAsync_检查存在_Test()
{ {
await _repository.AddAsync(new EmailMessage { Md5 = "md5_value", Subject = "Test" }); await _repository.AddAsync(new EmailMessage { Md5 = "md5_value", Subject = "Test" });
var msg = await _repository.ExistsAsync("md5_value"); var msg = await _repository.ExistsAsync("md5_value");
msg.Should().NotBeNull(); msg.Should().NotBeNull();
var notfound = await _repository.ExistsAsync("other"); var notfound = await _repository.ExistsAsync("other");
notfound.Should().BeNull(); notfound.Should().BeNull();
} }
@@ -33,7 +33,7 @@ public class EmailMessageRepositoryTest : RepositoryTestBase
// Assuming ID order follows insertion (mostly true for snowflakes if generated sequentially) // Assuming ID order follows insertion (mostly true for snowflakes if generated sequentially)
// But ReceivedDate is the primary sort in logic usually. // But ReceivedDate is the primary sort in logic usually.
// Let's verify standard cursor pagination usually sorts by Date DESC, ID DESC. // Let's verify standard cursor pagination usually sorts by Date DESC, ID DESC.
await _repository.AddAsync(m1); await _repository.AddAsync(m1);
await _repository.AddAsync(m2); await _repository.AddAsync(m2);
await _repository.AddAsync(m3); await _repository.AddAsync(m3);

View File

@@ -13,7 +13,7 @@ public class PushSubscriptionRepositoryTest : RepositoryTestBase
public async Task GetByEndpointAsync_通过Endpoint获取_Test() public async Task GetByEndpointAsync_通过Endpoint获取_Test()
{ {
await _repository.AddAsync(new PushSubscription { Endpoint = "ep1" }); await _repository.AddAsync(new PushSubscription { Endpoint = "ep1" });
var sub = await _repository.GetByEndpointAsync("ep1"); var sub = await _repository.GetByEndpointAsync("ep1");
sub.Should().NotBeNull(); sub.Should().NotBeNull();
sub.Endpoint.Should().Be("ep1"); sub.Endpoint.Should().Be("ep1");

View File

@@ -14,10 +14,10 @@ public class TransactionPeriodicRepositoryTest : TransactionTestBase
{ {
// 应该执行的NextExecuteTime <= Now // 应该执行的NextExecuteTime <= Now
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill1", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = true }); await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill1", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = true });
// 不该执行的NextExecuteTime > Now // 不该执行的NextExecuteTime > Now
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill2", NextExecuteTime = DateTime.Now.AddDays(1), IsEnabled = true }); await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill2", NextExecuteTime = DateTime.Now.AddDays(1), IsEnabled = true });
// 不该执行的:未激活 // 不该执行的:未激活
await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill3", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = false }); await _repository.AddAsync(new TransactionPeriodic { Reason = "Bill3", NextExecuteTime = DateTime.Now.AddDays(-1), IsEnabled = false });

View File

@@ -47,11 +47,11 @@ public class TransactionRecordRepositoryTest : TransactionTestBase
var results = await _repository.QueryAsync( var results = await _repository.QueryAsync(
startDate: new DateTime(2023, 1, 1), startDate: new DateTime(2023, 1, 1),
endDate: new DateTime(2023, 2, 28)); // Include Feb endDate: new DateTime(2023, 2, 28)); // Include Feb
results.Should().HaveCount(2); results.Should().HaveCount(2);
} }
[Fact] [Fact]
public async Task QueryAsync_按年月筛选_Test() public async Task QueryAsync_按年月筛选_Test()
{ {
await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 15))); await _repository.AddAsync(CreateExpense(100, new DateTime(2023, 1, 15)));
@@ -99,7 +99,7 @@ public class TransactionRecordRepositoryTest : TransactionTestBase
var records = await _repository.QueryAsync(reason: "麦当劳"); var records = await _repository.QueryAsync(reason: "麦当劳");
records.All(r => r.Classify == "快餐").Should().BeTrue(); records.All(r => r.Classify == "快餐").Should().BeTrue();
var kfc = await _repository.QueryAsync(reason: "肯德基"); var kfc = await _repository.QueryAsync(reason: "肯德基");
kfc.First().Classify.Should().Be("餐饮"); kfc.First().Classify.Should().Be("餐饮");
} }

View File

@@ -51,7 +51,7 @@ public class TransactionPeriodicServiceTest : BaseTest
// Assert // Assert
// Service inserts Amount directly from periodicBill.Amount (100 is positive) // Service inserts Amount directly from periodicBill.Amount (100 is positive)
await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t => await _transactionRepository.Received(1).AddAsync(Arg.Is<TransactionRecord>(t =>
t.Amount == 100m && t.Amount == 100m &&
t.Type == TransactionType.Expense && t.Type == TransactionType.Expense &&
t.Classify == "餐饮" && t.Classify == "餐饮" &&
t.Reason == "每日餐费" && t.Reason == "每日餐费" &&
@@ -69,7 +69,7 @@ public class TransactionPeriodicServiceTest : BaseTest
await _periodicRepository.Received(1).UpdateExecuteTimeAsync( await _periodicRepository.Received(1).UpdateExecuteTimeAsync(
Arg.Is(1L), Arg.Is(1L),
Arg.Any<DateTime>(), Arg.Any<DateTime>(),
Arg.Any<DateTime?>() Arg.Any<DateTime?>()
); );
} }
@@ -149,7 +149,7 @@ public class TransactionPeriodicServiceTest : BaseTest
m.Content.Contains("每月工资") m.Content.Contains("每月工资")
)); ));
} }
[Fact] [Fact]
public async Task ExecutePeriodicBillsAsync_未达到执行时间() public async Task ExecutePeriodicBillsAsync_未达到执行时间()
{ {
@@ -158,7 +158,7 @@ public class TransactionPeriodicServiceTest : BaseTest
{ {
Id = 1, Id = 1,
PeriodicType = PeriodicType.Weekly, PeriodicType = PeriodicType.Weekly,
PeriodicConfig = "1,3,5", PeriodicConfig = "1,3,5",
Amount = 200m, Amount = 200m,
Type = TransactionType.Expense, Type = TransactionType.Expense,
Classify = "交通", Classify = "交通",
@@ -191,7 +191,7 @@ public class TransactionPeriodicServiceTest : BaseTest
Classify = "餐饮", Classify = "餐饮",
Reason = "每日餐费", Reason = "每日餐费",
IsEnabled = true, IsEnabled = true,
LastExecuteTime = DateTime.Today, LastExecuteTime = DateTime.Today,
NextExecuteTime = DateTime.Today.AddDays(1) NextExecuteTime = DateTime.Today.AddDays(1)
}; };

View File

@@ -93,7 +93,7 @@ public class TransactionStatisticsServiceTest : BaseTest
// Mock Logic: filter by year (Arg[0]) and month (Arg[1]) and type (Arg[4]) if provided // Mock Logic: filter by year (Arg[0]) and month (Arg[1]) and type (Arg[4]) if provided
_transactionRepository.QueryAsync( _transactionRepository.QueryAsync(
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(), Arg.Any<TransactionType?>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>() Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(), Arg.Any<TransactionType?>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()
).Returns(callInfo => ).Returns(callInfo =>
{ {
var y = callInfo.ArgAt<int?>(0); var y = callInfo.ArgAt<int?>(0);
var m = callInfo.ArgAt<int?>(1); var m = callInfo.ArgAt<int?>(1);
@@ -106,7 +106,7 @@ public class TransactionStatisticsServiceTest : BaseTest
// In GetTrendStatisticsAsync: transactionRepository.QueryAsync(year: targetYear, month: targetMonth...) // In GetTrendStatisticsAsync: transactionRepository.QueryAsync(year: targetYear, month: targetMonth...)
// It does NOT pass type. So type is null. // It does NOT pass type. So type is null.
// But Service THEN filters by Type in memory. // But Service THEN filters by Type in memory.
return query.ToList(); return query.ToList();
}); });
@@ -178,7 +178,7 @@ public class TransactionStatisticsServiceTest : BaseTest
// Assert // Assert
result[("餐饮", TransactionType.Expense)].Should().Be(-150m); // Expect Negative (Sum of amounts) result[("餐饮", TransactionType.Expense)].Should().Be(-150m); // Expect Negative (Sum of amounts)
} }
// Additional tests from original file to maintain coverage, with minimal adjustments if needed // Additional tests from original file to maintain coverage, with minimal adjustments if needed
[Fact] [Fact]
public async Task GetCategoryStatisticsAsync_支出分类() public async Task GetCategoryStatisticsAsync_支出分类()
@@ -190,18 +190,18 @@ public class TransactionStatisticsServiceTest : BaseTest
new() { Amount = -50m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Amount = -50m, Type = TransactionType.Expense, Classify = "餐饮" },
new() { Amount = -200m, Type = TransactionType.Expense, Classify = "交通" } new() { Amount = -200m, Type = TransactionType.Expense, Classify = "交通" }
}; };
// Mock filtering by Type // Mock filtering by Type
_transactionRepository.QueryAsync( _transactionRepository.QueryAsync(
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(), Arg.Any<TransactionType?>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>() Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<DateTime?>(), Arg.Any<DateTime?>(), Arg.Any<TransactionType?>(), Arg.Any<string[]>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()
).Returns(callInfo => ).Returns(callInfo =>
{ {
var type = callInfo.ArgAt<TransactionType?>(4); var type = callInfo.ArgAt<TransactionType?>(4);
return testData.Where(t => !type.HasValue || t.Type == type).ToList(); return testData.Where(t => !type.HasValue || t.Type == type).ToList();
}); });
var result = await _service.GetCategoryStatisticsAsync(year, month, TransactionType.Expense); var result = await _service.GetCategoryStatisticsAsync(year, month, TransactionType.Expense);
result.First(c => c.Classify == "餐饮").Amount.Should().Be(150m); result.First(c => c.Classify == "餐饮").Amount.Should().Be(150m);
result.First(c => c.Classify == "交通").Amount.Should().Be(200m); result.First(c => c.Classify == "交通").Amount.Should().Be(200m);
} }

View File

@@ -94,7 +94,7 @@ public class BaseResponse<T> : BaseResponse
/// 返回数据 /// 返回数据
/// </summary> /// </summary>
public T? Data { get; set; } public T? Data { get; set; }
public new static BaseResponse<T> Fail(string message) public new static BaseResponse<T> Fail(string message)
{ {
return new BaseResponse<T> return new BaseResponse<T>

View File

@@ -3,7 +3,7 @@
public class PagedResponse<T> : BaseResponse<T[]> public class PagedResponse<T> : BaseResponse<T[]>
{ {
public long LastId { get; set; } public long LastId { get; set; }
/// <summary> /// <summary>
/// 最后一条记录的时间(用于游标分页) /// 最后一条记录的时间(用于游标分页)
/// </summary> /// </summary>
@@ -13,7 +13,7 @@ public class PagedResponse<T> : BaseResponse<T[]>
/// 总记录数 /// 总记录数
/// </summary> /// </summary>
public int Total { get; set; } public int Total { get; set; }
public new static PagedResponse<T> Fail(string message) public new static PagedResponse<T> Fail(string message)
{ {
return new PagedResponse<T> return new PagedResponse<T>

View File

@@ -21,7 +21,7 @@ public class JobController(ISchedulerFactory schedulerFactory, ILogger<JobContro
var jobDetail = await scheduler.GetJobDetail(jobKey); var jobDetail = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey); var triggers = await scheduler.GetTriggersOfJob(jobKey);
var trigger = triggers.FirstOrDefault(); var trigger = triggers.FirstOrDefault();
var status = "Unknown"; var status = "Unknown";
DateTime? nextFireTime = null; DateTime? nextFireTime = null;

View File

@@ -9,15 +9,15 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
/// <summary> /// <summary>
/// 获取日志列表(分页) /// 获取日志列表(分页)
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<PagedResponse<LogEntry>> GetListAsync( public async Task<PagedResponse<LogEntry>> GetListAsync(
[FromQuery] int pageIndex = 1, [FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50, [FromQuery] int pageSize = 50,
[FromQuery] string? searchKeyword = null, [FromQuery] string? searchKeyword = null,
[FromQuery] string? logLevel = null, [FromQuery] string? logLevel = null,
[FromQuery] string? date = null, [FromQuery] string? date = null,
[FromQuery] string? className = null [FromQuery] string? className = null
) )
{ {
try try
{ {
@@ -221,7 +221,7 @@ public async Task<PagedResponse<LogEntry>> GetListAsync(
logger.LogError(ex, "获取类名列表失败"); logger.LogError(ex, "获取类名列表失败");
return $"获取类名列表失败: {ex.Message}".Fail<string[]>(); return $"获取类名列表失败: {ex.Message}".Fail<string[]>();
} }
} }
/// <summary> /// <summary>
/// 合并多行日志(已废弃,现在在流式读取中处理) /// 合并多行日志(已废弃,现在在流式读取中处理)
@@ -385,27 +385,27 @@ public async Task<PagedResponse<LogEntry>> GetListAsync(
/// <summary> /// <summary>
/// 读取日志 /// 读取日志
/// </summary> /// </summary>
private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync( private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync(
string path, string path,
int pageIndex, int pageIndex,
int pageSize, int pageSize,
string? searchKeyword, string? searchKeyword,
string? logLevel, string? logLevel,
string? className) string? className)
{
var allLines = await ReadAllLinesAsync(path);
var merged = MergeMultiLineLog(allLines);
var parsed = new List<LogEntry>();
foreach (var line in merged)
{ {
var entry = ParseLogLine(line); var allLines = await ReadAllLinesAsync(path);
if (entry != null && PassFilter(entry, searchKeyword, logLevel, className))
var merged = MergeMultiLineLog(allLines);
var parsed = new List<LogEntry>();
foreach (var line in merged)
{ {
parsed.Add(entry); var entry = ParseLogLine(line);
if (entry != null && PassFilter(entry, searchKeyword, logLevel, className))
{
parsed.Add(entry);
}
} }
}
parsed.Reverse(); parsed.Reverse();
@@ -419,28 +419,28 @@ private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync(
/// <summary> /// <summary>
/// 检查日志条目是否通过过滤条件 /// 检查日志条目是否通过过滤条件
/// </summary> /// </summary>
private bool PassFilter(LogEntry logEntry, string? searchKeyword, string? logLevel, string? className) private bool PassFilter(LogEntry logEntry, string? searchKeyword, string? logLevel, string? className)
{
if (!string.IsNullOrEmpty(searchKeyword) &&
!logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{ {
return false; if (!string.IsNullOrEmpty(searchKeyword) &&
} !logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(logLevel) && if (!string.IsNullOrEmpty(logLevel) &&
!logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase)) !logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase))
{ {
return false; return false;
} }
if (!string.IsNullOrEmpty(className) && if (!string.IsNullOrEmpty(className) &&
!logEntry.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase)) !logEntry.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase))
{ {
return false; return false;
} }
return true; return true;
} }
/// <summary> /// <summary>
/// 读取文件所有行(支持共享读取) /// 读取文件所有行(支持共享读取)

View File

@@ -32,7 +32,7 @@ public class NotificationController(INotificationService notificationService) :
return ex.Message.Fail(); return ex.Message.Fail();
} }
} }
public async Task<BaseResponse> TestNotification([FromQuery] string message) public async Task<BaseResponse> TestNotification([FromQuery] string message)
{ {
try try

View File

@@ -26,7 +26,7 @@ public class TransactionCategoryController(
{ {
categories = (await categoryRepository.GetAllAsync()).ToList(); categories = (await categoryRepository.GetAllAsync()).ToList();
} }
return categories.Ok(); return categories.Ok();
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -38,18 +38,18 @@ public class TransactionRecordController(
: classify.Split(',', StringSplitOptions.RemoveEmptyEntries); : classify.Split(',', StringSplitOptions.RemoveEmptyEntries);
TransactionType? transactionType = type.HasValue ? (TransactionType)type.Value : null; TransactionType? transactionType = type.HasValue ? (TransactionType)type.Value : null;
var list = await transactionRepository.QueryAsync( var list = await transactionRepository.QueryAsync(
year: year, year: year,
month: month, month: month,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate,
type: transactionType, type: transactionType,
classifies: classifies, classifies: classifies,
searchKeyword: searchKeyword, searchKeyword: searchKeyword,
reason: reason, reason: reason,
pageIndex: pageIndex, pageIndex: pageIndex,
pageSize: pageSize, pageSize: pageSize,
sortByAmount: sortByAmount); sortByAmount: sortByAmount);
var total = await transactionRepository.CountAsync( var total = await transactionRepository.CountAsync(
year: year, year: year,
month: month, month: month,
@@ -214,6 +214,12 @@ var list = await transactionRepository.QueryAsync(
transaction.Type = dto.Type; transaction.Type = dto.Type;
transaction.Classify = dto.Classify ?? string.Empty; transaction.Classify = dto.Classify ?? string.Empty;
// 更新交易时间
if (!string.IsNullOrEmpty(dto.OccurredAt) && DateTime.TryParse(dto.OccurredAt, out var occurredAt))
{
transaction.OccurredAt = occurredAt;
}
// 清除待确认状态 // 清除待确认状态
transaction.UnconfirmedClassify = null; transaction.UnconfirmedClassify = null;
transaction.UnconfirmedType = null; transaction.UnconfirmedType = null;
@@ -272,7 +278,7 @@ var list = await transactionRepository.QueryAsync(
// 获取每日统计数据 // 获取每日统计数据
var statistics = await transactionStatisticsService.GetDailyStatisticsAsync(year, month, savingClassify); var statistics = await transactionStatisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
// 按日期排序并计算累积余额 // 按日期排序并计算累积余额
var sortedStats = statistics.OrderBy(s => s.Key).ToList(); var sortedStats = statistics.OrderBy(s => s.Key).ToList();
var result = new List<BalanceStatisticsDto>(); var result = new List<BalanceStatisticsDto>();
@@ -693,7 +699,7 @@ var list = await transactionRepository.QueryAsync(
} }
} }
private async Task WriteEventAsync(string eventType, string data) private async Task WriteEventAsync(string eventType, string data)
{ {
var message = $"event: {eventType}\ndata: {data}\n\n"; var message = $"event: {eventType}\ndata: {data}\n\n";
await Response.WriteAsync(message); await Response.WriteAsync(message);
@@ -728,7 +734,8 @@ public record UpdateTransactionDto(
decimal Amount, decimal Amount,
decimal Balance, decimal Balance,
TransactionType Type, TransactionType Type,
string? Classify string? Classify,
string? OccurredAt = null
); );
/// <summary> /// <summary>

View File

@@ -14,9 +14,9 @@ public class RequestIdMiddleware
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
var requestId = context.Request.Headers["X-Request-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); var requestId = context.Request.Headers["X-Request-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
context.Items["RequestId"] = requestId; context.Items["RequestId"] = requestId;
using (LogContext.PushProperty("RequestId", requestId)) using (LogContext.PushProperty("RequestId", requestId))
{ {
await _next(context); await _next(context);
@@ -30,7 +30,7 @@ public static class RequestIdExtensions
{ {
return context.Items["RequestId"] as string; return context.Items["RequestId"] as string;
} }
public static IApplicationBuilder UseRequestId(this IApplicationBuilder builder) public static IApplicationBuilder UseRequestId(this IApplicationBuilder builder)
{ {
return builder.UseMiddleware<RequestIdMiddleware>(); return builder.UseMiddleware<RequestIdMiddleware>();

View File

@@ -110,7 +110,7 @@ var fsql = new FreeSqlBuilder()
.UseAutoSyncStructure(true) .UseAutoSyncStructure(true)
.UseLazyLoading(true) .UseLazyLoading(true)
.UseMonitorCommand( .UseMonitorCommand(
cmd => cmd =>
{ {
Log.Verbose("执行SQL: {Sql}", cmd.CommandText); Log.Verbose("执行SQL: {Sql}", cmd.CommandText);
} }

4954
v2.pen Normal file

File diff suppressed because it is too large Load Diff