新增定时账单功能
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 30s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s

This commit is contained in:
孙诚
2025-12-29 15:20:32 +08:00
parent 13bf23a48c
commit 9719c6043a
19 changed files with 2409 additions and 27 deletions

View File

@@ -10,6 +10,7 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" 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.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<!-- Logging -->
<PackageVersion Include="Serilog" Version="4.3.0" />
@@ -25,5 +26,8 @@
<PackageVersion Include="CsvHelper" Version="33.0.1" />
<PackageVersion Include="EPPlus" Version="7.5.2" />
<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>
</Project>

View 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
}

View 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;
}
}
}

View 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", "");
}
}

View 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 知道任务失败
}
}
}

View 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("周期性账单后台服务已停止");
}
}

View File

@@ -10,12 +10,15 @@
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="EPPlus" />
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="Quartz" />
<PackageReference Include="Quartz.Extensions.Hosting" />
</ItemGroup>
</Project>

View 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);
}
}

View 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 }
})
}

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

View File

@@ -75,6 +75,12 @@ const router = createRouter({
name: 'message',
component: () => import('../views/MessageView.vue'),
meta: { requiresAuth: true },
},
{
path: '/periodic-record',
name: 'periodic-record',
component: () => import('../views/PeriodicRecord.vue'),
meta: { requiresAuth: true },
}
],
})

View File

@@ -22,7 +22,10 @@
<div class="date-transactions">
<div class="popup-header">
<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 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 year = date.getFullYear();

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

View File

@@ -3,18 +3,19 @@
<van-nav-bar title="设置" placeholder/>
<div class="scroll-content">
<div class="detail-header" style="padding-bottom: 5px;">
<p>账单导入</p>
<p>账单</p>
</div>
<van-cell-group inset>
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
<van-cell title="周期记录" is-link @click="handlePeriodicRecord" />
</van-cell-group>
<!-- 隐藏的文件选择器 -->
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
<div class="detail-header" style="padding-bottom: 5px;">
<p>分类处理</p>
<p>分类</p>
</div>
<van-cell-group inset>
<van-cell title="编辑分类" is-link @click="handleEditClassification" />
@@ -22,6 +23,7 @@
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
<van-cell title="自然语言分类" is-link @click="handleNaturalLanguageClassification" />
</van-cell-group>
<div class="detail-header" style="padding-bottom: 5px;">
<p>账户</p>
</div>
@@ -53,6 +55,10 @@ const handleImportClick = (type) => {
fileInputRef.value?.click()
}
const handlePeriodicRecord = () => {
router.push({ name: 'periodic-record' })
}
/**
* 处理文件选择
*/
@@ -168,6 +174,7 @@ const handleLogout = async () => {
.detail-header {
padding: 16px 16px 5px 16px;
margin-bottom: 5px;
}
.detail-header p {

View 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 });
}
}
}

View 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
View 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;
});
}
}

View File

@@ -1,10 +1,10 @@
using System.Text;
using FreeSql;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using Service.AppSettingModel;
using WebApi;
using Yitter.IdGenerator;
// 初始化雪花算法ID生成器
@@ -97,6 +97,9 @@ builder.Services.AddSingleton(fsql);
// 自动扫描注册服务和仓储
builder.Services.AddServices();
// 配置 Quartz.NET 定时任务
builder.AddScheduler();
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -122,27 +125,4 @@ app.MapControllers();
// 添加 SPA 回退路由(用于前端路由)
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();

View File

@@ -66,6 +66,11 @@
"AuthSettings": {
"Password": "SCsunch940622"
},
"Quartz": {
"quartz.scheduler.instanceName": "EmailBillScheduler",
"quartz.jobStore.type": "Quartz.Simpl.RAMJobStore, Quartz",
"quartz.threadPool.threadCount": 10
},
"OpenAI": {
"Endpoint": "https://api.deepseek.com/v1",
"Key": "sk-2240d91e2ab1475881147e3810b343d3",