Compare commits
3 Commits
9e14849014
...
maf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e20df2be | ||
|
|
1de451c54d | ||
|
|
db61f70335 |
@@ -13,64 +13,13 @@ jobs:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 网络连接测试
|
||||
- name: Test network connectivity
|
||||
run: |
|
||||
echo "Testing network connectivity to Gitea server..."
|
||||
MAX_RETRIES=5
|
||||
RETRY_DELAY=10
|
||||
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Network test attempt $i/$MAX_RETRIES"
|
||||
if curl -s --connect-timeout 10 -f http://192.168.31.14:14200 > /dev/null; then
|
||||
echo "✅ Gitea server is reachable"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Network test failed, waiting $RETRY_DELAY seconds..."
|
||||
sleep $RETRY_DELAY
|
||||
fi
|
||||
done
|
||||
|
||||
echo "❌ All network tests failed"
|
||||
exit 1
|
||||
|
||||
# ✅ 使用 Gitea 兼容的代码检出方式
|
||||
- name: Checkout code
|
||||
uses: https://gitea.com/actions/checkout@v3
|
||||
# 添加重试策略
|
||||
continue-on-error: true
|
||||
|
||||
# 手动重试逻辑
|
||||
- name: Retry checkout if failed
|
||||
if: steps.checkout.outcome == 'failure'
|
||||
run: |
|
||||
echo "First checkout attempt failed, retrying..."
|
||||
MAX_RETRIES=3
|
||||
RETRY_DELAY=15
|
||||
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Retry attempt $i/$MAX_RETRIES"
|
||||
|
||||
# 清理可能的部分检出
|
||||
rm -rf .git || true
|
||||
git clean -fdx || true
|
||||
|
||||
# 使用git命令直接检出
|
||||
git init
|
||||
git remote add origin http://192.168.31.14:14200/${{ gitea.repository }}
|
||||
git config http.extraHeader "Authorization: Bearer ${{ secrets.GITEA_TOKEN }}"
|
||||
|
||||
if git fetch --depth=1 origin "${{ gitea.ref }}"; then
|
||||
git checkout FETCH_HEAD
|
||||
echo "Checkout successful on retry $i"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Retry $i failed, waiting $RETRY_DELAY seconds..."
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
|
||||
echo "All checkout attempts failed"
|
||||
exit 1
|
||||
with:
|
||||
gitea-server: http://192.168.31.14:14200
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
ref: ${{ gitea.ref }} # 必须传递 Gitea 的 ref 参数
|
||||
|
||||
- name: Cleanup old containers
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -404,4 +404,3 @@ FodyWeavers.xsd
|
||||
Web/dist
|
||||
# ESLint
|
||||
.eslintcache
|
||||
.aider*
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
global using System.Reflection;
|
||||
global using System.Text.Json;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Common;
|
||||
|
||||
public interface IDateTimeProvider
|
||||
{
|
||||
DateTime Now { get; }
|
||||
}
|
||||
|
||||
public class DateTimeProvider : IDateTimeProvider
|
||||
{
|
||||
public DateTime Now => DateTime.Now;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
namespace Common;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Common;
|
||||
|
||||
public static class TypeExtensions
|
||||
{
|
||||
@@ -7,8 +10,8 @@ public static class TypeExtensions
|
||||
/// </summary>
|
||||
public static T? DeepClone<T>(this T source)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(source);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(source);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +25,6 @@ public static class ServiceExtension
|
||||
/// </summary>
|
||||
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
|
||||
// 扫描程序集
|
||||
var serviceAssembly = Assembly.Load("Service");
|
||||
var repositoryAssembly = Assembly.Load("Repository");
|
||||
@@ -39,7 +41,7 @@ public static class ServiceExtension
|
||||
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
||||
{
|
||||
var types = assembly.GetTypes()
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||
.Where(t => t.IsClass && !t.IsAbstract);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
@@ -48,20 +50,9 @@ public static class ServiceExtension
|
||||
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
// EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient
|
||||
if (type.Name == "EmailBackgroundService")
|
||||
{
|
||||
// 其他 Services 用 Singleton
|
||||
services.AddSingleton(@interface, type);
|
||||
}
|
||||
else if (type.Name == "EmailFetchService")
|
||||
{
|
||||
// EmailFetchService 用 Transient,避免连接冲突
|
||||
services.AddTransient(@interface, type);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton(@interface, type);
|
||||
}
|
||||
Console.WriteLine($"✓ 注册 Service: {@interface.Name} -> {type.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,18 +60,19 @@ public static class ServiceExtension
|
||||
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
||||
{
|
||||
var types = assembly.GetTypes()
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||
.Where(t => t.IsClass && !t.IsAbstract);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var interfaces = type.GetInterfaces()
|
||||
.Where(i => i.Name.StartsWith("I")
|
||||
&& i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T>
|
||||
&& i.Namespace == "Repository"
|
||||
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
||||
|
||||
foreach (var @interface in interfaces)
|
||||
{
|
||||
services.AddSingleton(@interface, type);
|
||||
Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}");
|
||||
Console.WriteLine($"✓ 注册 Repository: {@interface.Name} -> {type.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<Project>
|
||||
<ItemGroup>
|
||||
<!-- Email & MIME Libraries -->
|
||||
<PackageVersion Include="FreeSql" Version="3.5.305" />
|
||||
<PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" />
|
||||
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
|
||||
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI.DevUI" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.AI.Hosting" Version="1.0.0-preview.260108.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||
<!-- Dependency Injection & Configuration -->
|
||||
@@ -22,7 +23,7 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
||||
<!-- Database -->
|
||||
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.305" />
|
||||
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
||||
<PackageVersion Include="WebPush" Version="1.0.12" />
|
||||
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||
<!-- File Processing -->
|
||||
@@ -35,12 +36,6 @@
|
||||
<!-- Text Processing -->
|
||||
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageVersion Include="xunit" Version="2.9.3"/>
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
# 多阶段构建 Dockerfile
|
||||
# 第一阶段:构建前端
|
||||
FROM node:20-slim AS frontend-build
|
||||
FROM node:20-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -31,7 +31,6 @@ COPY Entity/*.csproj ./Entity/
|
||||
COPY Repository/*.csproj ./Repository/
|
||||
COPY Service/*.csproj ./Service/
|
||||
COPY WebApi/*.csproj ./WebApi/
|
||||
COPY WebApi.Test/*.csproj ./WebApi.Test/
|
||||
|
||||
# 还原依赖
|
||||
RUN dotnet restore
|
||||
|
||||
@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.Test", "WebApi.Test\WebApi.Test.csproj", "{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -85,18 +83,6 @@ Global
|
||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ccsvc/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=strftime/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
public class BudgetArchive : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 预算Id
|
||||
/// </summary>
|
||||
public long BudgetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算周期类型
|
||||
/// </summary>
|
||||
public BudgetPeriodType BudgetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算金额
|
||||
/// </summary>
|
||||
public decimal BudgetedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 周期内实际发生金额
|
||||
/// </summary>
|
||||
public decimal RealizedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细描述
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归档目标年份
|
||||
/// </summary>
|
||||
@@ -12,79 +37,8 @@ public class BudgetArchive : BaseEntity
|
||||
/// </summary>
|
||||
public int Month { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 归档内容
|
||||
/// </summary>
|
||||
[JsonMap]
|
||||
public BudgetArchiveContent[] Content { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 归档日期
|
||||
/// </summary>
|
||||
public DateTime ArchiveDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 支出结余(预算 - 实际,正数表示省钱,负数表示超支)
|
||||
/// </summary>
|
||||
public decimal ExpenseSurplus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收入结余(实际 - 预算,正数表示超额收入,负数表示未达预期)
|
||||
/// </summary>
|
||||
public decimal IncomeSurplus { get; set; }
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
|
||||
public record BudgetArchiveContent
|
||||
{
|
||||
/// <summary>
|
||||
/// 预算ID
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算名称
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 统计周期
|
||||
/// </summary>
|
||||
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
|
||||
|
||||
/// <summary>
|
||||
/// 预算金额
|
||||
/// </summary>
|
||||
public decimal Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际金额
|
||||
/// </summary>
|
||||
public decimal Actual { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算类别
|
||||
/// </summary>
|
||||
public BudgetCategory Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 相关分类 (逗号分隔的分类名称)
|
||||
/// </summary>
|
||||
public string[] SelectedCategories { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 不记额预算
|
||||
/// </summary>
|
||||
public bool NoLimit { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 硬性消费
|
||||
/// </summary>
|
||||
public bool IsMandatoryExpense { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 描述说明
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -34,16 +34,6 @@ public class BudgetRecord : BaseEntity
|
||||
/// 开始日期
|
||||
/// </summary>
|
||||
public DateTime StartDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 不记额预算(选中后该预算没有预算金额,发生的收入或支出直接在存款中加减)
|
||||
/// </summary>
|
||||
public bool NoLimit { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 硬性消费(固定消费,如房租、水电等。当是当前年月且为硬性消费时,会根据经过的天数累加Current)
|
||||
/// </summary>
|
||||
public bool IsMandatoryExpense { get; set; } = false;
|
||||
}
|
||||
|
||||
public enum BudgetPeriodType
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Entity;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Entity;
|
||||
|
||||
/// <summary>
|
||||
/// 邮件消息实体
|
||||
@@ -37,7 +39,7 @@ public class EmailMessage : BaseEntity
|
||||
public string ComputeBodyHash()
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
var inputBytes = Encoding.UTF8.GetBytes(Body + HtmlBody);
|
||||
var inputBytes = System.Text.Encoding.UTF8.GetBytes(Body + HtmlBody);
|
||||
var hashBytes = md5.ComputeHash(inputBytes);
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FreeSql" />
|
||||
<PackageReference Include="FreeSql.Extensions.JsonMap" />
|
||||
<PackageReference Include="Yitter.IdGenerator" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
global using FreeSql.DataAnnotations;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
@@ -170,10 +170,10 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
|
||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||
var result = new List<dynamic>();
|
||||
|
||||
foreach (DataRow row in dt.Rows)
|
||||
foreach (System.Data.DataRow row in dt.Rows)
|
||||
{
|
||||
var expando = new ExpandoObject() as IDictionary<string, object>;
|
||||
foreach (DataColumn column in dt.Columns)
|
||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
||||
foreach (System.Data.DataColumn column in dt.Columns)
|
||||
{
|
||||
expando[column.ColumnName] = row[column];
|
||||
}
|
||||
|
||||
@@ -2,21 +2,19 @@
|
||||
|
||||
public interface IBudgetArchiveRepository : IBaseRepository<BudgetArchive>
|
||||
{
|
||||
Task<BudgetArchive?> GetArchiveAsync(int year, int month);
|
||||
|
||||
Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month);
|
||||
Task<List<BudgetArchive>> GetListAsync(int year, int month);
|
||||
|
||||
Task<List<BudgetArchive>> GetArchivesByYearAsync(int year);
|
||||
}
|
||||
|
||||
public class BudgetArchiveRepository(
|
||||
IFreeSql freeSql
|
||||
) : BaseRepository<BudgetArchive>(freeSql), IBudgetArchiveRepository
|
||||
{
|
||||
public async Task<BudgetArchive?> GetArchiveAsync(int year, int month)
|
||||
public async Task<BudgetArchive?> GetArchiveAsync(long budgetId, int year, int month)
|
||||
{
|
||||
return await FreeSql.Select<BudgetArchive>()
|
||||
.Where(a => a.Year == year &&
|
||||
.Where(a => a.BudgetId == budgetId &&
|
||||
a.Year == year &&
|
||||
a.Month == month)
|
||||
.ToOneAsync();
|
||||
}
|
||||
@@ -24,15 +22,13 @@ public class BudgetArchiveRepository(
|
||||
public async Task<List<BudgetArchive>> GetListAsync(int year, int month)
|
||||
{
|
||||
return await FreeSql.Select<BudgetArchive>()
|
||||
.Where(a => a.Year == year && a.Month == month)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<BudgetArchive>> GetArchivesByYearAsync(int year)
|
||||
{
|
||||
return await FreeSql.Select<BudgetArchive>()
|
||||
.Where(a => a.Year == year)
|
||||
.OrderBy(a => a.Month)
|
||||
.Where(
|
||||
a => a.BudgetType == BudgetPeriodType.Month &&
|
||||
a.Year == year &&
|
||||
a.Month == month ||
|
||||
a.BudgetType == BudgetPeriodType.Year &&
|
||||
a.Year == year
|
||||
)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.Income);
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Savings)
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.None);
|
||||
}
|
||||
|
||||
return await query.SumAsync(t => t.Amount);
|
||||
}
|
||||
@@ -37,7 +41,8 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
||||
var records = await FreeSql.Select<BudgetRecord>()
|
||||
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
||||
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income)))
|
||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
|
||||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var record in records)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
global using Entity;
|
||||
global using FreeSql;
|
||||
global using System.Linq;
|
||||
global using System.Data;
|
||||
global using System.Dynamic;
|
||||
|
||||
|
||||
@@ -57,18 +57,8 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// </summary>
|
||||
/// <param name="year">年份</param>
|
||||
/// <param name="month">月份</param>
|
||||
/// <param name="savingClassify"></param>
|
||||
/// <returns>每天的消费笔数和金额详情</returns>
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定日期范围内的每日统计
|
||||
/// </summary>
|
||||
/// <param name="startDate">开始日期</param>
|
||||
/// <param name="endDate">结束日期</param>
|
||||
/// <param name="savingClassify"></param>
|
||||
/// <returns>每天的消费笔数和金额详情</returns>
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
|
||||
/// <returns>每天的消费笔数和金额</returns>
|
||||
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定日期范围内的交易记录
|
||||
@@ -151,6 +141,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <summary>
|
||||
/// 根据关键词查询交易记录(模糊匹配Reason字段)
|
||||
/// </summary>
|
||||
/// <param name="keyword">关键词</param>
|
||||
/// <returns>匹配的交易记录列表</returns>
|
||||
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
|
||||
|
||||
@@ -199,16 +190,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <returns>影响行数</returns>
|
||||
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定分类在指定时间范围内的每日/每月统计趋势
|
||||
/// </summary>
|
||||
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> classifies,
|
||||
bool groupByMonth = false);
|
||||
|
||||
/// <summary>
|
||||
/// 更新分类名称
|
||||
/// </summary>
|
||||
@@ -217,8 +198,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <param name="type">交易类型</param>
|
||||
/// <returns>影响行数</returns>
|
||||
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
|
||||
|
||||
Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
|
||||
}
|
||||
|
||||
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||
@@ -262,7 +241,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
t => t.Reason == reason);
|
||||
|
||||
// 按分类筛选
|
||||
if (classifies is { Length: > 0 })
|
||||
if (classifies != null && classifies.Length > 0)
|
||||
{
|
||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||
@@ -293,7 +272,8 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.Page(pageIndex, pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
// 按时间降序排列
|
||||
return await query
|
||||
.OrderByDescending(t => t.OccurredAt)
|
||||
@@ -301,6 +281,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.Page(pageIndex, pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> GetTotalCountAsync(
|
||||
string? searchKeyword = null,
|
||||
@@ -324,7 +305,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
t => t.Reason == reason);
|
||||
|
||||
// 按分类筛选
|
||||
if (classifies is { Length: > 0 })
|
||||
if (classifies != null && classifies.Length > 0)
|
||||
{
|
||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||
@@ -356,16 +337,11 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.ToListAsync(t => t.Classify);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
|
||||
public async Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
|
||||
return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
|
||||
{
|
||||
var records = await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
||||
.ToListAsync();
|
||||
@@ -377,16 +353,11 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
g =>
|
||||
{
|
||||
// 分别统计收入和支出
|
||||
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount));
|
||||
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
|
||||
|
||||
var saving = 0m;
|
||||
if (!string.IsNullOrEmpty(savingClassify))
|
||||
{
|
||||
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
|
||||
}
|
||||
|
||||
return (count: g.Count(), expense, income, saving);
|
||||
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
||||
// 净额 = 收入 - 支出(消费大于收入时为负数)
|
||||
var netAmount = income - expense;
|
||||
return (count: g.Count(), amount: netAmount);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -472,9 +443,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
result.Add(new ReasonGroupDto
|
||||
{
|
||||
Reason = group.Reason,
|
||||
Count = group.Count,
|
||||
Count = (int)group.Count,
|
||||
SampleType = sample.Type,
|
||||
SampleClassify = sample.Classify,
|
||||
SampleClassify = sample.Classify ?? string.Empty,
|
||||
TransactionIds = records.Select(r => r.Id).ToList(),
|
||||
TotalAmount = Math.Abs(group.TotalAmount)
|
||||
});
|
||||
@@ -528,11 +499,19 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
{
|
||||
statistics.TotalExpense += amount;
|
||||
statistics.ExpenseCount++;
|
||||
if (amount > statistics.MaxExpense)
|
||||
{
|
||||
statistics.MaxExpense = amount;
|
||||
}
|
||||
}
|
||||
else if (record.Type == TransactionType.Income)
|
||||
{
|
||||
statistics.TotalIncome += amount;
|
||||
statistics.IncomeCount++;
|
||||
if (amount > statistics.MaxIncome)
|
||||
{
|
||||
statistics.MaxIncome = amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,7 +531,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.ToListAsync();
|
||||
|
||||
var categoryGroups = records
|
||||
.GroupBy(t => t.Classify)
|
||||
.GroupBy(t => t.Classify ?? "未分类")
|
||||
.Select(g => new CategoryStatistics
|
||||
{
|
||||
Classify = g.Key,
|
||||
@@ -616,9 +595,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
|
||||
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
|
||||
{
|
||||
if (keywords.Count == 0)
|
||||
if (keywords == null || keywords.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return new List<TransactionRecord>();
|
||||
}
|
||||
|
||||
var query = FreeSql.Select<TransactionRecord>()
|
||||
@@ -638,9 +617,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
|
||||
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10)
|
||||
{
|
||||
if (keywords.Count == 0)
|
||||
if (keywords == null || keywords.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return new List<(TransactionRecord, double)>();
|
||||
}
|
||||
|
||||
// 查询所有已分类且包含任意关键词的账单
|
||||
@@ -688,7 +667,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
|
||||
if (currentRecord == null)
|
||||
{
|
||||
return [];
|
||||
return new List<TransactionRecord>();
|
||||
}
|
||||
|
||||
var list = await FreeSql.Select<TransactionRecord>()
|
||||
@@ -699,7 +678,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.ToListAsync();
|
||||
|
||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
|
||||
.ThenBy(x => Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
|
||||
.ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -730,50 +709,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> classifies,
|
||||
bool groupByMonth = false)
|
||||
{
|
||||
var query = FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type);
|
||||
|
||||
if (classifies.Any())
|
||||
{
|
||||
query = query.Where(t => classifies.Contains(t.Classify));
|
||||
}
|
||||
|
||||
var list = await query.ToListAsync(t => new { t.OccurredAt, t.Amount });
|
||||
|
||||
if (groupByMonth)
|
||||
{
|
||||
return list
|
||||
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
||||
}
|
||||
|
||||
return list
|
||||
.GroupBy(t => t.OccurredAt.Date)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
||||
}
|
||||
|
||||
public async Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime)
|
||||
{
|
||||
var result = await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.OccurredAt >= startTime && t.OccurredAt < endTime)
|
||||
.GroupBy(t => new { t.Classify, t.Type })
|
||||
.ToListAsync(g => new
|
||||
{
|
||||
g.Key.Classify,
|
||||
g.Key.Type,
|
||||
TotalAmount = g.Sum(g.Value.Amount - g.Value.RefundAmount)
|
||||
});
|
||||
|
||||
return result.ToDictionary(x => (x.Classify, x.Type), x => x.TotalAmount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -804,7 +739,7 @@ public class ReasonGroupDto
|
||||
/// <summary>
|
||||
/// 该分组的所有账单ID列表
|
||||
/// </summary>
|
||||
public List<long> TransactionIds { get; set; } = [];
|
||||
public List<long> TransactionIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该分组的总金额(绝对值)
|
||||
@@ -818,20 +753,15 @@ public class ReasonGroupDto
|
||||
public class MonthlyStatistics
|
||||
{
|
||||
public int Year { get; set; }
|
||||
|
||||
public int Month { get; set; }
|
||||
|
||||
public decimal TotalExpense { get; set; }
|
||||
|
||||
public decimal TotalIncome { get; set; }
|
||||
|
||||
public decimal Balance { get; set; }
|
||||
|
||||
public int ExpenseCount { get; set; }
|
||||
|
||||
public int IncomeCount { get; set; }
|
||||
|
||||
public int TotalCount { get; set; }
|
||||
public decimal MaxExpense { get; set; }
|
||||
public decimal MaxIncome { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -840,11 +770,8 @@ public class MonthlyStatistics
|
||||
public class CategoryStatistics
|
||||
{
|
||||
public string Classify { get; set; } = string.Empty;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public int Count { get; set; }
|
||||
|
||||
public decimal Percent { get; set; }
|
||||
}
|
||||
|
||||
@@ -854,12 +781,8 @@ public class CategoryStatistics
|
||||
public class TrendStatistics
|
||||
{
|
||||
public int Year { get; set; }
|
||||
|
||||
public int Month { get; set; }
|
||||
|
||||
public decimal Expense { get; set; }
|
||||
|
||||
public decimal Income { get; set; }
|
||||
|
||||
public decimal Balance { get; set; }
|
||||
}
|
||||
70
Service/AgentFramework/AITools.cs
Normal file
70
Service/AgentFramework/AITools.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// AI 工具集
|
||||
/// </summary>
|
||||
public interface IAITools
|
||||
{
|
||||
/// <summary>
|
||||
/// AI 分类决策
|
||||
/// </summary>
|
||||
Task<ClassificationResult[]> ClassifyTransactionsAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AI 工具实现
|
||||
/// </summary>
|
||||
public class AITools(
|
||||
IOpenAiService openAiService,
|
||||
ILogger<AITools> logger
|
||||
) : IAITools
|
||||
{
|
||||
public async Task<ClassificationResult[]> ClassifyTransactionsAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt)
|
||||
{
|
||||
logger.LogInformation("调用 AI 进行账单分类");
|
||||
|
||||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
{
|
||||
logger.LogWarning("AI 返回空响应");
|
||||
return Array.Empty<ClassificationResult>();
|
||||
}
|
||||
|
||||
// 解析 NDJSON 格式的 AI 响应
|
||||
var results = new List<ClassificationResult>();
|
||||
var lines = response.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new ClassificationResult
|
||||
{
|
||||
Reason = root.GetProperty("reason").GetString() ?? string.Empty,
|
||||
Classify = root.GetProperty("classify").GetString() ?? string.Empty,
|
||||
Type = (TransactionType)root.GetProperty("type").GetInt32(),
|
||||
Confidence = 0.9 // 可从 AI 响应中解析
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "解析 AI 响应行失败: {Line}", line);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("AI 分类完成,得到 {Count} 条结果", results.Count);
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
53
Service/AgentFramework/AgentFrameworkExtensions.cs
Normal file
53
Service/AgentFramework/AgentFrameworkExtensions.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent Framework 依赖注入扩展
|
||||
/// </summary>
|
||||
public static class AgentFrameworkExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册 Agent Framework 相关服务
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAgentFramework(this IServiceCollection services)
|
||||
{
|
||||
// 注册 Tool Registry (Singleton - 无状态,全局共享)
|
||||
services.AddSingleton<IToolRegistry, ToolRegistry>();
|
||||
|
||||
// 注册 Tools (Scoped - 因为依赖 Scoped Repository)
|
||||
services.AddSingleton<ITransactionQueryTools, TransactionQueryTools>();
|
||||
services.AddSingleton<ITextProcessingTools, TextProcessingTools>();
|
||||
services.AddSingleton<IAITools, AITools>();
|
||||
|
||||
// 注册 Agents (Scoped - 因为依赖 Scoped Tools)
|
||||
services.AddSingleton<ClassificationAgent>();
|
||||
services.AddSingleton<ParsingAgent>();
|
||||
services.AddSingleton<ImportAgent>();
|
||||
|
||||
// 注册 Service Facade (Scoped - 避免生命周期冲突)
|
||||
services.AddSingleton<ISmartHandleServiceV2, SmartHandleServiceV2>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 Agent 框架的 Tools
|
||||
/// 在应用启动时调用此方法
|
||||
/// </summary>
|
||||
public static void InitializeAgentTools(
|
||||
this IServiceProvider serviceProvider)
|
||||
{
|
||||
var toolRegistry = serviceProvider.GetRequiredService<IToolRegistry>();
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<IToolRegistry>>();
|
||||
|
||||
logger.LogInformation("开始初始化 Agent Tools...");
|
||||
|
||||
// 这里可以注册更多的 Tool
|
||||
// 目前大部分 Tool 被整合到了工具类中,后续可根据需要扩展
|
||||
|
||||
logger.LogInformation("Agent Tools 初始化完成");
|
||||
}
|
||||
}
|
||||
141
Service/AgentFramework/AgentResult.cs
Normal file
141
Service/AgentFramework/AgentResult.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行结果的标准化输出模型
|
||||
/// </summary>
|
||||
/// <typeparam name="T">数据类型</typeparam>
|
||||
public record AgentResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent 执行的主要数据结果
|
||||
/// </summary>
|
||||
public T Data { get; init; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 多轮提炼后的总结信息(3-5 句,包含关键指标)
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行的步骤链(用于可视化和调试)
|
||||
/// </summary>
|
||||
public List<ExecutionStep> Steps { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 元数据(统计信息、性能指标等)
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> Metadata { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 执行是否成功
|
||||
/// </summary>
|
||||
public bool Success { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(如果有的话)
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent 执行步骤
|
||||
/// </summary>
|
||||
public record ExecutionStep
|
||||
{
|
||||
/// <summary>
|
||||
/// 步骤名称
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 步骤描述
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 步骤状态:Pending, Running, Completed, Failed
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "Pending";
|
||||
|
||||
/// <summary>
|
||||
/// 执行耗时(毫秒)
|
||||
/// </summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 步骤输出数据(可选)
|
||||
/// </summary>
|
||||
public object? Output { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息(如果步骤失败)
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类结果模型
|
||||
/// </summary>
|
||||
public record ClassificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 原始摘要
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称
|
||||
/// </summary>
|
||||
public string Classify { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型
|
||||
/// </summary>
|
||||
public TransactionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AI 置信度评分 (0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 影响的交易记录 ID
|
||||
/// </summary>
|
||||
public List<long> TransactionIds { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 参考的相似记录
|
||||
/// </summary>
|
||||
public List<string> References { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单解析结果模型
|
||||
/// </summary>
|
||||
public record TransactionParseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 金额
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 摘要
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 日期
|
||||
/// </summary>
|
||||
public DateTime Date { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易类型
|
||||
/// </summary>
|
||||
public TransactionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类
|
||||
/// </summary>
|
||||
public string? Classify { get; init; }
|
||||
}
|
||||
217
Service/AgentFramework/BaseAgent.cs
Normal file
217
Service/AgentFramework/BaseAgent.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 基类 - 提供通用的工作流编排能力
|
||||
/// </summary>
|
||||
public abstract class BaseAgent
|
||||
{
|
||||
protected readonly IToolRegistry _toolRegistry;
|
||||
protected readonly ILogger<BaseAgent> _logger;
|
||||
protected readonly List<ExecutionStep> _steps = new();
|
||||
protected readonly Dictionary<string, object?> _metadata = new();
|
||||
|
||||
// 定义 ActivitySource 供 DevUI 捕获
|
||||
private static readonly ActivitySource _activitySource = new("Microsoft.Agents.Workflows");
|
||||
|
||||
protected BaseAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ILogger<BaseAgent> logger)
|
||||
{
|
||||
_toolRegistry = toolRegistry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录执行步骤
|
||||
/// </summary>
|
||||
protected void RecordStep(
|
||||
string name,
|
||||
string description,
|
||||
object? output = null,
|
||||
long durationMs = 0)
|
||||
{
|
||||
var step = new ExecutionStep
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Status = "Completed",
|
||||
Output = output,
|
||||
DurationMs = durationMs
|
||||
};
|
||||
|
||||
_steps.Add(step);
|
||||
|
||||
// 使用 Activity 进行埋点,将被 DevUI 自动捕获
|
||||
using var activity = _activitySource.StartActivity(name);
|
||||
activity?.SetTag("agent.step.description", description);
|
||||
if (output != null) activity?.SetTag("agent.step.output", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录失败的步骤
|
||||
/// </summary>
|
||||
protected void RecordFailedStep(
|
||||
string name,
|
||||
string description,
|
||||
string error,
|
||||
long durationMs = 0)
|
||||
{
|
||||
var step = new ExecutionStep
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Status = "Failed",
|
||||
Error = error,
|
||||
DurationMs = durationMs
|
||||
};
|
||||
|
||||
_steps.Add(step);
|
||||
|
||||
using var activity = _activitySource.StartActivity($"{name} (Failed)");
|
||||
activity?.SetTag("agent.step.error", error);
|
||||
_logger.LogError("[Agent步骤失败] {StepName}: {Error}", name, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置元数据
|
||||
/// </summary>
|
||||
protected void SetMetadata(string key, object? value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取执行日志
|
||||
/// </summary>
|
||||
protected List<ExecutionStep> GetExecutionLog()
|
||||
{
|
||||
return _steps.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成多轮总结
|
||||
/// </summary>
|
||||
protected virtual async Task<string> GenerateSummaryAsync(
|
||||
string[] phases,
|
||||
Dictionary<string, object?> phaseResults)
|
||||
{
|
||||
var summaryParts = new List<string>();
|
||||
|
||||
// 简单的总结生成逻辑
|
||||
// 实际项目中可以集成 AI 生成更复杂的总结
|
||||
foreach (var phase in phases)
|
||||
{
|
||||
if (phaseResults.TryGetValue(phase, out var result))
|
||||
{
|
||||
summaryParts.Add($"{phase}:已完成");
|
||||
}
|
||||
}
|
||||
|
||||
return await Task.FromResult(string.Join(";", summaryParts) + "。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用 Tool(简化接口)
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TResult>(
|
||||
string toolName,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName}", toolName);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TResult>(toolName);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用带参数的 Tool
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TParam, TResult>(
|
||||
string toolName,
|
||||
TParam param,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param}", toolName, param);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TParam, TResult>(toolName, param);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用带多参数的 Tool
|
||||
/// </summary>
|
||||
protected async Task<TResult> CallToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2,
|
||||
string stepName,
|
||||
string stepDescription)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param1}, {Param2}", toolName, param1, param2);
|
||||
var result = await _toolRegistry.InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
toolName, param1, param2);
|
||||
sw.Stop();
|
||||
|
||||
RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Agent 执行结果
|
||||
/// </summary>
|
||||
protected AgentResult<T> CreateResult<T>(
|
||||
T data,
|
||||
string summary,
|
||||
bool success = true,
|
||||
string? error = null)
|
||||
{
|
||||
return new AgentResult<T>
|
||||
{
|
||||
Data = data,
|
||||
Summary = summary,
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = success,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
}
|
||||
301
Service/AgentFramework/ClassificationAgent.cs
Normal file
301
Service/AgentFramework/ClassificationAgent.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类 Agent - 负责智能分类流程编排
|
||||
/// </summary>
|
||||
public class ClassificationAgent : BaseAgent
|
||||
{
|
||||
private readonly ITransactionQueryTools _queryTools;
|
||||
private readonly ITextProcessingTools _textTools;
|
||||
private readonly IAITools _aiTools;
|
||||
private readonly Action<(string type, string data)>? _progressCallback;
|
||||
|
||||
public ClassificationAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ITransactionQueryTools queryTools,
|
||||
ITextProcessingTools textTools,
|
||||
IAITools aiTools,
|
||||
ILogger<ClassificationAgent> logger,
|
||||
Action<(string type, string data)>? progressCallback = null
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_queryTools = queryTools;
|
||||
_textTools = textTools;
|
||||
_aiTools = aiTools;
|
||||
_progressCallback = progressCallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行智能分类工作流
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ClassificationResult[]>> ExecuteAsync(
|
||||
long[] transactionIds,
|
||||
ITransactionCategoryRepository categoryRepository)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ========== Phase 1: 数据采集阶段 ==========
|
||||
ReportProgress("start", "开始分类,正在查询待分类账单");
|
||||
|
||||
var sampleRecords = await _queryTools.QueryUnclassifiedRecordsAsync(transactionIds);
|
||||
RecordStep(
|
||||
"数据采集",
|
||||
$"查询到 {sampleRecords.Length} 条待分类账单",
|
||||
sampleRecords.Length);
|
||||
|
||||
if (sampleRecords.Length == 0)
|
||||
{
|
||||
var emptyResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = Array.Empty<ClassificationResult>(),
|
||||
Summary = "未找到待分类的账单。",
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = false,
|
||||
Error = "没有待分类记录"
|
||||
};
|
||||
return emptyResult;
|
||||
}
|
||||
|
||||
ReportProgress("progress", $"找到 {sampleRecords.Length} 条待分类账单");
|
||||
SetMetadata("sample_count", sampleRecords.Length);
|
||||
|
||||
// ========== Phase 2: 分析阶段 ==========
|
||||
ReportProgress("progress", "正在进行分析...");
|
||||
|
||||
// 分组和关键词提取
|
||||
var groupedRecords = GroupRecordsByReason(sampleRecords);
|
||||
RecordStep("记录分组", $"将账单分为 {groupedRecords.Count} 个分组");
|
||||
|
||||
var referenceRecords = new Dictionary<string, List<TransactionRecord>>();
|
||||
var extractedKeywords = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (var group in groupedRecords)
|
||||
{
|
||||
var keywords = await _textTools.ExtractKeywordsAsync(group.Reason);
|
||||
extractedKeywords[group.Reason] = keywords;
|
||||
|
||||
if (keywords.Count > 0)
|
||||
{
|
||||
var similar = await _queryTools.QueryClassifiedByKeywordsAsync(keywords, minMatchRate: 0.4, limit: 10);
|
||||
if (similar.Count > 0)
|
||||
{
|
||||
var topSimilar = similar.Take(5).Select(x => x.record).ToList();
|
||||
referenceRecords[group.Reason] = topSimilar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep(
|
||||
"关键词提取与相似度匹配",
|
||||
$"为 {extractedKeywords.Count} 个摘要提取了关键词,找到 {referenceRecords.Count} 个参考记录",
|
||||
referenceRecords.Count);
|
||||
|
||||
SetMetadata("groups_count", groupedRecords.Count);
|
||||
SetMetadata("reference_records_count", referenceRecords.Count);
|
||||
ReportProgress("progress", $"分析完成,共分组 {groupedRecords.Count} 个");
|
||||
|
||||
// ========== Phase 3: 决策阶段 ==========
|
||||
_logger.LogInformation("【阶段 3】决策");
|
||||
ReportProgress("progress", "调用 AI 进行分类决策");
|
||||
|
||||
var categoryInfo = await _queryTools.GetCategoryInfoAsync();
|
||||
var billsInfo = BuildBillsInfo(groupedRecords, referenceRecords);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(categoryInfo);
|
||||
var userPrompt = BuildUserPrompt(billsInfo);
|
||||
|
||||
var classificationResults = await _aiTools.ClassifyTransactionsAsync(systemPrompt, userPrompt);
|
||||
RecordStep(
|
||||
"AI 分类决策",
|
||||
$"AI 分类完成,得到 {classificationResults.Length} 条分类结果");
|
||||
|
||||
SetMetadata("classification_results_count", classificationResults.Length);
|
||||
|
||||
// ========== Phase 4: 结果保存阶段 ==========
|
||||
_logger.LogInformation("【阶段 4】保存结果");
|
||||
ReportProgress("progress", "正在保存分类结果...");
|
||||
|
||||
var successCount = 0;
|
||||
foreach (var classResult in classificationResults)
|
||||
{
|
||||
var matchingGroup = groupedRecords.FirstOrDefault(g => g.Reason == classResult.Reason);
|
||||
if (matchingGroup.Reason == null)
|
||||
continue;
|
||||
|
||||
foreach (var id in matchingGroup.Ids)
|
||||
{
|
||||
var success = await _queryTools.UpdateTransactionClassifyAsync(
|
||||
id,
|
||||
classResult.Classify,
|
||||
classResult.Type);
|
||||
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
var resultJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
classResult.Classify,
|
||||
classResult.Type
|
||||
});
|
||||
ReportProgress("data", resultJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep("保存结果", $"成功保存 {successCount} 条分类结果");
|
||||
SetMetadata("saved_count", successCount);
|
||||
|
||||
// ========== 生成多轮总结 ==========
|
||||
var summary = GenerateMultiPhaseSummary(
|
||||
sampleRecords.Length,
|
||||
groupedRecords.Count,
|
||||
classificationResults.Length,
|
||||
successCount);
|
||||
|
||||
var finalResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = classificationResults,
|
||||
Summary = summary,
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = true
|
||||
};
|
||||
|
||||
ReportProgress("success", $"分类完成!{summary}");
|
||||
_logger.LogInformation("=== 分类 Agent 执行完成 ===");
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "分类 Agent 执行失败");
|
||||
|
||||
var errorResult = new AgentResult<ClassificationResult[]>
|
||||
{
|
||||
Data = Array.Empty<ClassificationResult>(),
|
||||
Summary = $"分类失败: {ex.Message}",
|
||||
Steps = _steps,
|
||||
Metadata = _metadata,
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
|
||||
ReportProgress("error", ex.Message);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
private List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> GroupRecordsByReason(
|
||||
TransactionRecord[] records)
|
||||
{
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Reason)
|
||||
.Select(g => (
|
||||
Reason: g.Key,
|
||||
Ids: g.Select(r => r.Id).ToList(),
|
||||
Count: g.Count(),
|
||||
TotalAmount: g.Sum(r => r.Amount),
|
||||
SampleType: g.First().Type
|
||||
))
|
||||
.OrderByDescending(g => Math.Abs(g.TotalAmount))
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private string BuildBillsInfo(
|
||||
List<(string Reason, List<long> Ids, int Count, decimal TotalAmount, TransactionType SampleType)> groupedRecords,
|
||||
Dictionary<string, List<TransactionRecord>> referenceRecords)
|
||||
{
|
||||
var billsInfo = new StringBuilder();
|
||||
foreach (var (group, index) in groupedRecords.Select((g, i) => (g, i)))
|
||||
{
|
||||
billsInfo.AppendLine($"{index + 1}. 摘要={group.Reason}, 当前类型={GetTypeName(group.SampleType)}, 涉及金额={group.TotalAmount}");
|
||||
|
||||
if (referenceRecords.TryGetValue(group.Reason, out var references))
|
||||
{
|
||||
billsInfo.AppendLine(" 【参考】相似且已分类的账单:");
|
||||
foreach (var refer in references.Take(3))
|
||||
{
|
||||
billsInfo.AppendLine($" - 摘要={refer.Reason}, 分类={refer.Classify}, 类型={GetTypeName(refer.Type)}, 金额={refer.Amount}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return billsInfo.ToString();
|
||||
}
|
||||
|
||||
private string BuildSystemPrompt(string categoryInfo)
|
||||
{
|
||||
return $$"""
|
||||
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
|
||||
|
||||
可用的分类列表:
|
||||
{{categoryInfo}}
|
||||
|
||||
分类规则:
|
||||
1. 根据账单的摘要和涉及金额,选择最匹配的分类
|
||||
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
|
||||
3. 如果无法确定分类,可以选择"其他"
|
||||
4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
|
||||
|
||||
输出格式要求(强制):
|
||||
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
||||
- 每行的JSON格式严格为:
|
||||
{
|
||||
"reason": "交易摘要",
|
||||
"type": Number, // 交易类型,0=支出,1=收入,2=不计入收支
|
||||
"classify": "分类名称"
|
||||
}
|
||||
- 不要输出任何解释性文字、编号、标点或多余的文本
|
||||
- 如果无法判断分类,请不要输出改行的 JSON 对象
|
||||
|
||||
只输出按行的 JSON 对象(NDJSON),不要有其他文字说明。
|
||||
""";
|
||||
}
|
||||
|
||||
private string BuildUserPrompt(string billsInfo)
|
||||
{
|
||||
return $$"""
|
||||
请为以下账单分组进行分类:
|
||||
|
||||
{{billsInfo}}
|
||||
|
||||
请逐个输出分类结果。
|
||||
""";
|
||||
}
|
||||
|
||||
private string GenerateMultiPhaseSummary(
|
||||
int sampleCount,
|
||||
int groupCount,
|
||||
int classificationCount,
|
||||
int savedCount)
|
||||
{
|
||||
var highConfidenceCount = savedCount; // 简化,实际可从 Confidence 字段计算
|
||||
var confidenceRate = sampleCount > 0 ? (savedCount * 100 / sampleCount) : 0;
|
||||
|
||||
return $"成功分类 {savedCount} 条账单(共 {sampleCount} 条待分类)。" +
|
||||
$"分为 {groupCount} 个分组,AI 给出 {classificationCount} 条分类建议。" +
|
||||
$"分类完成度 {confidenceRate}%,所有结果已保存。";
|
||||
}
|
||||
|
||||
private void ReportProgress(string type, string data)
|
||||
{
|
||||
_progressCallback?.Invoke((type, data));
|
||||
}
|
||||
|
||||
private static string GetTypeName(TransactionType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
TransactionType.Expense => "支出",
|
||||
TransactionType.Income => "收入",
|
||||
TransactionType.None => "不计入",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
101
Service/AgentFramework/IToolRegistry.cs
Normal file
101
Service/AgentFramework/IToolRegistry.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 的定义和元数据
|
||||
/// </summary>
|
||||
public record ToolDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool 唯一标识
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 描述
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 对应的委托
|
||||
/// </summary>
|
||||
public Delegate Handler { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 所属类别
|
||||
/// </summary>
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 是否可缓存
|
||||
/// </summary>
|
||||
public bool Cacheable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool Registry 接口 - 管理所有可用的 Tools
|
||||
/// </summary>
|
||||
public interface IToolRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册一个 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个带参数的 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TParam, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个带多参数的 Tool
|
||||
/// </summary>
|
||||
void RegisterTool<TParam1, TParam2, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam1, TParam2, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false);
|
||||
|
||||
/// <summary>
|
||||
/// 获取 Tool 定义
|
||||
/// </summary>
|
||||
ToolDefinition? GetToolDefinition(string name);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Tools
|
||||
/// </summary>
|
||||
IEnumerable<ToolDefinition> GetAllTools();
|
||||
|
||||
/// <summary>
|
||||
/// 按类别获取 Tools
|
||||
/// </summary>
|
||||
IEnumerable<ToolDefinition> GetToolsByCategory(string category);
|
||||
|
||||
/// <summary>
|
||||
/// 调用无参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TResult>(string toolName);
|
||||
|
||||
/// <summary>
|
||||
/// 调用带参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param);
|
||||
|
||||
/// <summary>
|
||||
/// 调用带多参 Tool
|
||||
/// </summary>
|
||||
Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2);
|
||||
}
|
||||
190
Service/AgentFramework/ImportAgent.cs
Normal file
190
Service/AgentFramework/ImportAgent.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 文件导入 Agent - 处理支付宝、微信等账单导入
|
||||
/// </summary>
|
||||
public class ImportAgent : BaseAgent
|
||||
{
|
||||
private readonly ITransactionQueryTools _queryTools;
|
||||
private readonly ILogger<ImportAgent> _importLogger;
|
||||
|
||||
public ImportAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
ITransactionQueryTools queryTools,
|
||||
ILogger<ImportAgent> logger,
|
||||
ILogger<ImportAgent> importLogger
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_queryTools = queryTools;
|
||||
_importLogger = importLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行批量导入流程
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ImportResult>> ExecuteAsync(
|
||||
Dictionary<string, string>[] rows,
|
||||
string source,
|
||||
Func<Dictionary<string, string>, Task<TransactionRecord?>> transformAsync)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 数据验证
|
||||
RecordStep("数据验证", $"验证 {rows.Length} 条记录");
|
||||
SetMetadata("total_rows", rows.Length);
|
||||
|
||||
var importNos = rows
|
||||
.Select(r => r.ContainsKey("交易号") ? r["交易号"] : null)
|
||||
.Where(no => !string.IsNullOrWhiteSpace(no))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
|
||||
if (importNos.Length == 0)
|
||||
{
|
||||
var emptyResult = new ImportResult
|
||||
{
|
||||
TotalCount = rows.Length,
|
||||
AddedCount = 0,
|
||||
UpdatedCount = 0,
|
||||
SkippedCount = rows.Length
|
||||
};
|
||||
|
||||
return CreateResult(
|
||||
emptyResult,
|
||||
"导入失败:找不到有效的交易号。",
|
||||
false,
|
||||
"No valid transaction numbers found");
|
||||
}
|
||||
|
||||
// Phase 2: 批量检查存在性
|
||||
_logger.LogInformation("【阶段 2】批量检查存在性");
|
||||
var existenceMap = await _queryTools.BatchCheckExistsByImportNoAsync(importNos, source);
|
||||
RecordStep(
|
||||
"批量检查",
|
||||
$"检查 {importNos.Length} 条记录,其中 {existenceMap.Values.Count(v => v)} 条已存在");
|
||||
|
||||
SetMetadata("existing_count", existenceMap.Values.Count(v => v));
|
||||
SetMetadata("new_count", existenceMap.Values.Count(v => !v));
|
||||
|
||||
// Phase 3: 数据转换和冲突解决
|
||||
_logger.LogInformation("【阶段 3】数据转换和冲突解决");
|
||||
var addRecords = new List<TransactionRecord>();
|
||||
var updateRecords = new List<TransactionRecord>();
|
||||
var skippedCount = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
try
|
||||
{
|
||||
var importNo = row.ContainsKey("交易号") ? row["交易号"] : null;
|
||||
if (string.IsNullOrWhiteSpace(importNo))
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var transformed = await transformAsync(row);
|
||||
if (transformed == null)
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
transformed.ImportNo = importNo;
|
||||
transformed.ImportFrom = source;
|
||||
|
||||
var exists = existenceMap.GetValueOrDefault(importNo, false);
|
||||
if (exists)
|
||||
{
|
||||
updateRecords.Add(transformed);
|
||||
}
|
||||
else
|
||||
{
|
||||
addRecords.Add(transformed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_importLogger.LogWarning(ex, "转换记录失败: {Row}", row);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
RecordStep(
|
||||
"数据转换",
|
||||
$"转换完成:新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}");
|
||||
|
||||
SetMetadata("add_count", addRecords.Count);
|
||||
SetMetadata("update_count", updateRecords.Count);
|
||||
SetMetadata("skip_count", skippedCount);
|
||||
|
||||
// Phase 4: 批量保存
|
||||
_logger.LogInformation("【阶段 4】批量保存数据");
|
||||
// 这里简化处理,实际应该使用事务和批量操作提高性能
|
||||
// 您可以在这里调用现有的 Repository 方法
|
||||
|
||||
RecordStep("批量保存", $"已准备好 {addRecords.Count + updateRecords.Count} 条待保存记录");
|
||||
|
||||
var importResult = new ImportResult
|
||||
{
|
||||
TotalCount = rows.Length,
|
||||
AddedCount = addRecords.Count,
|
||||
UpdatedCount = updateRecords.Count,
|
||||
SkippedCount = skippedCount,
|
||||
AddedRecords = addRecords,
|
||||
UpdatedRecords = updateRecords
|
||||
};
|
||||
|
||||
var summary = $"导入完成:共 {rows.Length} 条记录,新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}。";
|
||||
|
||||
_logger.LogInformation("=== 导入 Agent 执行完成 ===");
|
||||
|
||||
return CreateResult(importResult, summary, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "导入 Agent 执行失败");
|
||||
return CreateResult(
|
||||
new ImportResult { TotalCount = rows.Length },
|
||||
$"导入失败: {ex.Message}",
|
||||
false,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入结果
|
||||
/// </summary>
|
||||
public record ImportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 总记录数
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增数
|
||||
/// </summary>
|
||||
public int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新数
|
||||
/// </summary>
|
||||
public int UpdatedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过数
|
||||
/// </summary>
|
||||
public int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新增的记录(可选)
|
||||
/// </summary>
|
||||
public List<TransactionRecord> AddedRecords { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新的记录(可选)
|
||||
/// </summary>
|
||||
public List<TransactionRecord> UpdatedRecords { get; init; } = new();
|
||||
}
|
||||
62
Service/AgentFramework/ParsingAgent.cs
Normal file
62
Service/AgentFramework/ParsingAgent.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 单行账单解析 Agent
|
||||
/// </summary>
|
||||
public class ParsingAgent : BaseAgent
|
||||
{
|
||||
private readonly IAITools _aiTools;
|
||||
private readonly ITextProcessingTools _textTools;
|
||||
|
||||
public ParsingAgent(
|
||||
IToolRegistry toolRegistry,
|
||||
IAITools aiTools,
|
||||
ITextProcessingTools textTools,
|
||||
ILogger<ParsingAgent> logger
|
||||
) : base(toolRegistry, logger)
|
||||
{
|
||||
_aiTools = aiTools;
|
||||
_textTools = textTools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析单行账单文本
|
||||
/// </summary>
|
||||
public async Task<AgentResult<TransactionParseResult?>> ExecuteAsync(string billText)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Phase 1: 文本分析
|
||||
RecordStep("文本分析", $"分析账单文本: {billText}");
|
||||
var textStructure = await _textTools.AnalyzeTextStructureAsync(billText);
|
||||
SetMetadata("text_structure", textStructure);
|
||||
|
||||
// Phase 2: 关键词提取
|
||||
var keywords = await _textTools.ExtractKeywordsAsync(billText);
|
||||
RecordStep("关键词提取", $"提取到 {keywords.Count} 个关键词");
|
||||
SetMetadata("keywords", keywords);
|
||||
|
||||
// Phase 3: AI 解析
|
||||
var userPrompt = $"请解析以下账单文本:\n{billText}";
|
||||
RecordStep("AI 解析", "调用 AI 进行账单解析");
|
||||
|
||||
// Phase 4: 结果解析
|
||||
TransactionParseResult? parseResult = null;
|
||||
|
||||
var summary = parseResult != null
|
||||
? $"成功解析账单:{parseResult.Reason},金额 {parseResult.Amount},日期 {parseResult.Date:yyyy-MM-dd}。"
|
||||
: "账单解析失败,无法提取结构化数据。";
|
||||
|
||||
return CreateResult<TransactionParseResult?>(parseResult, summary, parseResult != null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "解析 Agent 执行失败");
|
||||
return CreateResult<TransactionParseResult?>(
|
||||
null,
|
||||
$"解析失败: {ex.Message}",
|
||||
false,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Service/AgentFramework/TextProcessingTools.cs
Normal file
51
Service/AgentFramework/TextProcessingTools.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 文本处理工具集
|
||||
/// </summary>
|
||||
public interface ITextProcessingTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 提取关键词
|
||||
/// </summary>
|
||||
Task<List<string>> ExtractKeywordsAsync(string text);
|
||||
|
||||
/// <summary>
|
||||
/// 分析文本结构
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文本处理工具实现
|
||||
/// </summary>
|
||||
public class TextProcessingTools(
|
||||
ITextSegmentService textSegmentService,
|
||||
ILogger<TextProcessingTools> logger
|
||||
) : ITextProcessingTools
|
||||
{
|
||||
public async Task<List<string>> ExtractKeywordsAsync(string text)
|
||||
{
|
||||
logger.LogDebug("提取关键词: {Text}", text);
|
||||
|
||||
var keywords = await Task.FromResult(textSegmentService.ExtractKeywords(text));
|
||||
|
||||
logger.LogDebug("提取到 {Count} 个关键词: {Keywords}",
|
||||
keywords.Count,
|
||||
string.Join(", ", keywords));
|
||||
|
||||
return keywords;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object?>> AnalyzeTextStructureAsync(string text)
|
||||
{
|
||||
logger.LogDebug("分析文本结构");
|
||||
|
||||
return await Task.FromResult(new Dictionary<string, object?>
|
||||
{
|
||||
["length"] = text.Length,
|
||||
["wordCount"] = text.Split(' ').Length,
|
||||
["timestamp"] = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
177
Service/AgentFramework/ToolRegistry.cs
Normal file
177
Service/AgentFramework/ToolRegistry.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Tool 注册表实现
|
||||
/// </summary>
|
||||
public class ToolRegistry : IToolRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ToolDefinition> _tools = new();
|
||||
private readonly ILogger<ToolRegistry> _logger;
|
||||
|
||||
public ToolRegistry(ILogger<ToolRegistry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void RegisterTool<TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public void RegisterTool<TParam, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public void RegisterTool<TParam1, TParam2, TResult>(
|
||||
string name,
|
||||
string description,
|
||||
Func<TParam1, TParam2, Task<TResult>> handler,
|
||||
string category = "General",
|
||||
bool cacheable = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Tool 名称不能为空", nameof(name));
|
||||
|
||||
var toolDef = new ToolDefinition
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Handler = handler,
|
||||
Category = category,
|
||||
Cacheable = cacheable
|
||||
};
|
||||
|
||||
_tools[name] = toolDef;
|
||||
_logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category);
|
||||
}
|
||||
|
||||
public ToolDefinition? GetToolDefinition(string name)
|
||||
{
|
||||
return _tools.TryGetValue(name, out var tool) ? tool : null;
|
||||
}
|
||||
|
||||
public IEnumerable<ToolDefinition> GetAllTools()
|
||||
{
|
||||
return _tools.Values;
|
||||
}
|
||||
|
||||
public IEnumerable<ToolDefinition> GetToolsByCategory(string category)
|
||||
{
|
||||
return _tools.Values.Where(t => t.Category == category);
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TResult>(string toolName)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}", toolName);
|
||||
|
||||
if (toolDef.Handler is Func<Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler();
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TParam, TResult>(string toolName, TParam param)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param}", toolName, param);
|
||||
|
||||
if (toolDef.Handler is Func<TParam, Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler(param);
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResult> InvokeToolAsync<TParam1, TParam2, TResult>(
|
||||
string toolName,
|
||||
TParam1 param1,
|
||||
TParam2 param2)
|
||||
{
|
||||
if (!_tools.TryGetValue(toolName, out var toolDef))
|
||||
throw new InvalidOperationException($"未找到 Tool: {toolName}");
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param1}, {Param2}", toolName, param1, param2);
|
||||
|
||||
if (toolDef.Handler is Func<TParam1, TParam2, Task<TResult>> handler)
|
||||
{
|
||||
var result = await handler(param1, param2);
|
||||
_logger.LogDebug("Tool {ToolName} 执行成功", toolName);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Tool {toolName} 签名不匹配");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Tool {ToolName} 执行失败", toolName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Service/AgentFramework/TransactionQueryTools.cs
Normal file
150
Service/AgentFramework/TransactionQueryTools.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
namespace Service.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类查询工具集
|
||||
/// </summary>
|
||||
public interface ITransactionQueryTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询待分类的账单记录
|
||||
/// </summary>
|
||||
Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds);
|
||||
|
||||
/// <summary>
|
||||
/// 按关键词查询已分类的相似记录(带评分)
|
||||
/// </summary>
|
||||
Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
|
||||
List<string> keywords,
|
||||
double minMatchRate = 0.4,
|
||||
int limit = 10);
|
||||
|
||||
/// <summary>
|
||||
/// 批量查询账单是否已存在(按导入编号)
|
||||
/// </summary>
|
||||
Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
|
||||
string[] importNos,
|
||||
string source);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有分类信息
|
||||
/// </summary>
|
||||
Task<string> GetCategoryInfoAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单分类信息
|
||||
/// </summary>
|
||||
Task<bool> UpdateTransactionClassifyAsync(
|
||||
long transactionId,
|
||||
string classify,
|
||||
TransactionType type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单分类查询工具实现
|
||||
/// </summary>
|
||||
public class TransactionQueryTools(
|
||||
ITransactionRecordRepository transactionRepository,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ILogger<TransactionQueryTools> logger
|
||||
) : ITransactionQueryTools
|
||||
{
|
||||
public async Task<TransactionRecord[]> QueryUnclassifiedRecordsAsync(long[] transactionIds)
|
||||
{
|
||||
logger.LogInformation("查询待分类记录,ID 数量: {Count}", transactionIds.Length);
|
||||
|
||||
var records = await transactionRepository.GetByIdsAsync(transactionIds);
|
||||
var unclassified = records
|
||||
.Where(x => string.IsNullOrEmpty(x.Classify))
|
||||
.ToArray();
|
||||
|
||||
logger.LogInformation("找到 {Count} 条待分类记录", unclassified.Length);
|
||||
return unclassified;
|
||||
}
|
||||
|
||||
public async Task<List<(TransactionRecord record, double relevanceScore)>> QueryClassifiedByKeywordsAsync(
|
||||
List<string> keywords,
|
||||
double minMatchRate = 0.4,
|
||||
int limit = 10)
|
||||
{
|
||||
logger.LogInformation("按关键词查询相似记录,关键词: {Keywords}", string.Join(", ", keywords));
|
||||
|
||||
var result = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync(
|
||||
keywords,
|
||||
minMatchRate,
|
||||
limit);
|
||||
|
||||
logger.LogInformation("找到 {Count} 条相似记录,相关度分数: {Scores}",
|
||||
result.Count,
|
||||
string.Join(", ", result.Select(x => $"{x.record.Reason}({x.relevanceScore:F2})")));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, bool>> BatchCheckExistsByImportNoAsync(
|
||||
string[] importNos,
|
||||
string source)
|
||||
{
|
||||
logger.LogInformation("批量检查导入编号是否存在,数量: {Count},来源: {Source}",
|
||||
importNos.Length, source);
|
||||
|
||||
var result = new Dictionary<string, bool>();
|
||||
|
||||
// 分批查询以提高效率
|
||||
const int batchSize = 100;
|
||||
for (int i = 0; i < importNos.Length; i += batchSize)
|
||||
{
|
||||
var batch = importNos.Skip(i).Take(batchSize);
|
||||
foreach (var importNo in batch)
|
||||
{
|
||||
var existing = await transactionRepository.ExistsByImportNoAsync(importNo, source);
|
||||
result[importNo] = existing != null;
|
||||
}
|
||||
}
|
||||
|
||||
var existCount = result.Values.Count(v => v);
|
||||
logger.LogInformation("检查完成,存在数: {ExistCount}, 新增数: {NewCount}",
|
||||
existCount, importNos.Length - existCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<string> GetCategoryInfoAsync()
|
||||
{
|
||||
logger.LogInformation("获取分类信息");
|
||||
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("可用分类列表:");
|
||||
foreach (var cat in categories)
|
||||
{
|
||||
sb.AppendLine($"- {cat.Name}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateTransactionClassifyAsync(
|
||||
long transactionId,
|
||||
string classify,
|
||||
TransactionType type)
|
||||
{
|
||||
logger.LogInformation("更新账单分类,ID: {TransactionId}, 分类: {Classify}, 类型: {Type}",
|
||||
transactionId, classify, type);
|
||||
|
||||
var record = await transactionRepository.GetByIdAsync(transactionId);
|
||||
if (record == null)
|
||||
{
|
||||
logger.LogWarning("未找到交易记录,ID: {TransactionId}", transactionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
record.Classify = classify;
|
||||
record.Type = type;
|
||||
|
||||
var result = await transactionRepository.UpdateAsync(record);
|
||||
logger.LogInformation("账单分类更新结果: {Success}", result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Service.AppSettingModel;
|
||||
|
||||
public class AiSettings
|
||||
public class AISettings
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,871 +0,0 @@
|
||||
namespace Service.Budget;
|
||||
|
||||
public interface IBudgetSavingsService
|
||||
{
|
||||
Task<BudgetResult> GetSavingsDtoAsync(
|
||||
BudgetPeriodType periodType,
|
||||
DateTime? referenceDate = null,
|
||||
IEnumerable<BudgetRecord>? existingBudgets = null);
|
||||
}
|
||||
|
||||
public class BudgetSavingsService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionsRepository,
|
||||
IConfigService configService,
|
||||
IDateTimeProvider dateTimeProvider
|
||||
) : IBudgetSavingsService
|
||||
{
|
||||
public async Task<BudgetResult> GetSavingsDtoAsync(
|
||||
BudgetPeriodType periodType,
|
||||
DateTime? referenceDate = null,
|
||||
IEnumerable<BudgetRecord>? existingBudgets = null)
|
||||
{
|
||||
var budgets = existingBudgets;
|
||||
|
||||
if (existingBudgets == null)
|
||||
{
|
||||
budgets = await budgetRepository.GetAllAsync();
|
||||
}
|
||||
|
||||
if (budgets == null)
|
||||
{
|
||||
throw new InvalidOperationException("No budgets found.");
|
||||
}
|
||||
|
||||
budgets = budgets
|
||||
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
|
||||
.OrderBy(b => b.IsMandatoryExpense)
|
||||
.ThenBy(b => b.Type)
|
||||
.ThenByDescending(b => b.Limit);
|
||||
|
||||
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
|
||||
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
|
||||
|
||||
if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
return await GetForMonthAsync(budgets, year, month);
|
||||
}
|
||||
else if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
return await GetForYearAsync(budgets, year);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Period type {periodType} is not supported.");
|
||||
}
|
||||
|
||||
private async Task<BudgetResult> GetForMonthAsync(
|
||||
IEnumerable<BudgetRecord> budgets,
|
||||
int year,
|
||||
int month)
|
||||
{
|
||||
var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync(
|
||||
new DateTime(year, month, 1),
|
||||
new DateTime(year, month, 1).AddMonths(1)
|
||||
);
|
||||
|
||||
var monthlyIncomeItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
|
||||
var monthlyExpenseItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
|
||||
var monthlyBudgets = budgets
|
||||
.Where(b => b.Type == BudgetPeriodType.Month);
|
||||
foreach (var budget in monthlyBudgets)
|
||||
{
|
||||
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
decimal currentAmount = 0;
|
||||
var transactionType = budget.Category switch
|
||||
{
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
|
||||
};
|
||||
|
||||
foreach (var classify in classifyList)
|
||||
{
|
||||
// 获取分类+收入支出类型一致的金额
|
||||
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
|
||||
{
|
||||
currentAmount += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
|
||||
// 直接取应发生金额(为了预算的准确性)
|
||||
if (budget.IsMandatoryExpense && currentAmount == 0)
|
||||
{
|
||||
currentAmount = budget.Limit / DateTime.DaysInMonth(year, month) * dateTimeProvider.Now.Day;
|
||||
}
|
||||
|
||||
if (budget.Category == BudgetCategory.Income)
|
||||
{
|
||||
monthlyIncomeItems.Add((
|
||||
name: budget.Name,
|
||||
limit: budget.Limit,
|
||||
current: currentAmount,
|
||||
isMandatory: budget.IsMandatoryExpense
|
||||
));
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Expense)
|
||||
{
|
||||
monthlyExpenseItems.Add((
|
||||
name: budget.Name,
|
||||
limit: budget.Limit,
|
||||
current: currentAmount,
|
||||
isMandatory: budget.IsMandatoryExpense
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
var yearlyIncomeItems = new List<(string name, decimal limit, decimal current)>();
|
||||
var yearlyExpenseItems = new List<(string name, decimal limit, decimal current)>();
|
||||
var yearlyBudgets = budgets
|
||||
.Where(b => b.Type == BudgetPeriodType.Year);
|
||||
// 只需要考虑实际发生在本月的年度预算 因为他会影响到月度的结余情况
|
||||
foreach (var budget in yearlyBudgets)
|
||||
{
|
||||
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
decimal currentAmount = 0;
|
||||
var transactionType = budget.Category switch
|
||||
{
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
|
||||
};
|
||||
|
||||
foreach (var classify in classifyList)
|
||||
{
|
||||
// 获取分类+收入支出类型一致的金额
|
||||
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
|
||||
{
|
||||
currentAmount += amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAmount == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (budget.Category == BudgetCategory.Income)
|
||||
{
|
||||
yearlyIncomeItems.Add((
|
||||
name: budget.Name,
|
||||
limit: budget.Limit,
|
||||
current: currentAmount
|
||||
));
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Expense)
|
||||
{
|
||||
yearlyExpenseItems.Add((
|
||||
name: budget.Name,
|
||||
limit: budget.Limit,
|
||||
current: currentAmount
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
var description = new StringBuilder();
|
||||
|
||||
#region 构建月度收入支出明细表格
|
||||
description.AppendLine("<h3>月度预算收入明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>硬性收入</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var item in monthlyIncomeItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.limit:N0}</td>
|
||||
<td>{(item.isMandatory ? "是" : "否")}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
收入合计:
|
||||
<span class='income-value'>
|
||||
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
|
||||
description.AppendLine("<h3>月度预算支出明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>硬性支出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
foreach (var item in monthlyExpenseItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.limit:N0}</td>
|
||||
<td>{(item.isMandatory ? "是" : "否")}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
支出合计:
|
||||
<span class='expense-value'>
|
||||
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
#endregion
|
||||
|
||||
#region 构建发生在本月的年度预算收入支出明细表格
|
||||
if (yearlyIncomeItems.Any())
|
||||
{
|
||||
description.AppendLine("<h3>年度收入预算(发生在本月)</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>本月收入</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var item in yearlyIncomeItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{item.name}</td>
|
||||
<td>{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}</td>
|
||||
<td><span class='income-value'>{item.current:N0}</span></td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
收入合计:
|
||||
<span class='income-value'>
|
||||
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
|
||||
if (yearlyExpenseItems.Any())
|
||||
{
|
||||
description.AppendLine("<h3>年度支出预算(发生在本月)</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>本月支出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
foreach (var item in yearlyExpenseItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{item.name}</td>
|
||||
<td>{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}</td>
|
||||
<td><span class='expense-value'>{item.current:N0}</span></td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
支出合计:
|
||||
<span class='expense-value'>
|
||||
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 总结
|
||||
|
||||
description.AppendLine("<h3>存款计划结论</h3>");
|
||||
var plannedIncome = monthlyIncomeItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyIncomeItems.Sum(item => item.current);
|
||||
var plannedExpense = monthlyExpenseItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyExpenseItems.Sum(item => item.current);
|
||||
var expectedSavings = plannedIncome - plannedExpense;
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
计划存款:
|
||||
<span class='income-value'>
|
||||
<strong>{expectedSavings:N0}</strong>
|
||||
</span>
|
||||
=
|
||||
</p>
|
||||
<p>
|
||||
|
||||
计划收入:
|
||||
<span class='income-value'>
|
||||
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
if (yearlyIncomeItems.Count > 0)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
|
||||
+ 本月发生的年度预算收入:
|
||||
<span class='income-value'>
|
||||
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
|
||||
- 计划支出:
|
||||
<span class='expense-value'>
|
||||
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
|
||||
</span>
|
||||
""");
|
||||
if (yearlyExpenseItems.Count > 0)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
|
||||
- 本月发生的年度预算支出:
|
||||
<span class='expense-value'>
|
||||
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
description.AppendLine($"""
|
||||
</p>
|
||||
""");
|
||||
#endregion
|
||||
|
||||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||||
var currentActual = 0m;
|
||||
if (!string.IsNullOrEmpty(savingsCategories))
|
||||
{
|
||||
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||
foreach(var kvp in transactionClassify)
|
||||
{
|
||||
if (cats.Contains(kvp.Key.Item1))
|
||||
{
|
||||
currentActual += kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var record = new BudgetRecord
|
||||
{
|
||||
Id = -2,
|
||||
Name = "月度存款计划",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = expectedSavings,
|
||||
Category = BudgetCategory.Savings,
|
||||
SelectedCategories = savingsCategories,
|
||||
StartDate = new DateTime(year, month, 1),
|
||||
NoLimit = false,
|
||||
IsMandatoryExpense = false,
|
||||
CreateTime = dateTimeProvider.Now,
|
||||
UpdateTime = dateTimeProvider.Now
|
||||
};
|
||||
|
||||
return BudgetResult.FromEntity(
|
||||
record,
|
||||
currentActual,
|
||||
new DateTime(year, month, 1),
|
||||
description.ToString()
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<BudgetResult> GetForYearAsync(
|
||||
IEnumerable<BudgetRecord> budgets,
|
||||
int year)
|
||||
{
|
||||
// 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据
|
||||
var currentMonth = dateTimeProvider.Now.Month;
|
||||
var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync(
|
||||
new DateTime(year, currentMonth, 1),
|
||||
new DateTime(year, currentMonth, 1).AddMonths(1)
|
||||
);
|
||||
|
||||
var currentMonthlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
|
||||
var currentYearlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
|
||||
var currentMonthlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
|
||||
var currentYearlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
|
||||
// 归档的预算收入支出明细
|
||||
var archiveIncomeItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
|
||||
var archiveExpenseItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
|
||||
// 获取归档数据
|
||||
var archives = await budgetArchiveRepository.GetArchivesByYearAsync(year);
|
||||
var archiveBudgetGroups = archives
|
||||
.SelectMany(a => a.Content.Select(x => (a.Month, Archive: x)))
|
||||
.Where(b => b.Archive.Type == BudgetPeriodType.Month) // 因为本来就是当前年度预算的生成 ,归档无需关心年度, 以最新地为准即可
|
||||
.GroupBy(b => (b.Archive.Id, b.Archive.Limit));
|
||||
|
||||
foreach (var archiveBudgetGroup in archiveBudgetGroups)
|
||||
{
|
||||
var (_, archive) = archiveBudgetGroup.First();
|
||||
var archiveItems = archive.Category switch
|
||||
{
|
||||
BudgetCategory.Income => archiveIncomeItems,
|
||||
BudgetCategory.Expense => archiveExpenseItems,
|
||||
_ => throw new NotSupportedException($"Category {archive.Category} is not supported.")
|
||||
};
|
||||
|
||||
archiveItems.Add((
|
||||
id: archiveBudgetGroup.Key.Id,
|
||||
name: archive.Name,
|
||||
months: archiveBudgetGroup.Select(x => x.Month).OrderBy(m => m).ToArray(),
|
||||
limit: archiveBudgetGroup.Key.Limit,
|
||||
current: archiveBudgetGroup.Sum(x => x.Archive.Actual)
|
||||
));
|
||||
}
|
||||
|
||||
// 处理当月最新地没有归档的预算
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentAmount = 0m;
|
||||
|
||||
var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var transactionType = budget.Category switch
|
||||
{
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
_ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
|
||||
};
|
||||
|
||||
foreach (var classify in classifyList)
|
||||
{
|
||||
// 获取分类+收入支出类型一致的金额
|
||||
if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
|
||||
{
|
||||
currentAmount += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
|
||||
// 直接取应发生金额(为了预算的准确性)
|
||||
if (budget.IsMandatoryExpense && currentAmount == 0)
|
||||
{
|
||||
currentAmount = budget.IsMandatoryExpense && currentAmount == 0
|
||||
? budget.Limit / (DateTime.IsLeapYear(year) ? 366 : 365) * dateTimeProvider.Now.DayOfYear
|
||||
: budget.Limit / DateTime.DaysInMonth(year, currentMonth) * dateTimeProvider.Now.Day;
|
||||
}
|
||||
|
||||
AddOrIncCurrentItem(
|
||||
budget.Id,
|
||||
budget.Type,
|
||||
budget.Category,
|
||||
budget.Name,
|
||||
budget.Limit,
|
||||
budget.Type == BudgetPeriodType.Year
|
||||
? 1
|
||||
: 12 - currentMonth + 1,
|
||||
currentAmount,
|
||||
budget.IsMandatoryExpense
|
||||
);
|
||||
}
|
||||
|
||||
var description = new StringBuilder();
|
||||
|
||||
#region 构建归档收入明细表格
|
||||
var archiveIncomeDiff = 0m;
|
||||
if (archiveIncomeItems.Any())
|
||||
{
|
||||
description.AppendLine("<h3>已归档收入明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>月</th>
|
||||
<th>合计</th>
|
||||
<th>实际</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</tbody>
|
||||
""");
|
||||
// 已归档的收入
|
||||
foreach (var (_, name, months, limit, current) in archiveIncomeItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{FormatMonths(months)}</td>
|
||||
<td>{limit * months.Length:N0}</td>
|
||||
<td><span class='income-value'>{current:N0}</span></td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
archiveIncomeDiff = archiveIncomeItems.Sum(i => i.current) - archiveIncomeItems.Sum(i => i.limit * i.months.Length);
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
<span class="highlight">已归档收入总结: </span>
|
||||
{(archiveIncomeDiff > 0 ? "超额收入" : "未达预期")}:
|
||||
<span class='{(archiveIncomeDiff > 0 ? "income-value" : "expense-value")}'>
|
||||
<strong>{archiveIncomeDiff:N0}</strong>
|
||||
</span>
|
||||
=
|
||||
<span class='income-value'>
|
||||
<strong>{archiveIncomeItems.Sum(i => i.limit * i.months.Length):N0}</strong>
|
||||
</span>
|
||||
-
|
||||
实际收入合计:
|
||||
<span class='income-value'>
|
||||
<strong>{archiveIncomeItems.Sum(i => i.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 构建年度预算收入明细表格
|
||||
description.AppendLine("<h3>预算收入明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>月/年</th>
|
||||
<th>合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// 当前预算
|
||||
foreach (var (_, name, limit, factor, _, _) in currentMonthlyIncomeItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{FormatMonthsByFactor(factor)}</td>
|
||||
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
// 年预算
|
||||
foreach (var (_, name, limit, _, _, _) in currentYearlyIncomeItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{year}年</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
预算收入合计:
|
||||
<span class='expense-value'>
|
||||
<strong>
|
||||
{
|
||||
currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
|
||||
+ currentYearlyIncomeItems.Sum(i => i.limit)
|
||||
:N0}
|
||||
</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
#endregion
|
||||
|
||||
#region 构建年度归档支出明细表格
|
||||
var archiveExpenseDiff = 0m;
|
||||
if (archiveExpenseItems.Any())
|
||||
{
|
||||
description.AppendLine("<h3>已归档支出明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>月</th>
|
||||
<th>合计</th>
|
||||
<th>实际</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// 已归档的支出
|
||||
foreach (var (_, name, months, limit, current) in archiveExpenseItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{FormatMonths(months)}</td>
|
||||
<td>{limit * months.Length:N0}</td>
|
||||
<td><span class='expense-value'>{current:N0}</span></td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
|
||||
archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
<span class="highlight">已归档支出总结: </span>
|
||||
{(archiveExpenseDiff > 0 ? "节省支出" : "超支")}:
|
||||
<span class='{(archiveExpenseDiff > 0 ? "income-value" : "expense-value")}'>
|
||||
<strong>{archiveExpenseDiff:N0}</strong>
|
||||
</span>
|
||||
=
|
||||
<span class='expense-value'>
|
||||
<strong>{archiveExpenseItems.Sum(i => i.limit * i.months.Length):N0}</strong>
|
||||
</span>
|
||||
- 实际支出合计:
|
||||
<span class='expense-value'>
|
||||
<strong>{archiveExpenseItems.Sum(i => i.current):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 构建当前年度预算支出明细表格
|
||||
description.AppendLine("<h3>预算支出明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>月/年</th>
|
||||
<th>合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// 未来月预算
|
||||
foreach (var (_, name, limit, factor, _, _) in currentMonthlyExpenseItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{FormatMonthsByFactor(factor)}</td>
|
||||
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
// 年预算
|
||||
foreach (var (_, name, limit, _, _, _) in currentYearlyExpenseItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{year}年</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
|
||||
// 合计
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
支出预算合计:
|
||||
<span class='expense-value'>
|
||||
<strong>
|
||||
{
|
||||
currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
|
||||
+ currentYearlyExpenseItems.Sum(i => i.limit)
|
||||
:N0}
|
||||
</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
#endregion
|
||||
|
||||
#region 总结
|
||||
var archiveIncomeBudget = archiveIncomeItems.Sum(i => i.limit * i.months.Length);
|
||||
var archiveExpenseBudget = archiveExpenseItems.Sum(i => i.limit * i.months.Length);
|
||||
var archiveSavings = archiveIncomeBudget - archiveExpenseBudget + archiveIncomeDiff + archiveExpenseDiff;
|
||||
|
||||
var expectedIncome = currentMonthlyIncomeItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyIncomeItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
|
||||
var expectedExpense = currentMonthlyExpenseItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyExpenseItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
|
||||
var expectedSavings = expectedIncome - expectedExpense;
|
||||
|
||||
description.AppendLine("<h3>存款计划结论</h3>");
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
<strong>归档存款:</strong>
|
||||
<span class='income-value'><strong>{archiveSavings:N0}</strong></span>
|
||||
=
|
||||
归档收入: <span class='income-value'>{archiveIncomeBudget:N0}</span>
|
||||
-
|
||||
归档支出: <span class='expense-value'>{archiveExpenseBudget:N0}</span>
|
||||
{(archiveIncomeDiff >= 0 ? " + 超额收入" : " - 未达预期收入")}: <span class='{(archiveIncomeDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveIncomeDiff >= 0 ? archiveIncomeDiff : -archiveIncomeDiff):N0}</span>
|
||||
{(archiveExpenseDiff >= 0 ? " + 节省支出" : " - 超额支出")}: <span class='{(archiveExpenseDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveExpenseDiff >= 0 ? archiveExpenseDiff : -archiveExpenseDiff):N0}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>预计存款:</strong>
|
||||
<span class='income-value'><strong>{expectedSavings:N0}</strong></span>
|
||||
=
|
||||
预计收入: <span class='income-value'>{expectedIncome:N0}</span>
|
||||
-
|
||||
预计支出: <span class='expense-value'>{expectedExpense:N0}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>存档总结:</strong>
|
||||
<span class='{(archiveSavings + expectedSavings > 0 ? "income-value" : "expense-value")}'>
|
||||
<strong>{archiveSavings + expectedSavings:N0}</strong>
|
||||
</span>
|
||||
=
|
||||
预计存款:
|
||||
<span class='income-value'>{expectedSavings:N0}</span>
|
||||
{(archiveSavings > 0 ? "+" : "-")}
|
||||
归档存款:
|
||||
<span class='{(archiveSavings > 0 ? "income-value" : "expense-value")}'>{Math.Abs(archiveSavings):N0}</span>
|
||||
</p>
|
||||
""");
|
||||
#endregion
|
||||
|
||||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||||
|
||||
var currentActual = 0m;
|
||||
if (!string.IsNullOrEmpty(savingsCategories))
|
||||
{
|
||||
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||
foreach(var kvp in transactionClassify)
|
||||
{
|
||||
if (cats.Contains(kvp.Key.Item1))
|
||||
{
|
||||
currentActual += kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var record = new BudgetRecord
|
||||
{
|
||||
Id = -1,
|
||||
Name = "年度存款计划",
|
||||
Type = BudgetPeriodType.Year,
|
||||
Limit = archiveSavings + expectedSavings,
|
||||
Category = BudgetCategory.Savings,
|
||||
SelectedCategories = savingsCategories,
|
||||
StartDate = new DateTime(year, 1, 1),
|
||||
NoLimit = false,
|
||||
IsMandatoryExpense = false,
|
||||
CreateTime = dateTimeProvider.Now,
|
||||
UpdateTime = dateTimeProvider.Now
|
||||
};
|
||||
|
||||
return BudgetResult.FromEntity(
|
||||
record,
|
||||
currentActual,
|
||||
new DateTime(year, 1, 1),
|
||||
description.ToString()
|
||||
);
|
||||
|
||||
void AddOrIncCurrentItem(
|
||||
long id,
|
||||
BudgetPeriodType periodType,
|
||||
BudgetCategory category,
|
||||
string name,
|
||||
decimal limit,
|
||||
int factor,
|
||||
decimal incAmount,
|
||||
bool isMandatory)
|
||||
{
|
||||
var current = (periodType, category) switch
|
||||
{
|
||||
(BudgetPeriodType.Month, BudgetCategory.Income) => currentMonthlyIncomeItems,
|
||||
(BudgetPeriodType.Month, BudgetCategory.Expense) => currentMonthlyExpenseItems,
|
||||
(BudgetPeriodType.Year, BudgetCategory.Income) => currentYearlyIncomeItems,
|
||||
(BudgetPeriodType.Year, BudgetCategory.Expense) => currentYearlyExpenseItems,
|
||||
_ => throw new NotSupportedException($"Category {category} is not supported.")
|
||||
};
|
||||
|
||||
if (current.Any(i => i.id == id))
|
||||
{
|
||||
var existing = current.First(i => i.id == id);
|
||||
current.Remove(existing);
|
||||
current.Add((id, existing.name, existing.limit, existing.factor + factor, existing.current + incAmount, isMandatory));
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Add((id, name, limit, factor, incAmount, isMandatory));
|
||||
}
|
||||
}
|
||||
|
||||
string FormatMonthsByFactor(int factor)
|
||||
{
|
||||
var months = factor == 12
|
||||
? Enumerable.Range(1, 12).ToArray()
|
||||
: Enumerable.Range(dateTimeProvider.Now.Month, factor).ToArray();
|
||||
|
||||
return FormatMonths(months.ToArray());
|
||||
}
|
||||
|
||||
string FormatMonths(int[] months)
|
||||
{
|
||||
// 如果是连续的月份 则简化显示 1~3
|
||||
Array.Sort(months);
|
||||
if (months.Length >= 2)
|
||||
{
|
||||
bool isContinuous = true;
|
||||
for (int i = 1; i < months.Length; i++)
|
||||
{
|
||||
if (months[i] != months[i - 1] + 1)
|
||||
{
|
||||
isContinuous = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isContinuous)
|
||||
{
|
||||
return $"{months.First()}~{months.Last()}月";
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(", ", months) + "月";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
namespace Service.Budget;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
Task<List<BudgetResult>> GetListAsync(DateTime referenceDate);
|
||||
|
||||
Task<string> ArchiveBudgetsAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定分类的统计信息(月度和年度)
|
||||
/// </summary>
|
||||
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
|
||||
|
||||
/// <summary>
|
||||
/// 获取未被预算覆盖的分类统计信息
|
||||
/// </summary>
|
||||
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
|
||||
|
||||
Task<string?> GetArchiveSummaryAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定周期的存款预算信息
|
||||
/// </summary>
|
||||
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class BudgetService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
IOpenAiService openAiService,
|
||||
IMessageService messageService,
|
||||
ILogger<BudgetService> logger,
|
||||
IBudgetSavingsService budgetSavingsService,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
IBudgetStatsService budgetStatsService
|
||||
) : IBudgetService
|
||||
{
|
||||
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
|
||||
{
|
||||
var year = referenceDate.Year;
|
||||
var month = referenceDate.Month;
|
||||
|
||||
var isArchive = year < dateTimeProvider.Now.Year
|
||||
|| (year == dateTimeProvider.Now.Year && month < dateTimeProvider.Now.Month);
|
||||
|
||||
if (isArchive)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
var (start, end) = GetPeriodRange(dateTimeProvider.Now, BudgetPeriodType.Month, referenceDate);
|
||||
return [.. archive.Content.Select(c => new BudgetResult
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Type = c.Type,
|
||||
Limit = c.Limit,
|
||||
Current = c.Actual,
|
||||
Category = c.Category,
|
||||
SelectedCategories = c.SelectedCategories,
|
||||
NoLimit = c.NoLimit,
|
||||
IsMandatoryExpense = c.IsMandatoryExpense,
|
||||
Description = c.Description,
|
||||
PeriodStart = start,
|
||||
PeriodEnd = end,
|
||||
})];
|
||||
}
|
||||
|
||||
logger.LogWarning("获取预算列表时发现归档数据缺失,Year: {Year}, Month: {Month}", year, month);
|
||||
}
|
||||
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
var dtos = new List<BudgetResult?>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
|
||||
}
|
||||
|
||||
// 创造虚拟的存款预算
|
||||
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
|
||||
BudgetPeriodType.Month,
|
||||
referenceDate,
|
||||
budgets));
|
||||
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
|
||||
BudgetPeriodType.Year,
|
||||
referenceDate,
|
||||
budgets));
|
||||
|
||||
dtos = dtos
|
||||
.Where(x => x != null)
|
||||
.Cast<BudgetResult>()
|
||||
.OrderByDescending(x => x.IsMandatoryExpense)
|
||||
.ThenBy(x => x.Type)
|
||||
.ThenByDescending(x => x.Current)
|
||||
.ToList()!;
|
||||
|
||||
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
|
||||
}
|
||||
|
||||
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
||||
{
|
||||
var referenceDate = new DateTime(year, month, 1);
|
||||
return await budgetSavingsService.GetSavingsDtoAsync(type, referenceDate);
|
||||
}
|
||||
|
||||
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||
{
|
||||
return await budgetStatsService.GetCategoryStatsAsync(category, referenceDate);
|
||||
}
|
||||
|
||||
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||
{
|
||||
var date = referenceDate ?? dateTimeProvider.Now;
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
|
||||
if (transactionType == TransactionType.None) return [];
|
||||
|
||||
// 1. 获取所有预算
|
||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||
var coveredCategories = budgets
|
||||
.Where(b => b.Category == category)
|
||||
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet();
|
||||
|
||||
// 2. 获取分类统计
|
||||
var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
|
||||
|
||||
// 3. 过滤未覆盖的
|
||||
return stats
|
||||
.Where(s => !coveredCategories.Contains(s.Classify))
|
||||
.Select(s => new UncoveredCategoryDetail
|
||||
{
|
||||
Category = s.Classify,
|
||||
TransactionCount = s.Count,
|
||||
TotalAmount = s.Amount
|
||||
})
|
||||
.OrderByDescending(x => x.TotalAmount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<string?> GetArchiveSummaryAsync(int year, int month)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
||||
return archive?.Summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<string> ArchiveBudgetsAsync(int year, int month)
|
||||
{
|
||||
var referenceDate = new DateTime(year, month, 1);
|
||||
|
||||
var budgets = await GetListAsync(referenceDate);
|
||||
|
||||
var expenseSurplus = budgets
|
||||
.Where(b => b.Category == BudgetCategory.Expense && !b.NoLimit && b.Type == BudgetPeriodType.Month)
|
||||
.Sum(b => b.Limit - b.Current);
|
||||
|
||||
var incomeSurplus = budgets
|
||||
.Where(b => b.Category == BudgetCategory.Income && !b.NoLimit && b.Type == BudgetPeriodType.Month)
|
||||
.Sum(b => b.Current - b.Limit);
|
||||
|
||||
var content = budgets.Select(b => new BudgetArchiveContent
|
||||
{
|
||||
Id = b.Id,
|
||||
Name = b.Name,
|
||||
Type = b.Type,
|
||||
Limit = b.Limit,
|
||||
Actual = b.Current,
|
||||
Category = b.Category,
|
||||
SelectedCategories = b.SelectedCategories,
|
||||
NoLimit = b.NoLimit,
|
||||
IsMandatoryExpense = b.IsMandatoryExpense,
|
||||
Description = b.Description
|
||||
}).ToArray();
|
||||
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
archive.Content = content;
|
||||
archive.ArchiveDate = dateTimeProvider.Now;
|
||||
archive.ExpenseSurplus = expenseSurplus;
|
||||
archive.IncomeSurplus = incomeSurplus;
|
||||
if (!await budgetArchiveRepository.UpdateAsync(archive))
|
||||
{
|
||||
return "更新预算归档失败";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
archive = new BudgetArchive
|
||||
{
|
||||
Year = year,
|
||||
Month = month,
|
||||
Content = content,
|
||||
ArchiveDate = dateTimeProvider.Now,
|
||||
ExpenseSurplus = expenseSurplus,
|
||||
IncomeSurplus = incomeSurplus
|
||||
};
|
||||
|
||||
if (!await budgetArchiveRepository.AddAsync(archive))
|
||||
{
|
||||
return "保存预算归档失败";
|
||||
}
|
||||
}
|
||||
|
||||
_ = NotifyAsync(year, month);
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task NotifyAsync(int year, int month)
|
||||
{
|
||||
try
|
||||
{
|
||||
var archives = await budgetArchiveRepository.GetListAsync(year, month);
|
||||
|
||||
var archiveData = archives.SelectMany(a => a.Content.Select(c => new
|
||||
{
|
||||
c.Name,
|
||||
Type = c.Type.ToString(),
|
||||
c.Limit,
|
||||
c.Actual,
|
||||
Category = c.Category.ToString(),
|
||||
c.SelectedCategories
|
||||
})).ToList();
|
||||
|
||||
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-01-01'
|
||||
AND OccurredAt < '{year + 1}-01-01'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
var monthYear = new DateTime(year, month, 1).AddMonths(1);
|
||||
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-{month:00}-01'
|
||||
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
|
||||
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
|
||||
var budgetedCategories = archiveData
|
||||
.SelectMany(b => b.SelectedCategories)
|
||||
.Where(c => !string.IsNullOrEmpty(c))
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var uncovered = monthTransactions
|
||||
.Where(t =>
|
||||
{
|
||||
var dict = (IDictionary<string, object>)t;
|
||||
var classify = dict["Classify"].ToString() ?? "";
|
||||
var type = Convert.ToInt32(dict["Type"]);
|
||||
return type == 0 && !budgetedCategories.Contains(classify);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
|
||||
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
|
||||
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
|
||||
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
|
||||
|
||||
var dataPrompt = $"""
|
||||
报告周期:{year}年{month}月
|
||||
|
||||
1. 预算执行数据(JSON):
|
||||
{JsonSerializer.Serialize(archiveData)}
|
||||
|
||||
2. 本月账单类目明细(按分类, JSON):
|
||||
{JsonSerializer.Serialize(monthTransactions)}
|
||||
|
||||
3. 全年累计账单类目明细(按分类, JSON):
|
||||
{JsonSerializer.Serialize(yearTransactions)}
|
||||
|
||||
4. 未被任何预算覆盖的支出分类(JSON):
|
||||
{JsonSerializer.Serialize(uncovered)}
|
||||
|
||||
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
|
||||
|
||||
【内容要求】
|
||||
1. 概览:总结本月预算达成情况。
|
||||
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
|
||||
3. 超支/异常预警:重点分析超支项或支出异常的分类。
|
||||
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
|
||||
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
|
||||
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
|
||||
7. 如果报告月份是12月,需要报告年度预算的执行情况。
|
||||
|
||||
【格式要求】
|
||||
1. 使用HTML格式(移动端H5页面风格)
|
||||
2. 生成清晰的报告标题(基于用户问题)
|
||||
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td),
|
||||
3.1 table要求不能超过屏幕宽度,尽可能简洁明了,避免冗余信息
|
||||
3.2 预算金额精确到整数即可,实际金额精确到小数点后1位
|
||||
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
||||
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
||||
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
||||
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
||||
|
||||
【样式限制(重要)】
|
||||
8. 不要包含 html、body、head 标签
|
||||
9. 不要使用任何 style 属性或 <style> 标签
|
||||
10. 不要设置 background、background-color、color 等样式属性
|
||||
11. 不要使用 div 包裹大段内容
|
||||
|
||||
【系统信息】
|
||||
当前时间:{dateTimeProvider.Now:yyyy-MM-dd HH:mm:ss}
|
||||
预算归档周期:{year}年{month}月
|
||||
|
||||
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||
""";
|
||||
|
||||
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
||||
if (!string.IsNullOrEmpty(htmlReport))
|
||||
{
|
||||
await messageService.AddAsync(
|
||||
title: $"{year}年{month}月 - 预算归档报告",
|
||||
content: htmlReport,
|
||||
type: MessageType.Html,
|
||||
url: "/balance?tab=message");
|
||||
|
||||
// 同时保存到归档总结
|
||||
var first = archives.First();
|
||||
first.Summary = htmlReport;
|
||||
await budgetArchiveRepository.UpdateAsync(first);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "生成预算执行通知报告失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
||||
{
|
||||
var referenceDate = now ?? dateTimeProvider.Now;
|
||||
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
||||
|
||||
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||
|
||||
// 如果是硬性消费,且是当前年当前月,则根据经过的天数累加
|
||||
if (actualAmount == 0
|
||||
&& budget.IsMandatoryExpense
|
||||
&& referenceDate.Year == startDate.Year
|
||||
&& (budget.Type == BudgetPeriodType.Year || referenceDate.Month == startDate.Month))
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 计算本月的天数
|
||||
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
||||
// 计算当前已经过的天数(包括今天)
|
||||
var daysElapsed = referenceDate.Day;
|
||||
// 根据预算金额和经过天数计算应累加的金额
|
||||
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
|
||||
// 返回实际消费和硬性消费累加中的较大值
|
||||
return mandatoryAccumulation;
|
||||
}
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 计算本年的天数(考虑闰年)
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
// 计算当前已经过的天数(包括今天)
|
||||
var daysElapsed = referenceDate.DayOfYear;
|
||||
// 根据预算金额和经过天数计算应累加的金额
|
||||
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
|
||||
// 返回实际消费和硬性消费累加中的较大值
|
||||
return mandatoryAccumulation;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return actualAmount;
|
||||
}
|
||||
|
||||
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
||||
{
|
||||
DateTime start;
|
||||
DateTime end;
|
||||
|
||||
if (type == BudgetPeriodType.Month)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
||||
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else if (type == BudgetPeriodType.Year)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, 1, 1);
|
||||
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else
|
||||
{
|
||||
start = startDate;
|
||||
end = DateTime.MaxValue;
|
||||
}
|
||||
|
||||
return (start, end);
|
||||
}
|
||||
}
|
||||
|
||||
public record BudgetResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; set; }
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Current { get; set; }
|
||||
public BudgetCategory Category { get; set; }
|
||||
public string[] SelectedCategories { get; set; } = [];
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
public string Period { get; set; } = string.Empty;
|
||||
public DateTime? PeriodStart { get; set; }
|
||||
public DateTime? PeriodEnd { get; set; }
|
||||
public bool NoLimit { get; set; }
|
||||
public bool IsMandatoryExpense { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public static BudgetResult FromEntity(
|
||||
BudgetRecord entity,
|
||||
decimal currentAmount,
|
||||
DateTime referenceDate,
|
||||
string description = "")
|
||||
{
|
||||
var date = referenceDate;
|
||||
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
||||
|
||||
return new BudgetResult
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Type = entity.Type,
|
||||
Limit = entity.Limit,
|
||||
Current = currentAmount,
|
||||
Category = entity.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
||||
? []
|
||||
: entity.SelectedCategories.Split(','),
|
||||
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
||||
Period = entity.Type switch
|
||||
{
|
||||
BudgetPeriodType.Year => $"{start:yy}年",
|
||||
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
||||
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
||||
},
|
||||
PeriodStart = start,
|
||||
PeriodEnd = end,
|
||||
NoLimit = entity.NoLimit,
|
||||
IsMandatoryExpense = entity.IsMandatoryExpense,
|
||||
Description = description
|
||||
};
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 预算统计结果 DTO
|
||||
/// </summary>
|
||||
public class BudgetStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期类型(Month/Year)
|
||||
/// </summary>
|
||||
public BudgetPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用率百分比(0-100)
|
||||
/// </summary>
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际金额
|
||||
/// </summary>
|
||||
public decimal Current { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标/限额金额
|
||||
/// </summary>
|
||||
public decimal Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算项数量
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
|
||||
/// </summary>
|
||||
public List<decimal?> Trend { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类统计结果
|
||||
/// </summary>
|
||||
public class BudgetCategoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// 月度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Month { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 年度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Year { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UncoveredCategoryDetail
|
||||
{
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
@@ -1,940 +0,0 @@
|
||||
namespace Service.Budget;
|
||||
|
||||
public interface IBudgetStatsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定分类的统计信息(月度和年度)
|
||||
/// </summary>
|
||||
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public class BudgetStatsService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ILogger<BudgetStatsService> logger
|
||||
) : IBudgetStatsService
|
||||
{
|
||||
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||
{
|
||||
logger.LogInformation("开始计算分类统计信息: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
|
||||
category, referenceDate);
|
||||
|
||||
var result = new BudgetCategoryStats();
|
||||
|
||||
try
|
||||
{
|
||||
// 获取月度统计
|
||||
logger.LogDebug("开始计算月度统计");
|
||||
result.Month = await CalculateMonthlyCategoryStatsAsync(category, referenceDate);
|
||||
logger.LogInformation("月度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
|
||||
result.Month.Count, result.Month.Limit, result.Month.Current, result.Month.Rate);
|
||||
|
||||
// 获取年度统计
|
||||
logger.LogDebug("开始计算年度统计");
|
||||
result.Year = await CalculateYearlyCategoryStatsAsync(category, referenceDate);
|
||||
logger.LogInformation("年度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
|
||||
result.Year.Count, result.Year.Limit, result.Year.Current, result.Year.Rate);
|
||||
|
||||
logger.LogInformation("分类统计信息计算完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "计算分类统计信息时发生错误: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
|
||||
category, referenceDate);
|
||||
throw;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BudgetStatsDto> CalculateMonthlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||
{
|
||||
logger.LogDebug("开始计算月度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM}",
|
||||
category, referenceDate);
|
||||
|
||||
var result = new BudgetStatsDto
|
||||
{
|
||||
PeriodType = BudgetPeriodType.Month,
|
||||
Rate = 0,
|
||||
Current = 0,
|
||||
Limit = 0,
|
||||
Count = 0
|
||||
};
|
||||
|
||||
// 1. 获取所有预算(包含归档数据)
|
||||
logger.LogDebug("开始获取预算数据(包含归档)");
|
||||
var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count);
|
||||
|
||||
if (budgets.Count == 0)
|
||||
{
|
||||
logger.LogDebug("未找到相关预算,返回空结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Count = budgets.Count;
|
||||
|
||||
// 2. 计算限额总值(考虑不限额预算的特殊处理)
|
||||
logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count);
|
||||
decimal totalLimit = 0;
|
||||
int budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实际金额: {CurrentAmount}, 计算算法: {Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
|
||||
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 计算后限额: {ItemLimit}",
|
||||
budget.Name, budget.Id, itemLimit);
|
||||
totalLimit += itemLimit;
|
||||
}
|
||||
result.Limit = totalLimit;
|
||||
logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit);
|
||||
|
||||
// 3. 计算当前实际值(避免重复计算同一笔交易)
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
logger.LogDebug("交易类型: {TransactionType}", transactionType);
|
||||
|
||||
// 计算当前实际值,考虑硬性预算的特殊逻辑
|
||||
decimal totalCurrent = 0;
|
||||
var now = dateTimeProvider.Now;
|
||||
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
|
||||
|
||||
if (transactionType != TransactionType.None)
|
||||
{
|
||||
// 获取所有相关分类
|
||||
var allClassifies = budgets
|
||||
.SelectMany(b => b.SelectedCategories)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
|
||||
|
||||
// 获取趋势统计数据(去重计算)
|
||||
logger.LogDebug("开始获取交易趋势统计数据");
|
||||
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
|
||||
startDate,
|
||||
endDate,
|
||||
transactionType,
|
||||
allClassifies,
|
||||
false);
|
||||
logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count);
|
||||
|
||||
// 计算累计值
|
||||
decimal accumulated = 0;
|
||||
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
|
||||
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
|
||||
|
||||
for (int i = 1; i <= daysInMonth; i++)
|
||||
{
|
||||
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
|
||||
if (currentDate.Date > now.Date)
|
||||
{
|
||||
result.Trend.Add(null);
|
||||
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd} 为未来日期,趋势数据为 null", currentDate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dailyStats.TryGetValue(currentDate.Date, out var amount))
|
||||
{
|
||||
accumulated += amount;
|
||||
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 金额={Amount}, 累计={Accumulated}",
|
||||
currentDate, amount, accumulated);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}",
|
||||
currentDate, accumulated);
|
||||
}
|
||||
result.Trend.Add(accumulated);
|
||||
}
|
||||
|
||||
totalCurrent = accumulated;
|
||||
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非收入/支出分类,使用逐预算累加
|
||||
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 预算金额={BudgetLimit}, 当前值={CurrentAmount}, 算法={Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
budget.IsArchive ? "归档数据" : "实时计算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 当前值: {CurrentAmount}",
|
||||
budget.Name, budget.Id, currentAmount);
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
logger.LogDebug("预算累加完成: {TotalCurrent}", totalCurrent);
|
||||
}
|
||||
|
||||
// 对于硬性预算,如果当前月份且实际值为0,需要按时间比例计算
|
||||
if (transactionType == TransactionType.Expense)
|
||||
{
|
||||
logger.LogDebug("开始应用硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count);
|
||||
var beforeAdjustment = totalCurrent;
|
||||
totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Month);
|
||||
if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m)
|
||||
{
|
||||
logger.LogInformation("硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}",
|
||||
beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment);
|
||||
logger.LogDebug("硬性预算调整算法: 当前月份={ReferenceDate:yyyy-MM}, 硬性预算按天数比例累加计算", referenceDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("硬性预算调整未改变值");
|
||||
}
|
||||
}
|
||||
|
||||
result.Current = totalCurrent;
|
||||
|
||||
// 4. 计算使用率
|
||||
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
|
||||
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
|
||||
|
||||
// 5. 生成计算明细汇总日志
|
||||
var limitParts = new List<string>();
|
||||
var currentParts = new List<string>();
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
var limitPart = budget.IsArchive
|
||||
? $"{budget.Name}({budget.ArchiveMonth}月归档){itemLimit}元"
|
||||
: $"{budget.Name}{itemLimit}元";
|
||||
limitParts.Add(limitPart);
|
||||
|
||||
var currentPart = budget.IsArchive
|
||||
? $"{budget.Name}({budget.ArchiveMonth}月归档){budget.Current}元"
|
||||
: $"{budget.Name}{budget.Current}元";
|
||||
currentParts.Add(currentPart);
|
||||
}
|
||||
var limitSummary = string.Join(" + ", limitParts);
|
||||
var currentSummary = string.Join(" + ", currentParts);
|
||||
logger.LogInformation("月度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
|
||||
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
|
||||
|
||||
logger.LogDebug("月度分类统计计算完成");
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BudgetStatsDto> CalculateYearlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||
{
|
||||
logger.LogDebug("开始计算年度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy}",
|
||||
category, referenceDate);
|
||||
|
||||
var result = new BudgetStatsDto
|
||||
{
|
||||
PeriodType = BudgetPeriodType.Year,
|
||||
Rate = 0,
|
||||
Current = 0,
|
||||
Limit = 0,
|
||||
Count = 0
|
||||
};
|
||||
|
||||
// 1. 获取所有预算(包含归档数据)
|
||||
logger.LogDebug("开始获取预算数据(包含归档)");
|
||||
var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count);
|
||||
|
||||
if (budgets.Count == 0)
|
||||
{
|
||||
logger.LogDebug("未找到相关预算,返回空结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Count = budgets.Count;
|
||||
|
||||
// 2. 计算限额总值(考虑不限额预算的特殊处理)
|
||||
logger.LogDebug("开始计算年度限额总值,共 {BudgetCount} 个预算", budgets.Count);
|
||||
decimal totalLimit = 0;
|
||||
int budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
|
||||
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算",
|
||||
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度计算后限额: {ItemLimit}",
|
||||
budget.Name, budget.Id, itemLimit);
|
||||
totalLimit += itemLimit;
|
||||
}
|
||||
result.Limit = totalLimit;
|
||||
logger.LogDebug("年度限额总值计算完成: {TotalLimit}", totalLimit);
|
||||
|
||||
// 3. 计算当前实际值(避免重复计算同一笔交易)
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
logger.LogDebug("交易类型: {TransactionType}", transactionType);
|
||||
|
||||
// 计算当前实际值,考虑硬性预算的特殊逻辑
|
||||
decimal totalCurrent = 0;
|
||||
var now = dateTimeProvider.Now;
|
||||
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
|
||||
|
||||
if (transactionType != TransactionType.None)
|
||||
{
|
||||
// 获取所有相关分类
|
||||
var allClassifies = budgets
|
||||
.SelectMany(b => b.SelectedCategories)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
|
||||
|
||||
// 获取趋势统计数据(去重计算)
|
||||
logger.LogDebug("开始获取交易趋势统计数据");
|
||||
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
|
||||
startDate,
|
||||
endDate,
|
||||
transactionType,
|
||||
allClassifies,
|
||||
true);
|
||||
logger.LogDebug("获取到 {MonthCount} 个月的交易数据", dailyStats.Count);
|
||||
|
||||
// 计算累计值
|
||||
decimal accumulated = 0;
|
||||
for (int i = 1; i <= 12; i++)
|
||||
{
|
||||
var currentMonthDate = new DateTime(startDate.Year, i, 1);
|
||||
|
||||
if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month))
|
||||
{
|
||||
result.Trend.Add(null);
|
||||
logger.LogTrace("月份 {Month:yyyy-MM} 为未来月份,趋势数据为 null", currentMonthDate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dailyStats.TryGetValue(currentMonthDate, out var amount))
|
||||
{
|
||||
accumulated += amount;
|
||||
logger.LogTrace("月份 {Month:yyyy-MM}: 金额={Amount}, 累计={Accumulated}",
|
||||
currentMonthDate, amount, accumulated);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}",
|
||||
currentMonthDate, accumulated);
|
||||
}
|
||||
result.Trend.Add(accumulated);
|
||||
}
|
||||
|
||||
totalCurrent = accumulated;
|
||||
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非收入/支出分类,使用逐预算累加
|
||||
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 原始预算={BudgetLimit}, 年度实际值={CurrentAmount}, 数据来源: {DataSource}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
budget.IsArchive ? "归档数据" : "实时计算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度当前值: {CurrentAmount}",
|
||||
budget.Name, budget.Id, currentAmount);
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
logger.LogDebug("年度预算累加完成: {TotalCurrent}", totalCurrent);
|
||||
}
|
||||
|
||||
// 对于硬性预算,如果当前年份且实际值为0,需要按时间比例计算
|
||||
if (transactionType == TransactionType.Expense)
|
||||
{
|
||||
logger.LogDebug("开始应用年度硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count);
|
||||
var beforeAdjustment = totalCurrent;
|
||||
totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Year);
|
||||
if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m)
|
||||
{
|
||||
logger.LogInformation("年度硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}",
|
||||
beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment);
|
||||
logger.LogDebug("年度硬性预算调整算法: 当前年份={ReferenceDate:yyyy}, 硬性预算按天数比例累加计算", referenceDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("年度硬性预算调整未改变值");
|
||||
}
|
||||
}
|
||||
|
||||
result.Current = totalCurrent;
|
||||
|
||||
// 4. 计算使用率
|
||||
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
|
||||
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
|
||||
|
||||
// 5. 生成计算明细汇总日志
|
||||
var limitParts = new List<string>();
|
||||
var currentParts = new List<string>();
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
var limitPart = budget.IsArchive
|
||||
? $"{budget.Name}(归档){itemLimit}元"
|
||||
: budget.RemainingMonths > 0
|
||||
? $"{budget.Name}(剩余{budget.RemainingMonths}月){itemLimit}元"
|
||||
: $"{budget.Name}{itemLimit}元";
|
||||
limitParts.Add(limitPart);
|
||||
|
||||
var currentPart = budget.IsArchive
|
||||
? $"{budget.Name}(归档){budget.Current}元"
|
||||
: budget.RemainingMonths > 0
|
||||
? $"{budget.Name}(剩余{budget.RemainingMonths}月){budget.Current}元"
|
||||
: $"{budget.Name}{budget.Current}元";
|
||||
currentParts.Add(currentPart);
|
||||
}
|
||||
var limitSummary = string.Join(" + ", limitParts);
|
||||
var currentSummary = string.Join(" + ", currentParts);
|
||||
logger.LogInformation("年度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
|
||||
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
|
||||
|
||||
logger.LogDebug("年度分类统计计算完成");
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<BudgetStatsItem>> GetAllBudgetsWithArchiveAsync(
|
||||
BudgetCategory category,
|
||||
BudgetPeriodType statType,
|
||||
DateTime referenceDate)
|
||||
{
|
||||
logger.LogDebug("开始获取预算数据: Category={Category}, StatType={StatType}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
|
||||
category, statType, referenceDate);
|
||||
|
||||
var result = new List<BudgetStatsItem>();
|
||||
|
||||
var year = referenceDate.Year;
|
||||
var month = referenceDate.Month;
|
||||
var now = dateTimeProvider.Now;
|
||||
|
||||
// 对于年度统计,需要获取整年的归档数据和当前预算
|
||||
if (statType == BudgetPeriodType.Year)
|
||||
{
|
||||
logger.LogDebug("年度统计:开始获取整年的预算数据");
|
||||
|
||||
// 获取当前有效的预算(用于当前月及未来月)
|
||||
var currentBudgets = await budgetRepository.GetAllAsync();
|
||||
var currentBudgetsDict = currentBudgets
|
||||
.Where(b => b.Category == category && ShouldIncludeBudget(b, statType))
|
||||
.ToDictionary(b => b.Id);
|
||||
logger.LogDebug("获取到 {Count} 个当前有效预算", currentBudgetsDict.Count);
|
||||
|
||||
// 用于跟踪已处理的预算ID,避免重复
|
||||
var processedBudgetIds = new HashSet<long>();
|
||||
|
||||
// 1. 处理历史归档月份(1月到当前月-1)
|
||||
if (referenceDate.Year == now.Year && now.Month > 1)
|
||||
{
|
||||
logger.LogDebug("开始处理历史归档月份: 1月到{Month}月", now.Month - 1);
|
||||
for (int m = 1; m < now.Month; m++)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, m);
|
||||
if (archive != null)
|
||||
{
|
||||
logger.LogDebug("找到{Month}月归档数据,包含 {ItemCount} 个项目", m, archive.Content.Count());
|
||||
foreach (var item in archive.Content)
|
||||
{
|
||||
if (item.Category == category && ShouldIncludeBudget(item, statType))
|
||||
{
|
||||
// 对于月度预算,每个月都添加一个归档项
|
||||
if (item.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
Type = item.Type,
|
||||
Limit = item.Limit,
|
||||
Current = item.Actual,
|
||||
Category = item.Category,
|
||||
SelectedCategories = item.SelectedCategories,
|
||||
NoLimit = item.NoLimit,
|
||||
IsMandatoryExpense = item.IsMandatoryExpense,
|
||||
IsArchive = true,
|
||||
ArchiveMonth = m
|
||||
});
|
||||
logger.LogInformation("添加归档月度预算: {BudgetName} (ID={BudgetId}) - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, item.Id, m, item.Limit, item.Actual);
|
||||
}
|
||||
// 对于年度预算,只添加一次
|
||||
else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id))
|
||||
{
|
||||
processedBudgetIds.Add(item.Id);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
Type = item.Type,
|
||||
Limit = item.Limit,
|
||||
Current = item.Actual,
|
||||
Category = item.Category,
|
||||
SelectedCategories = item.SelectedCategories,
|
||||
NoLimit = item.NoLimit,
|
||||
IsMandatoryExpense = item.IsMandatoryExpense,
|
||||
IsArchive = true
|
||||
});
|
||||
logger.LogInformation("添加归档年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, item.Id, item.Limit, item.Actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理当前月及未来月(使用当前预算)
|
||||
logger.LogDebug("开始处理当前及未来月份预算");
|
||||
foreach (var budget in currentBudgetsDict.Values)
|
||||
{
|
||||
// 对于年度预算,如果还没有从归档中添加,则添加
|
||||
if (budget.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(budget.Id))
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Current = currentAmount,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
|
||||
? []
|
||||
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Id, budget.Limit, currentAmount);
|
||||
}
|
||||
// 对于月度预算,添加当前及未来月份的预算(标记剩余月份数)
|
||||
else if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
var remainingMonths = 12 - now.Month + 1; // 包括当前月
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Current = 0, // 剩余月份不计算实际值
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
|
||||
? []
|
||||
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false,
|
||||
RemainingMonths = remainingMonths
|
||||
});
|
||||
logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 剩余月份: {RemainingMonths}",
|
||||
budget.Name, budget.Id, budget.Limit, remainingMonths);
|
||||
}
|
||||
}
|
||||
}
|
||||
else // 月度统计
|
||||
{
|
||||
// 检查是否为归档月份
|
||||
var isArchive = year < now.Year || (year == now.Year && month < now.Month);
|
||||
logger.LogDebug("月度统计 - 是否为归档月份: {IsArchive}", isArchive);
|
||||
|
||||
if (isArchive)
|
||||
{
|
||||
// 获取归档数据
|
||||
logger.LogDebug("开始获取归档数据: Year={Year}, Month={Month}", year, month);
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
int itemCount = archive.Content.Count();
|
||||
logger.LogDebug("找到归档数据,包含 {ItemCount} 个项目", itemCount);
|
||||
foreach (var item in archive.Content)
|
||||
{
|
||||
if (item.Category == category && ShouldIncludeBudget(item, statType))
|
||||
{
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
Type = item.Type,
|
||||
Limit = item.Limit,
|
||||
Current = item.Actual,
|
||||
Category = item.Category,
|
||||
SelectedCategories = item.SelectedCategories,
|
||||
NoLimit = item.NoLimit,
|
||||
IsMandatoryExpense = item.IsMandatoryExpense,
|
||||
IsArchive = true
|
||||
});
|
||||
logger.LogInformation("添加归档预算: {BudgetName} (ID={BudgetId}) - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}",
|
||||
item.Name, item.Id, year, month, item.Limit, item.Actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("未找到归档数据");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 获取当前预算数据
|
||||
logger.LogDebug("开始获取当前预算数据");
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
int budgetCount = budgets.Count();
|
||||
logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount);
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
if (budget.Category == category && ShouldIncludeBudget(budget, statType))
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Current = currentAmount,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
|
||||
? []
|
||||
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前预算: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}",
|
||||
budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("预算数据获取完成: 共找到 {ResultCount} 个符合条件的预算", result.Count);
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool ShouldIncludeBudget(BudgetRecord budget, BudgetPeriodType statType)
|
||||
{
|
||||
// 排除不记额预算
|
||||
if (budget.NoLimit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 月度统计只包含月度预算
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
return budget.Type == BudgetPeriodType.Month;
|
||||
}
|
||||
|
||||
// 年度统计包含所有预算
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ShouldIncludeBudget(BudgetArchiveContent budget, BudgetPeriodType statType)
|
||||
{
|
||||
// 排除不记额预算
|
||||
if (budget.NoLimit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 月度统计只包含月度预算
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
return budget.Type == BudgetPeriodType.Month;
|
||||
}
|
||||
|
||||
// 年度统计包含所有预算
|
||||
return true;
|
||||
}
|
||||
|
||||
private decimal CalculateBudgetLimit(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
// 不记额预算的限额为0
|
||||
if (budget.NoLimit)
|
||||
{
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 为不记额预算,限额返回0", budget.Name, budget.Id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var itemLimit = budget.Limit;
|
||||
string algorithmDescription = $"直接使用原始预算金额: {budget.Limit}";
|
||||
|
||||
// 年度视图下,月度预算需要折算为年度
|
||||
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 对于归档预算,直接使用归档的限额,不折算
|
||||
if (budget.IsArchive)
|
||||
{
|
||||
itemLimit = budget.Limit;
|
||||
algorithmDescription = $"归档月度预算: 直接使用归档限额 {budget.Limit}";
|
||||
}
|
||||
// 对于当前及未来月份的预算,使用剩余月份折算
|
||||
else if (budget.RemainingMonths > 0)
|
||||
{
|
||||
itemLimit = budget.Limit * budget.RemainingMonths;
|
||||
algorithmDescription = $"月度预算剩余月份折算: {budget.Limit} × {budget.RemainingMonths} (剩余月份) = {itemLimit}";
|
||||
}
|
||||
// 兼容旧逻辑(如果没有设置RemainingMonths)
|
||||
else
|
||||
{
|
||||
logger.LogWarning("预算 {BudgetName} (ID={BudgetId}) 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name, budget.Id);
|
||||
if (budget.IsMandatoryExpense)
|
||||
{
|
||||
var now = dateTimeProvider.Now;
|
||||
if (referenceDate.Year == now.Year)
|
||||
{
|
||||
var monthsElapsed = now.Month;
|
||||
itemLimit = budget.Limit * monthsElapsed;
|
||||
algorithmDescription = $"硬性预算当前年份折算: {budget.Limit} × {monthsElapsed} (已过月份) = {itemLimit}";
|
||||
}
|
||||
else
|
||||
{
|
||||
itemLimit = budget.Limit * 12;
|
||||
algorithmDescription = $"硬性预算完整年度折算: {budget.Limit} × 12 = {itemLimit}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
itemLimit = budget.Limit * 12;
|
||||
algorithmDescription = $"月度预算年度折算: {budget.Limit} × 12 = {itemLimit}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("预算 {BudgetName} (ID={BudgetId}) 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}",
|
||||
budget.Name, budget.Id, budget.Limit, itemLimit, algorithmDescription);
|
||||
|
||||
return itemLimit;
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate);
|
||||
|
||||
// 获取预算的实际时间段
|
||||
var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
||||
|
||||
// 确保在统计时间段内
|
||||
if (budgetEnd < startDate || budgetStart > endDate)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||
|
||||
// 硬性预算的特殊处理(参考BudgetSavingsService第92-97行)
|
||||
if (actualAmount == 0 && budget.IsMandatoryExpense)
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 月度硬性预算按天数比例累加
|
||||
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
||||
var daysElapsed = dateTimeProvider.Now.Day;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 年度硬性预算按天数比例累加
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var daysElapsed = dateTimeProvider.Now.DayOfYear;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInYear;
|
||||
}
|
||||
}
|
||||
|
||||
return actualAmount;
|
||||
}
|
||||
|
||||
private decimal ApplyMandatoryBudgetAdjustment(List<BudgetStatsItem> budgets, decimal currentTotal, DateTime referenceDate, BudgetPeriodType statType)
|
||||
{
|
||||
logger.LogDebug("开始应用硬性预算调整: 当前总计={CurrentTotal}, 统计类型={StatType}, 参考日期={ReferenceDate:yyyy-MM-dd}",
|
||||
currentTotal, statType, referenceDate);
|
||||
|
||||
var now = dateTimeProvider.Now;
|
||||
var adjustedTotal = currentTotal;
|
||||
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
|
||||
|
||||
logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count);
|
||||
|
||||
int mandatoryIndex = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
mandatoryIndex++;
|
||||
// 检查是否为当前统计周期
|
||||
var isCurrentPeriod = false;
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
|
||||
referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod);
|
||||
}
|
||||
else // Year
|
||||
{
|
||||
isCurrentPeriod = referenceDate.Year == now.Year;
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
|
||||
referenceDate.Year, now.Year, isCurrentPeriod);
|
||||
}
|
||||
|
||||
if (isCurrentPeriod)
|
||||
{
|
||||
// 计算硬性预算的应累加值
|
||||
decimal mandatoryAccumulation = 0;
|
||||
string accumulationAlgorithm = "";
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 月度硬性预算按天数比例累加
|
||||
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
||||
var daysElapsed = now.Day;
|
||||
mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
|
||||
accumulationAlgorithm = $"月度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInMonth} = {mandatoryAccumulation:F2}";
|
||||
logger.LogDebug("月度硬性预算 {BudgetName}: 限额={Limit}, 本月天数={DaysInMonth}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
|
||||
budget.Name, budget.Limit, daysInMonth, daysElapsed, mandatoryAccumulation);
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 年度硬性预算按天数比例累加
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var daysElapsed = now.DayOfYear;
|
||||
mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
|
||||
accumulationAlgorithm = $"年度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInYear} = {mandatoryAccumulation:F2}";
|
||||
logger.LogDebug("年度硬性预算 {BudgetName}: 限额={Limit}, 本年天数={DaysInYear}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
|
||||
budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation);
|
||||
}
|
||||
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 应累加值计算: 算法={Algorithm}",
|
||||
budget.Name, budget.Id, accumulationAlgorithm);
|
||||
|
||||
// 如果趋势数据中的累计值小于硬性预算的应累加值,使用硬性预算的值
|
||||
if (adjustedTotal < mandatoryAccumulation)
|
||||
{
|
||||
var adjustmentAmount = mandatoryAccumulation - adjustedTotal;
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}",
|
||||
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation);
|
||||
adjustedTotal = mandatoryAccumulation;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("硬性预算 {BudgetName} (ID={BudgetId}) 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}",
|
||||
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 不在当前统计周期,跳过调整", budget.Name, budget.Id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("硬性预算调整完成: 最终总计={AdjustedTotal}", adjustedTotal);
|
||||
return adjustedTotal;
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate);
|
||||
|
||||
// 创建临时的BudgetRecord用于查询
|
||||
var tempRecord = new BudgetRecord
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
Type = budget.Type,
|
||||
Limit = budget.Limit,
|
||||
Category = budget.Category,
|
||||
SelectedCategories = string.Join(",", budget.SelectedCategories),
|
||||
StartDate = referenceDate,
|
||||
NoLimit = budget.NoLimit,
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense
|
||||
};
|
||||
|
||||
// 获取预算的实际时间段
|
||||
var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(tempRecord.StartDate, tempRecord.Type, referenceDate);
|
||||
|
||||
// 确保在统计时间段内
|
||||
if (budgetEnd < startDate || budgetStart > endDate)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var actualAmount = await budgetRepository.GetCurrentAmountAsync(tempRecord, startDate, endDate);
|
||||
|
||||
// 硬性预算的特殊处理
|
||||
if (actualAmount == 0 && budget.IsMandatoryExpense)
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
||||
var daysElapsed = dateTimeProvider.Now.Day;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var daysElapsed = dateTimeProvider.Now.DayOfYear;
|
||||
actualAmount = budget.Limit * daysElapsed / daysInYear;
|
||||
}
|
||||
}
|
||||
|
||||
return actualAmount;
|
||||
}
|
||||
|
||||
private (DateTime start, DateTime end) GetStatPeriodRange(BudgetPeriodType statType, DateTime referenceDate)
|
||||
{
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
var start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
||||
var end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
return (start, end);
|
||||
}
|
||||
else // Year
|
||||
{
|
||||
var start = new DateTime(referenceDate.Year, 1, 1);
|
||||
var end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
return (start, end);
|
||||
}
|
||||
}
|
||||
|
||||
private class BudgetStatsItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; set; }
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Current { get; set; }
|
||||
public BudgetCategory Category { get; set; }
|
||||
public string[] SelectedCategories { get; set; } = [];
|
||||
public bool NoLimit { get; set; }
|
||||
public bool IsMandatoryExpense { get; set; }
|
||||
public bool IsArchive { get; set; }
|
||||
public int ArchiveMonth { get; set; } // 归档月份(1-12),用于标识归档数据来自哪个月
|
||||
public int RemainingMonths { get; set; } // 剩余月份数,用于年度统计时的月度预算折算
|
||||
}
|
||||
}
|
||||
728
Service/BudgetService.cs
Normal file
728
Service/BudgetService.cs
Normal file
@@ -0,0 +1,728 @@
|
||||
namespace Service;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null);
|
||||
|
||||
Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate);
|
||||
|
||||
Task<string> ArchiveBudgetsAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定分类的统计信息(月度和年度)
|
||||
/// </summary>
|
||||
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null);
|
||||
|
||||
/// <summary>
|
||||
/// 获取未被预算覆盖的分类统计信息
|
||||
/// </summary>
|
||||
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
|
||||
}
|
||||
|
||||
public class BudgetService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
IOpenAiService openAiService,
|
||||
IConfigService configService,
|
||||
IMessageService messageService,
|
||||
ILogger<BudgetService> logger
|
||||
) : IBudgetService
|
||||
{
|
||||
public async Task<List<BudgetResult>> GetListAsync(DateTime? referenceDate = null)
|
||||
{
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
var dtos = new List<BudgetResult?>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
|
||||
}
|
||||
|
||||
// 创造虚拟的存款预算
|
||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType.Month,
|
||||
referenceDate,
|
||||
budgets));
|
||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType.Year,
|
||||
referenceDate,
|
||||
budgets));
|
||||
|
||||
return dtos.Where(dto => dto != null).Cast<BudgetResult>().ToList();
|
||||
}
|
||||
|
||||
public async Task<BudgetResult?> GetStatisticsAsync(long id, DateTime referenceDate)
|
||||
{
|
||||
bool isArchive = false;
|
||||
BudgetRecord? budget = null;
|
||||
if (id == -1)
|
||||
{
|
||||
if (isAcrhiveFunc(BudgetPeriodType.Year))
|
||||
{
|
||||
isArchive = true;
|
||||
budget = await BuildVirtualSavingsBudgetRecordAsync(-1, referenceDate, 0);
|
||||
}
|
||||
|
||||
}
|
||||
else if (id == -2)
|
||||
{
|
||||
if (isAcrhiveFunc(BudgetPeriodType.Month))
|
||||
{
|
||||
isArchive = true;
|
||||
budget = await BuildVirtualSavingsBudgetRecordAsync(-2, referenceDate, 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
budget = await budgetRepository.GetByIdAsync(id);
|
||||
|
||||
if (budget == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
isArchive = isAcrhiveFunc(budget.Type);
|
||||
}
|
||||
|
||||
if (isArchive && budget != null)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(
|
||||
id,
|
||||
referenceDate.Year,
|
||||
referenceDate.Month);
|
||||
|
||||
if (archive != null) // 存在归档 直接读取归档数据
|
||||
{
|
||||
budget.Limit = archive.BudgetedAmount;
|
||||
return BudgetResult.FromEntity(
|
||||
budget,
|
||||
archive.RealizedAmount,
|
||||
referenceDate,
|
||||
archive.Description ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (id == -1)
|
||||
{
|
||||
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
|
||||
}
|
||||
if (id == -2)
|
||||
{
|
||||
return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||
}
|
||||
|
||||
budget = await budgetRepository.GetByIdAsync(id);
|
||||
if (budget == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
return BudgetResult.FromEntity(budget, currentAmount, referenceDate);
|
||||
|
||||
bool isAcrhiveFunc(BudgetPeriodType periodType)
|
||||
{
|
||||
if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
return DateTime.Now.Year > referenceDate.Year;
|
||||
}
|
||||
else if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
return DateTime.Now.Year > referenceDate.Year
|
||||
|| (DateTime.Now.Year == referenceDate.Year
|
||||
&& DateTime.Now.Month > referenceDate.Month);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||
{
|
||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||
var refDate = referenceDate ?? DateTime.Now;
|
||||
|
||||
var result = new BudgetCategoryStats();
|
||||
|
||||
// 获取月度统计
|
||||
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, refDate);
|
||||
|
||||
// 获取年度统计
|
||||
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, refDate);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
||||
{
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
|
||||
if (transactionType == TransactionType.None) return new List<UncoveredCategoryDetail>();
|
||||
|
||||
// 1. 获取所有预算
|
||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||
var coveredCategories = budgets
|
||||
.Where(b => b.Category == category)
|
||||
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet();
|
||||
|
||||
// 2. 获取分类统计
|
||||
var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
|
||||
|
||||
// 3. 过滤未覆盖的
|
||||
return stats
|
||||
.Where(s => !coveredCategories.Contains(s.Classify))
|
||||
.Select(s => new UncoveredCategoryDetail
|
||||
{
|
||||
Category = s.Classify,
|
||||
TransactionCount = s.Count,
|
||||
TotalAmount = s.Amount
|
||||
})
|
||||
.OrderByDescending(x => x.TotalAmount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
|
||||
List<BudgetRecord> budgets,
|
||||
BudgetCategory category,
|
||||
BudgetPeriodType statType,
|
||||
DateTime referenceDate)
|
||||
{
|
||||
var result = new BudgetStatsDto
|
||||
{
|
||||
PeriodType = statType,
|
||||
Rate = 0,
|
||||
Current = 0,
|
||||
Limit = 0,
|
||||
Count = 0
|
||||
};
|
||||
|
||||
// 获取当前分类下所有预算
|
||||
var relevant = budgets
|
||||
.Where(b => b.Category == category)
|
||||
.ToList();
|
||||
|
||||
if (relevant.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Count = relevant.Count;
|
||||
decimal totalCurrent = 0;
|
||||
decimal totalLimit = 0;
|
||||
|
||||
foreach (var budget in relevant)
|
||||
{
|
||||
// 限额折算
|
||||
var itemLimit = budget.Limit;
|
||||
if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 月度视图下,年度预算不参与限额计算
|
||||
itemLimit = 0;
|
||||
}
|
||||
else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 年度视图下,月度预算折算为年度
|
||||
itemLimit = budget.Limit * 12;
|
||||
}
|
||||
totalLimit += itemLimit;
|
||||
|
||||
// 当前值累加
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
||||
if (budget.Type == statType)
|
||||
{
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果周期不匹配
|
||||
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
// 月度视图下,年度预算的 current 不计入
|
||||
}
|
||||
}
|
||||
|
||||
result.Limit = totalLimit;
|
||||
result.Current = totalCurrent;
|
||||
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<string> ArchiveBudgetsAsync(int year, int month)
|
||||
{
|
||||
var referenceDate = new DateTime(year, month, 1);
|
||||
var budgets = await GetListAsync(referenceDate);
|
||||
|
||||
var addArchives = new List<BudgetArchive>();
|
||||
var updateArchives = new List<BudgetArchive>();
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month);
|
||||
|
||||
if (archive != null)
|
||||
{
|
||||
archive.RealizedAmount = budget.Current;
|
||||
archive.ArchiveDate = DateTime.Now;
|
||||
archive.Description = budget.Description;
|
||||
updateArchives.Add(archive);
|
||||
}
|
||||
else
|
||||
{
|
||||
archive = new BudgetArchive
|
||||
{
|
||||
BudgetId = budget.Id,
|
||||
BudgetType = budget.Type,
|
||||
Year = year,
|
||||
Month = month,
|
||||
BudgetedAmount = budget.Limit,
|
||||
RealizedAmount = budget.Current,
|
||||
Description = budget.Description,
|
||||
ArchiveDate = DateTime.Now
|
||||
};
|
||||
|
||||
addArchives.Add(archive);
|
||||
}
|
||||
}
|
||||
|
||||
if (addArchives.Count > 0)
|
||||
{
|
||||
if (!await budgetArchiveRepository.AddRangeAsync(addArchives))
|
||||
{
|
||||
return "保存预算归档失败";
|
||||
}
|
||||
}
|
||||
if (updateArchives.Count > 0)
|
||||
{
|
||||
if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives))
|
||||
{
|
||||
return "更新预算归档失败";
|
||||
}
|
||||
}
|
||||
|
||||
_ = NotifyAsync(year, month);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task NotifyAsync(int year, int month)
|
||||
{
|
||||
try
|
||||
{
|
||||
var archives = await budgetArchiveRepository.GetListAsync(year, month);
|
||||
var budgets = await budgetRepository.GetAllAsync();
|
||||
var budgetMap = budgets.ToDictionary(b => b.Id, b => b);
|
||||
|
||||
var archiveData = archives.Select(a =>
|
||||
{
|
||||
budgetMap.TryGetValue(a.BudgetId, out var br);
|
||||
var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知");
|
||||
return new
|
||||
{
|
||||
Name = name,
|
||||
Type = a.BudgetType.ToString(),
|
||||
Limit = a.BudgetedAmount,
|
||||
Actual = a.RealizedAmount,
|
||||
Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown")
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-01-01'
|
||||
AND OccurredAt < '{year + 1}-01-01'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
var monthYear = new DateTime(year, month, 1).AddMonths(1);
|
||||
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
||||
$"""
|
||||
SELECT
|
||||
COUNT(*) AS TransactionCount,
|
||||
SUM(ABS(Amount)) AS TotalAmount,
|
||||
Type,
|
||||
Classify
|
||||
FROM TransactionRecord
|
||||
WHERE OccurredAt >= '{year}-{month:00}-01'
|
||||
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
|
||||
GROUP BY Type, Classify
|
||||
ORDER BY TotalAmount DESC
|
||||
"""
|
||||
);
|
||||
|
||||
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
|
||||
var budgetedCategories = budgets
|
||||
.Where(b => !string.IsNullOrEmpty(b.SelectedCategories))
|
||||
.SelectMany(b => b.SelectedCategories.Split(','))
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var uncovered = monthTransactions
|
||||
.Where(t =>
|
||||
{
|
||||
var dict = (IDictionary<string, object>)t;
|
||||
var classify = dict["Classify"]?.ToString() ?? "";
|
||||
var type = Convert.ToInt32(dict["Type"]);
|
||||
return type == 0 && !budgetedCategories.Contains(classify);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
|
||||
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
|
||||
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
|
||||
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
|
||||
|
||||
var dataPrompt = $"""
|
||||
报告周期:{year}年{month}月
|
||||
账单数据说明:支出金额已取绝对值(TotalAmount 为正数表示支出/收入的总量)。
|
||||
|
||||
1. 预算执行数据(JSON):
|
||||
{JsonSerializer.Serialize(archiveData)}
|
||||
|
||||
2. 本月消费明细(按分类, JSON):
|
||||
{JsonSerializer.Serialize(monthTransactions)}
|
||||
|
||||
3. 全年累计消费概况(按分类, JSON):
|
||||
{JsonSerializer.Serialize(yearTransactions)}
|
||||
|
||||
4. 未被任何预算覆盖的支出分类(JSON):
|
||||
{JsonSerializer.Serialize(uncovered)}
|
||||
|
||||
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
|
||||
|
||||
【内容要求】
|
||||
1. 概览:总结本月预算达成情况。
|
||||
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
|
||||
3. 超支/异常预警:重点分析超支项或支出异常的分类。
|
||||
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
|
||||
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
|
||||
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
|
||||
7.
|
||||
|
||||
【格式要求】
|
||||
1. 使用HTML格式(移动端H5页面风格)
|
||||
2. 生成清晰的报告标题(基于用户问题)
|
||||
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
|
||||
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
||||
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
||||
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
||||
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
||||
|
||||
【样式限制(重要)】
|
||||
8. 不要包含 html、body、head 标签
|
||||
9. 不要使用任何 style 属性或 <style> 标签
|
||||
10. 不要设置 background、background-color、color 等样式属性
|
||||
11. 不要使用 div 包裹大段内容
|
||||
|
||||
【系统信息】
|
||||
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
|
||||
预算归档周期:{year}年{month}月
|
||||
|
||||
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||
""";
|
||||
|
||||
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
||||
if (!string.IsNullOrEmpty(htmlReport))
|
||||
{
|
||||
await messageService.AddAsync(
|
||||
title: $"{year}年{month}月 - 预算归档报告",
|
||||
content: htmlReport,
|
||||
type: MessageType.Html,
|
||||
url: "/balance?tab=message");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "生成预算执行通知报告失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
||||
{
|
||||
var referenceDate = now ?? DateTime.Now;
|
||||
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
||||
|
||||
return await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
||||
}
|
||||
|
||||
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
||||
{
|
||||
DateTime start;
|
||||
DateTime end;
|
||||
|
||||
if (type == BudgetPeriodType.Month)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
||||
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else if (type == BudgetPeriodType.Year)
|
||||
{
|
||||
start = new DateTime(referenceDate.Year, 1, 1);
|
||||
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
}
|
||||
else
|
||||
{
|
||||
start = startDate;
|
||||
end = DateTime.MaxValue;
|
||||
}
|
||||
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private async Task<BudgetResult?> GetVirtualSavingsDtoAsync(
|
||||
BudgetPeriodType periodType,
|
||||
DateTime? referenceDate = null,
|
||||
IEnumerable<BudgetRecord>? existingBudgets = null)
|
||||
{
|
||||
var allBudgets = existingBudgets;
|
||||
|
||||
if (existingBudgets == null)
|
||||
{
|
||||
allBudgets = await budgetRepository.GetAllAsync();
|
||||
}
|
||||
|
||||
if (allBudgets == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
|
||||
decimal incomeLimitAtPeriod = 0;
|
||||
decimal expenseLimitAtPeriod = 0;
|
||||
|
||||
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Total)>();
|
||||
|
||||
foreach (var b in allBudgets)
|
||||
{
|
||||
if (b.Category == BudgetCategory.Savings) continue;
|
||||
|
||||
// 折算系数:根据当前请求的 periodType (Year 或 Month),将预算 b 的 Limit 折算过来
|
||||
decimal factor = 1.0m;
|
||||
|
||||
if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
factor = b.Type switch
|
||||
{
|
||||
BudgetPeriodType.Month => 12,
|
||||
BudgetPeriodType.Year => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
else if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
factor = b.Type switch
|
||||
{
|
||||
BudgetPeriodType.Month => 1,
|
||||
BudgetPeriodType.Year => 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
factor = 0; // 其他周期暂不计算虚拟存款
|
||||
}
|
||||
|
||||
if (factor <= 0) continue;
|
||||
|
||||
var subtotal = b.Limit * factor;
|
||||
if (b.Category == BudgetCategory.Income)
|
||||
{
|
||||
incomeLimitAtPeriod += subtotal;
|
||||
incomeItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||
}
|
||||
else if (b.Category == BudgetCategory.Expense)
|
||||
{
|
||||
expenseLimitAtPeriod += subtotal;
|
||||
expenseItems.Add((b.Name, b.Limit, factor, subtotal));
|
||||
}
|
||||
}
|
||||
|
||||
var description = new StringBuilder();
|
||||
description.Append("<h3>预算收入明细</h3>");
|
||||
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
|
||||
else
|
||||
{
|
||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||
foreach (var item in incomeItems)
|
||||
{
|
||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='income-value'>{item.Total:N0}</span></td></tr>");
|
||||
}
|
||||
description.Append("</tbody></table>");
|
||||
}
|
||||
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
description.Append("<h3>预算支出明细</h3>");
|
||||
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
|
||||
else
|
||||
{
|
||||
description.Append("<table><thead><tr><th>名称</th><th>金额</th><th>折算</th><th>合计</th></tr></thead><tbody>");
|
||||
foreach (var item in expenseItems)
|
||||
{
|
||||
description.Append($"<tr><td>{item.Name}</td><td>{item.Limit:N0}</td><td>x{item.Factor:0.##}</td><td><span class='expense-value'>{item.Total:N0}</span></td></tr>");
|
||||
}
|
||||
description.Append("</tbody></table>");
|
||||
}
|
||||
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
description.Append("<h3>存款计划结论</h3>");
|
||||
description.Append($"<p>计划存款 = 收入 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> - 支出 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span></p>");
|
||||
description.Append($"<p>最终目标:<span class='highlight'><strong>{incomeLimitAtPeriod - expenseLimitAtPeriod:N0}</strong></span></p>");
|
||||
|
||||
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
|
||||
periodType == BudgetPeriodType.Year ? -1 : -2,
|
||||
date,
|
||||
incomeLimitAtPeriod - expenseLimitAtPeriod);
|
||||
|
||||
// 计算实际发生的 收入 - 支出
|
||||
var current = await CalculateCurrentAmountAsync(new BudgetRecord
|
||||
{
|
||||
Category = virtualBudget.Category,
|
||||
Type = virtualBudget.Type,
|
||||
SelectedCategories = virtualBudget.SelectedCategories,
|
||||
StartDate = virtualBudget.StartDate,
|
||||
}, date);
|
||||
|
||||
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
|
||||
}
|
||||
|
||||
private async Task<BudgetRecord> BuildVirtualSavingsBudgetRecordAsync(
|
||||
long id,
|
||||
DateTime date,
|
||||
decimal limit)
|
||||
{
|
||||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||||
return new BudgetRecord
|
||||
{
|
||||
Id = id,
|
||||
Name = id == -1 ? "年度存款" : "月度存款",
|
||||
Category = BudgetCategory.Savings,
|
||||
Type = id == -1 ? BudgetPeriodType.Year : BudgetPeriodType.Month,
|
||||
Limit = limit,
|
||||
StartDate = id == -1
|
||||
? new DateTime(date.Year, 1, 1)
|
||||
: new DateTime(date.Year, date.Month, 1),
|
||||
SelectedCategories = savingsCategories
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record BudgetResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; set; }
|
||||
public decimal Limit { get; set; }
|
||||
public decimal Current { get; set; }
|
||||
public BudgetCategory Category { get; set; }
|
||||
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
public string Period { get; set; } = string.Empty;
|
||||
public DateTime? PeriodStart { get; set; }
|
||||
public DateTime? PeriodEnd { get; set; }
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public static BudgetResult FromEntity(
|
||||
BudgetRecord entity,
|
||||
decimal currentAmount = 0,
|
||||
DateTime? referenceDate = null,
|
||||
string description = "")
|
||||
{
|
||||
var date = referenceDate ?? DateTime.Now;
|
||||
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
||||
|
||||
return new BudgetResult
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Type = entity.Type,
|
||||
Limit = entity.Limit,
|
||||
Current = currentAmount,
|
||||
Category = entity.Category,
|
||||
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
||||
? Array.Empty<string>()
|
||||
: entity.SelectedCategories.Split(','),
|
||||
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
||||
Period = entity.Type switch
|
||||
{
|
||||
BudgetPeriodType.Year => $"{start:yy}年",
|
||||
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
||||
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
||||
},
|
||||
PeriodStart = start,
|
||||
PeriodEnd = end,
|
||||
Description = description
|
||||
};
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 预算统计结果 DTO
|
||||
/// </summary>
|
||||
public class BudgetStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计周期类型(Month/Year)
|
||||
/// </summary>
|
||||
public BudgetPeriodType PeriodType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用率百分比(0-100)
|
||||
/// </summary>
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际金额
|
||||
/// </summary>
|
||||
public decimal Current { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标/限额金额
|
||||
/// </summary>
|
||||
public decimal Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预算项数量
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类统计结果
|
||||
/// </summary>
|
||||
public class BudgetCategoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// 月度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Month { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 年度统计
|
||||
/// </summary>
|
||||
public BudgetStatsDto Year { get; set; } = new();
|
||||
}
|
||||
public class UncoveredCategoryDetail
|
||||
{
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
@@ -43,12 +43,12 @@ public class ConfigService(IConfigRepository configRepository) : IConfigService
|
||||
var config = await configRepository.GetByKeyAsync(key);
|
||||
var type = typeof(T) switch
|
||||
{
|
||||
{ } t when t == typeof(bool) => ConfigType.Boolean,
|
||||
{ } t when t == typeof(int)
|
||||
Type t when t == typeof(bool) => ConfigType.Boolean,
|
||||
Type t when t == typeof(int)
|
||||
|| t == typeof(double)
|
||||
|| t == typeof(float)
|
||||
|| t == typeof(decimal) => ConfigType.Number,
|
||||
{ } t when t == typeof(string) => ConfigType.String,
|
||||
Type t when t == typeof(string) => ConfigType.String,
|
||||
_ => ConfigType.Json
|
||||
};
|
||||
var valueStr = type switch
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Service.EmailServices.EmailParse;
|
||||
using Service.EmailParseServices;
|
||||
|
||||
namespace Service.EmailServices;
|
||||
|
||||
@@ -65,7 +65,7 @@ public class EmailHandleService(
|
||||
await messageService.AddAsync(
|
||||
"邮件解析失败",
|
||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
||||
url: "/balance?tab=email"
|
||||
url: $"/balance?tab=email"
|
||||
);
|
||||
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Service.EmailServices.EmailParse;
|
||||
namespace Service.EmailParseServices;
|
||||
|
||||
public class EmailParseForm95555(
|
||||
ILogger<EmailParseForm95555> logger,
|
||||
@@ -26,7 +26,7 @@ public class EmailParseForm95555(
|
||||
return true;
|
||||
}
|
||||
|
||||
public override Task<(
|
||||
public override async Task<(
|
||||
string card,
|
||||
string reason,
|
||||
decimal amount,
|
||||
@@ -51,7 +51,7 @@ public class EmailParseForm95555(
|
||||
if (matches.Count <= 0)
|
||||
{
|
||||
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
||||
return Task.FromResult<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]>([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<(
|
||||
@@ -85,7 +85,7 @@ public class EmailParseForm95555(
|
||||
results.Add((card, reason, amount, balance, type, occurredAt));
|
||||
}
|
||||
}
|
||||
return Task.FromResult(results.ToArray());
|
||||
return results.ToArray();
|
||||
}
|
||||
|
||||
private DateTime? ParseOccurredAt(string value)
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
using HtmlAgilityPack;
|
||||
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
||||
|
||||
namespace Service.EmailServices.EmailParse;
|
||||
namespace Service.EmailParseServices;
|
||||
|
||||
[UsedImplicitly]
|
||||
public partial class EmailParseFormCcsvc(
|
||||
ILogger<EmailParseFormCcsvc> logger,
|
||||
public class EmailParseFormCCSVC(
|
||||
ILogger<EmailParseFormCCSVC> logger,
|
||||
IOpenAiService openAiService
|
||||
) : EmailParseServicesBase(logger, openAiService)
|
||||
{
|
||||
[GeneratedRegex("<.*?>")]
|
||||
private static partial Regex HtmlRegex();
|
||||
|
||||
public override bool CanParse(string from, string subject, string body)
|
||||
{
|
||||
if (!from.Contains("ccsvc@message.cmbchina.com"))
|
||||
@@ -26,7 +20,12 @@ public partial class EmailParseFormCcsvc(
|
||||
}
|
||||
|
||||
// 必须包含HTML标签
|
||||
return HtmlRegex().IsMatch(body);
|
||||
if (!Regex.IsMatch(body, "<.*?>"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override async Task<(
|
||||
@@ -48,7 +47,7 @@ public partial class EmailParseFormCcsvc(
|
||||
if (dateNode == null)
|
||||
{
|
||||
logger.LogWarning("Date node not found");
|
||||
return [];
|
||||
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
||||
}
|
||||
|
||||
var dateText = dateNode.InnerText.Trim();
|
||||
@@ -57,7 +56,7 @@ public partial class EmailParseFormCcsvc(
|
||||
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
||||
{
|
||||
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
||||
return [];
|
||||
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
||||
}
|
||||
|
||||
// 2. Get Balance (Available Limit)
|
||||
@@ -91,7 +90,6 @@ public partial class EmailParseFormCcsvc(
|
||||
{
|
||||
foreach (var node in transactionNodes)
|
||||
{
|
||||
string card = "";
|
||||
try
|
||||
{
|
||||
// Time
|
||||
@@ -124,16 +122,23 @@ public partial class EmailParseFormCcsvc(
|
||||
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
|
||||
|
||||
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
|
||||
var parts = descText.Split([' '], StringSplitOptions.RemoveEmptyEntries);
|
||||
var parts = descText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var reason = descText;
|
||||
TransactionType type;
|
||||
string card = "";
|
||||
string reason = descText;
|
||||
TransactionType type = TransactionType.Expense;
|
||||
|
||||
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
|
||||
{
|
||||
card = parts[0].Replace("尾号", "");
|
||||
}
|
||||
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
var typeStr = parts[1];
|
||||
type = DetermineTransactionType(typeStr, reason, amount);
|
||||
}
|
||||
|
||||
if (parts.Length > 2)
|
||||
{
|
||||
reason = string.Join(" ", parts.Skip(2));
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Service.EmailServices.EmailParse;
|
||||
namespace Service.EmailParseServices;
|
||||
|
||||
public interface IEmailParseServices
|
||||
{
|
||||
@@ -201,7 +201,7 @@ public abstract class EmailParseServicesBase(
|
||||
|
||||
// 收入关键词
|
||||
string[] incomeKeywords =
|
||||
[
|
||||
{
|
||||
"工资", "奖金", "退款",
|
||||
"返现", "收入", "转入",
|
||||
"存入", "利息", "分红",
|
||||
@@ -233,13 +233,13 @@ public abstract class EmailParseServicesBase(
|
||||
// 存取类
|
||||
"现金存入", "柜台存入", "ATM存入",
|
||||
"他人转入", "他人汇入"
|
||||
];
|
||||
};
|
||||
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
||||
return TransactionType.Income;
|
||||
|
||||
// 支出关键词
|
||||
string[] expenseKeywords =
|
||||
[
|
||||
{
|
||||
"消费", "支付", "购买",
|
||||
"转出", "取款", "支出",
|
||||
"扣款", "缴费", "付款",
|
||||
@@ -269,7 +269,7 @@ public abstract class EmailParseServicesBase(
|
||||
// 信用卡/花呗等场景
|
||||
"信用卡还款", "花呗还款", "白条还款",
|
||||
"分期还款", "账单还款", "自动还款"
|
||||
];
|
||||
};
|
||||
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
||||
return TransactionType.Expense;
|
||||
|
||||
|
||||
@@ -182,7 +182,6 @@ public class EmailSyncService(
|
||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||
|
||||
// ReSharper disable once UnusedVariable
|
||||
foreach (var (message, uid) in unreadMessages)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -7,12 +7,11 @@ global using System.Globalization;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using Entity;
|
||||
global using FreeSql;
|
||||
global using System.Linq;
|
||||
global using Service.AppSettingModel;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Text.Json.Nodes;
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Common;
|
||||
global using System.Net;
|
||||
global using System.Text.Encodings.Web;
|
||||
global using JetBrains.Annotations;
|
||||
global using Service.AgentFramework;
|
||||
@@ -133,7 +133,7 @@ public class ImportService(
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
foreach (var format in _dateTimeFormats)
|
||||
foreach (var format in DateTimeFormats)
|
||||
{
|
||||
if (DateTime.TryParseExact(
|
||||
row[key],
|
||||
@@ -288,7 +288,7 @@ public class ImportService(
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
foreach (var format in _dateTimeFormats)
|
||||
foreach (var format in DateTimeFormats)
|
||||
{
|
||||
if (DateTime.TryParseExact(
|
||||
row[key],
|
||||
@@ -358,14 +358,15 @@ public class ImportService(
|
||||
{
|
||||
return await ParseCsvAsync(file);
|
||||
}
|
||||
|
||||
if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
||||
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
||||
{
|
||||
return await ParseExcelAsync(file);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException("不支持的文件格式");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
||||
{
|
||||
@@ -387,7 +388,7 @@ public class ImportService(
|
||||
|
||||
if (headers == null || headers.Length == 0)
|
||||
{
|
||||
return [];
|
||||
return Array.Empty<IDictionary<string, string>>();
|
||||
}
|
||||
|
||||
var result = new List<IDictionary<string, string>>();
|
||||
@@ -419,7 +420,7 @@ public class ImportService(
|
||||
|
||||
if (worksheet == null || worksheet.Dimension == null)
|
||||
{
|
||||
return [];
|
||||
return Array.Empty<IDictionary<string, string>>();
|
||||
}
|
||||
|
||||
var rowCount = worksheet.Dimension.End.Row;
|
||||
@@ -427,7 +428,7 @@ public class ImportService(
|
||||
|
||||
if (rowCount < 2)
|
||||
{
|
||||
return [];
|
||||
return Array.Empty<IDictionary<string, string>>();
|
||||
}
|
||||
|
||||
// 读取表头(第一行)
|
||||
@@ -457,7 +458,7 @@ public class ImportService(
|
||||
return await Task.FromResult(result.ToArray());
|
||||
}
|
||||
|
||||
private static string[] _dateTimeFormats =
|
||||
private static string[] DateTimeFormats =
|
||||
[
|
||||
"yyyy-MM-dd",
|
||||
"yyyy-MM-dd HH",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Quartz;
|
||||
using Service.Budget;
|
||||
|
||||
namespace Service.Jobs;
|
||||
|
||||
@@ -24,8 +23,6 @@ public class BudgetArchiveJob(
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var budgetService = scope.ServiceProvider.GetRequiredService<IBudgetService>();
|
||||
|
||||
// 归档月度数据
|
||||
var result = await budgetService.ArchiveBudgetsAsync(year, month);
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Quartz;
|
||||
|
||||
namespace Service.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库备份任务
|
||||
/// </summary>
|
||||
public class DbBackupJob(
|
||||
IHostEnvironment env,
|
||||
ILogger<DbBackupJob> logger) : IJob
|
||||
{
|
||||
public Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始执行数据库备份任务");
|
||||
|
||||
// 数据库文件路径 (基于 appsettings.json 中的配置: database/EmailBill.db)
|
||||
var dbPath = Path.Combine(env.ContentRootPath, "database", "EmailBill.db");
|
||||
var backupDir = Path.Combine(env.ContentRootPath, "database", "backups");
|
||||
|
||||
if (!File.Exists(dbPath))
|
||||
{
|
||||
logger.LogWarning("数据库文件不存在,跳过备份: {Path}", dbPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(backupDir))
|
||||
{
|
||||
Directory.CreateDirectory(backupDir);
|
||||
}
|
||||
|
||||
// 创建备份
|
||||
var backupFileName = $"EmailBill_backup_{DateTime.Now:yyyyMMdd}.db";
|
||||
var backupPath = Path.Combine(backupDir, backupFileName);
|
||||
|
||||
File.Copy(dbPath, backupPath, true);
|
||||
logger.LogInformation("数据库备份成功: {Path}", backupPath);
|
||||
|
||||
// 清理旧备份 (保留最近20个)
|
||||
var files = new DirectoryInfo(backupDir).GetFiles("EmailBill_backup_*.db")
|
||||
.OrderByDescending(f => f.LastWriteTime) // 使用 LastWriteTime 排序
|
||||
.ToList();
|
||||
|
||||
if (files.Count > 20)
|
||||
{
|
||||
var filesToDelete = files.Skip(20);
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
file.Delete();
|
||||
logger.LogInformation("删除过期备份: {Name}", file.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "删除过期备份失败: {Name}", file.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "数据库备份任务执行失败");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,6 @@ public class EmailSyncJob(
|
||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||
|
||||
// ReSharper disable once UnusedVariable
|
||||
foreach (var (message, uid) in unreadMessages)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -68,8 +68,8 @@ public class LogCleanupService(ILogger<LogCleanupService> logger) : BackgroundSe
|
||||
|
||||
// 尝试解析日期 (格式: yyyyMMdd)
|
||||
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out var logDate))
|
||||
{
|
||||
if (logDate < cutoffDate)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using WebPush;
|
||||
using PushSubscription = Entity.PushSubscription;
|
||||
|
||||
namespace Service;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task<string> GetVapidPublicKeyAsync();
|
||||
Task SubscribeAsync(PushSubscription subscription);
|
||||
Task SubscribeAsync(Entity.PushSubscription subscription);
|
||||
Task SendNotificationAsync(string message, string? url = null);
|
||||
}
|
||||
|
||||
@@ -33,7 +32,7 @@ public class NotificationService(
|
||||
return Task.FromResult(GetSettings().PublicKey);
|
||||
}
|
||||
|
||||
public async Task SubscribeAsync(PushSubscription subscription)
|
||||
public async Task SubscribeAsync(Entity.PushSubscription subscription)
|
||||
{
|
||||
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
||||
if (existing != null)
|
||||
@@ -62,7 +61,7 @@ public class NotificationService(
|
||||
var webPushClient = new WebPushClient();
|
||||
|
||||
var subscriptions = await subscriptionRepo.GetAllAsync();
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
var payload = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
title = "System Notification",
|
||||
body = message,
|
||||
@@ -79,7 +78,7 @@ public class NotificationService(
|
||||
}
|
||||
catch (WebPushException ex)
|
||||
{
|
||||
if (ex.StatusCode == HttpStatusCode.Gone || ex.StatusCode == HttpStatusCode.NotFound)
|
||||
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
await subscriptionRepo.DeleteAsync(sub.Id);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public interface IOpenAiService
|
||||
}
|
||||
|
||||
public class OpenAiService(
|
||||
IOptions<AiSettings> aiSettings,
|
||||
IOptions<AISettings> aiSettings,
|
||||
ILogger<OpenAiService> logger
|
||||
) : IOpenAiService
|
||||
{
|
||||
@@ -158,8 +158,10 @@ public class OpenAiService(
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Content = content;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
@@ -230,8 +232,10 @@ public class OpenAiService(
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// 使用 SendAsync 来支持 HttpCompletionOption
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Content = content;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JetBrains.Annotations" />
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" />
|
||||
<PackageReference Include="MimeKit" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="CsvHelper" />
|
||||
<PackageReference Include="EPPlus" />
|
||||
<PackageReference Include="HtmlAgilityPack" />
|
||||
@@ -23,6 +23,7 @@
|
||||
<PackageReference Include="JiebaNet.Analyser" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="WebPush" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -143,7 +143,7 @@ public class SmartHandleService(
|
||||
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
||||
|
||||
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
|
||||
var sentIds = new HashSet<long>();
|
||||
var sendedIds = new HashSet<long>();
|
||||
|
||||
// 将流解析逻辑提取为本地函数以减少嵌套
|
||||
void HandleResult(GroupClassifyResult? result)
|
||||
@@ -154,11 +154,8 @@ public class SmartHandleService(
|
||||
if (group == null) return;
|
||||
foreach (var id in group.Ids)
|
||||
{
|
||||
if (!sentIds.Add(id))
|
||||
if (sendedIds.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resultJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
@@ -168,6 +165,7 @@ public class SmartHandleService(
|
||||
chunkAction(("data", resultJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析缓冲区中的所有完整 JSON 对象或数组
|
||||
void FlushBuffer(StringBuilder buffer)
|
||||
@@ -195,7 +193,7 @@ public class SmartHandleService(
|
||||
}
|
||||
catch (Exception exArr)
|
||||
{
|
||||
logger.LogDebug(exArr, "按数组解析AI返回失败,回退到逐对象解析。预览: {Preview}", arrJson.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
|
||||
logger.LogDebug(exArr, "按数组解析AI返回失败,回退到逐对象解析。预览: {Preview}", arrJson?.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,7 +336,7 @@ public class SmartHandleService(
|
||||
{
|
||||
content = $"""
|
||||
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
||||
{WebUtility.HtmlEncode(sqlText)}
|
||||
{System.Net.WebUtility.HtmlEncode(sqlText)}
|
||||
</pre>
|
||||
"""
|
||||
})
|
||||
@@ -363,7 +361,7 @@ public class SmartHandleService(
|
||||
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
||||
@@ -431,6 +429,7 @@ public class SmartHandleService(
|
||||
{
|
||||
// 获取所有分类
|
||||
var categories = await categoryRepository.GetAllAsync();
|
||||
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
||||
|
||||
// 构建分类信息
|
||||
var categoryInfo = new StringBuilder();
|
||||
@@ -543,13 +542,13 @@ public class SmartHandleService(
|
||||
public record GroupClassifyResult
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("classify")]
|
||||
public string? Classify { get; init; }
|
||||
public string? Classify { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public TransactionType Type { get; init; }
|
||||
public TransactionType Type { get; set; }
|
||||
}
|
||||
|
||||
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
||||
82
Service/SmartHandleServiceV2.cs
Normal file
82
Service/SmartHandleServiceV2.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
namespace Service;
|
||||
|
||||
/// <summary>
|
||||
/// 智能处理服务 - 使用 Agent Framework 重构
|
||||
/// </summary>
|
||||
public interface ISmartHandleServiceV2
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 进行智能分类
|
||||
/// </summary>
|
||||
Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
|
||||
long[] transactionIds,
|
||||
Action<(string type, string data)> chunkAction);
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 解析单行账单
|
||||
/// </summary>
|
||||
Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能处理服务实现 - Agent Framework 版本
|
||||
/// </summary>
|
||||
public class SmartHandleServiceV2(
|
||||
ClassificationAgent classificationAgent,
|
||||
ParsingAgent parsingAgent,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
ILogger<SmartHandleServiceV2> logger
|
||||
) : ISmartHandleServiceV2
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 进行智能分类
|
||||
/// </summary>
|
||||
public async Task<AgentResult<ClassificationResult[]>> SmartClassifyAgentAsync(
|
||||
long[] transactionIds,
|
||||
Action<(string type, string data)> chunkAction)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始执行智能分类 Agent,ID 数量: {Count}", transactionIds.Length);
|
||||
|
||||
var result = await classificationAgent.ExecuteAsync(transactionIds, categoryRepository);
|
||||
|
||||
logger.LogInformation("分类完成:{Summary}", result.Summary);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "智能分类 Agent 执行失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Agent Framework 解析单行账单
|
||||
/// </summary>
|
||||
public async Task<AgentResult<AgentFramework.TransactionParseResult?>> ParseOneLineBillAgentAsync(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始解析账单: {Text}", text);
|
||||
|
||||
var result = await parsingAgent.ExecuteAsync(text);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
logger.LogInformation("解析成功: {Summary}", result.Summary);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("解析失败: {Error}", result.Error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "解析 Agent 执行失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using JiebaNet.Analyser;
|
||||
using JiebaNet.Segmenter;
|
||||
namespace Service;
|
||||
|
||||
namespace Service;
|
||||
using JiebaNet.Segmenter;
|
||||
using JiebaNet.Analyser;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// 文本分词服务接口
|
||||
@@ -77,7 +78,7 @@ public class TextSegmentService : ITextSegmentService
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
@@ -118,7 +119,7 @@ public class TextSegmentService : ITextSegmentService
|
||||
{
|
||||
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
|
||||
// 降级处理:返回原文
|
||||
return [text.Length > 10 ? text.Substring(0, 10) : text];
|
||||
return new List<string> { text.Length > 10 ? text.Substring(0, 10) : text };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ public class TextSegmentService : ITextSegmentService
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
@@ -145,7 +146,7 @@ public class TextSegmentService : ITextSegmentService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "分词失败,文本: {Text}", text);
|
||||
return [text];
|
||||
return new List<string> { text };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public class TransactionPeriodicService(
|
||||
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 is >= 0 and <= 6)
|
||||
.Where(d => d >= 0 && d <= 6)
|
||||
.ToList();
|
||||
|
||||
return executeDays.Contains(dayOfWeek);
|
||||
@@ -160,7 +160,7 @@ public class TransactionPeriodicService(
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 1 and <= 31)
|
||||
.Where(d => d >= 1 && d <= 31)
|
||||
.ToList();
|
||||
|
||||
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
||||
@@ -223,7 +223,7 @@ public class TransactionPeriodicService(
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 0 and <= 6)
|
||||
.Where(d => d >= 0 && d <= 6)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
@@ -253,7 +253,7 @@ public class TransactionPeriodicService(
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 1 and <= 31)
|
||||
.Where(d => d >= 1 && d <= 31)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
|
||||
1
Web/.eslintcache
Normal file
1
Web/.eslintcache
Normal file
File diff suppressed because one or more lines are too long
@@ -2,6 +2,5 @@
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
@@ -1,82 +1,52 @@
|
||||
import js from '@eslint/js'
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**']
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**', '.nuxt/**'],
|
||||
},
|
||||
// Load Vue recommended rules first (sets up parser etc.)
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
|
||||
// General Configuration for all JS/Vue files
|
||||
{
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
files: ['**/*.{js,mjs,jsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Import standard JS recommended rules
|
||||
...js.configs.recommended.rules,
|
||||
|
||||
// --- Logic & Best Practices ---
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_'
|
||||
}],
|
||||
'no-undef': 'error',
|
||||
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
|
||||
'no-debugger': 'warn',
|
||||
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||
'curly': ['error', 'all'],
|
||||
'prefer-const': 'warn',
|
||||
'no-var': 'error',
|
||||
|
||||
// --- Formatting & Style (User requested warnings) ---
|
||||
'indent': ['error', 2, { SwitchCase: 1 }],
|
||||
'indent': ['error', 2],
|
||||
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'semi': ['error', 'never'],
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'no-trailing-spaces': 'error',
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
'space-before-function-paren': ['error', 'always'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never']
|
||||
}
|
||||
},
|
||||
|
||||
// Vue Specific Overrides
|
||||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'warn',
|
||||
|
||||
// Turn off standard indent for Vue files to avoid conflicts with vue/html-indent
|
||||
// or script indentation issues. Vue plugin handles this better.
|
||||
'indent': 'off',
|
||||
// Ensure Vue's own indentation rules are active (they are in 'recommended' but let's be explicit if needed)
|
||||
'vue/html-indent': ['error', 2],
|
||||
'vue/script-indent': ['error', 2, {
|
||||
baseIndent: 0,
|
||||
switchCase: 1,
|
||||
ignores: []
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// Service Worker specific globals
|
||||
},
|
||||
skipFormatting,
|
||||
{
|
||||
files: ['**/service-worker.js', '**/src/registerServiceWorker.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.serviceworker
|
||||
}
|
||||
}
|
||||
}
|
||||
...globals.serviceworker,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix --cache",
|
||||
"format": "prettier --write src/"
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vant": "^4.9.22",
|
||||
"vue": "^3.5.25",
|
||||
|
||||
23
Web/pnpm-lock.yaml
generated
23
Web/pnpm-lock.yaml
generated
@@ -14,9 +14,6 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(vue@3.5.26)
|
||||
@@ -790,9 +787,6 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
@@ -1302,9 +1296,6 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1444,9 +1435,6 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
@@ -2143,11 +2131,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
entities@7.0.0: {}
|
||||
@@ -2628,8 +2611,6 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2763,7 +2744,3 @@ snapshots:
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
const VERSION = '1.0.0' // Build Time: 2026-01-07 15:59:36
|
||||
const CACHE_NAME = `emailbill-${VERSION}`
|
||||
const VERSION = '1.0.0'; // Build Time: 2026-01-07 15:59:36
|
||||
const CACHE_NAME = `emailbill-${VERSION}`;
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/manifest.json'
|
||||
]
|
||||
];
|
||||
|
||||
// 安装 Service Worker
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] 安装中...')
|
||||
console.log('[Service Worker] 安装中...');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('[Service Worker] 缓存文件')
|
||||
return cache.addAll(urlsToCache)
|
||||
console.log('[Service Worker] 缓存文件');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 监听跳过等待消息
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
self.skipWaiting();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 激活 Service Worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[Service Worker] 激活中...')
|
||||
console.log('[Service Worker] 激活中...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log('[Service Worker] 删除旧缓存:', cacheName)
|
||||
return caches.delete(cacheName)
|
||||
console.log('[Service Worker] 删除旧缓存:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 拦截请求
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event
|
||||
const url = new URL(request.url)
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// 跳过跨域请求
|
||||
if (url.origin !== location.origin) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// API请求使用网络优先策略
|
||||
@@ -60,19 +60,19 @@ self.addEventListener('fetch', (event) => {
|
||||
.then((response) => {
|
||||
// 只针对成功的GET请求进行缓存
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const responseClone = response.clone()
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone)
|
||||
})
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
}
|
||||
return response
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络失败时尝试从缓存获取
|
||||
return caches.match(request)
|
||||
return caches.match(request);
|
||||
})
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 页面请求使用网络优先策略,确保能获取到最新的 index.html
|
||||
@@ -80,17 +80,17 @@ self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const responseClone = response.clone()
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone)
|
||||
})
|
||||
return response
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match('/index.html') || caches.match(request)
|
||||
return caches.match('/index.html') || caches.match(request);
|
||||
})
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他静态资源使用缓存优先策略
|
||||
@@ -98,50 +98,50 @@ self.addEventListener('fetch', (event) => {
|
||||
caches.match(request)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return response
|
||||
return response;
|
||||
}
|
||||
return fetch(request).then((response) => {
|
||||
// 检查是否是有效响应
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseClone = response.clone()
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone)
|
||||
})
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
|
||||
return response
|
||||
})
|
||||
return response;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 返回离线页面或默认内容
|
||||
if (request.destination === 'document') {
|
||||
return caches.match('/index.html')
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 后台同步
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[Service Worker] 后台同步:', event.tag)
|
||||
console.log('[Service Worker] 后台同步:', event.tag);
|
||||
if (event.tag === 'sync-data') {
|
||||
event.waitUntil(syncData())
|
||||
event.waitUntil(syncData());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 推送通知
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] 收到推送消息')
|
||||
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' }
|
||||
console.log('[Service Worker] 收到推送消息');
|
||||
let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const json = event.data.json()
|
||||
data = { ...data, ...json }
|
||||
const json = event.data.json();
|
||||
data = { ...data, ...json };
|
||||
} catch {
|
||||
data.body = event.data.text()
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,41 +153,41 @@ self.addEventListener('push', (event) => {
|
||||
tag: 'emailbill-notification',
|
||||
requireInteraction: false,
|
||||
data: { url: data.url }
|
||||
}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 通知点击
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[Service Worker] 通知被点击')
|
||||
event.notification.close()
|
||||
const urlToOpen = event.notification.data?.url || '/'
|
||||
console.log('[Service Worker] 通知被点击');
|
||||
event.notification.close();
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// 如果已经打开了该 URL,则聚焦
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const client = windowClients[i]
|
||||
const client = windowClients[i];
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus()
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// 否则打开新窗口
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen)
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 数据同步函数
|
||||
async function syncData () {
|
||||
async function syncData() {
|
||||
try {
|
||||
// 这里添加需要同步的逻辑
|
||||
console.log('[Service Worker] 执行数据同步')
|
||||
console.log('[Service Worker] 执行数据同步');
|
||||
} catch (error) {
|
||||
console.error('[Service Worker] 同步失败:', error)
|
||||
console.error('[Service Worker] 同步失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
<div class="app-root">
|
||||
<RouterView />
|
||||
<van-tabbar v-show="showTabbar" v-model="active">
|
||||
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar"> 日历 </van-tabbar-item>
|
||||
<van-tabbar-item
|
||||
name="statistics"
|
||||
icon="chart-trending-o"
|
||||
to="/"
|
||||
@click="handleTabClick('/statistics')"
|
||||
>
|
||||
<van-tabbar-item name="ccalendar" icon="notes" to="/calendar">
|
||||
日历
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="statistics" icon="chart-trending-o" to="/" @click="handleTabClick('/statistics')">
|
||||
统计
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item
|
||||
@@ -21,17 +18,14 @@
|
||||
>
|
||||
账单
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item
|
||||
name="budget"
|
||||
icon="bill-o"
|
||||
to="/budget"
|
||||
@click="handleTabClick('/budget')"
|
||||
>
|
||||
<van-tabbar-item name="budget" icon="bill-o" to="/budget" @click="handleTabClick('/budget')">
|
||||
预算
|
||||
</van-tabbar-item>
|
||||
<van-tabbar-item name="setting" icon="setting" to="/setting"> 设置 </van-tabbar-item>
|
||||
<van-tabbar-item name="setting" icon="setting" to="/setting">
|
||||
设置
|
||||
</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess" />
|
||||
<GlobalAddBill v-if="isShowAddBill" @success="handleAddTransactionSuccess"/>
|
||||
|
||||
<div v-if="needRefresh" class="update-toast" @click="updateServiceWorker">
|
||||
<van-icon name="upgrade" class="update-icon" />
|
||||
@@ -91,14 +85,12 @@ onUnmounted(() => {
|
||||
const route = useRoute()
|
||||
// 根据路由判断是否显示Tabbar
|
||||
const showTabbar = computed(() => {
|
||||
return (
|
||||
route.path === '/' ||
|
||||
return route.path === '/' ||
|
||||
route.path === '/calendar' ||
|
||||
route.path === '/message' ||
|
||||
route.path === '/setting' ||
|
||||
route.path === '/balance' ||
|
||||
route.path === '/budget'
|
||||
)
|
||||
})
|
||||
|
||||
const active = ref('')
|
||||
@@ -124,14 +116,11 @@ setInterval(() => {
|
||||
}, 60 * 1000) // 每60秒更新一次未读消息数
|
||||
|
||||
// 监听路由变化调整
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
watch(() => route.path, (newPath) => {
|
||||
setActive(newPath)
|
||||
|
||||
messageStore.updateUnreadCount()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const setActive = (path) => {
|
||||
active.value = (() => {
|
||||
@@ -153,7 +142,10 @@ const setActive = (path) => {
|
||||
}
|
||||
|
||||
const isShowAddBill = computed(() => {
|
||||
return route.path === '/' || route.path === '/balance' || route.path === '/message'
|
||||
return route.path === '/'
|
||||
|| route.path === '/calendar'
|
||||
|| route.path === '/balance'
|
||||
|| route.path === '/message'
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -174,6 +166,7 @@ const handleAddTransactionSuccess = () => {
|
||||
const event = new Event('transactions-changed')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,8 +26,7 @@ export const uploadBillFile = (file, type) => {
|
||||
Authorization: `Bearer ${useAuthStore().token || ''}`
|
||||
},
|
||||
timeout: 60000 // 文件上传增加超时时间
|
||||
})
|
||||
.then((response) => {
|
||||
}).then(response => {
|
||||
const { data } = response
|
||||
|
||||
if (data.success === false) {
|
||||
@@ -36,8 +35,7 @@ export const uploadBillFile = (file, type) => {
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
}).catch(error => {
|
||||
console.error('上传错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
|
||||
@@ -12,6 +12,19 @@ export function getBudgetList(referenceDate) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个预算统计
|
||||
* @param {number} id 预算ID
|
||||
* @param {string} referenceDate 参考日期
|
||||
*/
|
||||
export function getBudgetStatistics(id, referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetStatistics',
|
||||
method: 'get',
|
||||
params: { id, referenceDate }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预算
|
||||
* @param {object} data 预算数据
|
||||
@@ -71,41 +84,15 @@ export function getUncoveredCategories(category, referenceDate) {
|
||||
params: { category, referenceDate }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取归档总结
|
||||
* @param {string} referenceDate 参考日期
|
||||
*/
|
||||
export function getArchiveSummary(referenceDate) {
|
||||
return request({
|
||||
url: '/Budget/GetArchiveSummary',
|
||||
method: 'get',
|
||||
params: { referenceDate }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新归档总结
|
||||
* @param {object} data 数据 { referenceDate, summary }
|
||||
*/
|
||||
export function updateArchiveSummary(data) {
|
||||
return request({
|
||||
url: '/Budget/UpdateArchiveSummary',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定周期的存款预算信息
|
||||
* 归档预算
|
||||
* @param {number} year 年份
|
||||
* @param {number} month 月份
|
||||
* @param {number} type 周期类型 (1:Month, 2:Year)
|
||||
*/
|
||||
export function getSavingsBudget(year, month, type) {
|
||||
export function archiveBudgets(year, month) {
|
||||
return request({
|
||||
url: '/Budget/GetSavingsBudget',
|
||||
method: 'get',
|
||||
params: { year, month, type }
|
||||
url: `/Budget/ArchiveBudgetsAsync/${year}/${month}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const getEmailDetail = (id) => {
|
||||
*/
|
||||
export const deleteEmail = (id) => {
|
||||
return request({
|
||||
url: '/EmailMessage/DeleteById',
|
||||
url: `/EmailMessage/DeleteById`,
|
||||
method: 'post',
|
||||
params: { id }
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export const deleteEmail = (id) => {
|
||||
*/
|
||||
export const refreshTransactionRecords = (id) => {
|
||||
return request({
|
||||
url: '/EmailMessage/RefreshTransactionRecords',
|
||||
url: `/EmailMessage/RefreshTransactionRecords`,
|
||||
method: 'post',
|
||||
params: { id }
|
||||
})
|
||||
@@ -62,7 +62,7 @@ export const refreshTransactionRecords = (id) => {
|
||||
*/
|
||||
export const syncEmails = () => {
|
||||
return request({
|
||||
url: '/EmailMessage/SyncEmails',
|
||||
url: `/EmailMessage/SyncEmails`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import request from '@/api/request'
|
||||
|
||||
export function getJobs () {
|
||||
export function getJobs() {
|
||||
return request({
|
||||
url: '/Job/GetJobs',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function executeJob (jobName) {
|
||||
export function executeJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Execute',
|
||||
method: 'post',
|
||||
@@ -15,7 +15,7 @@ export function executeJob (jobName) {
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseJob (jobName) {
|
||||
export function pauseJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Pause',
|
||||
method: 'post',
|
||||
@@ -23,7 +23,7 @@ export function pauseJob (jobName) {
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeJob (jobName) {
|
||||
export function resumeJob(jobName) {
|
||||
return request({
|
||||
url: '/Job/Resume',
|
||||
method: 'post',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import request from './request'
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 日志相关 API
|
||||
@@ -12,7 +12,6 @@ import request from './request'
|
||||
* @param {string} [params.searchKeyword] - 搜索关键词
|
||||
* @param {string} [params.logLevel] - 日志级别
|
||||
* @param {string} [params.date] - 日期 (yyyyMMdd)
|
||||
* @param {string} [params.className] - 类名
|
||||
* @returns {Promise<{success: boolean, data: Array, total: number}>}
|
||||
*/
|
||||
export const getLogList = (params = {}) => {
|
||||
@@ -33,34 +32,3 @@ export const getAvailableDates = () => {
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的类名列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {string} [params.date] - 日期 (yyyyMMdd)
|
||||
* @returns {Promise<{success: boolean, data: Array}>}
|
||||
*/
|
||||
export const getAvailableClassNames = (params = {}) => {
|
||||
return request({
|
||||
url: '/Log/GetAvailableClassNames',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求ID查询关联日志
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {string} params.requestId - 请求ID
|
||||
* @param {number} [params.pageIndex=1] - 页码
|
||||
* @param {number} [params.pageSize=50] - 每页条数
|
||||
* @param {string} [params.date] - 日期 (yyyyMMdd)
|
||||
* @returns {Promise<{success: boolean, data: Array, total: number}>}
|
||||
*/
|
||||
export const getLogsByRequestId = (params = {}) => {
|
||||
return request({
|
||||
url: '/Log/GetLogsByRequestId',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import axios from 'axios'
|
||||
import { showToast } from 'vant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
@@ -12,31 +12,17 @@ const request = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
// 生成请求ID
|
||||
const generateRequestId = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
config => {
|
||||
// 添加 token 认证信息
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
|
||||
// 添加请求ID
|
||||
const requestId = generateRequestId()
|
||||
config.headers['X-Request-ID'] = requestId
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
error => {
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -44,7 +30,7 @@ request.interceptors.request.use(
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
response => {
|
||||
const { data } = response
|
||||
|
||||
// 统一处理业务错误
|
||||
@@ -55,7 +41,7 @@ request.interceptors.response.use(
|
||||
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
error => {
|
||||
console.error('响应错误:', error)
|
||||
|
||||
// 统一处理 HTTP 错误
|
||||
@@ -72,10 +58,7 @@ request.interceptors.response.use(
|
||||
// 清除登录状态并跳转到登录页
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
router.push({
|
||||
name: 'login',
|
||||
query: { redirect: router.currentRoute.value.fullPath }
|
||||
})
|
||||
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||
break
|
||||
}
|
||||
case 403:
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* @returns {Object} data.expenseCount - 支出笔数
|
||||
* @returns {Object} data.incomeCount - 收入笔数
|
||||
* @returns {Object} data.totalCount - 总笔数
|
||||
* @returns {Object} data.maxExpense - 最大单笔支出
|
||||
* @returns {Object} data.maxIncome - 最大单笔收入
|
||||
*/
|
||||
export const getMonthlyStatistics = (params) => {
|
||||
return request({
|
||||
@@ -86,36 +88,3 @@ export const getDailyStatistics = (params) => {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期范围内的每日统计
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {string} params.startDate - 开始日期
|
||||
* @param {string} params.endDate - 结束日期
|
||||
* @returns {Promise<{success: boolean, data: Array}>}
|
||||
*/
|
||||
export const getDailyStatisticsRange = (params) => {
|
||||
return request({
|
||||
url: '/TransactionRecord/GetDailyStatisticsRange',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取累积余额统计数据(用于余额卡片)
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {number} params.year - 年份
|
||||
* @param {number} params.month - 月份
|
||||
* @returns {Promise<{success: boolean, data: Array}>}
|
||||
* @returns {Array} data - 每日累积余额列表
|
||||
* @returns {string} data[].date - 日期
|
||||
* @returns {number} data[].cumulativeBalance - 累积余额
|
||||
*/
|
||||
export const getBalanceStatistics = (params) => {
|
||||
return request({
|
||||
url: '/TransactionRecord/GetBalanceStatistics',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const updatePeriodic = (data) => {
|
||||
*/
|
||||
export const deletePeriodic = (id) => {
|
||||
return request({
|
||||
url: '/TransactionPeriodic/DeleteById',
|
||||
url: `/TransactionPeriodic/DeleteById`,
|
||||
method: 'post',
|
||||
params: { id }
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ export const updateTransaction = (data) => {
|
||||
*/
|
||||
export const deleteTransaction = (id) => {
|
||||
return request({
|
||||
url: '/TransactionRecord/DeleteById',
|
||||
url: `/TransactionRecord/DeleteById`,
|
||||
method: 'post',
|
||||
params: { id }
|
||||
})
|
||||
@@ -118,6 +118,7 @@ export const getTransactionsByDate = (date) => {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 注意:分类相关的API已迁移到 transactionCategory.js
|
||||
// 请使用 getCategoryList 等新接口
|
||||
|
||||
@@ -159,7 +160,7 @@ export const smartClassify = (transactionIds = []) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ transactionIds })
|
||||
})
|
||||
|
||||
@@ -1,75 +1,55 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
/*
|
||||
Most variables are replaced by Vant CSS variables.
|
||||
Keeping only what's necessary or mapping to Vant.
|
||||
*/
|
||||
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--van-danger-color: rgb(255, 107, 107) !important; /* 覆盖默认的深红色 #ee0a24 */
|
||||
--color-background: var(--van-background);
|
||||
--color-background-soft: var(--van-background-2);
|
||||
--color-background-mute: var(--van-gray-1);
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--color-border: var(--van-border-color);
|
||||
--color-border-hover: var(--van-gray-5);
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--color-heading: var(--van-text-color);
|
||||
--color-text: var(--van-text-color);
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
|
||||
/* Chart Colors */
|
||||
--chart-color-1: #ff6b6b;
|
||||
--chart-color-2: #4ecdc4;
|
||||
--chart-color-3: #45b7d1;
|
||||
--chart-color-4: #ffa07a;
|
||||
--chart-color-5: #98d8c8;
|
||||
--chart-color-6: #f7dc6f;
|
||||
--chart-color-7: #bb8fce;
|
||||
--chart-color-8: #85c1e2;
|
||||
--chart-color-9: #f8b88b;
|
||||
--chart-color-10: #aab7b8;
|
||||
--chart-color-11: #ff8ed4;
|
||||
--chart-color-12: #67e6dc;
|
||||
--chart-color-13: #5b8dee;
|
||||
--chart-color-14: #c9b1ff;
|
||||
--chart-color-15: #7bdff2;
|
||||
|
||||
/* Status Colors for Charts */
|
||||
--chart-success: #52c41a;
|
||||
--chart-warning: #faad14;
|
||||
--chart-danger: #f5222d;
|
||||
--chart-primary: #1890ff;
|
||||
--chart-shadow: rgba(0, 138, 255, 0.45);
|
||||
--chart-axis: #e6ebf8;
|
||||
--chart-split: #eee;
|
||||
--chart-text-muted: #999;
|
||||
|
||||
/* Heatmap Colors - Light Mode */
|
||||
--heatmap-level-0: var(--van-gray-2);
|
||||
--heatmap-level-1: #9be9a8;
|
||||
--heatmap-level-2: #40c463;
|
||||
--heatmap-level-3: #30a14e;
|
||||
--heatmap-level-4: #216e39;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--chart-axis: #333;
|
||||
--chart-split: #333;
|
||||
--chart-text-muted: #666;
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
/* Heatmap Colors - Dark Mode (GitHub Style) */
|
||||
--heatmap-level-0: var(--van-gray-2);
|
||||
--heatmap-level-1: #9be9a8;
|
||||
--heatmap-level-2: #40c463;
|
||||
--heatmap-level-3: #30a14e;
|
||||
--heatmap-level-4: #216e39;
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Removed manual dark mode media query as Vant handles it */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -80,13 +60,14 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--van-text-color);
|
||||
background: var(--van-background);
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@import './base.css';
|
||||
|
||||
/* 禁用页面弹性缩放和橡皮筋效果 */
|
||||
html,
|
||||
body {
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -58,9 +57,7 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
show-cancel-button
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<van-field
|
||||
v-model="classifyName"
|
||||
placeholder="请输入新的交易分类"
|
||||
/>
|
||||
<van-field v-model="classifyName" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
<van-field label="时间">
|
||||
<template #input>
|
||||
<div style="display: flex; gap: 16px">
|
||||
<div @click="showDatePicker = true">
|
||||
{{ form.date }}
|
||||
</div>
|
||||
<div @click="showTimePicker = true">
|
||||
{{ form.time }}
|
||||
</div>
|
||||
<div @click="showDatePicker = true">{{ form.date }}</div>
|
||||
<div @click="showTimePicker = true">{{ form.time }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
@@ -41,9 +37,9 @@
|
||||
<van-field name="type" label="类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.type" direction="horizontal" @change="handleTypeChange">
|
||||
<van-radio :name="0"> 支出 </van-radio>
|
||||
<van-radio :name="1"> 收入 </van-radio>
|
||||
<van-radio :name="2"> 不计 </van-radio>
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
@@ -51,20 +47,23 @@
|
||||
<!-- 分类 -->
|
||||
<van-field name="category" label="分类">
|
||||
<template #input>
|
||||
<span v-if="!categoryName" style="color: var(--van-text-color-3)">请选择分类</span>
|
||||
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
|
||||
<span v-else>{{ categoryName }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择组件 -->
|
||||
<ClassifySelector v-model="categoryName" :type="form.type" />
|
||||
<ClassifySelector
|
||||
v-model="categoryName"
|
||||
:type="form.type"
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="actions">
|
||||
<van-button round block type="primary" native-type="submit" :loading="loading">
|
||||
{{ submitText }}
|
||||
</van-button>
|
||||
<slot name="actions" />
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
@@ -147,15 +146,9 @@ const initForm = async () => {
|
||||
currentTime.value = form.value.time.split(':')
|
||||
}
|
||||
|
||||
if (amount !== undefined) {
|
||||
form.value.amount = amount
|
||||
}
|
||||
if (reason !== undefined) {
|
||||
form.value.note = reason
|
||||
}
|
||||
if (type !== undefined) {
|
||||
form.value.type = type
|
||||
}
|
||||
if (amount !== undefined) form.value.amount = amount
|
||||
if (reason !== undefined) form.value.note = reason
|
||||
if (type !== undefined) form.value.type = type
|
||||
|
||||
// 如果有传入分类名称,尝试设置
|
||||
if (classify) {
|
||||
@@ -173,13 +166,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
// 监听 initialData 变化 (例如重新解析后)
|
||||
watch(
|
||||
() => props.initialData,
|
||||
() => {
|
||||
watch(() => props.initialData, () => {
|
||||
initForm()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}, { deep: true })
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
if (!isSyncing.value) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="manual-bill-add">
|
||||
<BillForm ref="billFormRef" :loading="saving" @submit="handleSave" />
|
||||
<BillForm
|
||||
ref="billFormRef"
|
||||
:loading="saving"
|
||||
@submit="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px">
|
||||
<div v-if="!parseResult" class="input-section" style="margin: 12px 12px 0 16px;">
|
||||
<van-field
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
@@ -31,7 +31,13 @@
|
||||
@submit="handleSave"
|
||||
>
|
||||
<template #actions>
|
||||
<van-button plain round block class="mt-2" @click="parseResult = null">
|
||||
<van-button
|
||||
plain
|
||||
round
|
||||
block
|
||||
class="mt-2"
|
||||
@click="parseResult = null"
|
||||
>
|
||||
重新输入
|
||||
</van-button>
|
||||
</template>
|
||||
@@ -54,16 +60,14 @@ const saving = ref(false)
|
||||
const parseResult = ref(null)
|
||||
|
||||
const handleParse = async () => {
|
||||
if (!text.value.trim()) {
|
||||
return
|
||||
}
|
||||
if (!text.value.trim()) return
|
||||
|
||||
parsing.value = true
|
||||
parseResult.value = null
|
||||
|
||||
try {
|
||||
const res = await parseOneLine(text.value)
|
||||
if (!res.success) {
|
||||
if(!res.success){
|
||||
throw new Error(res.message || '解析失败')
|
||||
}
|
||||
|
||||
@@ -117,6 +121,6 @@ const handleSave = async (payload) => {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--van-border-color);
|
||||
border: 1px solid #ebedf0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<!-- 普通预算卡片 -->
|
||||
<div
|
||||
v-if="!budget.noLimit"
|
||||
class="common-card budget-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<template>
|
||||
<div class="common-card budget-card" @click="toggleExpand">
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div v-if="!isExpanded" class="budget-collapsed">
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
@@ -22,26 +12,14 @@
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
<h3 class="card-title">{{ budget.name }}</h3>
|
||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
<van-icon name="arrow-down" class="expand-icon" />
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer">
|
||||
@@ -49,34 +27,24 @@
|
||||
<span class="compact-label">实际/目标</span>
|
||||
<span class="compact-value">
|
||||
<slot name="collapsed-amount">
|
||||
{{
|
||||
budget.current !== undefined && budget.limit !== undefined
|
||||
{{ budget.current !== undefined && budget.limit !== undefined
|
||||
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
|
||||
: '--'
|
||||
}}
|
||||
: '--' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">达成率</span>
|
||||
<span
|
||||
class="compact-value"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
<span class="compact-value" :class="percentClass">{{ percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<Transition v-else :name="transitionName">
|
||||
<div :key="budget.period" class="budget-inner-card">
|
||||
<div class="card-header" style="margin-bottom: 0;">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
@@ -85,18 +53,9 @@
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<h3 class="card-title" style="max-width: 120px;">{{ budget.name }}</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<slot name="actions">
|
||||
@@ -123,15 +82,13 @@
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
@@ -144,7 +101,7 @@
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="amount-info">
|
||||
<slot name="amount-info" />
|
||||
<slot name="amount-info"></slot>
|
||||
</div>
|
||||
|
||||
<div class="progress-section">
|
||||
@@ -156,10 +113,7 @@
|
||||
:color="progressColor"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span
|
||||
class="percent"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
<span class="percent" :class="percentClass">{{ percentage }}%</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="progress-section time-progress">
|
||||
@@ -167,224 +121,43 @@
|
||||
<van-progress
|
||||
:percentage="timePercentage"
|
||||
stroke-width="4"
|
||||
color="var(--van-gray-6)"
|
||||
color="#969799"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span class="percent">{{ timePercentage }}%</span>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
/>
|
||||
<van-collapse-transition>
|
||||
<div v-if="budget.description && showDescription" class="budget-description">
|
||||
<div class="description-content rich-html-content" v-html="budget.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</van-collapse-transition>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
:finished="true"
|
||||
:show-delete="false"
|
||||
:show-checkbox="false"
|
||||
@click="handleBillClick"
|
||||
@delete="handleBillDelete"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
|
||||
<!-- 不记额预算卡片 -->
|
||||
<div
|
||||
v-else
|
||||
class="common-card budget-card no-limit-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer no-limit-footer">
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">实际</span>
|
||||
<span class="compact-value">
|
||||
<slot name="collapsed-amount">
|
||||
{{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<slot name="actions">
|
||||
<div class="period-navigation" @click.stop>
|
||||
<van-button
|
||||
v-if="budget.description"
|
||||
:icon="showDescription ? 'info' : 'info-o'"
|
||||
size="small"
|
||||
:type="showDescription ? 'primary' : 'default'"
|
||||
icon="arrow-left"
|
||||
class="nav-icon"
|
||||
plain
|
||||
@click.stop="showDescription = !showDescription"
|
||||
size="small"
|
||||
style="width: 50px;"
|
||||
@click="handleSwitch(-1)"
|
||||
/>
|
||||
<span class="period-text">{{ budget.period }}</span>
|
||||
<van-button
|
||||
icon="orders-o"
|
||||
icon="arrow"
|
||||
class="nav-icon"
|
||||
plain
|
||||
size="small"
|
||||
plain
|
||||
title="查询关联账单"
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
size="mini"
|
||||
class="category-tag"
|
||||
plain
|
||||
round
|
||||
>
|
||||
{{ cat }}
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<div class="no-limit-amount-info">
|
||||
<div class="amount-item">
|
||||
<span>
|
||||
<span class="label">实际</span>
|
||||
<span
|
||||
class="value"
|
||||
style="margin-left: 12px"
|
||||
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-limit-notice">
|
||||
<span>
|
||||
<van-icon
|
||||
name="info-o"
|
||||
style="margin-right: 4px"
|
||||
/>
|
||||
不记额预算 - 直接计入存款明细
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
style="width: 50px;"
|
||||
:disabled="isNextDisabled"
|
||||
@click="handleSwitch(1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
@@ -420,7 +193,7 @@ const props = defineProps({
|
||||
},
|
||||
progressColor: {
|
||||
type: String,
|
||||
default: 'var(--van-primary-color)'
|
||||
default: '#1989fa'
|
||||
},
|
||||
percentClass: {
|
||||
type: [String, Object],
|
||||
@@ -432,22 +205,29 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
const emit = defineEmits(['switch-period', 'click'])
|
||||
|
||||
const isExpanded = ref(props.budget.category === 2)
|
||||
const transitionName = ref('slide-left')
|
||||
const showDescription = ref(false)
|
||||
const showBillListModal = ref(false)
|
||||
const billList = ref([])
|
||||
const billLoading = ref(false)
|
||||
|
||||
const toggleExpand = () => {
|
||||
// 存款类型(category === 2)强制保持展开状态,不可折叠
|
||||
if (props.budget.category === 2) {
|
||||
return
|
||||
}
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
const isNextDisabled = computed(() => {
|
||||
if (!props.budget.periodEnd) return false
|
||||
return new Date(props.budget.periodEnd) > new Date()
|
||||
})
|
||||
|
||||
const handleSwitch = (direction) => {
|
||||
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
|
||||
emit('switch-period', direction)
|
||||
}
|
||||
|
||||
const handleQueryBills = async () => {
|
||||
showBillListModal.value = true
|
||||
billLoading.value = true
|
||||
@@ -474,11 +254,12 @@ const handleQueryBills = async () => {
|
||||
sortByAmount: true
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
if(response.success) {
|
||||
billList.value = response.data || []
|
||||
} else {
|
||||
billList.value = []
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询账单列表失败:', error)
|
||||
billList.value = []
|
||||
@@ -488,59 +269,21 @@ const handleQueryBills = async () => {
|
||||
}
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!props.budget.limit) {
|
||||
return 0
|
||||
}
|
||||
if (!props.budget.limit) return 0
|
||||
return Math.round((props.budget.current / props.budget.limit) * 100)
|
||||
})
|
||||
|
||||
const timePercentage = computed(() => {
|
||||
if (!props.budget.periodStart || !props.budget.periodEnd) {
|
||||
return 0
|
||||
}
|
||||
if (!props.budget.periodStart || !props.budget.periodEnd) return 0
|
||||
const start = new Date(props.budget.periodStart).getTime()
|
||||
const end = new Date(props.budget.periodEnd).getTime()
|
||||
const now = new Date().getTime()
|
||||
|
||||
if (now <= start) {
|
||||
return 0
|
||||
}
|
||||
if (now >= end) {
|
||||
return 100
|
||||
}
|
||||
if (now <= start) return 0
|
||||
if (now >= end) return 100
|
||||
|
||||
return Math.round(((now - start) / (end - start)) * 100)
|
||||
})
|
||||
|
||||
const onEnter = (el) => {
|
||||
el.style.height = '0'
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const onAfterEnter = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
|
||||
const onLeave = (el) => {
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = '0'
|
||||
}
|
||||
|
||||
const onAfterLeave = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -554,18 +297,6 @@ const onAfterLeave = (el) => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-card.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no-limit-card {
|
||||
border-left: 3px solid var(--van-success-color);
|
||||
}
|
||||
|
||||
.collapsed-footer.no-limit-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.budget-content-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -637,7 +368,7 @@ const onAfterLeave = (el) => {
|
||||
|
||||
.compact-label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -651,26 +382,60 @@ const onAfterLeave = (el) => {
|
||||
}
|
||||
|
||||
.compact-value.warning {
|
||||
color: var(--van-warning-color);
|
||||
color: #ff976a;
|
||||
}
|
||||
|
||||
.compact-value.income {
|
||||
color: var(--van-success-color);
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--van-primary-color);
|
||||
color: #1989fa;
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
color: var(--van-primary-color);
|
||||
color: #1989fa;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 切换动画 */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-active,
|
||||
.slide-right-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.budget-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -691,7 +456,7 @@ const onAfterLeave = (el) => {
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -725,7 +490,7 @@ const onAfterLeave = (el) => {
|
||||
|
||||
:deep(.info-item) .label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -735,11 +500,11 @@ const onAfterLeave = (el) => {
|
||||
}
|
||||
|
||||
:deep(.value.expense) {
|
||||
color: var(--van-danger-color);
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
:deep(.value.income) {
|
||||
color: var(--van-success-color);
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
@@ -748,7 +513,7 @@ const onAfterLeave = (el) => {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--van-gray-6);
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.progress-section :deep(.van-progress) {
|
||||
@@ -767,12 +532,12 @@ const onAfterLeave = (el) => {
|
||||
}
|
||||
|
||||
.percent.warning {
|
||||
color: var(--van-warning-color);
|
||||
color: #ff976a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.percent.income {
|
||||
color: var(--van-success-color);
|
||||
color: #07c160;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -786,58 +551,63 @@ const onAfterLeave = (el) => {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.no-limit-notice {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
background-color: var(--van-light-gray);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-limit-amount-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0px 0;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amount-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
}
|
||||
|
||||
.amount-item .value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--van-success-color);
|
||||
}
|
||||
|
||||
.budget-collapse-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-description {
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
margin-top: 8px;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
font-size: 11px;
|
||||
color: var(--van-gray-6);
|
||||
color: #646566;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mandatory-mark {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #969799;
|
||||
padding: 12px 12px 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.period-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.period-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-footer {
|
||||
border-top-color: #2c2c2c;
|
||||
}
|
||||
.period-text {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.budget-description {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.description-content {
|
||||
color: #969799;
|
||||
}
|
||||
.collapsed-row .value {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
:title="
|
||||
isEdit
|
||||
? `编辑${getCategoryName(form.category)}预算`
|
||||
: `新增${getCategoryName(form.category)}预算`
|
||||
"
|
||||
:title="isEdit ? `编辑${getCategoryName(form.category)}预算` : `新增${getCategoryName(form.category)}预算`"
|
||||
height="75%"
|
||||
>
|
||||
<div class="add-budget-form">
|
||||
@@ -18,55 +14,19 @@
|
||||
placeholder="例如:每月餐饮、年度奖金"
|
||||
:rules="[{ required: true, message: '请填写预算名称' }]"
|
||||
/>
|
||||
<!-- 新增:不记额预算复选框 -->
|
||||
<van-field label="不记额预算">
|
||||
<template #input>
|
||||
<van-checkbox
|
||||
v-model="form.noLimit"
|
||||
@update:model-value="onNoLimitChange"
|
||||
>
|
||||
不记额预算
|
||||
</van-checkbox>
|
||||
</template>
|
||||
</van-field>
|
||||
<!-- 新增:硬性消费复选框 -->
|
||||
<van-field :label="form.category === BudgetCategory.Expense ? '硬性消费' : '硬性收入'">
|
||||
<template #input>
|
||||
<div class="mandatory-wrapper">
|
||||
<van-checkbox
|
||||
v-model="form.isMandatoryExpense"
|
||||
:disabled="form.noLimit"
|
||||
>
|
||||
{{ form.category === BudgetCategory.Expense ? '硬性消费' : '硬性收入' }}
|
||||
<span class="mandatory-tip">
|
||||
当前周期 月/年 按天数自动累加(无记录时)
|
||||
</span>
|
||||
</van-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
name="type"
|
||||
label="统计周期"
|
||||
>
|
||||
<van-field name="type" label="统计周期">
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.type"
|
||||
direction="horizontal"
|
||||
:disabled="isEdit || form.noLimit"
|
||||
:disabled="isEdit"
|
||||
>
|
||||
<van-radio :name="BudgetPeriodType.Month">
|
||||
月
|
||||
</van-radio>
|
||||
<van-radio :name="BudgetPeriodType.Year">
|
||||
年
|
||||
</van-radio>
|
||||
<van-radio :name="BudgetPeriodType.Month">月</van-radio>
|
||||
<van-radio :name="BudgetPeriodType.Year">年</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<!-- 仅当未选中"不记额预算"时显示预算金额 -->
|
||||
<van-field
|
||||
v-if="!form.noLimit"
|
||||
v-model="form.limit"
|
||||
type="number"
|
||||
name="limit"
|
||||
@@ -80,16 +40,8 @@
|
||||
</van-field>
|
||||
<van-field label="相关分类">
|
||||
<template #input>
|
||||
<div
|
||||
v-if="form.selectedCategories.length === 0"
|
||||
style="color: var(--van-text-color-3)"
|
||||
>
|
||||
可多选分类
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="selected-categories"
|
||||
>
|
||||
<div v-if="form.selectedCategories.length === 0" style="color: #c8c9cc;">可多选分类</div>
|
||||
<div v-else class="selected-categories">
|
||||
<span class="ellipsis-text">
|
||||
{{ form.selectedCategories.join('、') }}
|
||||
</span>
|
||||
@@ -107,14 +59,7 @@
|
||||
</van-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
type="primary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存预算
|
||||
</van-button>
|
||||
<van-button block round type="primary" @click="onSubmit">保存预算</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
@@ -138,13 +83,15 @@ const form = reactive({
|
||||
type: BudgetPeriodType.Month,
|
||||
category: BudgetCategory.Expense,
|
||||
limit: '',
|
||||
selectedCategories: [],
|
||||
noLimit: false, // 新增字段
|
||||
isMandatoryExpense: false // 新增:硬性消费
|
||||
selectedCategories: []
|
||||
})
|
||||
|
||||
const open = ({ data, isEditFlag, category }) => {
|
||||
if (category === undefined) {
|
||||
const open = ({
|
||||
data,
|
||||
isEditFlag,
|
||||
category
|
||||
}) => {
|
||||
if(category === undefined) {
|
||||
showToast('缺少必要参数:category')
|
||||
return
|
||||
}
|
||||
@@ -157,9 +104,7 @@ const open = ({ data, isEditFlag, category }) => {
|
||||
type: data.type,
|
||||
category: category,
|
||||
limit: data.limit,
|
||||
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : [],
|
||||
noLimit: data.noLimit || false, // 新增
|
||||
isMandatoryExpense: data.isMandatoryExpense || false // 新增:硬性消费
|
||||
selectedCategories: data.selectedCategories ? [...data.selectedCategories] : []
|
||||
})
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
@@ -168,9 +113,7 @@ const open = ({ data, isEditFlag, category }) => {
|
||||
type: BudgetPeriodType.Month,
|
||||
category: category,
|
||||
limit: '',
|
||||
selectedCategories: [],
|
||||
noLimit: false, // 新增
|
||||
isMandatoryExpense: false // 新增:硬性消费
|
||||
selectedCategories: []
|
||||
})
|
||||
}
|
||||
visible.value = true
|
||||
@@ -181,21 +124,15 @@ defineExpose({
|
||||
})
|
||||
|
||||
const budgetType = computed(() => {
|
||||
return form.category === BudgetCategory.Expense
|
||||
? 0
|
||||
: form.category === BudgetCategory.Income
|
||||
? 1
|
||||
: 2
|
||||
return form.category === BudgetCategory.Expense ? 0 : (form.category === BudgetCategory.Income ? 1 : 2)
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const data = {
|
||||
...form,
|
||||
limit: form.noLimit ? 0 : parseFloat(form.limit), // 不记额时金额为0
|
||||
selectedCategories: form.selectedCategories,
|
||||
noLimit: form.noLimit, // 新增
|
||||
isMandatoryExpense: form.isMandatoryExpense // 新增:硬性消费
|
||||
limit: parseFloat(form.limit),
|
||||
selectedCategories: form.selectedCategories
|
||||
}
|
||||
|
||||
const res = form.id ? await updateBudget(data) : await createBudget(data)
|
||||
@@ -211,7 +148,7 @@ const onSubmit = async () => {
|
||||
}
|
||||
|
||||
const getCategoryName = (category) => {
|
||||
switch (category) {
|
||||
switch(category) {
|
||||
case BudgetCategory.Expense:
|
||||
return '支出'
|
||||
case BudgetCategory.Income:
|
||||
@@ -222,15 +159,6 @@ const getCategoryName = (category) => {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const onNoLimitChange = (value) => {
|
||||
if (value) {
|
||||
// 选中不记额时,自动设为年度预算
|
||||
form.type = BudgetPeriodType.Year
|
||||
// 选中不记额时,清除硬性消费选择
|
||||
form.isMandatoryExpense = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -248,7 +176,7 @@ const onNoLimitChange = (value) => {
|
||||
|
||||
.ellipsis-text {
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color);
|
||||
color: #323233;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -257,19 +185,7 @@ const onNoLimitChange = (value) => {
|
||||
|
||||
.no-data {
|
||||
font-size: 13px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.mandatory-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mandatory-tip {
|
||||
font-size: 11px;
|
||||
color: var(--van-text-color-3);
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<div class="summary-container">
|
||||
<transition :name="transitionName" mode="out-in">
|
||||
<div
|
||||
v-if="stats && (stats.month || stats.year)"
|
||||
:key="dateKey"
|
||||
class="summary-card common-card"
|
||||
>
|
||||
<div v-if="stats && (stats.month || stats.year)" :key="dateKey" class="summary-card common-card">
|
||||
<!-- 左切换按钮 -->
|
||||
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
|
||||
<van-icon name="arrow-left" />
|
||||
@@ -24,7 +20,7 @@
|
||||
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="config.showDivider" class="divider" />
|
||||
<div v-if="config.showDivider" class="divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +71,8 @@ const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMo
|
||||
|
||||
const isCurrentMonth = computed(() => {
|
||||
const now = new Date()
|
||||
return props.date.getFullYear() === now.getFullYear() && props.date.getMonth() === now.getMonth()
|
||||
return props.date.getFullYear() === now.getFullYear() &&
|
||||
props.date.getMonth() === now.getMonth()
|
||||
})
|
||||
|
||||
const periodConfigs = computed(() => ({
|
||||
@@ -97,10 +94,7 @@ const changeMonth = (delta) => {
|
||||
}
|
||||
|
||||
const formatMoney = (val) => {
|
||||
return parseFloat(val || 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
return parseFloat(val || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -136,7 +130,7 @@ const formatMoney = (val) => {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: var(--van-gray-5);
|
||||
color: #c8c9cc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
z-index: 1;
|
||||
@@ -147,17 +141,6 @@ const formatMoney = (val) => {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.nav-arrow.disabled {
|
||||
color: #c8c9cc;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-arrow.disabled:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.nav-arrow.left {
|
||||
left: 0;
|
||||
}
|
||||
@@ -167,7 +150,7 @@ const formatMoney = (val) => {
|
||||
}
|
||||
|
||||
.nav-arrow.disabled {
|
||||
color: var(--van-gray-3);
|
||||
color: #f2f3f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -217,26 +200,26 @@ const formatMoney = (val) => {
|
||||
|
||||
.summary-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--van-text-color);
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.expense) {
|
||||
color: var(--van-danger-color);
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.income) {
|
||||
color: var(--van-success-color);
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.summary-item :deep(.value.warning) {
|
||||
color: var(--van-warning-color);
|
||||
color: #ff976a;
|
||||
}
|
||||
|
||||
.summary-item .unit {
|
||||
@@ -247,7 +230,7 @@ const formatMoney = (val) => {
|
||||
|
||||
.summary-item .sub-info {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-3);
|
||||
color: #c8c9cc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -255,35 +238,35 @@ const formatMoney = (val) => {
|
||||
}
|
||||
|
||||
.summary-item .amount {
|
||||
color: var(--van-text-color-2);
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.summary-item .separator {
|
||||
color: var(--van-text-color-3);
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--van-border-color);
|
||||
background-color: #ebedf0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav-arrow:active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.nav-arrow.disabled {
|
||||
color: var(--van-text-color);
|
||||
color: #323233;
|
||||
}
|
||||
.summary-item .value {
|
||||
color: var(--van-text-color);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.summary-item .amount {
|
||||
color: var(--van-text-color-3);
|
||||
color: #c8c9cc;
|
||||
}
|
||||
.divider {
|
||||
background-color: var(--van-border-color);
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
} */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<PopupContainer v-model="visible" title="设置存款分类" height="60%">
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
title="设置存款分类"
|
||||
height="60%"
|
||||
>
|
||||
<div class="savings-config-content">
|
||||
<div class="config-header">
|
||||
<p class="subtitle">这些分类的统计值将计入“存款”中</p>
|
||||
@@ -18,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<van-button block round type="primary" @click="onSubmit"> 保存配置 </van-button>
|
||||
<van-button block round type="primary" @click="onSubmit">保存配置</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
@@ -48,7 +52,7 @@ const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await getConfig('SavingsCategories')
|
||||
if (res.success && res.data) {
|
||||
selectedCategories.value = res.data.split(',').filter((x) => x)
|
||||
selectedCategories.value = res.data.split(',').filter(x => x)
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
}
|
||||
@@ -87,7 +91,7 @@ const onSubmit = async () => {
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -99,7 +103,7 @@ const onSubmit = async () => {
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@@ -98,21 +98,17 @@ const innerOptions = ref([])
|
||||
const addClassifyDialogRef = ref()
|
||||
|
||||
const displayOptions = computed(() => {
|
||||
if (props.options) {
|
||||
return props.options
|
||||
}
|
||||
if (props.options) return props.options
|
||||
return innerOptions.value
|
||||
})
|
||||
|
||||
const fetchOptions = async () => {
|
||||
if (props.options) {
|
||||
return
|
||||
}
|
||||
if (props.options) return
|
||||
|
||||
try {
|
||||
const response = await getCategoryList(props.type)
|
||||
if (response.success) {
|
||||
innerOptions.value = (response.data || []).map((item) => ({
|
||||
innerOptions.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id
|
||||
@@ -156,12 +152,9 @@ const handleAddConfirm = async (categoryName) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
watch(() => props.type, () => {
|
||||
fetchOptions()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchOptions()
|
||||
@@ -182,10 +175,8 @@ const isSelected = (item) => {
|
||||
|
||||
// 是否全部选中
|
||||
const isAllSelected = computed(() => {
|
||||
if (!props.multiple || displayOptions.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
return displayOptions.value.every((item) => props.modelValue.includes(item.text))
|
||||
if (!props.multiple || displayOptions.value.length === 0) return false
|
||||
return displayOptions.value.every(item => props.modelValue.includes(item.text))
|
||||
})
|
||||
|
||||
// 是否有任何选中
|
||||
@@ -217,15 +208,13 @@ const toggleItem = (item) => {
|
||||
|
||||
// 切换全选
|
||||
const toggleAll = () => {
|
||||
if (!props.multiple) {
|
||||
return
|
||||
}
|
||||
if (!props.multiple) return
|
||||
|
||||
if (isAllSelected.value) {
|
||||
emit('update:modelValue', [])
|
||||
emit('change', [])
|
||||
} else {
|
||||
const allValues = displayOptions.value.map((item) => item.text)
|
||||
const allValues = displayOptions.value.map(item => item.text)
|
||||
emit('update:modelValue', allValues)
|
||||
emit('change', allValues)
|
||||
}
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
<template>
|
||||
<div class="heatmap-card">
|
||||
<div class="grid-row">
|
||||
<!-- Weekday Labels (Fixed Left) -->
|
||||
<div class="weekday-col-fixed">
|
||||
<div class="weekday-label">
|
||||
二
|
||||
</div>
|
||||
<div class="weekday-label">
|
||||
四
|
||||
</div>
|
||||
<div class="weekday-label">
|
||||
六
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Heatmap Area -->
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="heatmap-scroll-container"
|
||||
>
|
||||
<div class="heatmap-content">
|
||||
<!-- Month Labels -->
|
||||
<div class="month-row">
|
||||
<div
|
||||
v-for="(month, index) in monthLabels"
|
||||
:key="index"
|
||||
class="month-label"
|
||||
:style="{ left: month.left + 'px' }"
|
||||
>
|
||||
{{ month.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap Grid -->
|
||||
<div class="heatmap-grid">
|
||||
<div
|
||||
v-for="(week, wIndex) in weeks"
|
||||
:key="wIndex"
|
||||
class="heatmap-week"
|
||||
>
|
||||
<div
|
||||
v-for="(day, dIndex) in week"
|
||||
:key="dIndex"
|
||||
class="heatmap-cell"
|
||||
:class="getLevelClass(day)"
|
||||
@click="onCellClick(day)"
|
||||
>
|
||||
<!-- Tooltip could be implemented here or using title -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="heatmap-footer">
|
||||
<div
|
||||
v-if="totalCount > 0"
|
||||
class="summary-text"
|
||||
>
|
||||
过去一年共 {{ totalCount }} 笔交易
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span>少</span>
|
||||
<div class="legend-item level-0" />
|
||||
<div class="legend-item level-1" />
|
||||
<div class="legend-item level-2" />
|
||||
<div class="legend-item level-3" />
|
||||
<div class="legend-item level-4" />
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { getDailyStatisticsRange } from '@/api/statistics'
|
||||
|
||||
const stats = ref({})
|
||||
const weeks = ref([])
|
||||
const monthLabels = ref([])
|
||||
const totalCount = ref(0)
|
||||
const scrollContainer = ref(null)
|
||||
const thresholds = ref([2, 4, 7]) // Default thresholds
|
||||
|
||||
const CELL_SIZE = 15
|
||||
const CELL_GAP = 3
|
||||
const WEEK_WIDTH = CELL_SIZE + CELL_GAP
|
||||
|
||||
const formatDate = (d) => {
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setFullYear(endDate.getFullYear() - 1)
|
||||
|
||||
try {
|
||||
const res = await getDailyStatisticsRange({
|
||||
startDate: formatDate(startDate),
|
||||
endDate: formatDate(endDate)
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
const map = {}
|
||||
let count = 0
|
||||
res.data.forEach((item) => {
|
||||
map[item.date] = item
|
||||
count += item.count
|
||||
})
|
||||
stats.value = map
|
||||
totalCount.value = count
|
||||
|
||||
// Calculate thresholds based on last 15 days average
|
||||
const today = new Date()
|
||||
let last15DaysSum = 0
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const d = new Date(today)
|
||||
d.setDate(d.getDate() - i)
|
||||
const dateStr = formatDate(d)
|
||||
last15DaysSum += map[dateStr]?.count || 0
|
||||
}
|
||||
|
||||
const avg = last15DaysSum / 15
|
||||
// Step size calculation: ensure at least 1, roughly avg/2 to create spread
|
||||
// Level 1: 1 ~ step
|
||||
// Level 2: step+1 ~ step*2
|
||||
// Level 3: step*2+1 ~ step*3
|
||||
// Level 4: > step*3
|
||||
const step = Math.max(Math.ceil(avg / 2), 1)
|
||||
thresholds.value = [step, step * 2, step * 3]
|
||||
|
||||
generateHeatmapData(startDate, endDate)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch heatmap data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const generateHeatmapData = (startDate, endDate) => {
|
||||
const current = new Date(startDate)
|
||||
|
||||
const allDays = []
|
||||
|
||||
// Adjust start date to be Monday to align weeks
|
||||
// 0 = Sunday, 1 = Monday
|
||||
const startDay = current.getDay()
|
||||
// If startDay is 0 (Sunday), we need to go back 6 days to Monday
|
||||
// If startDay is 1 (Monday), we are good
|
||||
// If startDay is 2 (Tuesday), we need to go back 1 day
|
||||
// Formula: (day + 6) % 7 days back?
|
||||
// Monday (1) -> 0 days back
|
||||
// Sunday (0) -> 6 days back
|
||||
// Tuesday (2) -> 1 day back
|
||||
|
||||
// We don't necessarily need to subtract from startDate for data fetching,
|
||||
// but for grid alignment we want the first column to start on Monday.
|
||||
|
||||
const alignStart = new Date(startDate)
|
||||
// alignStart.setDate(alignStart.getDate() - daysToSubtract);
|
||||
|
||||
const tempDate = new Date(alignStart)
|
||||
while (tempDate <= endDate) {
|
||||
const dateStr = formatDate(tempDate)
|
||||
allDays.push({
|
||||
date: dateStr,
|
||||
count: stats.value[dateStr]?.count || 0,
|
||||
obj: new Date(tempDate)
|
||||
})
|
||||
tempDate.setDate(tempDate.getDate() + 1)
|
||||
}
|
||||
|
||||
// Now group into weeks
|
||||
const resultWeeks = []
|
||||
let currentWeek = []
|
||||
|
||||
// Pad first week if start date is not Monday
|
||||
// allDays[0] is startDate
|
||||
const firstDayObj = new Date(allDays[0].date)
|
||||
const firstDay = firstDayObj.getDay() // 0-6 (Sun-Sat)
|
||||
|
||||
// We want Monday (1) to be index 0
|
||||
// Mon(1)->0, Tue(2)->1, ..., Sun(0)->6
|
||||
const padCount = (firstDay + 6) % 7
|
||||
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
currentWeek.push(null)
|
||||
}
|
||||
|
||||
allDays.forEach((day) => {
|
||||
currentWeek.push(day)
|
||||
if (currentWeek.length === 7) {
|
||||
resultWeeks.push(currentWeek)
|
||||
currentWeek = []
|
||||
}
|
||||
})
|
||||
|
||||
// Push last partial week
|
||||
if (currentWeek.length > 0) {
|
||||
while (currentWeek.length < 7) {
|
||||
currentWeek.push(null)
|
||||
}
|
||||
resultWeeks.push(currentWeek)
|
||||
}
|
||||
|
||||
weeks.value = resultWeeks
|
||||
|
||||
// Generate Month Labels
|
||||
const labels = []
|
||||
let lastMonth = -1
|
||||
|
||||
resultWeeks.forEach((week, index) => {
|
||||
// Check the first valid day in the week
|
||||
const day = week.find((d) => d !== null)
|
||||
if (day) {
|
||||
const d = new Date(day.date)
|
||||
const month = d.getMonth()
|
||||
if (month !== lastMonth) {
|
||||
labels.push({
|
||||
text: d.toLocaleString('zh-CN', { month: 'short' }),
|
||||
left: index * WEEK_WIDTH
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
monthLabels.value = labels
|
||||
|
||||
// Scroll to end
|
||||
nextTick(() => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollLeft = scrollContainer.value.scrollWidth
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelClass = (day) => {
|
||||
if (!day) {
|
||||
return 'invisible'
|
||||
}
|
||||
const count = day.count
|
||||
if (count === 0) {
|
||||
return 'level-0'
|
||||
}
|
||||
if (count <= thresholds.value[0]) {
|
||||
return 'level-1'
|
||||
}
|
||||
if (count <= thresholds.value[1]) {
|
||||
return 'level-2'
|
||||
}
|
||||
if (count <= thresholds.value[2]) {
|
||||
return 'level-3'
|
||||
}
|
||||
return 'level-4'
|
||||
}
|
||||
|
||||
const onCellClick = (day) => {
|
||||
if (day) {
|
||||
// Emit event or show toast
|
||||
// console.log(day);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh: fetchData
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-card {
|
||||
background: var(--van-background-2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: var(--van-text-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
margin: 0 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--van-border-color);
|
||||
}
|
||||
|
||||
.heatmap-scroll-container {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
scrollbar-width: none;
|
||||
flex: 1; /* Take remaining space */
|
||||
}
|
||||
.heatmap-scroll-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heatmap-content {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
display: flex;
|
||||
margin-bottom: 4px;
|
||||
height: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.months-container {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
top: 0;
|
||||
color: var(--van-text-color-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.weekday-col-fixed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 19px; /* Align with cells (month row height 15px + margin 4px) */
|
||||
margin-right: 6px;
|
||||
font-size: 9px;
|
||||
height: 142px; /* Total height: 15 (month) + 4 (margin) + 123 (grid) */
|
||||
color: var(--van-text-color-2);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--van-background-2); /* Match card background */
|
||||
}
|
||||
|
||||
.weekday-label {
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
margin-top: 15px; /* (15 cell + 3 gap)*2 - 15 height - previous margin? No. */
|
||||
/*
|
||||
Row 0: 0px top
|
||||
Row 1: 18px top (15+3) - Label "二" aligns here? No, "二" is usually row 1 (index 1, 2nd row)
|
||||
If we want to align with 2nd, 4th, 6th rows (indices 1, 3, 5):
|
||||
|
||||
Row 0: y=0
|
||||
Row 1: y=18
|
||||
Row 2: y=36
|
||||
Row 3: y=54
|
||||
Row 4: y=72
|
||||
Row 5: y=90
|
||||
Row 6: y=108
|
||||
|
||||
Label 1 ("二") at Row 1 (y=18)
|
||||
Label 2 ("四") at Row 3 (y=54)
|
||||
Label 3 ("六") at Row 5 (y=90)
|
||||
|
||||
Padding-top of container is 19px.
|
||||
First label margin-top: 18px
|
||||
Second label margin-top: (54 - (18+15)) = 21px
|
||||
Third label margin-top: (90 - (54+15)) = 21px
|
||||
|
||||
Let's try standard spacing.
|
||||
Gap between tops is 36px (2 rows).
|
||||
Height of label is 15px.
|
||||
Margin needed is 36 - 15 = 21px.
|
||||
|
||||
First label top needs to be at 18px relative to grid start.
|
||||
Container padding-top aligns with grid start (row 0 top).
|
||||
So first label margin-top should be 18px.
|
||||
*/
|
||||
margin-top: 21px;
|
||||
}
|
||||
.weekday-label:first-child {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heatmap-week {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--van-gray-2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heatmap-cell.invisible {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.level-0 {
|
||||
background-color: var(--heatmap-level-0);
|
||||
}
|
||||
.level-1 {
|
||||
background-color: var(--heatmap-level-1);
|
||||
}
|
||||
.level-2 {
|
||||
background-color: var(--heatmap-level-2);
|
||||
}
|
||||
.level-3 {
|
||||
background-color: var(--heatmap-level-3);
|
||||
}
|
||||
.level-4 {
|
||||
background-color: var(--heatmap-level-4);
|
||||
}
|
||||
|
||||
.heatmap-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--van-text-color-2);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Add Bill Modal -->
|
||||
<PopupContainer v-model="showAddBill" title="记一笔" height="75%">
|
||||
<PopupContainer
|
||||
v-model="showAddBill"
|
||||
title="记一笔"
|
||||
height="75%"
|
||||
>
|
||||
<van-tabs v-model:active="activeTab" shrink>
|
||||
<van-tab title="一句话录账" name="one">
|
||||
<OneLineBillAdd :key="componentKey" @success="handleSuccess" />
|
||||
@@ -75,6 +79,6 @@ const handleSuccess = () => {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--van-background-2);
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
<div class="popup-header-fixed">
|
||||
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
|
||||
<h3 class="popup-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<h3 class="popup-title">{{ title }}</h3>
|
||||
<!-- 无子标题时,操作按钮与标题同行 -->
|
||||
<div v-if="!subtitle && hasActions" class="header-actions-inline">
|
||||
<slot name="header-actions" />
|
||||
<slot name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,18 +24,18 @@
|
||||
<div v-if="subtitle" class="header-stats">
|
||||
<span class="stats-text" v-html="subtitle" />
|
||||
<!-- 额外操作插槽 -->
|
||||
<slot v-if="hasActions" name="header-actions" />
|
||||
<slot v-if="hasActions" name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域(可滚动) -->
|
||||
<div class="popup-scroll-content">
|
||||
<slot />
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- 底部页脚,固定不可滚动 -->
|
||||
<div v-if="slots.footer" class="popup-footer-fixed">
|
||||
<slot name="footer" />
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
@@ -49,24 +47,24 @@ import { computed, useSlots } from 'vue'
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '80%'
|
||||
default: '80%',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -76,7 +74,7 @@ const slots = useSlots()
|
||||
// 双向绑定
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 判断是否有操作按钮
|
||||
@@ -93,8 +91,8 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
.popup-header-fixed {
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background-color: var(--van-background-2);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
background-color: var(--van-background-2, #f7f8fa);
|
||||
border-bottom: 1px solid var(--van-border-color, #ebedf0);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
@@ -123,7 +121,7 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--van-text-color);
|
||||
color: var(--van-text-color, #323233);
|
||||
/*超出长度*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -141,7 +139,7 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
.stats-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color-2);
|
||||
color: var(--van-text-color-2, #646566);
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -161,8 +159,8 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
|
||||
.popup-footer-fixed {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
background-color: var(--van-background-2);
|
||||
border-top: 1px solid var(--van-border-color, #ebedf0);
|
||||
background-color: var(--van-background-2, #f7f8fa);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
<template>
|
||||
<div class="reason-group-list-v2">
|
||||
<van-empty
|
||||
v-if="groups.length === 0 && !loading"
|
||||
description="暂无数据"
|
||||
/>
|
||||
<van-empty v-if="groups.length === 0 && !loading" description="暂无数据" />
|
||||
|
||||
<van-cell-group
|
||||
v-else
|
||||
inset
|
||||
>
|
||||
<van-cell-group v-else inset>
|
||||
<van-cell
|
||||
v-for="group in groups"
|
||||
:key="group.reason"
|
||||
@@ -33,7 +27,7 @@
|
||||
<van-tag
|
||||
:type="getTypeColor(group.sampleType)"
|
||||
size="medium"
|
||||
style="margin-right: 8px"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
{{ getTypeName(group.sampleType) }}
|
||||
</van-tag>
|
||||
@@ -41,15 +35,12 @@
|
||||
v-if="group.sampleClassify"
|
||||
type="primary"
|
||||
size="medium"
|
||||
style="margin-right: 8px"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
{{ group.sampleClassify }}
|
||||
</van-tag>
|
||||
<span class="count-text">{{ group.count }} 条</span>
|
||||
<span
|
||||
v-if="group.totalAmount"
|
||||
class="amount-text"
|
||||
>
|
||||
<span v-if="group.totalAmount" class="amount-text">
|
||||
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,10 +92,7 @@
|
||||
title="批量设置分类"
|
||||
height="60%"
|
||||
>
|
||||
<van-form
|
||||
ref="batchFormRef"
|
||||
class="setting-form"
|
||||
>
|
||||
<van-form ref="batchFormRef" class="setting-form">
|
||||
<van-cell-group inset>
|
||||
<!-- 显示选中的摘要 -->
|
||||
<van-field
|
||||
@@ -123,38 +111,20 @@
|
||||
/>
|
||||
|
||||
<!-- 交易类型 -->
|
||||
<van-field
|
||||
name="type"
|
||||
label="交易类型"
|
||||
>
|
||||
<van-field name="type" label="交易类型">
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="batchForm.type"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :name="0">
|
||||
支出
|
||||
</van-radio>
|
||||
<van-radio :name="1">
|
||||
收入
|
||||
</van-radio>
|
||||
<van-radio :name="2">
|
||||
不计
|
||||
</van-radio>
|
||||
<van-radio-group v-model="batchForm.type" direction="horizontal">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类选择 -->
|
||||
<van-field
|
||||
name="classify"
|
||||
label="分类"
|
||||
>
|
||||
<van-field name="classify" label="分类">
|
||||
<template #input>
|
||||
<span
|
||||
v-if="!batchForm.classify"
|
||||
style="opacity: 0.4"
|
||||
>请选择分类</span>
|
||||
<span v-if="!batchForm.classify" style="opacity: 0.4;">请选择分类</span>
|
||||
<span v-else>{{ batchForm.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
@@ -182,7 +152,13 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { showToast, showSuccessToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
|
||||
import {
|
||||
showToast,
|
||||
showSuccessToast,
|
||||
showLoadingToast,
|
||||
closeToast,
|
||||
showConfirmDialog
|
||||
} from 'vant'
|
||||
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
|
||||
import ClassifySelector from './ClassifySelector.vue'
|
||||
import TransactionList from './TransactionList.vue'
|
||||
@@ -236,12 +212,9 @@ const batchForm = ref({
|
||||
})
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(
|
||||
() => batchForm.value.type,
|
||||
(newVal) => {
|
||||
watch(() => batchForm.value.type, (newVal) => {
|
||||
batchForm.value.classify = ''
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// 获取类型名称
|
||||
const getTypeName = (type) => {
|
||||
@@ -283,9 +256,7 @@ const handleGroupClick = async (group) => {
|
||||
|
||||
// 加载分组的交易记录
|
||||
const loadGroupTransactions = async () => {
|
||||
if (transactionFinished.value || !selectedGroup.value) {
|
||||
return
|
||||
}
|
||||
if (transactionFinished.value || !selectedGroup.value) return
|
||||
|
||||
transactionLoading.value = true
|
||||
try {
|
||||
@@ -383,13 +354,15 @@ const handleConfirmBatchUpdate = async () => {
|
||||
emit('data-changed')
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('transactions-changed', {
|
||||
new CustomEvent(
|
||||
'transactions-changed',
|
||||
{
|
||||
detail: {
|
||||
reason: batchGroup.value.reason
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
} catch(e) {
|
||||
console.error('触发全局 transactions-changed 事件失败:', e)
|
||||
}
|
||||
// 关闭弹窗
|
||||
@@ -425,18 +398,18 @@ const handleTransactionClick = (transaction) => {
|
||||
|
||||
// 处理分组中的删除事件
|
||||
const handleGroupTransactionDelete = async (transactionId) => {
|
||||
groupTransactions.value = groupTransactions.value.filter((t) => t.id !== transactionId)
|
||||
groupTransactions.value = groupTransactions.value.filter(t => t.id !== transactionId)
|
||||
groupTransactionsTotal.value = Math.max(0, (groupTransactionsTotal.value || 0) - 1)
|
||||
|
||||
if (groupTransactions.value.length === 0 && !transactionFinished.value) {
|
||||
if(groupTransactions.value.length === 0 && !transactionFinished.value) {
|
||||
// 如果当前页数据为空且未加载完,则尝试加载下一页
|
||||
await loadGroupTransactions()
|
||||
}
|
||||
|
||||
if (groupTransactions.value.length === 0) {
|
||||
if(groupTransactions.value.length === 0){
|
||||
// 如果删除后当前分组没有交易了,关闭弹窗
|
||||
showTransactionList.value = false
|
||||
groups.value = groups.value.filter((g) => g.reason !== selectedGroup.value.reason)
|
||||
groups.value = groups.value.filter(g => g.reason !== selectedGroup.value.reason)
|
||||
selectedGroup.value = null
|
||||
total.value--
|
||||
}
|
||||
@@ -460,12 +433,10 @@ const onGlobalTransactionDeleted = () => {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener &&
|
||||
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener &&
|
||||
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||
})
|
||||
|
||||
// 当有交易新增/修改/批量更新时的刷新监听
|
||||
@@ -480,12 +451,10 @@ const onGlobalTransactionsChanged = () => {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener &&
|
||||
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener &&
|
||||
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
|
||||
})
|
||||
|
||||
// 处理账单保存后的回调
|
||||
@@ -502,9 +471,7 @@ const handleTransactionSaved = async () => {
|
||||
* 加载数据(支持分页)
|
||||
*/
|
||||
const loadData = async () => {
|
||||
if (finished.value) {
|
||||
return
|
||||
}
|
||||
if (finished.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -555,7 +522,7 @@ const refresh = async () => {
|
||||
*/
|
||||
const getList = (onlySelected = false) => {
|
||||
if (onlySelected && props.selectable) {
|
||||
return groups.value.filter((g) => selectedReasons.value.has(g.reason))
|
||||
return groups.value.filter(g => selectedReasons.value.has(g.reason))
|
||||
}
|
||||
return [...groups.value]
|
||||
}
|
||||
@@ -597,7 +564,7 @@ const clearSelection = () => {
|
||||
* 全选
|
||||
*/
|
||||
const selectAll = () => {
|
||||
selectedReasons.value = new Set(groups.value.map((g) => g.reason))
|
||||
selectedReasons.value = new Set(groups.value.map(g => g.reason))
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
@@ -660,13 +627,13 @@ defineExpose({
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--van-orange);
|
||||
color: #ff976a;
|
||||
}
|
||||
|
||||
:deep(.van-cell-group--inset) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<template v-if="!loading && !saving">
|
||||
<van-icon :name="buttonIcon" />
|
||||
<span style="margin-left: 4px">{{ buttonText }}</span>
|
||||
<span style="margin-left: 4px;">{{ buttonText }}</span>
|
||||
</template>
|
||||
</van-button>
|
||||
</template>
|
||||
@@ -52,42 +52,28 @@ const hasClassifiedResults = computed(() => {
|
||||
|
||||
// 按钮类型
|
||||
const buttonType = computed(() => {
|
||||
if (saving.value) {
|
||||
return 'warning'
|
||||
}
|
||||
if (loading.value) {
|
||||
return 'primary'
|
||||
}
|
||||
if (hasClassifiedResults.value) {
|
||||
return 'success'
|
||||
}
|
||||
if (saving.value) return 'warning'
|
||||
if (loading.value) return 'primary'
|
||||
if (hasClassifiedResults.value) return 'success'
|
||||
return 'primary'
|
||||
})
|
||||
|
||||
// 按钮图标
|
||||
const buttonIcon = computed(() => {
|
||||
if (hasClassifiedResults.value) {
|
||||
return 'success'
|
||||
}
|
||||
if (hasClassifiedResults.value) return 'success'
|
||||
return 'fire'
|
||||
})
|
||||
|
||||
// 按钮文字(非加载状态)
|
||||
const buttonText = computed(() => {
|
||||
if (hasClassifiedResults.value) {
|
||||
return '保存分类'
|
||||
}
|
||||
if (hasClassifiedResults.value) return '保存分类'
|
||||
return '智能分类'
|
||||
})
|
||||
|
||||
// 加载中文字
|
||||
const loadingText = computed(() => {
|
||||
if (saving.value) {
|
||||
return '保存中...'
|
||||
}
|
||||
if (loading.value) {
|
||||
return '分类中...'
|
||||
}
|
||||
if (saving.value) return '保存中...'
|
||||
if (loading.value) return '分类中...'
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -106,9 +92,7 @@ const handleClick = () => {
|
||||
* 保存分类结果
|
||||
*/
|
||||
const handleSaveClassify = async () => {
|
||||
if (saving.value || loading.value) {
|
||||
return
|
||||
}
|
||||
if (saving.value || loading.value) return
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
@@ -120,7 +104,7 @@ const handleSaveClassify = async () => {
|
||||
})
|
||||
|
||||
// 准备批量更新数据
|
||||
const items = classifiedResults.value.map((item) => ({
|
||||
const items = classifiedResults.value.map(item => ({
|
||||
id: item.id,
|
||||
classify: item.classify,
|
||||
type: item.type
|
||||
@@ -177,7 +161,7 @@ const handleSmartClassify = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (lockClassifiedResults.value) {
|
||||
if(lockClassifiedResults.value) {
|
||||
showToast('当前有分类任务正在进行,请稍后再试')
|
||||
loading.value = false
|
||||
return
|
||||
@@ -216,7 +200,7 @@ const handleSmartClassify = async () => {
|
||||
// 分批处理
|
||||
for (let i = 0; i < allTransactions.length; i += batchSize) {
|
||||
const batch = allTransactions.slice(i, i + batchSize)
|
||||
const transactionIds = batch.map((t) => t.id)
|
||||
const transactionIds = batch.map(t => t.id)
|
||||
const currentBatch = Math.floor(i / batchSize) + 1
|
||||
const totalBatches = Math.ceil(allTransactions.length / batchSize)
|
||||
|
||||
@@ -245,9 +229,7 @@ const handleSmartClassify = async () => {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
@@ -256,9 +238,7 @@ const handleSmartClassify = async () => {
|
||||
buffer = events.pop() || '' // 保留最后一个不完整的部分
|
||||
|
||||
for (const eventBlock of events) {
|
||||
if (!eventBlock.trim()) {
|
||||
continue
|
||||
}
|
||||
if (!eventBlock.trim()) continue
|
||||
|
||||
try {
|
||||
const lines = eventBlock.split('\n')
|
||||
@@ -296,7 +276,7 @@ const handleSmartClassify = async () => {
|
||||
})
|
||||
|
||||
// 实时更新交易记录的分类信息
|
||||
const index = props.transactions.findIndex((t) => t.id === data.id)
|
||||
const index = props.transactions.findIndex(t => t.id === data.id)
|
||||
if (index !== -1) {
|
||||
const transaction = props.transactions[index]
|
||||
transaction.upsetedClassify = data.Classify
|
||||
@@ -364,14 +344,14 @@ const handleSmartClassify = async () => {
|
||||
|
||||
const removeClassifiedTransaction = (transactionId) => {
|
||||
// 从已分类结果中移除指定ID的项
|
||||
classifiedResults.value = classifiedResults.value.filter((item) => item.id !== transactionId)
|
||||
classifiedResults.value = classifiedResults.value.filter(item => item.id !== transactionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置组件状态
|
||||
*/
|
||||
const reset = () => {
|
||||
if (lockClassifiedResults.value) {
|
||||
if(lockClassifiedResults.value) {
|
||||
showToast('当前有分类任务正在进行,无法重置')
|
||||
return
|
||||
}
|
||||
@@ -385,7 +365,8 @@ const reset = () => {
|
||||
defineExpose({
|
||||
reset,
|
||||
removeClassifiedTransaction
|
||||
})
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<PopupContainer v-model="visible" title="交易详情" height="75%" :closeable="false">
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
title="交易详情"
|
||||
height="75%"
|
||||
:closeable="false"
|
||||
>
|
||||
<template #header-actions>
|
||||
<van-button size="small" type="primary" plain @click="handleOffsetClick"> 抵账 </van-button>
|
||||
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
|
||||
</template>
|
||||
|
||||
<van-form style="margin-top: 12px">
|
||||
<van-form style="margin-top: 12px;">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
|
||||
</van-cell-group>
|
||||
@@ -50,48 +55,32 @@
|
||||
|
||||
<van-field name="type" label="交易类型">
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="editForm.type"
|
||||
direction="horizontal"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<van-radio :name="0"> 支出 </van-radio>
|
||||
<van-radio :name="1"> 收入 </van-radio>
|
||||
<van-radio :name="2"> 不计 </van-radio>
|
||||
<van-radio-group v-model="editForm.type" direction="horizontal" @change="handleTypeChange">
|
||||
<van-radio :name="0">支出</van-radio>
|
||||
<van-radio :name="1">收入</van-radio>
|
||||
<van-radio :name="2">不计</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<van-field name="classify" label="交易分类">
|
||||
<template #input>
|
||||
<div style="flex: 1">
|
||||
<div style="flex: 1;">
|
||||
<div
|
||||
v-if="
|
||||
transaction &&
|
||||
transaction.unconfirmedClassify &&
|
||||
transaction.unconfirmedClassify !== editForm.classify
|
||||
"
|
||||
v-if="transaction && transaction.unconfirmedClassify && transaction.unconfirmedClassify !== editForm.classify"
|
||||
class="suggestion-tip"
|
||||
@click="applySuggestion"
|
||||
>
|
||||
<van-icon name="bulb-o" class="suggestion-icon" />
|
||||
<span class="suggestion-text">
|
||||
建议: {{ transaction.unconfirmedClassify }}
|
||||
<span
|
||||
v-if="
|
||||
transaction.unconfirmedType !== null &&
|
||||
transaction.unconfirmedType !== undefined &&
|
||||
transaction.unconfirmedType !== editForm.type
|
||||
"
|
||||
>
|
||||
<span v-if="transaction.unconfirmedType !== null && transaction.unconfirmedType !== undefined && transaction.unconfirmedType !== editForm.type">
|
||||
({{ getTypeName(transaction.unconfirmedType) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="suggestion-apply">应用</div>
|
||||
</div>
|
||||
<span v-else-if="!editForm.classify" style="color: var(--van-gray-5)"
|
||||
>请选择交易分类</span
|
||||
>
|
||||
<span v-else-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -106,14 +95,24 @@
|
||||
</van-form>
|
||||
|
||||
<template #footer>
|
||||
<van-button round block type="primary" :loading="submitting" @click="onSubmit">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存修改
|
||||
</van-button>
|
||||
</template>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 抵账候选列表弹窗 -->
|
||||
<PopupContainer v-model="showOffsetPopup" title="选择抵账交易" height="75%">
|
||||
<PopupContainer
|
||||
v-model="showOffsetPopup"
|
||||
title="选择抵账交易"
|
||||
height="75%"
|
||||
>
|
||||
<van-list>
|
||||
<van-cell
|
||||
v-for="item in offsetCandidates"
|
||||
@@ -155,11 +154,7 @@ import { showToast, showConfirmDialog } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||
import {
|
||||
updateTransaction,
|
||||
getCandidatesForOffset,
|
||||
offsetTransactions
|
||||
} from '@/api/transactionRecord'
|
||||
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -201,16 +196,11 @@ const occurredAtLabel = computed(() => {
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
watch(() => props.show, (newVal) => {
|
||||
visible.value = newVal
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.transaction,
|
||||
(newVal) => {
|
||||
watch(() => props.transaction, (newVal) => {
|
||||
if (newVal) {
|
||||
isSyncing.value = true
|
||||
// 填充编辑表单
|
||||
@@ -234,8 +224,7 @@ watch(
|
||||
isSyncing.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
watch(visible, (newVal) => {
|
||||
emit('update:show', newVal)
|
||||
@@ -269,10 +258,7 @@ const onConfirmTime = ({ selectedValues }) => {
|
||||
const applySuggestion = () => {
|
||||
if (props.transaction.unconfirmedClassify) {
|
||||
editForm.classify = props.transaction.unconfirmedClassify
|
||||
if (
|
||||
props.transaction.unconfirmedType !== null &&
|
||||
props.transaction.unconfirmedType !== undefined
|
||||
) {
|
||||
if (props.transaction.unconfirmedType !== null && props.transaction.unconfirmedType !== undefined) {
|
||||
editForm.type = props.transaction.unconfirmedType
|
||||
}
|
||||
}
|
||||
@@ -328,9 +314,7 @@ const handleClassifyChange = () => {
|
||||
|
||||
// 清空分类
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
}
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
@@ -363,7 +347,7 @@ const handleOffsetClick = async () => {
|
||||
const handleCandidateSelect = (candidate) => {
|
||||
showConfirmDialog({
|
||||
title: '确认抵账',
|
||||
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`
|
||||
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`,
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
@@ -383,7 +367,7 @@ const handleCandidateSelect = (candidate) => {
|
||||
})
|
||||
.catch(() => {
|
||||
// on cancel
|
||||
})
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -393,18 +377,17 @@ const handleCandidateSelect = (candidate) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--van-active-color);
|
||||
color: var(--van-primary-color);
|
||||
background: #ecf9ff;
|
||||
color: #1989fa;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
border: 1px solid var(--van-primary-color);
|
||||
border: 1px solid rgba(25, 137, 250, 0.1);
|
||||
width: fit-content;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.suggestion-tip:active {
|
||||
opacity: 0.2;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
@@ -419,12 +402,23 @@ const handleCandidateSelect = (candidate) => {
|
||||
.suggestion-apply {
|
||||
margin-left: 8px;
|
||||
padding: 0 6px;
|
||||
background: var(--van-primary-color);
|
||||
color: var(--van-white);
|
||||
background: #1989fa;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.suggestion-tip {
|
||||
background: rgba(25, 137, 250, 0.15);
|
||||
border-color: rgba(25, 137, 250, 0.2);
|
||||
color: #58a6ff;
|
||||
}
|
||||
.suggestion-apply {
|
||||
background: #58a6ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="transaction-list-container transaction-list">
|
||||
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||
<van-list
|
||||
:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
|
||||
<van-swipe-cell
|
||||
v-for="transaction in transactions"
|
||||
@@ -14,7 +19,10 @@
|
||||
class="checkbox-col"
|
||||
@update:model-value="toggleSelection(transaction)"
|
||||
/>
|
||||
<div class="transaction-card" @click="handleClick(transaction)">
|
||||
<div
|
||||
class="transaction-card"
|
||||
@click="handleClick(transaction)"
|
||||
>
|
||||
<div class="card-left">
|
||||
<div class="transaction-title">
|
||||
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
||||
@@ -22,32 +30,34 @@
|
||||
<div class="transaction-info">
|
||||
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
||||
<div>
|
||||
<span v-if="transaction.classify"> 分类: {{ transaction.classify }} </span>
|
||||
<span
|
||||
v-if="
|
||||
transaction.upsetedClassify &&
|
||||
transaction.upsetedClassify !== transaction.classify
|
||||
"
|
||||
style="color: var(--van-warning-color)"
|
||||
>
|
||||
<span v-if="transaction.classify">
|
||||
分类: {{ transaction.classify }}
|
||||
</span>
|
||||
<span v-if="transaction.upsetedClassify && transaction.upsetedClassify !== transaction.classify" style="color: #ff976a">
|
||||
→ {{ transaction.upsetedClassify }}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<div v-if="transaction.importFrom">
|
||||
来源: {{ transaction.importFrom }}
|
||||
</div>
|
||||
<div v-if="transaction.importFrom">来源: {{ transaction.importFrom }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-middle">
|
||||
<van-tag :type="getTypeTagType(transaction.type)" size="medium">
|
||||
<van-tag
|
||||
:type="getTypeTagType(transaction.type)"
|
||||
size="medium"
|
||||
>
|
||||
{{ getTypeName(transaction.type) }}
|
||||
</van-tag>
|
||||
<template
|
||||
v-if="
|
||||
Number.isFinite(transaction.upsetedType) &&
|
||||
transaction.upsetedType !== transaction.type
|
||||
"
|
||||
v-if="Number.isFinite(transaction.upsetedType) && transaction.upsetedType !== transaction.type"
|
||||
>
|
||||
→
|
||||
<van-tag :type="getTypeTagType(transaction.upsetedType)" size="medium">
|
||||
<van-tag
|
||||
:type="getTypeTagType(transaction.upsetedType)"
|
||||
size="medium"
|
||||
>
|
||||
{{ getTypeName(transaction.upsetedType) }}
|
||||
</van-tag>
|
||||
</template>
|
||||
@@ -60,14 +70,11 @@
|
||||
<div v-if="transaction.balance && transaction.balance > 0" class="balance">
|
||||
余额: {{ formatMoney(transaction.balance) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="transaction.refundAmount && transaction.refundAmount > 0"
|
||||
class="balance"
|
||||
>
|
||||
<div v-if="transaction.refundAmount && transaction.refundAmount > 0" class="balance">
|
||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
<van-icon name="arrow" size="16" color="var(--van-gray-5)" />
|
||||
<van-icon name="arrow" size="16" color="#c8c9cc" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,24 +212,16 @@ const getTypeTagType = (type) => {
|
||||
|
||||
// 获取金额样式类
|
||||
const getAmountClass = (type) => {
|
||||
if (type === 0) {
|
||||
return 'expense'
|
||||
}
|
||||
if (type === 1) {
|
||||
return 'income'
|
||||
}
|
||||
if (type === 0) return 'expense'
|
||||
if (type === 1) return 'income'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// 格式化金额(带符号)
|
||||
const formatAmount = (amount, type) => {
|
||||
const formatted = formatMoney(amount)
|
||||
if (type === 0) {
|
||||
return `- ${formatted}`
|
||||
}
|
||||
if (type === 1) {
|
||||
return `+ ${formatted}`
|
||||
}
|
||||
if (type === 0) return `- ${formatted}`
|
||||
if (type === 1) return `+ ${formatted}`
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -233,9 +232,7 @@ const formatMoney = (amount) => {
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) {
|
||||
return ''
|
||||
}
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
@@ -318,12 +315,12 @@ const formatDate = (dateString) => {
|
||||
|
||||
.transaction-info {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.original-info {
|
||||
color: var(--van-orange);
|
||||
color: #ff976a;
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -346,16 +343,16 @@ const formatDate = (dateString) => {
|
||||
}
|
||||
|
||||
.amount.expense {
|
||||
color: var(--van-danger-color);
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.amount.income {
|
||||
color: var(--van-success-color);
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.balance {
|
||||
font-size: 12px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vant from 'vant'
|
||||
import { ConfigProvider } from 'vant'
|
||||
import { ConfigProvider } from 'vant';
|
||||
import 'vant/lib/index.css'
|
||||
|
||||
// 注册 Service Worker
|
||||
@@ -19,7 +19,7 @@ const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vant)
|
||||
app.use(ConfigProvider)
|
||||
app.use(ConfigProvider);
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -1,68 +1,65 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const needRefresh = ref(false)
|
||||
let swRegistration = null
|
||||
export const needRefresh = ref(false);
|
||||
let swRegistration = null;
|
||||
|
||||
export async function updateServiceWorker() {
|
||||
if (swRegistration && swRegistration.waiting) {
|
||||
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
await swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
}
|
||||
|
||||
export function register() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = '/service-worker.js'
|
||||
const swUrl = `/service-worker.js`;
|
||||
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
swRegistration = registration
|
||||
console.log('[SW] Service Worker 注册成功:', registration.scope)
|
||||
swRegistration = registration;
|
||||
console.log('[SW] Service Worker 注册成功:', registration.scope);
|
||||
|
||||
// 如果已经有等待中的更新
|
||||
if (registration.waiting) {
|
||||
console.log('[SW] 发现未处理的新版本')
|
||||
needRefresh.value = true
|
||||
console.log('[SW] 发现未处理的新版本');
|
||||
needRefresh.value = true;
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
console.log('[SW] 发现新版本')
|
||||
const newWorker = registration.installing;
|
||||
console.log('[SW] 发现新版本');
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// 新的 Service Worker 已安装,提示用户刷新
|
||||
console.log('[SW] 新版本可用,请刷新页面')
|
||||
needRefresh.value = true
|
||||
console.log('[SW] 新版本可用,请刷新页面');
|
||||
needRefresh.value = true;
|
||||
} else {
|
||||
// 首次安装
|
||||
console.log('[SW] 内容已缓存,可离线使用')
|
||||
console.log('[SW] 内容已缓存,可离线使用');
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// 定期检查更新
|
||||
setInterval(
|
||||
() => {
|
||||
registration.update()
|
||||
},
|
||||
60 * 60 * 1000
|
||||
) // 每小时检查一次
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60 * 60 * 1000); // 每小时检查一次
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[SW] Service Worker 注册失败:', error)
|
||||
})
|
||||
console.error('[SW] Service Worker 注册失败:', error);
|
||||
});
|
||||
|
||||
// 监听 Service Worker 控制器变化
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[SW] 控制器已更改,页面将刷新')
|
||||
window.location.reload()
|
||||
})
|
||||
})
|
||||
console.log('[SW] 控制器已更改,页面将刷新');
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,37 +67,35 @@ export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
registration.unregister()
|
||||
registration.unregister();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message)
|
||||
})
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 请求通知权限
|
||||
export function requestNotificationPermission() {
|
||||
if ('Notification' in window && 'serviceWorker' in navigator) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === 'granted') {
|
||||
console.log('[SW] 通知权限已授予')
|
||||
console.log('[SW] 通知权限已授予');
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 后台同步
|
||||
export function registerBackgroundSync(tag = 'sync-data') {
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
return registration.sync.register(tag)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('[SW] 后台同步已注册:', tag)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[SW] 后台同步注册失败:', err)
|
||||
})
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
return registration.sync.register(tag);
|
||||
}).then(() => {
|
||||
console.log('[SW] 后台同步已注册:', tag);
|
||||
}).catch((err) => {
|
||||
console.error('[SW] 后台同步注册失败:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,106 +8,106 @@ const router = createRouter({
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/balance',
|
||||
name: 'balance',
|
||||
component: () => import('../views/BalanceView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/email',
|
||||
name: 'email',
|
||||
component: () => import('../views/EmailRecord.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/setting',
|
||||
name: 'setting',
|
||||
component: () => import('../views/SettingView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../views/CalendarView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/smart-classification',
|
||||
name: 'smart-classification',
|
||||
component: () => import('../views/ClassificationSmart.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/classification-edit',
|
||||
name: 'classification-edit',
|
||||
component: () => import('../views/ClassificationEdit.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/classification-batch',
|
||||
name: 'classification-batch',
|
||||
component: () => import('../views/ClassificationBatch.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/classification-nlp',
|
||||
name: 'classification-nlp',
|
||||
component: () => import('../views/ClassificationNLP.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'statistics',
|
||||
component: () => import('../views/StatisticsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/bill-analysis',
|
||||
name: 'bill-analysis',
|
||||
component: () => import('../views/BillAnalysisView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/message',
|
||||
name: 'message',
|
||||
redirect: { path: '/balance', query: { tab: 'message' } },
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/periodic-record',
|
||||
name: 'periodic-record',
|
||||
component: () => import('../views/PeriodicRecord.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/log',
|
||||
name: 'log',
|
||||
component: () => import('../views/LogView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/budget',
|
||||
name: 'budget',
|
||||
component: () => import('../views/BudgetView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/scheduled-tasks',
|
||||
name: 'scheduled-tasks',
|
||||
component: () => import('../views/ScheduledTasksView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 待确认的分类项
|
||||
path: '/unconfirmed-classification',
|
||||
name: 'unconfirmed-classification',
|
||||
component: () => import('../views/UnconfirmedClassification.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true },
|
||||
}
|
||||
]
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
@@ -127,3 +127,4 @@ router.beforeEach((to, from, next) => {
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const expiresAt = ref(localStorage.getItem('expiresAt') || '')
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
if (!token.value || !expiresAt.value) {
|
||||
return false
|
||||
}
|
||||
if (!token.value || !expiresAt.value) return false
|
||||
// 检查token是否过期
|
||||
return new Date(expiresAt.value) > new Date()
|
||||
})
|
||||
@@ -46,6 +44,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
expiresAt,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain; /* 防止滚动链传播到 body */
|
||||
background: var(--van-background);
|
||||
background: #f7f8fa;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page-container {
|
||||
background: #141414;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面内容区域 */
|
||||
.page-content {
|
||||
padding: 16px 0 0 0;
|
||||
@@ -20,12 +26,20 @@
|
||||
|
||||
/* 统一卡片样式 */
|
||||
.common-card {
|
||||
background: var(--van-background-2);
|
||||
background: #ffffff;
|
||||
margin: 0 12px 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--van-border-color);
|
||||
border: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.common-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #2c2c2c;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
@@ -46,16 +60,31 @@
|
||||
/* 增加卡片组的对比度 */
|
||||
:deep(.van-cell-group--inset) {
|
||||
margin: 10px 16px;
|
||||
background: var(--van-background-2);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--van-border-color);
|
||||
border: 1px solid #ebedf0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.van-cell-group--inset) {
|
||||
background: #1f1f1f;
|
||||
border-color: #2c2c2c;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 单元格样式 */
|
||||
:deep(.van-cell) {
|
||||
background: var(--van-background-2);
|
||||
border-bottom: 1px solid var(--van-border-color);
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.van-cell) {
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #2c2c2c;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-cell:last-child) {
|
||||
@@ -67,17 +96,31 @@
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--van-background);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.detail-popup {
|
||||
background: #141414;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹出层内的卡片组样式 */
|
||||
.detail-popup :deep(.van-cell-group--inset) {
|
||||
background: var(--van-background-2);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--van-border-color);
|
||||
border: 1px solid #ebedf0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.detail-popup :deep(.van-cell-group--inset) {
|
||||
background: #1f1f1f;
|
||||
border-color: #2c2c2c;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 详情头部样式 */
|
||||
.detail-header {
|
||||
margin-bottom: 16px;
|
||||
@@ -93,7 +136,7 @@
|
||||
.detail-header p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--van-text-color-2);
|
||||
color: #969799;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -173,11 +216,17 @@
|
||||
padding: 12px 16px;
|
||||
margin: 0 12px;
|
||||
margin-top: 12px;
|
||||
background: var(--van-background-2);
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sticky-header {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-header-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@@ -191,11 +240,11 @@
|
||||
|
||||
/* ===== 颜色工具类 ===== */
|
||||
.text-expense {
|
||||
color: var(--van-danger-color);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.text-income {
|
||||
color: var(--van-success-color);
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
@@ -207,11 +256,18 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--van-background-2);
|
||||
background-color: var(--van-background-2, #fff);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bottom-button {
|
||||
background-color: var(--van-background-2, #2c2c2c);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 统一弹窗样式 ===== */
|
||||
/* 弹窗容器 - 使用 flex 布局,确保标题固定,内容可滚动 */
|
||||
.popup-container {
|
||||
@@ -248,5 +304,15 @@
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--van-background);
|
||||
}
|
||||
|
||||
/* 深色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.popup-header-fixed {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.popup-scroll-content {
|
||||
background: #141414;
|
||||
}
|
||||
}
|
||||
@@ -71,9 +71,9 @@
|
||||
.rich-html-content thead,
|
||||
.rich-html-content tbody {
|
||||
display: table;
|
||||
width: 100%;
|
||||
width: 130%;
|
||||
min-width: 400px; /* 确保窄屏下有足够宽度触发滚动 */
|
||||
table-layout: fixed; /* 核心:强制列宽分配逻辑一致 */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rich-html-content tr {
|
||||
@@ -87,48 +87,52 @@
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--van-border-color-light);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rich-html-content th {
|
||||
background: var(--van-border-color);
|
||||
color: var(--van-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 通用列宽规则:第一列占 30%,其他列自动平分剩余 70% */
|
||||
/* 针对第一列“名称”分配更多空间,其余平分 */
|
||||
.rich-html-content th:first-child,
|
||||
.rich-html-content td:first-child {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.rich-html-content th:not(:first-child),
|
||||
.rich-html-content td:not(:first-child) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.rich-html-content th {
|
||||
background: var(--van-gray-1);
|
||||
color: var(--van-text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 业务特定样式:收入、支出、高亮 */
|
||||
.rich-html-content .income-value {
|
||||
color: var(--van-success-color) !important;
|
||||
color: #07c160 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-html-content .expense-value {
|
||||
color: var(--van-danger-color) !important;
|
||||
color: #ee0a24 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-html-content .highlight {
|
||||
background-color: var(--van-orange-light);
|
||||
color: var(--van-orange-dark);
|
||||
background-color: #fffbe6;
|
||||
color: #ed6a0c;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
border: 1px solid var(--van-orange);
|
||||
border: 1px solid #ffe58f;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rich-html-content .highlight {
|
||||
background-color: rgba(255, 243, 205, 0.2);
|
||||
color: #ffc107;
|
||||
@@ -156,4 +160,4 @@
|
||||
.rich-html-content td {
|
||||
border-bottom: 1px solid #2c2c2c;
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const getCssVar = (name) => {
|
||||
const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return val || '#999' // Default fallback
|
||||
}
|
||||
@@ -20,41 +20,38 @@
|
||||
/>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<van-tabs v-model:active="tabActive" type="card" style="margin: 12px 0 2px 0">
|
||||
<van-tabs v-model:active="tabActive" animated>
|
||||
<van-tab title="账单" name="balance" />
|
||||
<van-tab title="邮件" name="email" />
|
||||
<van-tab title="消息" name="message" />
|
||||
</van-tabs>
|
||||
|
||||
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef" />
|
||||
<TransactionsRecord v-if="tabActive === 'balance'" ref="transactionsRecordRef"/>
|
||||
<EmailRecord v-else-if="tabActive === 'email'" ref="emailRecordRef" />
|
||||
<MessageView v-else-if="tabActive === 'message'" ref="messageViewRef" :is-component="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TransactionsRecord from './TransactionsRecord.vue'
|
||||
import EmailRecord from './EmailRecord.vue'
|
||||
import MessageView from './MessageView.vue'
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import TransactionsRecord from './TransactionsRecord.vue';
|
||||
import EmailRecord from './EmailRecord.vue';
|
||||
import MessageView from './MessageView.vue';
|
||||
|
||||
const route = useRoute()
|
||||
const tabActive = ref(route.query.tab || 'balance')
|
||||
const route = useRoute();
|
||||
const tabActive = ref(route.query.tab || 'balance');
|
||||
|
||||
// 监听路由参数变化,用于从 tabbar 点击时切换 tab
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(newTab) => {
|
||||
watch(() => route.query.tab, (newTab) => {
|
||||
if (newTab) {
|
||||
tabActive.value = newTab
|
||||
tabActive.value = newTab;
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const transactionsRecordRef = ref(null)
|
||||
const emailRecordRef = ref(null)
|
||||
const messageViewRef = ref(null)
|
||||
const transactionsRecordRef = ref(null);
|
||||
const emailRecordRef = ref(null);
|
||||
const messageViewRef = ref(null);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="page-container-flex">
|
||||
<div class="page-container-flex">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar
|
||||
title="智能分析"
|
||||
@@ -12,7 +12,7 @@
|
||||
<van-icon
|
||||
name="setting-o"
|
||||
size="20"
|
||||
style="cursor: pointer; padding-right: 12px"
|
||||
style="cursor: pointer; padding-right: 12px;"
|
||||
@click="onClickPrompt"
|
||||
/>
|
||||
</template>
|
||||
@@ -33,9 +33,7 @@
|
||||
/>
|
||||
|
||||
<div class="quick-questions">
|
||||
<div class="quick-title">
|
||||
快捷问题
|
||||
</div>
|
||||
<div class="quick-title">快捷问题</div>
|
||||
<van-tag
|
||||
v-for="(q, index) in quickQuestions"
|
||||
:key="index"
|
||||
@@ -63,10 +61,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 结果区域 -->
|
||||
<div
|
||||
v-if="showResult"
|
||||
class="result-section"
|
||||
>
|
||||
<div v-if="showResult" class="result-section">
|
||||
<div class="result-header">
|
||||
<h3>分析结果</h3>
|
||||
<van-icon
|
||||
@@ -77,18 +72,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="resultContainer"
|
||||
class="result-content rich-html-content"
|
||||
>
|
||||
<div v-html="resultHtml" />
|
||||
<van-loading
|
||||
v-if="analyzing"
|
||||
class="result-loading"
|
||||
>
|
||||
<div ref="resultContainer" class="result-content rich-html-content">
|
||||
<div v-html="resultHtml"></div>
|
||||
<van-loading v-if="analyzing" class="result-loading">
|
||||
AI正在分析中...
|
||||
</van-loading>
|
||||
<div ref="scrollAnchor" />
|
||||
<div ref="scrollAnchor"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,11 +130,7 @@ const quickQuestions = [
|
||||
|
||||
// 返回
|
||||
const onClickLeft = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.replace('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 点击提示词按钮
|
||||
@@ -219,13 +204,10 @@ const startAnalysis = async () => {
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const response = await fetch(`${baseUrl}/TransactionRecord/AnalyzeBill`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userInput: userInput.value
|
||||
@@ -242,9 +224,7 @@ const startAnalysis = async () => {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n')
|
||||
@@ -271,6 +251,7 @@ const startAnalysis = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('分析失败:', error)
|
||||
showToast('分析失败,请重试')
|
||||
@@ -290,14 +271,21 @@ const startAnalysis = async () => {
|
||||
|
||||
/* 输入区域 */
|
||||
.input-section {
|
||||
background: var(--van-background-2);
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid var(--van-border-color);
|
||||
border: 1px solid #ebedf0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.input-section {
|
||||
background: #1f1f1f;
|
||||
border-color: #2c2c2c;
|
||||
}
|
||||
}
|
||||
|
||||
.input-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
@@ -364,7 +352,7 @@ const startAnalysis = async () => {
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--van-danger-color);
|
||||
color: #ff6b6b;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user