feat: Implement scheduled tasks management and budget archiving functionality
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
- Added BudgetArchiveJob for monthly budget archiving. - Created BudgetArchive entity and BudgetArchiveRepository for managing archived budgets. - Introduced JobController for handling job execution, pausing, and resuming. - Developed ScheduledTasksView for displaying and managing scheduled tasks in the frontend. - Updated PeriodicBillJob to improve scope handling. - Enhanced OpenAiService with increased HTTP timeout. - Added archiveBudgets API endpoint for archiving budgets by year and month. - Refactored BudgetController to utilize new repository patterns and improved error handling. - Introduced rich-content styles for better rendering of HTML content in Vue components. - Updated various Vue components to support rich HTML content display.
This commit is contained in:
39
Entity/BudgetArchive.cs
Normal file
39
Entity/BudgetArchive.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
public class BudgetArchive : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 预算Id
|
||||||
|
/// </summary>
|
||||||
|
public long BudgetId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预算周期类型
|
||||||
|
/// </summary>
|
||||||
|
public BudgetPeriodType BudgetType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预算金额
|
||||||
|
/// </summary>
|
||||||
|
public decimal BudgetedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期内实际发生金额
|
||||||
|
/// </summary>
|
||||||
|
public decimal RealizedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 归档目标年份
|
||||||
|
/// </summary>
|
||||||
|
public int Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 归档目标月份
|
||||||
|
/// </summary>
|
||||||
|
public int Month { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 归档日期
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ArchiveDate { get; set; } = DateTime.Now;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ public interface IBaseRepository<T> where T : BaseEntity
|
|||||||
/// 添加数据
|
/// 添加数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> AddAsync(T entity);
|
Task<bool> AddAsync(T entity);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 添加数据
|
/// 添加数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -45,6 +45,13 @@ public interface IBaseRepository<T> where T : BaseEntity
|
|||||||
/// 删除数据
|
/// 删除数据
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> DeleteAsync(long id);
|
Task<bool> DeleteAsync(long id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行动态SQL查询,返回动态对象
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="completeSql">完整的SELECT SQL语句</param>
|
||||||
|
/// <returns>动态查询结果列表</returns>
|
||||||
|
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,4 +164,22 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
||||||
|
{
|
||||||
|
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||||
|
var result = new List<dynamic>();
|
||||||
|
|
||||||
|
foreach (System.Data.DataRow row in dt.Rows)
|
||||||
|
{
|
||||||
|
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||||
|
foreach (System.Data.DataColumn column in dt.Columns)
|
||||||
|
{
|
||||||
|
expando[column.ColumnName] = row[column];
|
||||||
|
}
|
||||||
|
result.Add(expando);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
Repository/BudgetArchiveRepository.cs
Normal file
34
Repository/BudgetArchiveRepository.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
|
||||||
|
{
|
||||||
|
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month);
|
||||||
|
Task<List<BudgetArchive>> GetListAsync(int year, int month);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BudgetArchiveRepository(
|
||||||
|
IFreeSql freeSql
|
||||||
|
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
|
||||||
|
{
|
||||||
|
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<BudgetArchive>()
|
||||||
|
.Where(a => a.BudgetId == budgetId &&
|
||||||
|
a.Year == year &&
|
||||||
|
a.Month == month)
|
||||||
|
.ToOneAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<BudgetArchive>()
|
||||||
|
.Where(
|
||||||
|
a => a.BudgetType == BudgetPeriodType.Month &&
|
||||||
|
a.Year == year &&
|
||||||
|
a.Month == month ||
|
||||||
|
a.BudgetType == BudgetPeriodType.Year &&
|
||||||
|
a.Year == year
|
||||||
|
)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,13 +146,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <returns>查询结果列表</returns>
|
/// <returns>查询结果列表</returns>
|
||||||
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
|
Task<List<TransactionRecord>> ExecuteRawSqlAsync(string completeSql);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 执行动态SQL查询,返回动态对象
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="completeSql">完整的SELECT SQL语句</param>
|
|
||||||
/// <returns>动态查询结果列表</returns>
|
|
||||||
Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据关键词查询已分类的账单(用于智能分类参考)
|
/// 根据关键词查询已分类的账单(用于智能分类参考)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -459,23 +452,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
|
return await FreeSql.Ado.QueryAsync<TransactionRecord>(completeSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
|
|
||||||
{
|
|
||||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
|
||||||
var result = new List<dynamic>();
|
|
||||||
|
|
||||||
foreach (System.Data.DataRow row in dt.Rows)
|
|
||||||
{
|
|
||||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
|
||||||
foreach (System.Data.DataColumn column in dt.Columns)
|
|
||||||
{
|
|
||||||
expando[column.ColumnName] = row[column];
|
|
||||||
}
|
|
||||||
result.Add(expando);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
|
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
|
||||||
{
|
{
|
||||||
var startDate = new DateTime(year, month, 1);
|
var startDate = new DateTime(year, month, 1);
|
||||||
|
|||||||
@@ -2,54 +2,255 @@
|
|||||||
|
|
||||||
public interface IBudgetService
|
public interface IBudgetService
|
||||||
{
|
{
|
||||||
Task<List<BudgetRecord>> GetAllAsync();
|
Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null);
|
||||||
Task<BudgetRecord?> GetByIdAsync(long id);
|
|
||||||
Task<bool> AddAsync(BudgetRecord budget);
|
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime? referenceDate = null);
|
||||||
Task<bool> DeleteAsync(long id);
|
|
||||||
Task<bool> UpdateAsync(BudgetRecord budget);
|
Task<string> ArchiveBudgetsAsync(int year, int month);
|
||||||
Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null);
|
|
||||||
Task<bool> ToggleStopAsync(long id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BudgetService(
|
public class BudgetService(
|
||||||
IBudgetRepository budgetRepository
|
IBudgetRepository budgetRepository,
|
||||||
|
IBudgetArchiveRepository budgetArchiveRepository,
|
||||||
|
ITransactionRecordRepository transactionRecordRepository,
|
||||||
|
IOpenAiService openAiService,
|
||||||
|
IConfigService configService,
|
||||||
|
IMessageRecordService messageService,
|
||||||
|
ILogger<BudgetService> logger
|
||||||
) : IBudgetService
|
) : IBudgetService
|
||||||
{
|
{
|
||||||
public async Task<List<BudgetRecord>> GetAllAsync()
|
public async Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
var list = await budgetRepository.GetAllAsync();
|
var budgets = await budgetRepository.GetAllAsync();
|
||||||
return list.ToList();
|
var dtos = new List<BudgetResult?>();
|
||||||
|
|
||||||
|
foreach (var budget in budgets)
|
||||||
|
{
|
||||||
|
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||||
|
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创造虚拟的存款预算
|
||||||
|
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||||
|
BudgetPeriodType.Month,
|
||||||
|
referenceDate,
|
||||||
|
budgets));
|
||||||
|
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||||
|
BudgetPeriodType.Year,
|
||||||
|
referenceDate,
|
||||||
|
budgets));
|
||||||
|
|
||||||
|
return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetRecord?> GetByIdAsync(long id)
|
public async Task<BudgetResult?> GetStatisticsAsync(long id, DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
return await budgetRepository.GetByIdAsync(id);
|
if (id == -1)
|
||||||
}
|
{
|
||||||
|
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
|
||||||
|
}
|
||||||
|
if (id == -2)
|
||||||
|
{
|
||||||
|
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> AddAsync(BudgetRecord budget)
|
|
||||||
{
|
|
||||||
return await budgetRepository.AddAsync(budget);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> DeleteAsync(long id)
|
|
||||||
{
|
|
||||||
return await budgetRepository.DeleteAsync(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> UpdateAsync(BudgetRecord budget)
|
|
||||||
{
|
|
||||||
return await budgetRepository.UpdateAsync(budget);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ToggleStopAsync(long id)
|
|
||||||
{
|
|
||||||
var budget = await budgetRepository.GetByIdAsync(id);
|
var budget = await budgetRepository.GetByIdAsync(id);
|
||||||
if (budget == null) return false;
|
if (budget == null)
|
||||||
budget.IsStopped = !budget.IsStopped;
|
{
|
||||||
return await budgetRepository.UpdateAsync(budget);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||||
|
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
public async Task<string> ArchiveBudgetsAsync(int year, int month)
|
||||||
|
{
|
||||||
|
var referenceDate = new DateTime(year, month, 1);
|
||||||
|
var budgets = await GetListAsync(referenceDate);
|
||||||
|
|
||||||
|
var addArchives = new List<BudgetArchive>();
|
||||||
|
var updateArchives = new List<BudgetArchive>();
|
||||||
|
foreach (var budget in budgets)
|
||||||
|
{
|
||||||
|
var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month);
|
||||||
|
|
||||||
|
if (archive != null)
|
||||||
|
{
|
||||||
|
archive.RealizedAmount = budget.Current;
|
||||||
|
archive.ArchiveDate = DateTime.Now;
|
||||||
|
updateArchives.Add(archive);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
archive = new BudgetArchive
|
||||||
|
{
|
||||||
|
BudgetId = budget.Id,
|
||||||
|
BudgetType = budget.Type,
|
||||||
|
Year = year,
|
||||||
|
Month = month,
|
||||||
|
BudgetedAmount = budget.Limit,
|
||||||
|
RealizedAmount = budget.Current,
|
||||||
|
ArchiveDate = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
addArchives.Add(archive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addArchives.Count > 0)
|
||||||
|
{
|
||||||
|
if (!await budgetArchiveRepository.AddRangeAsync(addArchives))
|
||||||
|
{
|
||||||
|
return "保存预算归档失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updateArchives.Count > 0)
|
||||||
|
{
|
||||||
|
if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives))
|
||||||
|
{
|
||||||
|
return "更新预算归档失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = NotifyAsync(year, month);
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyAsync(int year, int month)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var archives = await budgetArchiveRepository.GetListAsync(year, month);
|
||||||
|
var budgets = await budgetRepository.GetAllAsync();
|
||||||
|
var budgetMap = budgets.ToDictionary(b => b.Id, b => b);
|
||||||
|
|
||||||
|
var archiveData = archives.Select(a =>
|
||||||
|
{
|
||||||
|
budgetMap.TryGetValue(a.BudgetId, out var br);
|
||||||
|
var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知");
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Type = a.BudgetType.ToString(),
|
||||||
|
Limit = a.BudgetedAmount,
|
||||||
|
Actual = a.RealizedAmount,
|
||||||
|
Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown")
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||||
|
$"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS TransactionCount,
|
||||||
|
SUM(ABS(Amount)) AS TotalAmount,
|
||||||
|
Type,
|
||||||
|
Classify
|
||||||
|
FROM TransactionRecord
|
||||||
|
WHERE OccurredAt >= '{year}-01-01'
|
||||||
|
AND OccurredAt < '{year + 1}-01-01'
|
||||||
|
GROUP BY Type, Classify
|
||||||
|
ORDER BY TotalAmount DESC
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
var monthYear = new DateTime(year, month, 1).AddMonths(1);
|
||||||
|
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||||
|
$"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS TransactionCount,
|
||||||
|
SUM(ABS(Amount)) AS TotalAmount,
|
||||||
|
Type,
|
||||||
|
Classify
|
||||||
|
FROM TransactionRecord
|
||||||
|
WHERE OccurredAt >= '{year}-{month:00}-01'
|
||||||
|
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
|
||||||
|
GROUP BY Type, Classify
|
||||||
|
ORDER BY TotalAmount DESC
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
|
||||||
|
var budgetedCategories = budgets
|
||||||
|
.Where(b => !string.IsNullOrEmpty(b.SelectedCategories))
|
||||||
|
.SelectMany(b => b.SelectedCategories.Split(','))
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var uncovered = monthTransactions
|
||||||
|
.Where(t =>
|
||||||
|
{
|
||||||
|
var dict = (IDictionary<string, object>)t;
|
||||||
|
var classify = dict["Classify"]?.ToString() ?? "";
|
||||||
|
var type = Convert.ToInt32(dict["Type"]);
|
||||||
|
return type == 0 && !budgetedCategories.Contains(classify);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
|
||||||
|
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
|
||||||
|
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
|
||||||
|
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
|
||||||
|
|
||||||
|
var dataPrompt = $"""
|
||||||
|
报告周期:{year}年{month}月
|
||||||
|
账单数据说明:支出金额已取绝对值(TotalAmount 为正数表示支出/收入的总量)。
|
||||||
|
|
||||||
|
1. 预算执行数据(JSON):
|
||||||
|
{JsonSerializer.Serialize(archiveData)}
|
||||||
|
|
||||||
|
2. 本月消费明细(按分类, JSON):
|
||||||
|
{JsonSerializer.Serialize(monthTransactions)}
|
||||||
|
|
||||||
|
3. 全年累计消费概况(按分类, JSON):
|
||||||
|
{JsonSerializer.Serialize(yearTransactions)}
|
||||||
|
|
||||||
|
4. 未被任何预算覆盖的支出分类(JSON):
|
||||||
|
{JsonSerializer.Serialize(uncovered)}
|
||||||
|
|
||||||
|
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
|
||||||
|
|
||||||
|
【内容要求】
|
||||||
|
1. 概览:总结本月预算达成情况。
|
||||||
|
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
|
||||||
|
3. 超支/异常预警:重点分析超支项或支出异常的分类。
|
||||||
|
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
|
||||||
|
5. 改进建议:基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
|
||||||
|
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
|
||||||
|
|
||||||
|
【格式要求】
|
||||||
|
1. 使用HTML格式(移动端H5页面风格)
|
||||||
|
2. 生成清晰的报告标题(基于用户问题)
|
||||||
|
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
|
||||||
|
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
||||||
|
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
||||||
|
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
||||||
|
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
||||||
|
|
||||||
|
【样式限制(重要)】
|
||||||
|
8. 不要包含 html、body、head 标签
|
||||||
|
9. 不要使用任何 style 属性或 <style> 标签
|
||||||
|
10. 不要设置 background、background-color、color 等样式属性
|
||||||
|
11. 不要使用 div 包裹大段内容
|
||||||
|
|
||||||
|
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||||
|
""";
|
||||||
|
|
||||||
|
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
||||||
|
if (!string.IsNullOrEmpty(htmlReport))
|
||||||
|
{
|
||||||
|
await messageService.AddAsync(
|
||||||
|
title: $"{year}年{month}月 - 预算归档报告",
|
||||||
|
content: htmlReport,
|
||||||
|
type: MessageType.Html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "生成预算执行通知报告失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
||||||
{
|
{
|
||||||
if (budget.IsStopped) return 0;
|
if (budget.IsStopped) return 0;
|
||||||
|
|
||||||
@@ -59,7 +260,7 @@ public class BudgetService(
|
|||||||
return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
||||||
{
|
{
|
||||||
DateTime start;
|
DateTime start;
|
||||||
DateTime end;
|
DateTime end;
|
||||||
@@ -82,4 +283,179 @@ public class BudgetService(
|
|||||||
|
|
||||||
return (start, end);
|
return (start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<BudgetResult?> GetVirtualSavingsDtoAsync(
|
||||||
|
BudgetPeriodType periodType,
|
||||||
|
DateTime? referenceDate = null,
|
||||||
|
IEnumerable<BudgetRecord>? existingBudgets = null)
|
||||||
|
{
|
||||||
|
var allBudgets = existingBudgets;
|
||||||
|
|
||||||
|
if (existingBudgets == null)
|
||||||
|
{
|
||||||
|
allBudgets = await budgetRepository.GetAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allBudgets == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var date = referenceDate ?? DateTime.Now;
|
||||||
|
|
||||||
|
decimal incomeLimitAtPeriod = 0;
|
||||||
|
decimal expenseLimitAtPeriod = 0;
|
||||||
|
|
||||||
|
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||||
|
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||||
|
|
||||||
|
foreach (var b in allBudgets)
|
||||||
|
{
|
||||||
|
if (b.IsStopped || b.Category == BudgetCategory.Savings) continue;
|
||||||
|
|
||||||
|
// 折算系数:根据当前请求的 periodType (Year 或 Month),将预算 b 的 Limit 折算过来
|
||||||
|
decimal factor = 1.0m;
|
||||||
|
|
||||||
|
if (periodType == BudgetPeriodType.Year)
|
||||||
|
{
|
||||||
|
factor = b.Type switch
|
||||||
|
{
|
||||||
|
BudgetPeriodType.Month => 12,
|
||||||
|
BudgetPeriodType.Year => 1,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (periodType == BudgetPeriodType.Month)
|
||||||
|
{
|
||||||
|
factor = b.Type switch
|
||||||
|
{
|
||||||
|
BudgetPeriodType.Month => 1,
|
||||||
|
BudgetPeriodType.Year => 0,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
factor = 0; // 其他周期暂不计算虚拟存款
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factor <= 0) continue;
|
||||||
|
|
||||||
|
var subtotal = b.Limit * factor;
|
||||||
|
if (b.Category == BudgetCategory.Income)
|
||||||
|
{
|
||||||
|
incomeLimitAtPeriod += subtotal;
|
||||||
|
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||||
|
}
|
||||||
|
else if (b.Category == BudgetCategory.Expense)
|
||||||
|
{
|
||||||
|
expenseLimitAtPeriod += subtotal;
|
||||||
|
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description = new StringBuilder();
|
||||||
|
description.Append("<h3>预算收入明细</h3>");
|
||||||
|
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||||
|
foreach (var item in incomeItems)
|
||||||
|
{
|
||||||
|
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='income-value'>{item.Total:N0}</span></td></tr>");
|
||||||
|
}
|
||||||
|
description.Append("</tbody></table>");
|
||||||
|
}
|
||||||
|
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
|
||||||
|
|
||||||
|
description.Append("<h3>预算支出明细</h3>");
|
||||||
|
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||||
|
foreach (var item in expenseItems)
|
||||||
|
{
|
||||||
|
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='expense-value'>{item.Total:N0}</span></td></tr>");
|
||||||
|
}
|
||||||
|
description.Append("</tbody></table>");
|
||||||
|
}
|
||||||
|
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||||
|
|
||||||
|
description.Append("<h3>存款计划结论</h3>");
|
||||||
|
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
|
||||||
|
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||||
|
|
||||||
|
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||||||
|
var virtualBudget = new BudgetRecord
|
||||||
|
{
|
||||||
|
Id = periodType == BudgetPeriodType.Year ? -1 : -2,
|
||||||
|
Name = periodType == BudgetPeriodType.Year ? "年度存款" : "月度存款",
|
||||||
|
Category = BudgetCategory.Savings,
|
||||||
|
Type = periodType,
|
||||||
|
Limit = incomeLimitAtPeriod - expenseLimitAtPeriod,
|
||||||
|
StartDate = periodType == BudgetPeriodType.Year ? new DateTime(date.Year, 1, 1) : new DateTime(date.Year, date.Month, 1),
|
||||||
|
SelectedCategories = savingsCategories
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算实际发生的 收入 - 支出
|
||||||
|
var incomeHelper = new BudgetRecord { Category = BudgetCategory.Income, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
|
||||||
|
var expenseHelper = new BudgetRecord { Category = BudgetCategory.Expense, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
|
||||||
|
|
||||||
|
var actualIncome = await CalculateCurrentAmountAsync(incomeHelper, date);
|
||||||
|
var actualExpense = await CalculateCurrentAmountAsync(expenseHelper, date);
|
||||||
|
|
||||||
|
return BudgetResult.FromEntity(virtualBudget, actualIncome - actualExpense, date, description.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BudgetResult
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public BudgetPeriodType Type { get; set; }
|
||||||
|
public decimal Limit { get; set; }
|
||||||
|
public decimal Current { get; set; }
|
||||||
|
public BudgetCategory Category { get; set; }
|
||||||
|
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
||||||
|
public bool IsStopped { get; set; }
|
||||||
|
public string StartDate { get; set; } = string.Empty;
|
||||||
|
public string Period { get; set; } = string.Empty;
|
||||||
|
public DateTime? PeriodStart { get; set; }
|
||||||
|
public DateTime? PeriodEnd { get; set; }
|
||||||
|
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public static BudgetResult FromEntity(
|
||||||
|
BudgetRecord entity,
|
||||||
|
decimal currentAmount = 0,
|
||||||
|
DateTime? referenceDate = null,
|
||||||
|
string description = "")
|
||||||
|
{
|
||||||
|
var date = referenceDate ?? DateTime.Now;
|
||||||
|
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
||||||
|
|
||||||
|
return new BudgetResult
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Name = entity.Name,
|
||||||
|
Type = entity.Type,
|
||||||
|
Limit = entity.Limit,
|
||||||
|
Current = currentAmount,
|
||||||
|
Category = entity.Category,
|
||||||
|
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: entity.SelectedCategories.Split(','),
|
||||||
|
IsStopped = entity.IsStopped,
|
||||||
|
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
||||||
|
Period = entity.Type switch
|
||||||
|
{
|
||||||
|
BudgetPeriodType.Year => $"{start:yy}年",
|
||||||
|
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
||||||
|
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
||||||
|
},
|
||||||
|
PeriodStart = start,
|
||||||
|
PeriodEnd = end,
|
||||||
|
Description = description
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
Service/Jobs/BudgetArchiveJob.cs
Normal file
42
Service/Jobs/BudgetArchiveJob.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Service.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 预算归档定时任务
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution]
|
||||||
|
public class BudgetArchiveJob(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<BudgetArchiveJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("开始执行预算归档任务");
|
||||||
|
|
||||||
|
// 每个月1号执行,归档上个月的数据
|
||||||
|
var targetDate = DateTime.Now.AddMonths(-1);
|
||||||
|
var year = targetDate.Year;
|
||||||
|
var month = targetDate.Month;
|
||||||
|
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
|
||||||
|
var result = await budgetService.ArchiveBudgetsAsync(year, month);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(result))
|
||||||
|
{
|
||||||
|
logger.LogInformation("归档 {Year}年{Month}月 预算任务执行成功", year, month);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("归档 {Year}年{Month}月 预算任务提示: {Result}", year, month, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "预算归档任务执行出错");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,9 @@ public class PeriodicBillJob(
|
|||||||
logger.LogInformation("开始执行周期性账单检查任务");
|
logger.LogInformation("开始执行周期性账单检查任务");
|
||||||
|
|
||||||
// 执行周期性账单检查
|
// 执行周期性账单检查
|
||||||
using (var scope = serviceProvider.CreateScope())
|
using var scope = serviceProvider.CreateScope();
|
||||||
{
|
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
||||||
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
await periodicService.ExecutePeriodicBillsAsync();
|
||||||
await periodicService.ExecutePeriodicBillsAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("周期性账单检查任务执行完成");
|
logger.LogInformation("周期性账单检查任务执行完成");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ public class OpenAiService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
http.Timeout = TimeSpan.FromSeconds(30);
|
http.Timeout = TimeSpan.FromSeconds(60 * 5);
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
var payload = new
|
var payload = new
|
||||||
|
|||||||
@@ -72,3 +72,15 @@ export function toggleStopBudget(id) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档预算
|
||||||
|
* @param {number} year 年份
|
||||||
|
* @param {number} month 月份
|
||||||
|
*/
|
||||||
|
export function archiveBudgets(year, month) {
|
||||||
|
return request({
|
||||||
|
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
32
Web/src/api/job.js
Normal file
32
Web/src/api/job.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import request from '@/api/request'
|
||||||
|
|
||||||
|
export function getJobs() {
|
||||||
|
return request({
|
||||||
|
url: '/Job/GetJobs',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeJob(jobName) {
|
||||||
|
return request({
|
||||||
|
url: '/Job/Execute',
|
||||||
|
method: 'post',
|
||||||
|
data: { jobName }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseJob(jobName) {
|
||||||
|
return request({
|
||||||
|
url: '/Job/Pause',
|
||||||
|
method: 'post',
|
||||||
|
data: { jobName }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeJob(jobName) {
|
||||||
|
return request({
|
||||||
|
url: '/Job/Resume',
|
||||||
|
method: 'post',
|
||||||
|
data: { jobName }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
<van-collapse-transition>
|
<van-collapse-transition>
|
||||||
<div v-if="budget.description && showDescription" class="budget-description">
|
<div v-if="budget.description && showDescription" class="budget-description">
|
||||||
<div class="description-content" v-html="budget.description"></div>
|
<div class="description-content rich-html-content" v-html="budget.description"></div>
|
||||||
</div>
|
</div>
|
||||||
</van-collapse-transition>
|
</van-collapse-transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,78 +312,6 @@ const timePercentage = computed(() => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-content :deep(h3) {
|
|
||||||
margin: 12px 0 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #323233;
|
|
||||||
border-left: 3px solid #1989fa;
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 8px 0;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(th),
|
|
||||||
.description-content :deep(td) {
|
|
||||||
text-align: left;
|
|
||||||
padding: 6px 4px;
|
|
||||||
border-bottom: 1px solid #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(th) {
|
|
||||||
background-color: #f7f8fa;
|
|
||||||
color: #969799;
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(p) {
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(.income-value) {
|
|
||||||
color: #07c160;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(.expense-value) {
|
|
||||||
color: #ee0a24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-content :deep(.highlight) {
|
|
||||||
background-color: #fffbe6;
|
|
||||||
color: #ed6a0c;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 1px solid #ffe58f;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.description-content :deep(h3) {
|
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
|
||||||
.description-content :deep(table) {
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
.description-content :deep(th) {
|
|
||||||
background-color: #242424;
|
|
||||||
}
|
|
||||||
.description-content :deep(td) {
|
|
||||||
border-bottom-color: #2c2c2c;
|
|
||||||
}
|
|
||||||
.description-content :deep(.highlight) {
|
|
||||||
background-color: #3e371a;
|
|
||||||
color: #ff976a;
|
|
||||||
border-color: #594a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
import './styles/common.css'
|
import './styles/common.css'
|
||||||
|
import './styles/rich-content.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ const router = createRouter({
|
|||||||
name: 'budget',
|
name: 'budget',
|
||||||
component: () => import('../views/BudgetView.vue'),
|
component: () => import('../views/BudgetView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/scheduled-tasks',
|
||||||
|
name: 'scheduled-tasks',
|
||||||
|
component: () => import('../views/ScheduledTasksView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
141
Web/src/styles/rich-content.css
Normal file
141
Web/src/styles/rich-content.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* 后端返回的 HTML 富文本内容样式 */
|
||||||
|
.rich-html-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--van-text-color);
|
||||||
|
white-space: normal; /* 重置可能存在的 pre-wrap */
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content h1,
|
||||||
|
.rich-html-content h2,
|
||||||
|
.rich-html-content h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: var(--van-text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--van-border-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.rich-html-content h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.rich-html-content h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
border-left: 4px solid #1989fa;
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content p {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content ul,
|
||||||
|
.rich-html-content ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式优化 - 移动端适配滑动 */
|
||||||
|
.rich-html-content table {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
background: var(--van-background-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--van-border-color);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content th,
|
||||||
|
.rich-html-content td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--van-border-color);
|
||||||
|
min-width: 80px; /* 防止内容过于拥挤 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 针对第一列预算项增加最小宽度 */
|
||||||
|
.rich-html-content td:first-child,
|
||||||
|
.rich-html-content th:first-child {
|
||||||
|
min-width: 100px;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content th {
|
||||||
|
background: var(--van-gray-1);
|
||||||
|
color: var(--van-text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 业务特定样式:收入、支出、高亮 */
|
||||||
|
.rich-html-content .income-value {
|
||||||
|
color: #07c160 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content .expense-value {
|
||||||
|
color: #ee0a24 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content .highlight {
|
||||||
|
background-color: #fffbe6;
|
||||||
|
color: #ed6a0c;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式适配 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.rich-html-content .highlight {
|
||||||
|
background-color: rgba(255, 243, 205, 0.2);
|
||||||
|
color: #ffc107;
|
||||||
|
border-color: rgba(255, 229, 143, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content table {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content th {
|
||||||
|
background-color: #242424;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content td:first-child,
|
||||||
|
.rich-html-content th:first-child {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html-content th:first-child {
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="resultContainer" class="result-content">
|
<div ref="resultContainer" class="result-content rich-html-content">
|
||||||
<div v-html="resultHtml"></div>
|
<div v-html="resultHtml"></div>
|
||||||
<van-loading v-if="analyzing" class="result-loading">
|
<van-loading v-if="analyzing" class="result-loading">
|
||||||
AI正在分析中...
|
AI正在分析中...
|
||||||
@@ -351,103 +351,12 @@ const startAnalysis = async () => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 结果HTML样式 */
|
|
||||||
.result-content :deep(h1),
|
|
||||||
.result-content :deep(h2),
|
|
||||||
.result-content :deep(h3) {
|
|
||||||
color: var(--van-text-color);
|
|
||||||
margin: 16px 0 12px 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(h1) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(h2) {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(h3) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(p) {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: var(--van-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(ul),
|
|
||||||
.result-content :deep(ol) {
|
|
||||||
padding-left: 24px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(li) {
|
|
||||||
margin: 6px 0;
|
|
||||||
color: var(--van-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(th),
|
|
||||||
.result-content :deep(td) {
|
|
||||||
padding: 10px;
|
|
||||||
text-align: left;
|
|
||||||
border: 1px solid var(--van-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(th) {
|
|
||||||
background: var(--van-background-2);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--van-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(td) {
|
|
||||||
color: var(--van-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(strong) {
|
|
||||||
color: var(--van-text-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(.highlight) {
|
|
||||||
background: #fff3cd;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(.expense-value) {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content :deep(.income-value) {
|
|
||||||
color: #51cf66;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色模式适配 */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.result-content :deep(.highlight) {
|
|
||||||
background: rgba(255, 243, 205, 0.2);
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设置页面容器背景色 */
|
/* 设置页面容器背景色 */
|
||||||
:deep(.van-nav-bar) {
|
:deep(.van-nav-bar) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
|||||||
@@ -41,12 +41,12 @@
|
|||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="currentMessage.title"
|
:title="currentMessage.title"
|
||||||
:subtitle="currentMessage.createTime"
|
:subtitle="currentMessage.createTime"
|
||||||
height="50%"
|
height="80%"
|
||||||
:closeable="true"
|
:closeable="true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="currentMessage.messageType === 2"
|
v-if="currentMessage.messageType === 2"
|
||||||
class="detail-content"
|
class="detail-content rich-html-content"
|
||||||
v-html="currentMessage.content"
|
v-html="currentMessage.content"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,10 +275,13 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-content {
|
.detail-content {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--van-text-color);
|
color: var(--van-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content:not(.rich-html-content) {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
Web/src/views/ScheduledTasksView.vue
Normal file
182
Web/src/views/ScheduledTasksView.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container-flex">
|
||||||
|
<van-nav-bar title="定时任务" left-arrow placeholder @click-left="onClickLeft" />
|
||||||
|
<div class="scroll-content">
|
||||||
|
<van-pull-refresh v-model="loading" @refresh="fetchTasks">
|
||||||
|
<div v-for="task in tasks" :key="task.name" class="task-card">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell :title="task.jobDescription" :label="task.triggerDescription || task.name">
|
||||||
|
<template #value>
|
||||||
|
<van-tag :type="task.status === 'Paused' ? 'warning' : 'success'">
|
||||||
|
{{ task.status === 'Paused' ? '已暂停' : '已启动' }}
|
||||||
|
</van-tag>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="任务标识" :value="task.name" />
|
||||||
|
<van-cell title="下次执行" :value="task.nextRunTime || '无'" />
|
||||||
|
<div class="card-footer">
|
||||||
|
<van-row gutter="10">
|
||||||
|
<van-col span="12">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
icon="play"
|
||||||
|
@click="handleExecute(task)"
|
||||||
|
>
|
||||||
|
立即执行
|
||||||
|
</van-button>
|
||||||
|
</van-col>
|
||||||
|
<van-col span="12">
|
||||||
|
<van-button
|
||||||
|
v-if="task.status !== 'Paused'"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
icon="pause"
|
||||||
|
@click="handlePause(task)"
|
||||||
|
>
|
||||||
|
暂停任务
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
v-else
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
icon="play-circle-o"
|
||||||
|
@click="handleResume(task)"
|
||||||
|
>
|
||||||
|
恢复任务
|
||||||
|
</van-button>
|
||||||
|
</van-col>
|
||||||
|
</van-row>
|
||||||
|
</div>
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<van-empty v-if="tasks.length === 0 && !loading" description="无定时任务" />
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<div style="height: calc(20px + env(safe-area-inset-bottom, 0px))"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showConfirmDialog, showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
|
||||||
|
import { getJobs, executeJob, pauseJob, resumeJob } from '@/api/job'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const tasks = ref([])
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { success, data, message } = await getJobs()
|
||||||
|
if (success) {
|
||||||
|
tasks.value = data
|
||||||
|
} else {
|
||||||
|
showToast(message || '获取任务列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务列表失败:', error)
|
||||||
|
showToast('获取任务列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClickLeft = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExecute = async (task) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '确认执行',
|
||||||
|
message: `确定要立即执行"${task.jobDescription}"吗?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
showLoadingToast({
|
||||||
|
message: '执行中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const { success, message } = await executeJob(task.name)
|
||||||
|
if (success) {
|
||||||
|
showSuccessToast('执行指令已发送')
|
||||||
|
setTimeout(fetchTasks, 1000)
|
||||||
|
} else {
|
||||||
|
showToast(message || '执行失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('执行失败:', error)
|
||||||
|
showToast('执行失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
closeToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePause = async (task) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '确认暂停',
|
||||||
|
message: `确定要暂停"${task.jobDescription}"吗?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { success, message } = await pauseJob(task.name)
|
||||||
|
if (success) {
|
||||||
|
showSuccessToast('已暂停')
|
||||||
|
fetchTasks()
|
||||||
|
} else {
|
||||||
|
showToast(message || '暂停失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('暂停失败:', error)
|
||||||
|
showToast('暂停失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResume = async (task) => {
|
||||||
|
try {
|
||||||
|
const { success, message } = await resumeJob(task.name)
|
||||||
|
if (success) {
|
||||||
|
showSuccessToast('已恢复')
|
||||||
|
fetchTasks()
|
||||||
|
} else {
|
||||||
|
showToast(message || '恢复失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('恢复失败:', error)
|
||||||
|
showToast('恢复失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-card {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background-color: var(--van-background-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="查看日志" is-link @click="handleLogView" />
|
<van-cell title="查看日志" is-link @click="handleLogView" />
|
||||||
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" />
|
<van-cell title="清除缓存" is-link @click="handleReloadFromNetwork" />
|
||||||
|
<van-cell title="定时任务" is-link @click="handleScheduledTasks" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div class="detail-header" style="padding-bottom: 5px;">
|
||||||
@@ -291,6 +292,10 @@ const handleReloadFromNetwork = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScheduledTasks = () => {
|
||||||
|
router.push({ name: 'scheduled-tasks' })
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -4,42 +4,23 @@
|
|||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
public class BudgetController(
|
public class BudgetController(
|
||||||
IBudgetService budgetService,
|
IBudgetService budgetService,
|
||||||
IConfigService configService,
|
IBudgetRepository budgetRepository,
|
||||||
ILogger<BudgetController> logger) : ControllerBase
|
ILogger<BudgetController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取预算列表
|
/// 获取预算列表
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<BaseResponse<List<BudgetDto>>> GetListAsync([FromQuery] DateTime? referenceDate = null)
|
public async Task<BaseResponse<List<BudgetResult>>> GetListAsync([FromQuery] DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var budgets = await budgetService.GetAllAsync();
|
return (await budgetService.GetListAsync(referenceDate)).Ok();
|
||||||
var dtos = new List<BudgetDto?>();
|
|
||||||
|
|
||||||
foreach (var budget in budgets)
|
|
||||||
{
|
|
||||||
var currentAmount = await budgetService.CalculateCurrentAmountAsync(budget, referenceDate);
|
|
||||||
dtos.Add(BudgetDto.FromEntity(budget, currentAmount, referenceDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创造虚拟的存款预算
|
|
||||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
|
||||||
BudgetPeriodType.Month,
|
|
||||||
referenceDate,
|
|
||||||
budgets));
|
|
||||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
|
||||||
BudgetPeriodType.Year,
|
|
||||||
referenceDate,
|
|
||||||
budgets));
|
|
||||||
|
|
||||||
return dtos.Where(dto => dto != null).Cast<BudgetDto>().ToList().Ok();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "获取预算列表失败");
|
logger.LogError(ex, "获取预算列表失败");
|
||||||
return $"获取预算列表失败: {ex.Message}".Fail<List<BudgetDto>>();
|
return $"获取预算列表失败: {ex.Message}".Fail<List<BudgetResult>>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,29 +28,23 @@ public class BudgetController(
|
|||||||
/// 获取单个预算统计信息
|
/// 获取单个预算统计信息
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<BaseResponse<BudgetDto>> GetStatisticsAsync([FromQuery] long id, [FromQuery] DateTime? referenceDate = null)
|
public async Task<BaseResponse<BudgetResult>> GetStatisticsAsync([FromQuery] long id, [FromQuery] DateTime? referenceDate = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (id == -1)
|
var result = await budgetService.GetStatisticsAsync(id, referenceDate);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
{
|
{
|
||||||
return (await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate))!.Ok();
|
return "预算不存在".Fail<BudgetResult>();
|
||||||
}
|
|
||||||
if (id == -2)
|
|
||||||
{
|
|
||||||
return (await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate))!.Ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var budget = await budgetService.GetByIdAsync(id);
|
return result.Ok();
|
||||||
if (budget == null) return "预算不存在".Fail<BudgetDto>();
|
|
||||||
|
|
||||||
var currentAmount = await budgetService.CalculateCurrentAmountAsync(budget, referenceDate);
|
|
||||||
return BudgetDto.FromEntity(budget, currentAmount, referenceDate).Ok();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "获取预算统计失败, Id: {Id}", id);
|
logger.LogError(ex, "获取预算统计失败, Id: {Id}", id);
|
||||||
return $"获取预算统计失败: {ex.Message}".Fail<BudgetDto>();
|
return $"获取预算统计失败: {ex.Message}".Fail<BudgetResult>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +56,7 @@ public class BudgetController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = await budgetService.DeleteAsync(id);
|
var success = await budgetRepository.DeleteAsync(id);
|
||||||
return success ? BaseResponse.Done() : "删除预算失败".Fail();
|
return success ? BaseResponse.Done() : "删除预算失败".Fail();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -115,7 +90,7 @@ public class BudgetController(
|
|||||||
return varidationError.Fail<long>();
|
return varidationError.Fail<long>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = await budgetService.AddAsync(budget);
|
var success = await budgetRepository.AddAsync(budget);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
return budget.Id.Ok();
|
return budget.Id.Ok();
|
||||||
@@ -137,7 +112,7 @@ public class BudgetController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var budget = await budgetService.GetByIdAsync(dto.Id);
|
var budget = await budgetRepository.GetByIdAsync(dto.Id);
|
||||||
if (budget == null) return "预算不存在".Fail();
|
if (budget == null) return "预算不存在".Fail();
|
||||||
|
|
||||||
budget.Name = dto.Name;
|
budget.Name = dto.Name;
|
||||||
@@ -157,7 +132,7 @@ public class BudgetController(
|
|||||||
return varidationError.Fail();
|
return varidationError.Fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = await budgetService.UpdateAsync(budget);
|
var success = await budgetRepository.UpdateAsync(budget);
|
||||||
return success ? BaseResponse.Done() : "更新预算失败".Fail();
|
return success ? BaseResponse.Done() : "更新预算失败".Fail();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -167,132 +142,60 @@ public class BudgetController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BudgetDto?> GetVirtualSavingsDtoAsync(
|
/// <summary>
|
||||||
BudgetPeriodType periodType,
|
/// 切换预算暂停状态
|
||||||
DateTime? referenceDate = null,
|
/// </summary>
|
||||||
List<BudgetRecord>? existingBudgets = null)
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> ToggleStopAsync([FromQuery] long id)
|
||||||
{
|
{
|
||||||
var allBudgets = existingBudgets;
|
try
|
||||||
|
|
||||||
if(existingBudgets == null)
|
|
||||||
{
|
{
|
||||||
allBudgets = await budgetService.GetAllAsync();
|
var budget = await budgetRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (budget == null)
|
||||||
|
{
|
||||||
|
return "预算不存在".Fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.IsStopped = !budget.IsStopped;
|
||||||
|
|
||||||
|
var success = await budgetRepository.UpdateAsync(budget);
|
||||||
|
return success ? BaseResponse.Done() : "操作失败".Fail();
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
if(allBudgets == null)
|
|
||||||
{
|
{
|
||||||
return null;
|
logger.LogError(ex, "切换预算状态失败, Id: {Id}", id);
|
||||||
|
return $"操作失败: {ex.Message}".Fail();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var date = referenceDate ?? DateTime.Now;
|
/// <summary>
|
||||||
|
/// 归档预算
|
||||||
decimal incomeLimitAtPeriod = 0;
|
/// </summary>
|
||||||
decimal expenseLimitAtPeriod = 0;
|
[HttpPost("{year}/{month}")]
|
||||||
|
public async Task<BaseResponse> ArchiveBudgetsAsync(int year, int month)
|
||||||
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
{
|
||||||
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
try
|
||||||
|
|
||||||
foreach (var b in allBudgets)
|
|
||||||
{
|
{
|
||||||
if (b.IsStopped || b.Category == BudgetCategory.Savings) continue;
|
var msg = await budgetService.ArchiveBudgetsAsync(year, month);
|
||||||
|
|
||||||
// 折算系数:根据当前请求的 periodType (Year 或 Month),将预算 b 的 Limit 折算过来
|
if(!string.IsNullOrEmpty(msg))
|
||||||
decimal factor = 1.0m;
|
|
||||||
|
|
||||||
if (periodType == BudgetPeriodType.Year)
|
|
||||||
{
|
{
|
||||||
factor = b.Type switch
|
return msg.Fail();
|
||||||
{
|
|
||||||
BudgetPeriodType.Month => 12,
|
|
||||||
BudgetPeriodType.Year => 1,
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (periodType == BudgetPeriodType.Month)
|
|
||||||
{
|
|
||||||
factor = b.Type switch
|
|
||||||
{
|
|
||||||
BudgetPeriodType.Month => 1,
|
|
||||||
BudgetPeriodType.Year => 0,
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
factor = 0; // 其他周期暂不计算虚拟存款
|
|
||||||
}
|
|
||||||
|
|
||||||
if (factor <= 0) continue;
|
|
||||||
|
|
||||||
var subtotal = b.Limit * factor;
|
|
||||||
if (b.Category == BudgetCategory.Income)
|
|
||||||
{
|
|
||||||
incomeLimitAtPeriod += subtotal;
|
|
||||||
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
|
|
||||||
}
|
|
||||||
else if (b.Category == BudgetCategory.Expense)
|
|
||||||
{
|
|
||||||
expenseLimitAtPeriod += subtotal;
|
|
||||||
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
var description = new StringBuilder();
|
|
||||||
description.Append("<h3>预算收入明细</h3>");
|
|
||||||
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
logger.LogError(ex, "归档预算失败, 归档日期: {Year}-{Month}", year, month);
|
||||||
foreach (var item in incomeItems)
|
return $"归档预算失败: {ex.Message}".Fail();
|
||||||
{
|
|
||||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='income-value'>{item.Total:N0}</span></td></tr>");
|
|
||||||
}
|
|
||||||
description.Append("</tbody></table>");
|
|
||||||
}
|
}
|
||||||
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
|
|
||||||
|
|
||||||
description.Append("<h3>预算支出明细</h3>");
|
|
||||||
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
|
||||||
foreach (var item in expenseItems)
|
|
||||||
{
|
|
||||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='expense-value'>{item.Total:N0}</span></td></tr>");
|
|
||||||
}
|
|
||||||
description.Append("</tbody></table>");
|
|
||||||
}
|
|
||||||
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
|
|
||||||
|
|
||||||
description.Append("<h3>存款计划结论</h3>");
|
|
||||||
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
|
|
||||||
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
|
|
||||||
|
|
||||||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
|
||||||
var virtualBudget = new BudgetRecord
|
|
||||||
{
|
|
||||||
Id = periodType == BudgetPeriodType.Year ? -1 : -2,
|
|
||||||
Name = periodType == BudgetPeriodType.Year ? "年度存款" : "月度存款",
|
|
||||||
Category = BudgetCategory.Savings,
|
|
||||||
Type = periodType,
|
|
||||||
Limit = incomeLimitAtPeriod - expenseLimitAtPeriod,
|
|
||||||
StartDate = periodType == BudgetPeriodType.Year ? new DateTime(date.Year, 1, 1) : new DateTime(date.Year, date.Month, 1),
|
|
||||||
SelectedCategories = savingsCategories
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算实际发生的 收入 - 支出
|
|
||||||
var incomeHelper = new BudgetRecord { Category = BudgetCategory.Income, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
|
|
||||||
var expenseHelper = new BudgetRecord { Category = BudgetCategory.Expense, Type = periodType, StartDate = virtualBudget.StartDate, SelectedCategories = savingsCategories };
|
|
||||||
|
|
||||||
var actualIncome = await budgetService.CalculateCurrentAmountAsync(incomeHelper, date);
|
|
||||||
var actualExpense = await budgetService.CalculateCurrentAmountAsync(expenseHelper, date);
|
|
||||||
|
|
||||||
return BudgetDto.FromEntity(virtualBudget, actualIncome - actualExpense, date, description.ToString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record)
|
private async Task<string> ValidateBudgetSelectedCategoriesAsync(BudgetRecord record)
|
||||||
{
|
{
|
||||||
var allBudgets = await budgetService.GetAllAsync();
|
var allBudgets = await budgetRepository.GetAllAsync();
|
||||||
|
|
||||||
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||||
foreach (var budget in allBudgets)
|
foreach (var budget in allBudgets)
|
||||||
@@ -310,23 +213,4 @@ public class BudgetController(
|
|||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 切换预算暂停状态
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<BaseResponse> ToggleStopAsync([FromQuery] long id)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var success = await budgetService.ToggleStopAsync(id);
|
|
||||||
return success ? BaseResponse.Done() : "操作失败".Fail();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "切换预算状态失败, Id: {Id}", id);
|
|
||||||
return $"操作失败: {ex.Message}".Fail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,5 @@
|
|||||||
namespace WebApi.Controllers.Dto;
|
namespace WebApi.Controllers.Dto;
|
||||||
|
|
||||||
public class BudgetDto
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public BudgetPeriodType Type { get; set; }
|
|
||||||
public decimal Limit { get; set; }
|
|
||||||
public decimal Current { get; set; }
|
|
||||||
public BudgetCategory Category { get; set; }
|
|
||||||
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
|
||||||
public bool IsStopped { get; set; }
|
|
||||||
public string StartDate { get; set; } = string.Empty;
|
|
||||||
public string Period { get; set; } = string.Empty;
|
|
||||||
public DateTime? PeriodStart { get; set; }
|
|
||||||
public DateTime? PeriodEnd { get; set; }
|
|
||||||
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public static BudgetDto FromEntity(
|
|
||||||
BudgetRecord entity,
|
|
||||||
decimal currentAmount = 0,
|
|
||||||
DateTime? referenceDate = null,
|
|
||||||
string description = "")
|
|
||||||
{
|
|
||||||
var date = referenceDate ?? DateTime.Now;
|
|
||||||
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
|
||||||
|
|
||||||
return new BudgetDto
|
|
||||||
{
|
|
||||||
Id = entity.Id,
|
|
||||||
Name = entity.Name,
|
|
||||||
Type = entity.Type,
|
|
||||||
Limit = entity.Limit,
|
|
||||||
Current = currentAmount,
|
|
||||||
Category = entity.Category,
|
|
||||||
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
|
||||||
? Array.Empty<string>()
|
|
||||||
: entity.SelectedCategories.Split(','),
|
|
||||||
IsStopped = entity.IsStopped,
|
|
||||||
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
|
||||||
Period = entity.Type switch
|
|
||||||
{
|
|
||||||
BudgetPeriodType.Year => $"{start:yy}年",
|
|
||||||
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
|
||||||
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
|
||||||
},
|
|
||||||
PeriodStart = start,
|
|
||||||
PeriodEnd = end,
|
|
||||||
Description = description
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CreateBudgetDto
|
public class CreateBudgetDto
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|||||||
114
WebApi/Controllers/JobController.cs
Normal file
114
WebApi/Controllers/JobController.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class JobController(ISchedulerFactory schedulerFactory, ILogger<JobController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<JobStatus>>> GetJobsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobKeys = await scheduler.GetJobKeys(Quartz.Impl.Matchers.GroupMatcher<JobKey>.AnyGroup());
|
||||||
|
var jobStatuses = new List<JobStatus>();
|
||||||
|
|
||||||
|
foreach (var jobKey in jobKeys)
|
||||||
|
{
|
||||||
|
var jobDetail = await scheduler.GetJobDetail(jobKey);
|
||||||
|
var triggers = await scheduler.GetTriggersOfJob(jobKey);
|
||||||
|
var trigger = triggers.FirstOrDefault();
|
||||||
|
|
||||||
|
var status = "Unknown";
|
||||||
|
DateTime? nextFireTime = null;
|
||||||
|
|
||||||
|
if (trigger != null)
|
||||||
|
{
|
||||||
|
var triggerState = await scheduler.GetTriggerState(trigger.Key);
|
||||||
|
status = triggerState.ToString();
|
||||||
|
nextFireTime = trigger.GetNextFireTimeUtc()?.ToLocalTime().DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobStatuses.Add(new JobStatus
|
||||||
|
{
|
||||||
|
Name = jobKey.Name,
|
||||||
|
JobDescription = jobDetail?.Description ?? jobKey.Name,
|
||||||
|
TriggerDescription = trigger?.Description ?? string.Empty,
|
||||||
|
Status = status,
|
||||||
|
NextRunTime = nextFireTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobStatuses.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取任务列表失败");
|
||||||
|
return $"获取任务列表失败: {ex.Message}".Fail<List<JobStatus>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<bool>> ExecuteAsync([FromBody] JobRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
await scheduler.TriggerJob(new JobKey(request.JobName));
|
||||||
|
return true.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "执行任务失败: {JobName}", request.JobName);
|
||||||
|
return $"执行任务失败: {ex.Message}".Fail<bool>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<bool>> PauseAsync([FromBody] JobRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
await scheduler.PauseJob(new JobKey(request.JobName));
|
||||||
|
return true.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "暂停任务失败: {JobName}", request.JobName);
|
||||||
|
return $"暂停任务失败: {ex.Message}".Fail<bool>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<bool>> ResumeAsync([FromBody] JobRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
await scheduler.ResumeJob(new JobKey(request.JobName));
|
||||||
|
return true.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "恢复任务失败: {JobName}", request.JobName);
|
||||||
|
return $"恢复任务失败: {ex.Message}".Fail<bool>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JobStatus
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public string TriggerDescription { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string NextRunTime { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JobRequest
|
||||||
|
{
|
||||||
|
public string JobName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ public static class Expand
|
|||||||
|
|
||||||
// 配置邮件同步任务 - 每10分钟执行一次
|
// 配置邮件同步任务 - 每10分钟执行一次
|
||||||
var emailJobKey = new JobKey("EmailSyncJob");
|
var emailJobKey = new JobKey("EmailSyncJob");
|
||||||
q.AddJob<Service.Jobs.EmailSyncJob>(opts => opts.WithIdentity(emailJobKey));
|
q.AddJob<Service.Jobs.EmailSyncJob>(opts => opts
|
||||||
|
.WithIdentity(emailJobKey)
|
||||||
|
.WithDescription("邮件同步任务"));
|
||||||
q.AddTrigger(opts => opts
|
q.AddTrigger(opts => opts
|
||||||
.ForJob(emailJobKey)
|
.ForJob(emailJobKey)
|
||||||
.WithIdentity("EmailSyncTrigger")
|
.WithIdentity("EmailSyncTrigger")
|
||||||
@@ -22,12 +24,25 @@ public static class Expand
|
|||||||
|
|
||||||
// 配置周期性账单任务 - 每天早上6点执行
|
// 配置周期性账单任务 - 每天早上6点执行
|
||||||
var periodicBillJobKey = new JobKey("PeriodicBillJob");
|
var periodicBillJobKey = new JobKey("PeriodicBillJob");
|
||||||
q.AddJob<Service.Jobs.PeriodicBillJob>(opts => opts.WithIdentity(periodicBillJobKey));
|
q.AddJob<Service.Jobs.PeriodicBillJob>(opts => opts
|
||||||
|
.WithIdentity(periodicBillJobKey)
|
||||||
|
.WithDescription("周期性账单任务"));
|
||||||
q.AddTrigger(opts => opts
|
q.AddTrigger(opts => opts
|
||||||
.ForJob(periodicBillJobKey)
|
.ForJob(periodicBillJobKey)
|
||||||
.WithIdentity("PeriodicBillTrigger")
|
.WithIdentity("PeriodicBillTrigger")
|
||||||
.WithCronSchedule("0 0 6 * * ?") // 每天早上6点执行
|
.WithCronSchedule("0 0 6 * * ?") // 每天早上6点执行
|
||||||
.WithDescription("每天早上6点执行周期性账单检查"));
|
.WithDescription("每天早上6点执行周期性账单任务"));
|
||||||
|
|
||||||
|
// 配置预算归档任务 - 每个月1号晚11点执行
|
||||||
|
var budgetArchiveJobKey = new JobKey("BudgetArchiveJob");
|
||||||
|
q.AddJob<Service.Jobs.BudgetArchiveJob>(opts => opts
|
||||||
|
.WithIdentity(budgetArchiveJobKey)
|
||||||
|
.WithDescription("预算归档任务"));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(budgetArchiveJobKey)
|
||||||
|
.WithIdentity("BudgetArchiveTrigger")
|
||||||
|
.WithCronSchedule("0 0 23 1 * ?") // 每个月1号晚11点执行
|
||||||
|
.WithDescription("每个月1号晚11点执行预算归档"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加 Quartz Hosted Service
|
// 添加 Quartz Hosted Service
|
||||||
|
|||||||
Reference in New Issue
Block a user