新增定时账单功能
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||||
<!-- Logging -->
|
<!-- Logging -->
|
||||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||||
@@ -25,5 +26,8 @@
|
|||||||
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageVersion Include="EPPlus" Version="7.5.2" />
|
<PackageVersion Include="EPPlus" Version="7.5.2" />
|
||||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
|
<!-- Job Scheduling -->
|
||||||
|
<PackageVersion Include="Quartz" Version="3.13.1" />
|
||||||
|
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
87
Entity/TransactionPeriodic.cs
Normal file
87
Entity/TransactionPeriodic.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionPeriodic : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型:0-每天、1-每周、2-每月、3-每季度、4-每年
|
||||||
|
/// </summary>
|
||||||
|
public PeriodicType PeriodicType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期配置(JSON格式存储不同周期类型的配置)
|
||||||
|
/// 每周:存储星期几,如 "1,3,5" 表示周一、三、五
|
||||||
|
/// 每月:存储具体日期,如 "1,15" 表示每月1号和15号
|
||||||
|
/// 每季度:存储季度开始后第几天,如 "15" 表示每季度第15天
|
||||||
|
/// 每年:存储年开始后第几天,如 "100" 表示每年第100天
|
||||||
|
/// </summary>
|
||||||
|
public string PeriodicConfig { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型
|
||||||
|
/// </summary>
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易分类
|
||||||
|
/// </summary>
|
||||||
|
public string Classify { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易摘要/备注
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下次执行时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? NextExecuteTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后执行时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastExecuteTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期类型枚举
|
||||||
|
/// </summary>
|
||||||
|
public enum PeriodicType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 每天
|
||||||
|
/// </summary>
|
||||||
|
Daily = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每周
|
||||||
|
/// </summary>
|
||||||
|
Weekly = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每月
|
||||||
|
/// </summary>
|
||||||
|
Monthly = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每季度
|
||||||
|
/// </summary>
|
||||||
|
Quarterly = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每年
|
||||||
|
/// </summary>
|
||||||
|
Yearly = 4
|
||||||
|
}
|
||||||
97
Repository/TransactionPeriodicRepository.cs
Normal file
97
Repository/TransactionPeriodicRepository.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单仓储接口
|
||||||
|
/// </summary>
|
||||||
|
public interface ITransactionPeriodicRepository : IBaseRepository<TransactionPeriodic>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取分页列表
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync(int pageIndex, int pageSize, string? searchKeyword = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取总数
|
||||||
|
/// </summary>
|
||||||
|
Task<long> GetTotalCountAsync(string? searchKeyword = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取需要执行的周期性账单(包含今天应该执行的)
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<TransactionPeriodic>> GetPendingPeriodicBillsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新执行时间
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateExecuteTimeAsync(long id, DateTime lastExecuteTime, DateTime? nextExecuteTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单仓储实现
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionPeriodicRepository(IFreeSql freeSql)
|
||||||
|
: BaseRepository<TransactionPeriodic>(freeSql), ITransactionPeriodicRepository
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<TransactionPeriodic>> GetPagedListAsync(
|
||||||
|
int pageIndex,
|
||||||
|
int pageSize,
|
||||||
|
string? searchKeyword = null)
|
||||||
|
{
|
||||||
|
var query = FreeSql.Select<TransactionPeriodic>();
|
||||||
|
|
||||||
|
// 搜索关键词
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchKeyword))
|
||||||
|
{
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.Reason.Contains(searchKeyword) ||
|
||||||
|
x.Classify.Contains(searchKeyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(x => x.CreateTime)
|
||||||
|
.Skip((pageIndex - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalCountAsync(string? searchKeyword = null)
|
||||||
|
{
|
||||||
|
var query = FreeSql.Select<TransactionPeriodic>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchKeyword))
|
||||||
|
{
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.Reason.Contains(searchKeyword) ||
|
||||||
|
x.Classify.Contains(searchKeyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TransactionPeriodic>> GetPendingPeriodicBillsAsync()
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
return await FreeSql.Select<TransactionPeriodic>()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.Where(x => x.NextExecuteTime == null || x.NextExecuteTime <= now)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateExecuteTimeAsync(long id, DateTime lastExecuteTime, DateTime? nextExecuteTime)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await FreeSql.Update<TransactionPeriodic>()
|
||||||
|
.Set(x => x.LastExecuteTime, lastExecuteTime)
|
||||||
|
.Set(x => x.NextExecuteTime, nextExecuteTime)
|
||||||
|
.Set(x => x.UpdateTime, DateTime.Now)
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.ExecuteAffrowsAsync();
|
||||||
|
return result == 1;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
Service/Jobs/EmailSyncJob.cs
Normal file
185
Service/Jobs/EmailSyncJob.cs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
using MimeKit;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Service.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件同步定时任务
|
||||||
|
/// </summary>
|
||||||
|
public class EmailSyncJob(
|
||||||
|
IOptions<EmailSettings> emailSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IEmailHandleService emailHandleService,
|
||||||
|
ILogger<EmailSyncJob> logger) : IJob
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IEmailFetchService> _emailFetchServices = new();
|
||||||
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("开始执行邮件同步任务");
|
||||||
|
|
||||||
|
// 如果未初始化,先初始化连接
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
await InitializeConnectionsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行邮件同步
|
||||||
|
await FetchAndPostCmbTransactionsAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("邮件同步任务执行完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "邮件同步任务执行出错");
|
||||||
|
throw; // 让 Quartz 知道任务失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化所有邮箱连接
|
||||||
|
/// </summary>
|
||||||
|
private async Task InitializeConnectionsAsync()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
logger.LogWarning("连接已初始化,跳过重复初始化");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (emailSettings.Value.SmtpList.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置邮箱账户,无法初始化连接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("开始初始化 {EmailCount} 个邮箱连接...", emailSettings.Value.SmtpList.Length);
|
||||||
|
|
||||||
|
// 并行初始化所有邮箱连接
|
||||||
|
var tasks = emailSettings.Value.SmtpList.Select(async emailConfig =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var emailFetchService = ActivatorUtilities.CreateInstance<EmailFetchService>(serviceProvider);
|
||||||
|
var success = await emailFetchService.ConnectAsync(
|
||||||
|
emailConfig.ImapHost,
|
||||||
|
emailConfig.ImapPort,
|
||||||
|
emailConfig.UseSsl,
|
||||||
|
emailConfig.Email,
|
||||||
|
emailConfig.Password);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_emailFetchServices[emailConfig.Email] = emailFetchService;
|
||||||
|
logger.LogInformation("邮箱 {Email} 连接建立成功", emailConfig.Email);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogError("邮箱 {Email} 连接建立失败", emailConfig.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "初始化邮箱 {Email} 连接时出错", emailConfig.Email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
_isInitialized = true;
|
||||||
|
logger.LogInformation("所有邮箱连接初始化完成,成功连接 {Count} 个邮箱", _emailFetchServices.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "初始化邮箱连接失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 抓取并处理招商银行邮件交易
|
||||||
|
/// </summary>
|
||||||
|
private async Task FetchAndPostCmbTransactionsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_emailFetchServices.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("没有可用的邮箱连接,跳过抓取");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("开始抓取 {EmailCount} 个邮箱的邮件", _emailFetchServices.Count);
|
||||||
|
|
||||||
|
// 并行处理多个邮箱
|
||||||
|
var tasks = _emailFetchServices.Select(async kvp =>
|
||||||
|
{
|
||||||
|
var email = kvp.Key;
|
||||||
|
var emailFetchService = kvp.Value;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 获取未读邮件
|
||||||
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
|
foreach (var (message, uid) in unreadMessages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogDebug("邮件信息 - 发送者: {From}, 主题: {Subject}, 接收时间: {Date}",
|
||||||
|
message.From, message.Subject, message.Date);
|
||||||
|
logger.LogDebug("邮件内容预览: {Preview}", GetEmailBodyPreview(message));
|
||||||
|
|
||||||
|
if (await emailHandleService.HandleEmailAsync(
|
||||||
|
email,
|
||||||
|
message.From.ToString(),
|
||||||
|
message.Subject,
|
||||||
|
message.Date.DateTime,
|
||||||
|
message.TextBody ?? message.HtmlBody ?? string.Empty
|
||||||
|
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
|
||||||
|
#else
|
||||||
|
// 标记邮件为已读
|
||||||
|
await emailFetchService.MarkAsReadAsync(uid);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "处理邮件时出错");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("邮箱 {Email} 邮件抓取完成", email);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "邮箱 {Email} 邮件抓取失败", email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
logger.LogInformation("所有邮箱邮件抓取完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "抓取邮件异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取邮件内容预览
|
||||||
|
/// </summary>
|
||||||
|
private static string GetEmailBodyPreview(MimeMessage message)
|
||||||
|
{
|
||||||
|
var body = message.HtmlBody ?? message.TextBody ?? string.Empty;
|
||||||
|
var preview = body.Length > 100 ? body.Substring(0, 100) + "..." : body;
|
||||||
|
return preview.Replace("\n", " ").Replace("\r", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Service/Jobs/PeriodicBillJob.cs
Normal file
34
Service/Jobs/PeriodicBillJob.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Service.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单定时任务
|
||||||
|
/// </summary>
|
||||||
|
[DisallowConcurrentExecution] // 防止并发执行
|
||||||
|
public class PeriodicBillJob(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<PeriodicBillJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("开始执行周期性账单检查任务");
|
||||||
|
|
||||||
|
// 执行周期性账单检查
|
||||||
|
using (var scope = serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
||||||
|
await periodicService.ExecutePeriodicBillsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("周期性账单检查任务执行完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "周期性账单检查任务执行出错");
|
||||||
|
throw; // 让 Quartz 知道任务失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Service/PeriodicBillBackgroundService.cs
Normal file
61
Service/PeriodicBillBackgroundService.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单后台服务
|
||||||
|
/// </summary>
|
||||||
|
public class PeriodicBillBackgroundService(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<PeriodicBillBackgroundService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("周期性账单后台服务已启动");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
|
||||||
|
// 计算下次执行时间(每天早上6点)
|
||||||
|
var nextRun = now.Date.AddHours(6);
|
||||||
|
if (now >= nextRun)
|
||||||
|
{
|
||||||
|
nextRun = nextRun.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = nextRun - now;
|
||||||
|
logger.LogInformation("下次执行周期性账单检查时间: {NextRun}, 延迟: {Delay}",
|
||||||
|
nextRun.ToString("yyyy-MM-dd HH:mm:ss"), delay);
|
||||||
|
|
||||||
|
await Task.Delay(delay, stoppingToken);
|
||||||
|
|
||||||
|
if (stoppingToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 执行周期性账单检查
|
||||||
|
using (var scope = serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var periodicService = scope.ServiceProvider.GetRequiredService<ITransactionPeriodicService>();
|
||||||
|
await periodicService.ExecutePeriodicBillsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogInformation("周期性账单后台服务已取消");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "周期性账单后台服务执行出错");
|
||||||
|
// 出错后等待1小时再重试
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("周期性账单后台服务已停止");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,15 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
<PackageReference Include="Serilog" />
|
<PackageReference Include="Serilog" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||||
<PackageReference Include="CsvHelper" />
|
<PackageReference Include="CsvHelper" />
|
||||||
<PackageReference Include="EPPlus" />
|
<PackageReference Include="EPPlus" />
|
||||||
<PackageReference Include="HtmlAgilityPack" />
|
<PackageReference Include="HtmlAgilityPack" />
|
||||||
|
<PackageReference Include="Quartz" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
312
Service/TransactionPeriodicService.cs
Normal file
312
Service/TransactionPeriodicService.cs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
namespace Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单服务接口
|
||||||
|
/// </summary>
|
||||||
|
public interface ITransactionPeriodicService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 执行周期性账单检查和生成
|
||||||
|
/// </summary>
|
||||||
|
Task ExecutePeriodicBillsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算下次执行时间
|
||||||
|
/// </summary>
|
||||||
|
DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单服务实现
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionPeriodicService(
|
||||||
|
ITransactionPeriodicRepository periodicRepository,
|
||||||
|
ITransactionRecordRepository transactionRepository,
|
||||||
|
IMessageRecordRepository messageRepository,
|
||||||
|
ILogger<TransactionPeriodicService> logger
|
||||||
|
) : ITransactionPeriodicService
|
||||||
|
{
|
||||||
|
public async Task ExecutePeriodicBillsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("开始执行周期性账单检查...");
|
||||||
|
|
||||||
|
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
|
||||||
|
var billsList = pendingBills.ToList();
|
||||||
|
|
||||||
|
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
|
||||||
|
|
||||||
|
foreach (var bill in billsList)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查是否满足执行条件
|
||||||
|
if (!ShouldExecuteToday(bill))
|
||||||
|
{
|
||||||
|
logger.LogInformation("周期性账单 {Id} 今天不需要执行", bill.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易记录
|
||||||
|
var transaction = new TransactionRecord
|
||||||
|
{
|
||||||
|
Amount = bill.Amount,
|
||||||
|
Type = bill.Type,
|
||||||
|
Classify = bill.Classify,
|
||||||
|
Reason = bill.Reason,
|
||||||
|
OccurredAt = DateTime.Now,
|
||||||
|
Card = "周期性账单",
|
||||||
|
ImportFrom = "周期性账单自动生成"
|
||||||
|
};
|
||||||
|
|
||||||
|
var success = await transactionRepository.AddAsync(transaction);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
|
||||||
|
bill.Reason, bill.Amount);
|
||||||
|
|
||||||
|
// 创建未读消息
|
||||||
|
var message = new MessageRecord
|
||||||
|
{
|
||||||
|
Title = "周期性账单提醒",
|
||||||
|
Content = $"已自动生成{(bill.Type == TransactionType.Expense ? "支出" : "收入")}账单:{bill.Reason},金额:{bill.Amount:F2}元",
|
||||||
|
IsRead = false
|
||||||
|
};
|
||||||
|
await messageRepository.AddAsync(message);
|
||||||
|
|
||||||
|
// 更新执行时间
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var nextTime = CalculateNextExecuteTime(bill, now);
|
||||||
|
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
|
||||||
|
|
||||||
|
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
|
||||||
|
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("创建周期性账单交易记录失败: {BillId}", bill.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "处理周期性账单 {BillId} 时出错", bill.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("周期性账单检查执行完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "执行周期性账单检查时发生错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断今天是否需要执行
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldExecuteToday(TransactionPeriodic bill)
|
||||||
|
{
|
||||||
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
// 如果从未执行过,需要执行
|
||||||
|
if (bill.LastExecuteTime == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果今天已经执行过,不需要再执行
|
||||||
|
if (bill.LastExecuteTime.Value.Date == today)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bill.PeriodicType switch
|
||||||
|
{
|
||||||
|
PeriodicType.Daily => true, // 每天都执行
|
||||||
|
PeriodicType.Weekly => ShouldExecuteWeekly(bill.PeriodicConfig, today),
|
||||||
|
PeriodicType.Monthly => ShouldExecuteMonthly(bill.PeriodicConfig, today),
|
||||||
|
PeriodicType.Quarterly => ShouldExecuteQuarterly(bill.PeriodicConfig, today),
|
||||||
|
PeriodicType.Yearly => ShouldExecuteYearly(bill.PeriodicConfig, today),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否需要在本周执行
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldExecuteWeekly(string config, DateTime today)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||||
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
|
.Where(d => d >= 0 && d <= 6)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return executeDays.Contains(dayOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否需要在本月执行
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldExecuteMonthly(string config, DateTime today)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
|
.Where(d => d >= 1 && d <= 31)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return executeDays.Contains(today.Day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否需要在本季度执行
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldExecuteQuarterly(string config, DateTime today)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 计算当前是本季度的第几天
|
||||||
|
var quarterStartMonth = ((today.Month - 1) / 3) * 3 + 1;
|
||||||
|
var quarterStart = new DateTime(today.Year, quarterStartMonth, 1);
|
||||||
|
var daysSinceQuarterStart = (today - quarterStart).Days + 1;
|
||||||
|
|
||||||
|
return daysSinceQuarterStart == dayOfQuarter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否需要在本年执行
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldExecuteYearly(string config, DateTime today)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return today.DayOfYear == dayOfYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算下次执行时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime)
|
||||||
|
{
|
||||||
|
return periodic.PeriodicType switch
|
||||||
|
{
|
||||||
|
PeriodicType.Daily => baseTime.Date.AddDays(1),
|
||||||
|
PeriodicType.Weekly => CalculateNextWeekly(periodic.PeriodicConfig, baseTime),
|
||||||
|
PeriodicType.Monthly => CalculateNextMonthly(periodic.PeriodicConfig, baseTime),
|
||||||
|
PeriodicType.Quarterly => CalculateNextQuarterly(periodic.PeriodicConfig, baseTime),
|
||||||
|
PeriodicType.Yearly => CalculateNextYearly(periodic.PeriodicConfig, baseTime),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime? CalculateNextWeekly(string config, DateTime baseTime)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
|
.Where(d => d >= 0 && d <= 6)
|
||||||
|
.OrderBy(d => d)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!executeDays.Any())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var currentDayOfWeek = (int)baseTime.DayOfWeek;
|
||||||
|
|
||||||
|
// 找下一个执行日
|
||||||
|
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
|
||||||
|
if (nextDay > 0)
|
||||||
|
{
|
||||||
|
var daysToAdd = nextDay - currentDayOfWeek;
|
||||||
|
return baseTime.Date.AddDays(daysToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下周的第一个执行日
|
||||||
|
var firstDay = executeDays.First();
|
||||||
|
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
|
||||||
|
return baseTime.Date.AddDays(daysUntilNextWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime? CalculateNextMonthly(string config, DateTime baseTime)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
|
.Where(d => d >= 1 && d <= 31)
|
||||||
|
.OrderBy(d => d)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!executeDays.Any())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 找本月的下一个执行日
|
||||||
|
var nextDay = executeDays.FirstOrDefault(d => d > baseTime.Day);
|
||||||
|
if (nextDay > 0)
|
||||||
|
{
|
||||||
|
var daysInMonth = DateTime.DaysInMonth(baseTime.Year, baseTime.Month);
|
||||||
|
if (nextDay <= daysInMonth)
|
||||||
|
{
|
||||||
|
return new DateTime(baseTime.Year, baseTime.Month, nextDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下个月的第一个执行日
|
||||||
|
var nextMonth = baseTime.AddMonths(1);
|
||||||
|
var firstDay = executeDays.First();
|
||||||
|
var daysInNextMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month);
|
||||||
|
var actualDay = Math.Min(firstDay, daysInNextMonth);
|
||||||
|
return new DateTime(nextMonth.Year, nextMonth.Month, actualDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime? CalculateNextQuarterly(string config, DateTime baseTime)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 计算下一个季度的开始时间
|
||||||
|
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
|
||||||
|
var nextQuarterStartMonth = currentQuarterStartMonth + 3;
|
||||||
|
var nextQuarterYear = baseTime.Year;
|
||||||
|
|
||||||
|
if (nextQuarterStartMonth > 12)
|
||||||
|
{
|
||||||
|
nextQuarterStartMonth = 1;
|
||||||
|
nextQuarterYear++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextQuarterStart = new DateTime(nextQuarterYear, nextQuarterStartMonth, 1);
|
||||||
|
return nextQuarterStart.AddDays(dayOfQuarter - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime? CalculateNextYearly(string config, DateTime baseTime)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var nextYear = baseTime.Year;
|
||||||
|
if (baseTime.DayOfYear >= dayOfYear)
|
||||||
|
{
|
||||||
|
nextYear++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理闰年情况
|
||||||
|
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
|
||||||
|
var actualDay = Math.Min(dayOfYear, daysInYear);
|
||||||
|
|
||||||
|
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Web/src/api/transactionPeriodic.js
Normal file
100
Web/src/api/transactionPeriodic.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周期性账单相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取周期性账单列表(分页)
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} [params.pageIndex] - 页码
|
||||||
|
* @param {number} [params.pageSize] - 每页数量
|
||||||
|
* @param {string} [params.searchKeyword] - 搜索关键词
|
||||||
|
* @returns {Promise<{success: boolean, data: Array, total: number}>}
|
||||||
|
*/
|
||||||
|
export const getPeriodicList = (params = {}) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionPeriodic/GetList',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取周期性账单详情
|
||||||
|
* @param {number} id - 周期性账单ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const getPeriodicDetail = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionPeriodic/GetById/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建周期性账单
|
||||||
|
* @param {Object} data - 周期性账单数据
|
||||||
|
* @param {number} data.periodicType - 周期类型 (0:每天, 1:每周, 2:每月, 3:每季度, 4:每年)
|
||||||
|
* @param {string} data.periodicConfig - 周期配置
|
||||||
|
* @param {number} data.amount - 交易金额
|
||||||
|
* @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
|
||||||
|
* @param {string} data.classify - 交易分类
|
||||||
|
* @param {string} data.reason - 交易摘要/备注
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const createPeriodic = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionPeriodic/Create',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新周期性账单
|
||||||
|
* @param {Object} data - 周期性账单数据
|
||||||
|
* @param {number} data.id - 周期性账单ID
|
||||||
|
* @param {number} data.periodicType - 周期类型
|
||||||
|
* @param {string} data.periodicConfig - 周期配置
|
||||||
|
* @param {number} data.amount - 交易金额
|
||||||
|
* @param {number} data.type - 交易类型
|
||||||
|
* @param {string} data.classify - 交易分类
|
||||||
|
* @param {string} data.reason - 交易摘要/备注
|
||||||
|
* @param {boolean} data.isEnabled - 是否启用
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const updatePeriodic = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionPeriodic/Update',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除周期性账单
|
||||||
|
* @param {number} id - 周期性账单ID
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const deletePeriodic = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionPeriodic/DeleteById`,
|
||||||
|
method: 'post',
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用周期性账单
|
||||||
|
* @param {number} id - 周期性账单ID
|
||||||
|
* @param {boolean} enabled - 是否启用
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const togglePeriodicEnabled = (id, enabled) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionPeriodic/ToggleEnabled',
|
||||||
|
method: 'post',
|
||||||
|
params: { id, enabled }
|
||||||
|
})
|
||||||
|
}
|
||||||
169
Web/src/components/ClassifyPicker.vue
Normal file
169
Web/src/components/ClassifyPicker.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 分类选择器弹窗 -->
|
||||||
|
<van-popup v-model:show="visible" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
ref="pickerRef"
|
||||||
|
:columns="classifyColumns"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<van-button class="toolbar-cancel" size="small" @click="onClear">清空</van-button>
|
||||||
|
<van-button class="toolbar-add" size="small" type="primary" @click="showAddDialog = true">新增</van-button>
|
||||||
|
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmSelect">确认</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增分类对话框 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showAddDialog"
|
||||||
|
title="新增交易分类"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="addNewClassify"
|
||||||
|
>
|
||||||
|
<van-field v-model="newClassifyName" placeholder="请输入新的交易分类" />
|
||||||
|
</van-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前选中的分类
|
||||||
|
selectedClassify: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 交易类型(用于新增分类时传递)
|
||||||
|
transactionType: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'clear'])
|
||||||
|
|
||||||
|
const visible = ref(props.modelValue)
|
||||||
|
const pickerRef = ref(null)
|
||||||
|
const classifyColumns = ref([])
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const newClassifyName = ref('')
|
||||||
|
|
||||||
|
// 监听外部显示状态变化
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
visible.value = val
|
||||||
|
if (val) {
|
||||||
|
loadClassifyList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听内部显示状态变化
|
||||||
|
watch(visible, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
const loadClassifyList = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoryList()
|
||||||
|
if (response.success) {
|
||||||
|
classifyColumns.value = (response.data || []).map(item => ({
|
||||||
|
text: item.name,
|
||||||
|
value: item.name,
|
||||||
|
id: item.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const onConfirm = ({ selectedOptions }) => {
|
||||||
|
if (selectedOptions && selectedOptions[0]) {
|
||||||
|
emit('confirm', selectedOptions[0].text)
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
const onCancel = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空分类
|
||||||
|
const onClear = () => {
|
||||||
|
emit('clear')
|
||||||
|
visible.value = false
|
||||||
|
showToast('已清空分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择(从 picker 中获取选中值)
|
||||||
|
const confirmSelect = () => {
|
||||||
|
if (pickerRef.value) {
|
||||||
|
const selectedValues = pickerRef.value.getSelectedOptions()
|
||||||
|
if (selectedValues && selectedValues[0]) {
|
||||||
|
emit('confirm', selectedValues[0].text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增分类
|
||||||
|
const addNewClassify = async () => {
|
||||||
|
if (!newClassifyName.value.trim()) {
|
||||||
|
showToast('请输入分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createCategory({
|
||||||
|
name: newClassifyName.value.trim(),
|
||||||
|
type: props.transactionType
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('新增分类成功')
|
||||||
|
const newClassify = response.data.name
|
||||||
|
newClassifyName.value = ''
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList()
|
||||||
|
// 自动选中新增的分类
|
||||||
|
emit('confirm', newClassify)
|
||||||
|
visible.value = false
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '新增分类失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('新增分类失败:', error)
|
||||||
|
showToast('新增分类失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.picker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid var(--van-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-cancel {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-confirm {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -75,6 +75,12 @@ const router = createRouter({
|
|||||||
name: 'message',
|
name: 'message',
|
||||||
component: () => import('../views/MessageView.vue'),
|
component: () => import('../views/MessageView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/periodic-record',
|
||||||
|
name: 'periodic-record',
|
||||||
|
component: () => import('../views/PeriodicRecord.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
<div class="date-transactions">
|
<div class="date-transactions">
|
||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
<h3>{{ selectedDateText }}</h3>
|
<h3>{{ selectedDateText }}</h3>
|
||||||
<p v-if="dateTransactions.length">共 {{ dateTransactions.length }} 笔交易</p>
|
<p v-if="dateTransactions.length">
|
||||||
|
共 {{ dateTransactions.length }} 笔交易,
|
||||||
|
<span v-html="getBalance(dateTransactions)" />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bills-scroll-container">
|
<div class="bills-scroll-container">
|
||||||
@@ -132,6 +135,23 @@ const fetchDateTransactions = async (date) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBalance = (transactions) => {
|
||||||
|
let balance = 0;
|
||||||
|
transactions.forEach(tx => {
|
||||||
|
if(tx.type === 1) {
|
||||||
|
balance += tx.amount;
|
||||||
|
} else if(tx.type === 0) {
|
||||||
|
balance -= tx.amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(balance >= 0) {
|
||||||
|
return `结余<span style="color: var(--van-tag-success-color);">收入 ${balance.toFixed(1)} 元</span>`;
|
||||||
|
} else {
|
||||||
|
return `结余<span style="color: var(--van-tag-danger-color);">支出 ${(-balance).toFixed(1)} 元</span>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 当月份显示时触发
|
// 当月份显示时触发
|
||||||
const onMonthShow = ({ date }) => {
|
const onMonthShow = ({ date }) => {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
|||||||
848
Web/src/views/PeriodicRecord.vue
Normal file
848
Web/src/views/PeriodicRecord.vue
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container-flex periodic-record">
|
||||||
|
<van-nav-bar
|
||||||
|
:title="navTitle"
|
||||||
|
left-text="返回"
|
||||||
|
left-arrow
|
||||||
|
@click-left="handleBack"
|
||||||
|
placeholder
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 下拉刷新区域 -->
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<van-loading v-if="loading && !periodicList.length" vertical style="padding: 50px 0">
|
||||||
|
加载中...
|
||||||
|
</van-loading>
|
||||||
|
|
||||||
|
<!-- 周期性账单列表 -->
|
||||||
|
<van-list
|
||||||
|
v-model:loading="loading"
|
||||||
|
:finished="finished"
|
||||||
|
finished-text="没有更多了"
|
||||||
|
@load="onLoad"
|
||||||
|
class="periodic-list"
|
||||||
|
>
|
||||||
|
<van-cell-group inset v-for="item in periodicList" :key="item.id" class="periodic-item">
|
||||||
|
<van-swipe-cell>
|
||||||
|
<div @click="editPeriodic(item)">
|
||||||
|
<van-cell :title="item.reason || '无摘要'" :label="getPeriodicTypeText(item)">
|
||||||
|
<template #value>
|
||||||
|
<div class="amount-info">
|
||||||
|
<span :class="['amount', item.type === 1 ? 'income' : 'expense']">
|
||||||
|
{{ item.type === 1 ? '+' : '-' }}{{ item.amount.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="分类" :value="item.classify || '未分类'" />
|
||||||
|
<van-cell title="状态">
|
||||||
|
<template #value>
|
||||||
|
<van-switch
|
||||||
|
:model-value="item.isEnabled"
|
||||||
|
size="20px"
|
||||||
|
@update:model-value="(val) => toggleEnabled(item.id, val)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
</div>
|
||||||
|
<template #right>
|
||||||
|
<van-button
|
||||||
|
square
|
||||||
|
type="danger"
|
||||||
|
text="删除"
|
||||||
|
class="delete-button"
|
||||||
|
@click="deletePeriodic(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<van-empty
|
||||||
|
v-if="!loading && !periodicList.length"
|
||||||
|
description="暂无周期性账单"
|
||||||
|
image="search"
|
||||||
|
/>
|
||||||
|
</van-list>
|
||||||
|
|
||||||
|
<!-- 底部安全距离 -->
|
||||||
|
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))"></div>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<!-- 底部新增按钮 -->
|
||||||
|
<div class="bottom-add-button">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
round
|
||||||
|
icon="plus"
|
||||||
|
@click="openAddDialog"
|
||||||
|
>
|
||||||
|
新增周期账单
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="dialogVisible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '85%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
>
|
||||||
|
<div class="periodic-form">
|
||||||
|
<div class="form-header">
|
||||||
|
<h3>{{ isEdit ? '编辑周期账单' : '新增周期账单' }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-form @submit="onSubmit">
|
||||||
|
<van-cell-group inset title="基本信息">
|
||||||
|
<van-field
|
||||||
|
v-model="form.reason"
|
||||||
|
name="reason"
|
||||||
|
label="摘要"
|
||||||
|
placeholder="请输入交易摘要"
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="form.amount"
|
||||||
|
name="amount"
|
||||||
|
label="金额"
|
||||||
|
placeholder="请输入金额"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入金额' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="form.typeText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="type"
|
||||||
|
label="类型"
|
||||||
|
placeholder="请选择交易类型"
|
||||||
|
@click="showTypePicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||||
|
/>
|
||||||
|
<van-field name="classify" label="分类">
|
||||||
|
<template #input>
|
||||||
|
<span v-if="!form.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||||
|
<span v-else>{{ form.classify }}</span>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
|
||||||
|
<!-- 分类按钮网格 -->
|
||||||
|
<div class="classify-buttons">
|
||||||
|
<van-button
|
||||||
|
v-for="item in classifyColumns"
|
||||||
|
:key="item.id"
|
||||||
|
:type="form.classify === item.text ? 'primary' : 'default'"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="selectClassify(item.text)"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="showAddClassify = true"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
v-if="form.classify"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
class="classify-btn"
|
||||||
|
@click="clearClassify"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="周期设置">
|
||||||
|
<van-field
|
||||||
|
v-model="form.periodicTypeText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="periodicType"
|
||||||
|
label="周期"
|
||||||
|
placeholder="请选择周期类型"
|
||||||
|
@click="showPeriodicTypePicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择周期类型' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 每周配置 -->
|
||||||
|
<van-field
|
||||||
|
v-if="form.periodicType === 1"
|
||||||
|
v-model="form.weekdaysText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="weekdays"
|
||||||
|
label="星期"
|
||||||
|
placeholder="请选择星期几"
|
||||||
|
@click="showWeekdaysPicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择星期几' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 每月配置 -->
|
||||||
|
<van-field
|
||||||
|
v-if="form.periodicType === 2"
|
||||||
|
v-model="form.monthDaysText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="monthDays"
|
||||||
|
label="日期"
|
||||||
|
placeholder="请选择每月的日期"
|
||||||
|
@click="showMonthDaysPicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择日期' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 每季度配置 -->
|
||||||
|
<van-field
|
||||||
|
v-if="form.periodicType === 3"
|
||||||
|
v-model="form.quarterDay"
|
||||||
|
name="quarterDay"
|
||||||
|
label="季度第几天"
|
||||||
|
placeholder="请输入季度开始后第几天"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入季度开始后第几天' }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 每年配置 -->
|
||||||
|
<van-field
|
||||||
|
v-if="form.periodicType === 4"
|
||||||
|
v-model="form.yearDay"
|
||||||
|
name="yearDay"
|
||||||
|
label="年第几天"
|
||||||
|
placeholder="请输入年开始后第几天"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入年开始后第几天' }]"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div style="margin: 16px;">
|
||||||
|
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
||||||
|
{{ isEdit ? '更新' : '确认添加' }}
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-form>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 交易类型选择器 -->
|
||||||
|
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="typeColumns"
|
||||||
|
@confirm="onTypeConfirm"
|
||||||
|
@cancel="showTypePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 周期类型选择器 -->
|
||||||
|
<van-popup v-model:show="showPeriodicTypePicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="periodicTypeColumns"
|
||||||
|
@confirm="onPeriodicTypeConfirm"
|
||||||
|
@cancel="showPeriodicTypePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 星期选择器 -->
|
||||||
|
<van-popup v-model:show="showWeekdaysPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="weekdaysColumns"
|
||||||
|
@confirm="onWeekdaysConfirm"
|
||||||
|
@cancel="showWeekdaysPicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 日期选择器 -->
|
||||||
|
<van-popup v-model:show="showMonthDaysPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="monthDaysColumns"
|
||||||
|
@confirm="onMonthDaysConfirm"
|
||||||
|
@cancel="showMonthDaysPicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增分类对话框 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showAddClassify"
|
||||||
|
title="新增交易分类"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="addNewClassify"
|
||||||
|
>
|
||||||
|
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||||
|
</van-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
|
import {
|
||||||
|
getPeriodicList,
|
||||||
|
createPeriodic,
|
||||||
|
updatePeriodic,
|
||||||
|
deletePeriodic as deletePeriodicApi,
|
||||||
|
togglePeriodicEnabled
|
||||||
|
} from '@/api/transactionPeriodic'
|
||||||
|
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const navTitle = ref('周期账单')
|
||||||
|
|
||||||
|
const periodicList = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const pageIndex = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
// 弹窗相关
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const showTypePicker = ref(false)
|
||||||
|
const showPeriodicTypePicker = ref(false)
|
||||||
|
const showWeekdaysPicker = ref(false)
|
||||||
|
const showMonthDaysPicker = ref(false)
|
||||||
|
const showAddClassify = ref(false)
|
||||||
|
const newClassify = ref('')
|
||||||
|
|
||||||
|
// 分类列表
|
||||||
|
const classifyColumns = ref([])
|
||||||
|
|
||||||
|
// 交易类型
|
||||||
|
const typeColumns = [
|
||||||
|
{ text: '支出', value: 0 },
|
||||||
|
{ text: '收入', value: 1 },
|
||||||
|
{ text: '不计入收支', value: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 周期类型
|
||||||
|
const periodicTypeColumns = [
|
||||||
|
{ text: '每天', value: 0 },
|
||||||
|
{ text: '每周', value: 1 },
|
||||||
|
{ text: '每月', value: 2 },
|
||||||
|
{ text: '每季度', value: 3 },
|
||||||
|
{ text: '每年', value: 4 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 星期
|
||||||
|
const weekdaysColumns = [
|
||||||
|
{ text: '周日', value: 0 },
|
||||||
|
{ text: '周一', value: 1 },
|
||||||
|
{ text: '周二', value: 2 },
|
||||||
|
{ text: '周三', value: 3 },
|
||||||
|
{ text: '周四', value: 4 },
|
||||||
|
{ text: '周五', value: 5 },
|
||||||
|
{ text: '周六', value: 6 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 月份日期
|
||||||
|
const monthDaysColumns = Array.from({ length: 31 }, (_, i) => ({
|
||||||
|
text: `${i + 1}日`,
|
||||||
|
value: i + 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const form = reactive({
|
||||||
|
id: null,
|
||||||
|
reason: '',
|
||||||
|
amount: '',
|
||||||
|
type: 0,
|
||||||
|
typeText: '',
|
||||||
|
classify: '',
|
||||||
|
periodicType: 0,
|
||||||
|
periodicTypeText: '',
|
||||||
|
periodicConfig: '',
|
||||||
|
// 每周
|
||||||
|
weekdays: [],
|
||||||
|
weekdaysText: '',
|
||||||
|
// 每月
|
||||||
|
monthDays: [],
|
||||||
|
monthDaysText: '',
|
||||||
|
// 每季度
|
||||||
|
quarterDay: '',
|
||||||
|
// 每年
|
||||||
|
yearDay: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
pageIndex.value = 1
|
||||||
|
periodicList.value = []
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pageIndex: pageIndex.value,
|
||||||
|
pageSize: pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getPeriodicList(params)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const newList = response.data || []
|
||||||
|
total.value = response.total || 0
|
||||||
|
|
||||||
|
if (isRefresh) {
|
||||||
|
periodicList.value = newList
|
||||||
|
} else {
|
||||||
|
periodicList.value = [...periodicList.value, ...newList]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newList.length === 0 || newList.length < pageSize) {
|
||||||
|
finished.value = true
|
||||||
|
} else {
|
||||||
|
finished.value = false
|
||||||
|
pageIndex.value++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '加载数据失败')
|
||||||
|
finished.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载数据出错:', error)
|
||||||
|
showToast('加载数据出错')
|
||||||
|
finished.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = () => {
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onLoad = () => {
|
||||||
|
loadData(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
const handleBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取周期类型文本
|
||||||
|
const getPeriodicTypeText = (item) => {
|
||||||
|
const typeMap = {
|
||||||
|
0: '每天',
|
||||||
|
1: '每周',
|
||||||
|
2: '每月',
|
||||||
|
3: '每季度',
|
||||||
|
4: '每年'
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = typeMap[item.periodicType] || '未知'
|
||||||
|
|
||||||
|
if (item.periodicConfig) {
|
||||||
|
switch (item.periodicType) {
|
||||||
|
case 1: // 每周
|
||||||
|
{
|
||||||
|
const weekdays = item.periodicConfig.split(',').map(
|
||||||
|
d => {
|
||||||
|
const dayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return dayMap[parseInt(d)] || ''
|
||||||
|
}).join('、')
|
||||||
|
text += ` (${weekdays})`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 2: // 每月
|
||||||
|
{
|
||||||
|
const days = item.periodicConfig.split(',').join('、')
|
||||||
|
text += ` (${days}日)`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 3: // 每季度
|
||||||
|
text += ` (第${item.periodicConfig}天)`
|
||||||
|
break
|
||||||
|
case 4: // 每年
|
||||||
|
text += ` (第${item.periodicConfig}天)`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开新增弹窗
|
||||||
|
const openAddDialog = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
// 加载分类列表
|
||||||
|
loadClassifyList(form.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
const loadClassifyList = async (type = null) => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoryList(type)
|
||||||
|
if (response.success) {
|
||||||
|
classifyColumns.value = (response.data || []).map(item => ({
|
||||||
|
text: item.name,
|
||||||
|
value: item.name,
|
||||||
|
id: item.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const editPeriodic = (item) => {
|
||||||
|
isEdit.value = true
|
||||||
|
form.id = item.id
|
||||||
|
form.reason = item.reason
|
||||||
|
form.amount = item.amount.toString()
|
||||||
|
form.type = item.type
|
||||||
|
form.typeText = typeColumns.find(t => t.value === item.type)?.text || ''
|
||||||
|
form.classify = item.classify
|
||||||
|
form.periodicType = item.periodicType
|
||||||
|
form.periodicTypeText = periodicTypeColumns.find(t => t.value === item.periodicType)?.text || ''
|
||||||
|
|
||||||
|
// 加载对应类型的分类列表
|
||||||
|
loadClassifyList(item.type)
|
||||||
|
|
||||||
|
// 解析周期配置
|
||||||
|
if (item.periodicConfig) {
|
||||||
|
switch (item.periodicType) {
|
||||||
|
case 1: // 每周
|
||||||
|
form.weekdays = item.periodicConfig.split(',').map(d => parseInt(d))
|
||||||
|
form.weekdaysText = form.weekdays.map(d => {
|
||||||
|
return weekdaysColumns.find(w => w.value === d)?.text || ''
|
||||||
|
}).join('、')
|
||||||
|
break
|
||||||
|
case 2: // 每月
|
||||||
|
form.monthDays = item.periodicConfig.split(',').map(d => parseInt(d))
|
||||||
|
form.monthDaysText = form.monthDays.map(d => `${d}日`).join('、')
|
||||||
|
break
|
||||||
|
case 3: // 每季度
|
||||||
|
form.quarterDay = item.periodicConfig
|
||||||
|
break
|
||||||
|
case 4: // 每年
|
||||||
|
form.yearDay = item.periodicConfig
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const deletePeriodic = async (item) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '提示',
|
||||||
|
message: '确定要删除这条周期性账单吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await deletePeriodicApi(item.id)
|
||||||
|
if (response.success) {
|
||||||
|
showToast('删除成功')
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除出错:', error)
|
||||||
|
showToast('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用/禁用
|
||||||
|
const toggleEnabled = async (id, enabled) => {
|
||||||
|
try {
|
||||||
|
const response = await togglePeriodicEnabled(id, enabled)
|
||||||
|
if (response.success) {
|
||||||
|
showToast(enabled ? '已启用' : '已禁用')
|
||||||
|
// 更新本地数据
|
||||||
|
const item = periodicList.value.find(p => p.id === id)
|
||||||
|
if (item) {
|
||||||
|
item.isEnabled = enabled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '操作失败')
|
||||||
|
// 恢复状态
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('操作失败:', error)
|
||||||
|
showToast('操作失败')
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
form.id = null
|
||||||
|
form.reason = ''
|
||||||
|
form.amount = ''
|
||||||
|
form.type = 0
|
||||||
|
form.typeText = ''
|
||||||
|
form.classify = ''
|
||||||
|
form.periodicType = 0
|
||||||
|
form.periodicTypeText = ''
|
||||||
|
form.periodicConfig = ''
|
||||||
|
form.weekdays = []
|
||||||
|
form.weekdaysText = ''
|
||||||
|
form.monthDays = []
|
||||||
|
form.monthDaysText = ''
|
||||||
|
form.quarterDay = ''
|
||||||
|
form.yearDay = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择器确认事件
|
||||||
|
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
form.type = selectedValues[0]
|
||||||
|
form.typeText = selectedOptions[0].text
|
||||||
|
showTypePicker.value = false
|
||||||
|
// 清空已选的分类
|
||||||
|
form.classify = ''
|
||||||
|
// 重新加载对应类型的分类列表
|
||||||
|
loadClassifyList(form.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPeriodicTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
form.periodicType = selectedValues[0]
|
||||||
|
form.periodicTypeText = selectedOptions[0].text
|
||||||
|
// 清空之前的配置
|
||||||
|
form.weekdays = []
|
||||||
|
form.weekdaysText = ''
|
||||||
|
form.monthDays = []
|
||||||
|
form.monthDaysText = ''
|
||||||
|
form.quarterDay = ''
|
||||||
|
form.yearDay = ''
|
||||||
|
showPeriodicTypePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWeekdaysConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
form.weekdays = [selectedValues[0]]
|
||||||
|
form.weekdaysText = selectedOptions[0].text
|
||||||
|
showWeekdaysPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMonthDaysConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
form.monthDays = [selectedValues[0]]
|
||||||
|
form.monthDaysText = selectedOptions[0].text
|
||||||
|
showMonthDaysPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择分类
|
||||||
|
const selectClassify = (classify) => {
|
||||||
|
form.classify = classify
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空分类
|
||||||
|
const clearClassify = () => {
|
||||||
|
form.classify = ''
|
||||||
|
showToast('已清空分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增分类
|
||||||
|
const addNewClassify = async () => {
|
||||||
|
if (!newClassify.value.trim()) {
|
||||||
|
showToast('请输入分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryName = newClassify.value.trim()
|
||||||
|
|
||||||
|
// 调用API创建分类
|
||||||
|
const response = await createCategory({
|
||||||
|
name: categoryName,
|
||||||
|
type: form.type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('分类创建成功')
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList(form.type)
|
||||||
|
form.classify = categoryName
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '创建分类失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建分类出错:', error)
|
||||||
|
showToast('创建分类失败')
|
||||||
|
} finally {
|
||||||
|
newClassify.value = ''
|
||||||
|
showAddClassify.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
// 构建周期配置
|
||||||
|
let periodicConfig = ''
|
||||||
|
switch (form.periodicType) {
|
||||||
|
case 1: // 每周
|
||||||
|
if (!form.weekdays.length) {
|
||||||
|
showToast('请选择星期几')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.weekdays.join(',')
|
||||||
|
break
|
||||||
|
case 2: // 每月
|
||||||
|
if (!form.monthDays.length) {
|
||||||
|
showToast('请选择日期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.monthDays.join(',')
|
||||||
|
break
|
||||||
|
case 3: // 每季度
|
||||||
|
if (!form.quarterDay) {
|
||||||
|
showToast('请输入季度开始后第几天')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.quarterDay
|
||||||
|
break
|
||||||
|
case 4: // 每年
|
||||||
|
if (!form.yearDay) {
|
||||||
|
showToast('请输入年开始后第几天')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
periodicConfig = form.yearDay
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
periodicType: form.periodicType,
|
||||||
|
periodicConfig: periodicConfig,
|
||||||
|
amount: parseFloat(form.amount),
|
||||||
|
type: form.type,
|
||||||
|
classify: form.classify || '',
|
||||||
|
reason: form.reason || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
if (isEdit.value) {
|
||||||
|
data.id = form.id
|
||||||
|
data.isEnabled = true
|
||||||
|
response = await updatePeriodic(data)
|
||||||
|
} else {
|
||||||
|
response = await createPeriodic(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast(isEdit.value ? '更新成功' : '添加成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || (isEdit.value ? '更新失败' : '添加失败'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交出错:', error)
|
||||||
|
showToast((isEdit.value ? '更新' : '添加') + '失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// van-list 会自动触发 onLoad
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.periodic-record {
|
||||||
|
background: var(--van-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodic-list {
|
||||||
|
/* padding: 16px 0; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodic-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount.income {
|
||||||
|
color: var(--van-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount.expense {
|
||||||
|
color: var(--van-danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 70px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-add-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodic-form {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--van-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--van-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-cell-group) {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
<van-nav-bar title="设置" placeholder/>
|
<van-nav-bar title="设置" placeholder/>
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div class="detail-header" style="padding-bottom: 5px;">
|
||||||
<p>账单导入</p>
|
<p>账单</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
|
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
|
||||||
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
|
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
|
||||||
|
<van-cell title="周期记录" is-link @click="handlePeriodicRecord" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<!-- 隐藏的文件选择器 -->
|
<!-- 隐藏的文件选择器 -->
|
||||||
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
|
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div class="detail-header" style="padding-bottom: 5px;">
|
||||||
<p>分类处理</p>
|
<p>分类</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
|
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
||||||
<van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" />
|
<van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header" style="padding-bottom: 5px;">
|
<div class="detail-header" style="padding-bottom: 5px;">
|
||||||
<p>账户</p>
|
<p>账户</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +55,10 @@ const handleImportClick = (type) => {
|
|||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePeriodicRecord = () => {
|
||||||
|
router.push({ name: 'periodic-record' })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理文件选择
|
* 处理文件选择
|
||||||
*/
|
*/
|
||||||
@@ -168,6 +174,7 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
padding: 16px 16px 5px 16px;
|
padding: 16px 16px 5px 16px;
|
||||||
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header p {
|
.detail-header p {
|
||||||
|
|||||||
175
WebApi/Controllers/JobController.cs
Normal file
175
WebApi/Controllers/JobController.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时任务管理控制器
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class JobController(
|
||||||
|
ISchedulerFactory schedulerFactory,
|
||||||
|
ILogger<JobController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 手动触发邮件同步任务
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("sync-email")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> TriggerEmailSync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobKey = new JobKey("EmailSyncJob");
|
||||||
|
|
||||||
|
// 立即触发任务
|
||||||
|
await scheduler.TriggerJob(jobKey);
|
||||||
|
|
||||||
|
logger.LogInformation("手动触发邮件同步任务成功");
|
||||||
|
return Ok(new { message = "邮件同步任务已触发" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "触发邮件同步任务失败");
|
||||||
|
return StatusCode(500, new { message = "触发任务失败", error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手动触发周期性账单任务
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("periodic-bill")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> TriggerPeriodicBill()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobKey = new JobKey("PeriodicBillJob");
|
||||||
|
|
||||||
|
// 立即触发任务
|
||||||
|
await scheduler.TriggerJob(jobKey);
|
||||||
|
|
||||||
|
logger.LogInformation("手动触发周期性账单任务成功");
|
||||||
|
return Ok(new { message = "周期性账单任务已触发" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "触发周期性账单任务失败");
|
||||||
|
return StatusCode(500, new { message = "触发任务失败", error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有任务的状态
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("status")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetJobStatus()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobGroups = await scheduler.GetJobGroupNames();
|
||||||
|
var jobStatuses = new List<object>();
|
||||||
|
|
||||||
|
foreach (var group in jobGroups)
|
||||||
|
{
|
||||||
|
var jobKeys = await scheduler.GetJobKeys(Quartz.Impl.Matchers.GroupMatcher<JobKey>.GroupEquals(group));
|
||||||
|
|
||||||
|
foreach (var jobKey in jobKeys)
|
||||||
|
{
|
||||||
|
var triggers = await scheduler.GetTriggersOfJob(jobKey);
|
||||||
|
var jobDetail = await scheduler.GetJobDetail(jobKey);
|
||||||
|
|
||||||
|
foreach (var trigger in triggers)
|
||||||
|
{
|
||||||
|
var triggerState = await scheduler.GetTriggerState(trigger.Key);
|
||||||
|
var nextFireTime = trigger.GetNextFireTimeUtc();
|
||||||
|
var previousFireTime = trigger.GetPreviousFireTimeUtc();
|
||||||
|
|
||||||
|
jobStatuses.Add(new
|
||||||
|
{
|
||||||
|
jobName = jobKey.Name,
|
||||||
|
jobGroup = jobKey.Group,
|
||||||
|
triggerName = trigger.Key.Name,
|
||||||
|
triggerState = triggerState.ToString(),
|
||||||
|
nextFireTime = nextFireTime?.LocalDateTime,
|
||||||
|
previousFireTime = previousFireTime?.LocalDateTime,
|
||||||
|
description = trigger.Description,
|
||||||
|
jobType = jobDetail?.JobType.Name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(jobStatuses);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取任务状态失败");
|
||||||
|
return StatusCode(500, new { message = "获取任务状态失败", error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 暂停指定任务
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("pause/{jobName}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> PauseJob(string jobName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobKey = new JobKey(jobName);
|
||||||
|
|
||||||
|
if (!await scheduler.CheckExists(jobKey))
|
||||||
|
{
|
||||||
|
return NotFound(new { message = $"任务 {jobName} 不存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await scheduler.PauseJob(jobKey);
|
||||||
|
logger.LogInformation("任务 {JobName} 已暂停", jobName);
|
||||||
|
|
||||||
|
return Ok(new { message = $"任务 {jobName} 已暂停" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "暂停任务 {JobName} 失败", jobName);
|
||||||
|
return StatusCode(500, new { message = "暂停任务失败", error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 恢复指定任务
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("resume/{jobName}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> ResumeJob(string jobName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
|
var jobKey = new JobKey(jobName);
|
||||||
|
|
||||||
|
if (!await scheduler.CheckExists(jobKey))
|
||||||
|
{
|
||||||
|
return NotFound(new { message = $"任务 {jobName} 不存在" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await scheduler.ResumeJob(jobKey);
|
||||||
|
logger.LogInformation("任务 {JobName} 已恢复", jobName);
|
||||||
|
|
||||||
|
return Ok(new { message = $"任务 {jobName} 已恢复" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "恢复任务 {JobName} 失败", jobName);
|
||||||
|
return StatusCode(500, new { message = "恢复任务失败", error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
WebApi/Controllers/TransactionPeriodicController.cs
Normal file
249
WebApi/Controllers/TransactionPeriodicController.cs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
using Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 周期性账单控制器
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class TransactionPeriodicController(
|
||||||
|
ITransactionPeriodicRepository periodicRepository,
|
||||||
|
ITransactionPeriodicService periodicService,
|
||||||
|
ILogger<TransactionPeriodicController> logger
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取周期性账单列表(分页)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<PagedResponse<TransactionPeriodic>> GetListAsync(
|
||||||
|
[FromQuery] int pageIndex = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? searchKeyword = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await periodicRepository.GetPagedListAsync(pageIndex, pageSize, searchKeyword);
|
||||||
|
var total = await periodicRepository.GetTotalCountAsync(searchKeyword);
|
||||||
|
|
||||||
|
return new PagedResponse<TransactionPeriodic>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = list.ToArray(),
|
||||||
|
Total = (int)total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取周期性账单列表失败");
|
||||||
|
return PagedResponse<TransactionPeriodic>.Fail($"获取列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取周期性账单详情
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<BaseResponse<TransactionPeriodic>> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var periodic = await periodicRepository.GetByIdAsync(id);
|
||||||
|
if (periodic == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionPeriodic>.Fail("周期性账单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<TransactionPeriodic>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = periodic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取周期性账单详情失败,ID: {Id}", id);
|
||||||
|
return BaseResponse<TransactionPeriodic>.Fail($"获取详情失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建周期性账单
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<TransactionPeriodic>> CreateAsync([FromBody] CreatePeriodicRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var periodic = new TransactionPeriodic
|
||||||
|
{
|
||||||
|
PeriodicType = request.PeriodicType,
|
||||||
|
PeriodicConfig = request.PeriodicConfig ?? string.Empty,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Type = request.Type,
|
||||||
|
Classify = request.Classify ?? string.Empty,
|
||||||
|
Reason = request.Reason ?? string.Empty,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算下次执行时间
|
||||||
|
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
|
||||||
|
|
||||||
|
var success = await periodicRepository.AddAsync(periodic);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionPeriodic>.Fail("创建周期性账单失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<TransactionPeriodic>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = periodic,
|
||||||
|
Message = "创建成功"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "创建周期性账单失败");
|
||||||
|
return BaseResponse<TransactionPeriodic>.Fail($"创建失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新周期性账单
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<object>> UpdateAsync([FromBody] UpdatePeriodicRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var periodic = await periodicRepository.GetByIdAsync(request.Id);
|
||||||
|
if (periodic == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("周期性账单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
periodic.PeriodicType = request.PeriodicType;
|
||||||
|
periodic.PeriodicConfig = request.PeriodicConfig ?? string.Empty;
|
||||||
|
periodic.Amount = request.Amount;
|
||||||
|
periodic.Type = request.Type;
|
||||||
|
periodic.Classify = request.Classify ?? string.Empty;
|
||||||
|
periodic.Reason = request.Reason ?? string.Empty;
|
||||||
|
periodic.IsEnabled = request.IsEnabled;
|
||||||
|
periodic.UpdateTime = DateTime.Now;
|
||||||
|
|
||||||
|
// 重新计算下次执行时间
|
||||||
|
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
|
||||||
|
|
||||||
|
var success = await periodicRepository.UpdateAsync(periodic);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("更新周期性账单失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "更新成功"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "更新周期性账单失败,ID: {Id}", request.Id);
|
||||||
|
return BaseResponse<object>.Fail($"更新失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除周期性账单
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<object>> DeleteByIdAsync([FromQuery] long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await periodicRepository.DeleteAsync(id);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("删除周期性账单失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "删除成功"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "删除周期性账单失败,ID: {Id}", id);
|
||||||
|
return BaseResponse<object>.Fail($"删除失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启用/禁用周期性账单
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<object>> ToggleEnabledAsync([FromQuery] long id, [FromQuery] bool enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var periodic = await periodicRepository.GetByIdAsync(id);
|
||||||
|
if (periodic == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("周期性账单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
periodic.IsEnabled = enabled;
|
||||||
|
periodic.UpdateTime = DateTime.Now;
|
||||||
|
|
||||||
|
var success = await periodicRepository.UpdateAsync(periodic);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("操作失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<object>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = enabled ? "已启用" : "已禁用"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "启用/禁用周期性账单失败,ID: {Id}", id);
|
||||||
|
return BaseResponse<object>.Fail($"操作失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建周期性账单请求
|
||||||
|
/// </summary>
|
||||||
|
public class CreatePeriodicRequest
|
||||||
|
{
|
||||||
|
public PeriodicType PeriodicType { get; set; }
|
||||||
|
public string? PeriodicConfig { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
public string? Classify { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新周期性账单请求
|
||||||
|
/// </summary>
|
||||||
|
public class UpdatePeriodicRequest
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public PeriodicType PeriodicType { get; set; }
|
||||||
|
public string? PeriodicConfig { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
public string? Classify { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
}
|
||||||
40
WebApi/Expand.cs
Normal file
40
WebApi/Expand.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace WebApi;
|
||||||
|
|
||||||
|
public static class Expand
|
||||||
|
{
|
||||||
|
public static void AddScheduler(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
builder.Services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
// 配置调度器
|
||||||
|
q.SchedulerId = "EmailBillScheduler";
|
||||||
|
|
||||||
|
// 配置邮件同步任务 - 每10分钟执行一次
|
||||||
|
var emailJobKey = new JobKey("EmailSyncJob");
|
||||||
|
q.AddJob<Service.Jobs.EmailSyncJob>(opts => opts.WithIdentity(emailJobKey));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(emailJobKey)
|
||||||
|
.WithIdentity("EmailSyncTrigger")
|
||||||
|
.WithCronSchedule("0 0/20 * * * ?") // 每20分钟执行
|
||||||
|
.WithDescription("每20分钟同步一次邮件"));
|
||||||
|
|
||||||
|
// 配置周期性账单任务 - 每天早上6点执行
|
||||||
|
var periodicBillJobKey = new JobKey("PeriodicBillJob");
|
||||||
|
q.AddJob<Service.Jobs.PeriodicBillJob>(opts => opts.WithIdentity(periodicBillJobKey));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(periodicBillJobKey)
|
||||||
|
.WithIdentity("PeriodicBillTrigger")
|
||||||
|
.WithCronSchedule("0 0 6 * * ?") // 每天早上6点执行
|
||||||
|
.WithDescription("每天早上6点执行周期性账单检查"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加 Quartz Hosted Service
|
||||||
|
builder.Services.AddQuartzHostedService(options =>
|
||||||
|
{
|
||||||
|
// 等待任务完成后再关闭
|
||||||
|
options.WaitForJobsToComplete = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Text;
|
|
||||||
using FreeSql;
|
using FreeSql;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Service.AppSettingModel;
|
using Service.AppSettingModel;
|
||||||
|
using WebApi;
|
||||||
using Yitter.IdGenerator;
|
using Yitter.IdGenerator;
|
||||||
|
|
||||||
// 初始化雪花算法ID生成器
|
// 初始化雪花算法ID生成器
|
||||||
@@ -97,6 +97,9 @@ builder.Services.AddSingleton(fsql);
|
|||||||
// 自动扫描注册服务和仓储
|
// 自动扫描注册服务和仓储
|
||||||
builder.Services.AddServices();
|
builder.Services.AddServices();
|
||||||
|
|
||||||
|
// 配置 Quartz.NET 定时任务
|
||||||
|
builder.AddScheduler();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
@@ -122,27 +125,4 @@ app.MapControllers();
|
|||||||
// 添加 SPA 回退路由(用于前端路由)
|
// 添加 SPA 回退路由(用于前端路由)
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
// 启动后台邮件抓取服务(必须只注册一次)
|
|
||||||
app.Lifetime.ApplicationStarted.Register(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (app.Services.GetRequiredService<IEmailBackgroundService>() is not EmailBackgroundService emailService)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已在运行,避免重复启动
|
|
||||||
if (!emailService.IsBusy)
|
|
||||||
{
|
|
||||||
emailService.RunWorkerAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
|
||||||
logger.LogError(ex, "启动后台服务失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"AuthSettings": {
|
"AuthSettings": {
|
||||||
"Password": "SCsunch940622"
|
"Password": "SCsunch940622"
|
||||||
},
|
},
|
||||||
|
"Quartz": {
|
||||||
|
"quartz.scheduler.instanceName": "EmailBillScheduler",
|
||||||
|
"quartz.jobStore.type": "Quartz.Simpl.RAMJobStore, Quartz",
|
||||||
|
"quartz.threadPool.threadCount": 10
|
||||||
|
},
|
||||||
"OpenAI": {
|
"OpenAI": {
|
||||||
"Endpoint": "https://api.deepseek.com/v1",
|
"Endpoint": "https://api.deepseek.com/v1",
|
||||||
"Key": "sk-2240d91e2ab1475881147e3810b343d3",
|
"Key": "sk-2240d91e2ab1475881147e3810b343d3",
|
||||||
|
|||||||
Reference in New Issue
Block a user